feat: add rate trend chart with recharts

Toggle "Show Chart" displays line chart above the table.
Supports single-bank and comparison mode visualizations.
Also modularized components: extracted tables, status indicators,
and chart into separate files for maintainability.
This commit is contained in:
tiennm99 committed 2026-04-03 23:27:21 +07:00
1 parent 7c00e8b948
commit 75eed4a75a
6 files changed
+674 -202

No files matched your search

+391 -1
View File
@@ -16,6 +16,7 @@
"react": "^19.0.0",
"react-datepicker": "^8.4.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
@@ -1096,6 +1097,42 @@
"node": ">=12.4.0"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1110,6 +1147,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1406,6 +1455,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1427,6 +1539,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz",
@@ -2617,6 +2735,127 @@
"node": ">= 8"
}
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2706,6 +2945,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2989,6 +3234,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -3428,6 +3683,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3892,6 +4153,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3934,6 +4205,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -5393,9 +5673,76 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5440,6 +5787,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -6050,6 +6403,12 @@
"node": ">=18"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
@@ -6310,6 +6669,37 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -17,6 +17,7 @@
"react": "^19.0.0",
"react-datepicker": "^8.4.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
+40 -201
View File
@@ -5,138 +5,9 @@ import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import { format, subDays, startOfMonth } from "date-fns";
import * as XLSX from "xlsx";
function LoadingSpinner({ progress }) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
<p style={{ color: "var(--muted)" }}>
{progress ? `Fetching ${progress.current}/${progress.total} days...` : "Fetching exchange rates..."}
</p>
</div>
</div>
);
}
function ErrorMessage({ message }) {
return (
<div
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4"
style={{
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
color: "#ef4444",
}}
>
<span className="shrink-0 mt-0.5">&#9888;</span>
<span>{message}</span>
</div>
);
}
function EmptyState() {
return (
<div className="text-center py-12" style={{ color: "var(--muted)" }}>
<p className="text-lg mb-1">No results yet</p>
<p className="text-sm">Select a bank and date range, then click Fetch Rates.</p>
</div>
);
}
function ComparisonTable({ results }) {
const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"];
return (
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
<table className="min-w-full text-sm">
<thead>
<tr style={{ background: "var(--table-header-bg)" }}>
{headers.map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{results.map((row, idx) => (
<tr
key={idx}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{[row.date, row.currency, row.bidvBuyTm, row.bidvSell, row.tcbBidTm, row.tcbAsk].map((cell, i) => (
<td
key={i}
className="px-3 py-2 whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{cell || "-"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function RateTable({ results, selectedBank }) {
const bidvHeaders = ["Date", "Name (VI)", "Buy TM", "Buy CK", "Currency", "Name (EN)", "Sell"];
const tcbHeaders = ["Date", "Label", "Ask Rate", "Bid CK", "Bid TM", "Source", "Target", "Ask TM"];
const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders;
const getRowCells = (row) => {
if (selectedBank === "bidv") {
return [row.date, row.nameVI, row.muaTm, row.muaCk, row.currency, row.nameEN, row.ban];
}
return [row.date, row.label, row.askRate, row.bidRateCK, row.bidRateTM, row.sourceCurrency, row.targetCurrency, row.askRateTM];
};
return (
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
<table className="min-w-full text-sm">
<thead>
<tr style={{ background: "var(--table-header-bg)" }}>
{headers.map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{results.map((row, idx) => (
<tr
key={idx}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{getRowCells(row).map((cell, i) => (
<td
key={i}
className="px-3 py-2 whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status";
import { RateTable, ComparisonTable } from "./exchange-rate-table";
import { RateTrendChart } from "./exchange-rate-chart";
function loadSavedSettings() {
if (typeof window === "undefined") return null;
@@ -166,31 +37,31 @@ function saveSettings(bank, currency, startDate, endDate) {
} catch { /* ignore quota errors */ }
}
const CURRENCY_OPTIONS = [
"ALL", "USD", "EUR", "GBP", "JPY", "AUD",
"CAD", "CHF", "SGD", "THB", "HKD", "CNY", "KRW",
];
const DATE_PRESETS = [
{ label: "Today", start: () => new Date(), end: () => new Date() },
{ label: "Last 7 days", start: () => subDays(new Date(), 7), end: () => new Date() },
{ label: "Last 30 days", start: () => subDays(new Date(), 30), end: () => new Date() },
{ label: "This month", start: () => startOfMonth(new Date()), end: () => new Date() },
];
export default function ExchangeRateViewer() {
const [startDate, setStartDate] = useState(() => {
const saved = loadSavedSettings();
return saved?.startDate || subDays(new Date(), 7);
});
const [endDate, setEndDate] = useState(() => {
const saved = loadSavedSettings();
return saved?.endDate || new Date();
});
const [selectedBank, setSelectedBank] = useState(() => {
const saved = loadSavedSettings();
return saved?.bank || "bidv";
});
const [selectedCurrency, setSelectedCurrency] = useState(() => {
const saved = loadSavedSettings();
return saved?.currency || "USD";
});
const [startDate, setStartDate] = useState(() => loadSavedSettings()?.startDate || subDays(new Date(), 7));
const [endDate, setEndDate] = useState(() => loadSavedSettings()?.endDate || new Date());
const [selectedBank, setSelectedBank] = useState(() => loadSavedSettings()?.bank || "bidv");
const [selectedCurrency, setSelectedCurrency] = useState(() => loadSavedSettings()?.currency || "USD");
const [compareMode, setCompareMode] = useState(false);
const [showChart, setShowChart] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [results, setResults] = useState([]);
const [hasFetched, setHasFetched] = useState(false);
const [progress, setProgress] = useState(null);
// Persist settings to localStorage
useEffect(() => {
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
}, [selectedBank, selectedCurrency, startDate, endDate]);
@@ -230,7 +101,6 @@ export default function ExchangeRateViewer() {
try {
if (compareMode) {
const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]);
// Merge by date + currency
const merged = new Map();
for (const row of (bidvData.data || [])) {
const key = `${row.date}|${row.currency}`;
@@ -258,7 +128,6 @@ export default function ExchangeRateViewer() {
}
}, [startDate, endDate, selectedBank, selectedCurrency, compareMode]);
// Auto-fetch on initial load
useEffect(() => {
handleFetch();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
@@ -266,17 +135,14 @@ export default function ExchangeRateViewer() {
const getExportRows = () => {
if (compareMode) {
const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"];
const rows = results.map((row) => [row.date, row.currency, row.bidvBuyTm || "", row.bidvSell || "", row.tcbBidTm || "", row.tcbAsk || ""]);
return [headers, ...rows];
return [headers, ...results.map((r) => [r.date, r.currency, r.bidvBuyTm || "", r.bidvSell || "", r.tcbBidTm || "", r.tcbAsk || ""])];
}
if (selectedBank === "bidv") {
const headers = ["Date", "NameVI", "MuaTm", "MuaCk", "Currency", "NameEN", "Ban"];
const rows = results.map((row) => [row.date, row.nameVI, row.muaTm, row.muaCk, row.currency, row.nameEN, row.ban]);
return [headers, ...rows];
return [headers, ...results.map((r) => [r.date, r.nameVI, r.muaTm, r.muaCk, r.currency, r.nameEN, r.ban])];
}
const headers = ["Date", "Label", "AskRate", "BidRateCK", "BidRateTM", "SourceCurrency", "TargetCurrency", "AskRateTM"];
const rows = results.map((row) => [row.date, row.label, row.askRate, row.bidRateCK, row.bidRateTM, row.sourceCurrency, row.targetCurrency, row.askRateTM]);
return [headers, ...rows];
return [headers, ...results.map((r) => [r.date, r.label, r.askRate, r.bidRateCK, r.bidRateTM, r.sourceCurrency, r.targetCurrency, r.askRateTM])];
};
const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${new Date().toISOString().split("T")[0]}`;
@@ -312,7 +178,6 @@ export default function ExchangeRateViewer() {
return (
<div className="container mx-auto px-4 py-8 max-w-5xl w-full">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold tracking-tight">Exchange Rate Export</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted)" }}>
@@ -320,19 +185,15 @@ export default function ExchangeRateViewer() {
</p>
</div>
{/* Controls card */}
<div
className="rounded-xl p-5 mb-6"
style={{ background: "var(--card-bg)", border: "1px solid var(--card-border)" }}
onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) handleFetch(); }}
>
<div className="flex flex-col gap-4">
{/* Inputs row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="flex flex-col">
<label htmlFor="bank" className="text-sm font-medium mb-1.5">
Bank
</label>
<label htmlFor="bank" className="text-sm font-medium mb-1.5">Bank</label>
<select
id="bank"
value={selectedBank}
@@ -346,9 +207,7 @@ export default function ExchangeRateViewer() {
</select>
</div>
<div className="flex flex-col">
<label htmlFor="currency" className="text-sm font-medium mb-1.5">
Currency
</label>
<label htmlFor="currency" className="text-sm font-medium mb-1.5">Currency</label>
<select
id="currency"
value={selectedCurrency}
@@ -356,25 +215,13 @@ export default function ExchangeRateViewer() {
className="border rounded-lg px-3 py-2 text-sm w-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500/30"
style={inputStyle}
>
<option value="ALL">All Currencies</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
<option value="JPY">JPY</option>
<option value="AUD">AUD</option>
<option value="CAD">CAD</option>
<option value="CHF">CHF</option>
<option value="SGD">SGD</option>
<option value="THB">THB</option>
<option value="HKD">HKD</option>
<option value="CNY">CNY</option>
<option value="KRW">KRW</option>
{CURRENCY_OPTIONS.map((c) => (
<option key={c} value={c}>{c === "ALL" ? "All Currencies" : c}</option>
))}
</select>
</div>
<div className="flex flex-col">
<label htmlFor="start-date" className="text-sm font-medium mb-1.5">
Start Date
</label>
<label htmlFor="start-date" className="text-sm font-medium mb-1.5">Start Date</label>
<DatePicker
id="start-date"
selected={startDate}
@@ -386,9 +233,7 @@ export default function ExchangeRateViewer() {
/>
</div>
<div className="flex flex-col">
<label htmlFor="end-date" className="text-sm font-medium mb-1.5">
End Date
</label>
<label htmlFor="end-date" className="text-sm font-medium mb-1.5">End Date</label>
<DatePicker
id="end-date"
selected={endDate}
@@ -402,37 +247,29 @@ export default function ExchangeRateViewer() {
</div>
</div>
{/* Compare toggle + Date presets */}
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: "var(--muted)" }}>
<input
type="checkbox"
checked={compareMode}
onChange={(e) => setCompareMode(e.target.checked)}
className="rounded"
/>
<input type="checkbox" checked={compareMode} onChange={(e) => setCompareMode(e.target.checked)} className="rounded" />
Compare BIDV vs TCB
</label>
<label className="flex items-center gap-1.5 text-xs cursor-pointer" style={{ color: "var(--muted)" }}>
<input type="checkbox" checked={showChart} onChange={(e) => setShowChart(e.target.checked)} className="rounded" />
Show Chart
</label>
<span style={{ color: "var(--card-border)" }}>|</span>
{[
{ label: "Today", start: new Date(), end: new Date() },
{ label: "Last 7 days", start: subDays(new Date(), 7), end: new Date() },
{ label: "Last 30 days", start: subDays(new Date(), 30), end: new Date() },
{ label: "This month", start: startOfMonth(new Date()), end: new Date() },
].map((preset) => (
{DATE_PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
className="text-xs px-3 py-1 rounded-md border transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/20"
style={{ borderColor: "var(--input-border)", color: "var(--muted)" }}
onClick={() => { setStartDate(preset.start); setEndDate(preset.end); }}
onClick={() => { setStartDate(preset.start()); setEndDate(preset.end()); }}
>
{preset.label}
</button>
))}
</div>
{/* Buttons row */}
<div className="flex flex-row gap-2 justify-end pt-1">
<button
className="bg-blue-600 hover:bg-blue-700 active:bg-blue-800 text-white text-sm font-medium px-5 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/40"
@@ -460,7 +297,6 @@ export default function ExchangeRateViewer() {
</div>
</div>
{/* Status area - announced to screen readers */}
<div aria-live="polite" aria-atomic="true">
{error && <ErrorMessage message={error} />}
{isLoading && <LoadingSpinner progress={progress} />}
@@ -472,6 +308,9 @@ export default function ExchangeRateViewer() {
{results.length} {results.length === 1 ? "record" : "records"} found
</p>
</div>
{showChart && (
<RateTrendChart results={results} selectedBank={selectedBank} compareMode={compareMode} />
)}
{compareMode
? <ComparisonTable results={results} />
: <RateTable results={results} selectedBank={selectedBank} />
+104
View File
@@ -0,0 +1,104 @@
"use client";
import React from "react";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from "recharts";
// Parse rate string to number (handles comma-separated values like "25,880")
function parseRate(val) {
if (!val) return null;
const num = parseFloat(String(val).replace(/,/g, ""));
return isNaN(num) ? null : num;
}
export function RateTrendChart({ results, selectedBank, compareMode }) {
// Build chart data from results
const chartData = [];
if (compareMode) {
// Group by date, show BIDV sell vs TCB ask
const byDate = new Map();
for (const row of results) {
if (!byDate.has(row.date)) byDate.set(row.date, {});
const entry = byDate.get(row.date);
entry.date = row.date;
if (row.bidvSell) entry["BIDV Sell"] = parseRate(row.bidvSell);
if (row.tcbAsk) entry["TCB Ask"] = parseRate(row.tcbAsk);
}
chartData.push(...byDate.values());
} else if (selectedBank === "bidv") {
const byDate = new Map();
for (const row of results) {
if (!byDate.has(row.date)) byDate.set(row.date, { date: row.date });
const entry = byDate.get(row.date);
const label = row.currency || "USD";
entry[`${label} Buy`] = parseRate(row.muaTm);
entry[`${label} Sell`] = parseRate(row.ban);
}
chartData.push(...byDate.values());
} else {
const byDate = new Map();
for (const row of results) {
if (!byDate.has(row.date)) byDate.set(row.date, { date: row.date });
const entry = byDate.get(row.date);
const label = row.sourceCurrency || "USD";
entry[`${label} Bid`] = parseRate(row.bidRateTM);
entry[`${label} Ask`] = parseRate(row.askRate);
}
chartData.push(...byDate.values());
}
if (chartData.length === 0) return null;
// Collect all numeric keys for lines
const lineKeys = new Set();
for (const row of chartData) {
for (const key of Object.keys(row)) {
if (key !== "date" && typeof row[key] === "number") lineKeys.add(key);
}
}
const colors = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#84cc16"];
return (
<div
className="rounded-lg p-4 mb-4"
style={{ background: "var(--card-bg)", border: "1px solid var(--card-border)" }}
>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
<XAxis
dataKey="date"
tick={{ fontSize: 11, fill: "var(--muted)" }}
tickLine={false}
/>
<YAxis
tick={{ fontSize: 11, fill: "var(--muted)" }}
tickLine={false}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{
background: "var(--card-bg)",
border: "1px solid var(--card-border)",
borderRadius: 8,
fontSize: 12,
}}
/>
{lineKeys.size <= 8 && <Legend wrapperStyle={{ fontSize: 12 }} />}
{Array.from(lineKeys).map((key, i) => (
<Line
key={key}
type="monotone"
dataKey={key}
stroke={colors[i % colors.length]}
strokeWidth={2}
dot={chartData.length <= 31}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import React from "react";
export function LoadingSpinner({ progress }) {
return (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
<p style={{ color: "var(--muted)" }}>
{progress ? `Fetching ${progress.current}/${progress.total} days...` : "Fetching exchange rates..."}
</p>
</div>
</div>
);
}
export function ErrorMessage({ message }) {
return (
<div
className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4"
style={{
background: "rgba(239, 68, 68, 0.08)",
border: "1px solid rgba(239, 68, 68, 0.2)",
color: "#ef4444",
}}
>
<span className="shrink-0 mt-0.5">&#9888;</span>
<span>{message}</span>
</div>
);
}
export function EmptyState() {
return (
<div className="text-center py-12" style={{ color: "var(--muted)" }}>
<p className="text-lg mb-1">No results yet</p>
<p className="text-sm">Select a bank and date range, then click Fetch Rates.</p>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
"use client";
import React from "react";
export function ComparisonTable({ results }) {
const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"];
return (
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
<table className="min-w-full text-sm">
<thead>
<tr style={{ background: "var(--table-header-bg)" }}>
{headers.map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{results.map((row, idx) => (
<tr
key={idx}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{[row.date, row.currency, row.bidvBuyTm, row.bidvSell, row.tcbBidTm, row.tcbAsk].map((cell, i) => (
<td
key={i}
className="px-3 py-2 whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{cell || "-"}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
export function RateTable({ results, selectedBank }) {
const bidvHeaders = ["Date", "Name (VI)", "Buy TM", "Buy CK", "Currency", "Name (EN)", "Sell"];
const tcbHeaders = ["Date", "Label", "Ask Rate", "Bid CK", "Bid TM", "Source", "Target", "Ask TM"];
const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders;
const getRowCells = (row) => {
if (selectedBank === "bidv") {
return [row.date, row.nameVI, row.muaTm, row.muaCk, row.currency, row.nameEN, row.ban];
}
return [row.date, row.label, row.askRate, row.bidRateCK, row.bidRateTM, row.sourceCurrency, row.targetCurrency, row.askRateTM];
};
return (
<div className="overflow-x-auto rounded-lg" style={{ border: "1px solid var(--card-border)" }}>
<table className="min-w-full text-sm">
<thead>
<tr style={{ background: "var(--table-header-bg)" }}>
{headers.map((h) => (
<th
key={h}
className="px-3 py-2.5 text-left font-semibold whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{results.map((row, idx) => (
<tr
key={idx}
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
>
{getRowCells(row).map((cell, i) => (
<td
key={i}
className="px-3 py-2 whitespace-nowrap"
style={{ borderBottom: "1px solid var(--card-border)" }}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}