mirror of
https://github.com/tiennm99/exchange-rate-export.git
synced 2026-10-11 03:13:21 +00:00
Merge pull request #20 from tiennm99/dependabot/npm_and_yarn/flatted-3.4.2
build(deps-dev): bump flatted from 3.3.3 to 3.4.2
This commit is contained in:
1 parent
eb818e51eb
commit
38b6561e26
12 files changed
+1565
-385
No files matched your search
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read",
|
||||
"Edit",
|
||||
"Bash(git status *)",
|
||||
"Bash(git diff *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(git branch *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(git rebase *)",
|
||||
"Bash(git fetch *)",
|
||||
"Bash(git pull *)",
|
||||
"Bash(git show *)",
|
||||
"Bash(git blame *)",
|
||||
"Bash(ls *)",
|
||||
"Bash(cat *)",
|
||||
"Bash(find *)",
|
||||
"Bash(grep *)",
|
||||
"Bash(rg *)",
|
||||
"Bash(head *)",
|
||||
"Bash(tail *)",
|
||||
"Bash(wc *)",
|
||||
"Bash(sort *)",
|
||||
"Bash(uniq *)",
|
||||
"Bash(diff *)",
|
||||
"Bash(echo *)",
|
||||
"Bash(pwd)",
|
||||
"Bash(which *)",
|
||||
"Bash(mkdir *)",
|
||||
"Bash(cp *)",
|
||||
"Bash(mv *)",
|
||||
"Bash(touch *)",
|
||||
"Bash(cd *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npm install *)",
|
||||
"Bash(npx *)",
|
||||
"Bash(yarn *)",
|
||||
"Bash(pnpm *)",
|
||||
"Bash(gradle *)",
|
||||
"Bash(./gradlew *)",
|
||||
"Bash(go build *)",
|
||||
"Bash(go test *)",
|
||||
"Bash(go run *)",
|
||||
"Bash(go mod *)",
|
||||
"Bash(cargo *)",
|
||||
"Bash(make *)",
|
||||
"Bash(python *)",
|
||||
"Bash(pip *)",
|
||||
"Bash(node *)",
|
||||
"Bash(java *)",
|
||||
"Bash(javac *)",
|
||||
"Bash(sed *)",
|
||||
"Bash(awk *)",
|
||||
"Bash(jq *)",
|
||||
"Bash(tree *)",
|
||||
"Bash(date *)",
|
||||
"Bash(env *)",
|
||||
"Bash(printenv *)",
|
||||
"Bash(realpath *)",
|
||||
"Bash(dirname *)",
|
||||
"Bash(basename *)",
|
||||
"Bash(stat *)",
|
||||
"Bash(file *)",
|
||||
"Bash(xargs *)",
|
||||
"Bash(tee *)"
|
||||
],
|
||||
"deny": [
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git push -f *)",
|
||||
"Bash(git push * --force *)",
|
||||
"Bash(git push * -f *)",
|
||||
"Bash(rm -rf /)",
|
||||
"Bash(rm -rf /*)",
|
||||
"Bash(rm -rf ~)",
|
||||
"Bash(rm -rf ~/*)",
|
||||
"Bash(curl * | bash *)",
|
||||
"Bash(curl * | sh *)",
|
||||
"Bash(wget * | bash *)",
|
||||
"Bash(wget * | sh *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
.claude/session-state
|
||||
|
||||
|
||||
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
|
||||
Generated
+496
-2
@@ -15,7 +15,9 @@
|
||||
"next": "16.1.5",
|
||||
"react": "^19.0.0",
|
||||
"react-datepicker": "^8.4.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
@@ -1095,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",
|
||||
@@ -1109,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",
|
||||
@@ -1405,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",
|
||||
@@ -1426,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",
|
||||
@@ -2078,6 +2197,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -2477,6 +2605,19 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -2519,6 +2660,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -2558,6 +2708,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2573,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",
|
||||
@@ -2662,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",
|
||||
@@ -2945,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",
|
||||
@@ -3384,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",
|
||||
@@ -3561,6 +3866,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -3839,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",
|
||||
@@ -3881,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",
|
||||
@@ -5340,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",
|
||||
@@ -5387,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",
|
||||
@@ -5739,6 +6145,18 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -5985,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",
|
||||
@@ -6245,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",
|
||||
@@ -6350,6 +6805,24 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
@@ -6360,6 +6833,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
|
||||
+3
-1
@@ -16,7 +16,9 @@
|
||||
"next": "16.1.5",
|
||||
"react": "^19.0.0",
|
||||
"react-datepicker": "^8.4.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
||||
+119
-120
@@ -3,80 +3,102 @@ import axios from "axios";
|
||||
import https from "https";
|
||||
import crypto from "crypto";
|
||||
|
||||
// Configure axios with legacy SSL support
|
||||
const axiosInstance = axios.create({
|
||||
httpsAgent: new https.Agent({
|
||||
secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT,
|
||||
}),
|
||||
});
|
||||
|
||||
// Helper function to fetch BIDV exchange rates
|
||||
async function fetchBIDVRates(dateObj) {
|
||||
const DISPLAY_DATE_FORMAT = "yyyy-MM-dd";
|
||||
const PARALLEL_CHUNK_SIZE = 5;
|
||||
const MAX_RETRIES = 2;
|
||||
const INITIAL_BACKOFF_MS = 500;
|
||||
|
||||
// Retry a function with exponential backoff
|
||||
async function withRetry(fn, retries = MAX_RETRIES) {
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (attempt === retries) throw error;
|
||||
const delay = INITIAL_BACKOFF_MS * Math.pow(2, attempt);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBIDVRates(dateObj, currency) {
|
||||
const dateStr = format(dateObj, "dd/MM/yyyy");
|
||||
try {
|
||||
// First API call to get namerecord
|
||||
const timeUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailSearchTimeServlet?date=${dateStr}`;
|
||||
const timeRes = await axiosInstance.get(timeUrl);
|
||||
const timeRes = await withRetry(() => axiosInstance.get(timeUrl));
|
||||
const timeData = timeRes.data;
|
||||
if (timeData.status !== 1 || !timeData.data?.length) return null;
|
||||
// Get the latest record
|
||||
|
||||
const latest = timeData.data.reduce(
|
||||
(latest, current) => (current.time > latest.time ? current : latest),
|
||||
(best, current) => (current.time > best.time ? current : best),
|
||||
timeData.data[0]
|
||||
);
|
||||
// Second API call to get exchange rates
|
||||
|
||||
const rateUrl = `https://bidv.com.vn/ServicesBIDV/ExchangeDetailServlet?date=${dateStr}&time=${latest.namerecord}`;
|
||||
const rateRes = await axiosInstance.get(rateUrl);
|
||||
const rateRes = await withRetry(() => axiosInstance.get(rateUrl));
|
||||
const rateData = rateRes.data;
|
||||
if (rateData.status !== 1 || !rateData.data) return null;
|
||||
// Find USD data
|
||||
const usd = rateData.data.find((item) => item.currency === "USD");
|
||||
if (!usd) return null;
|
||||
return {
|
||||
date: dateStr,
|
||||
nameVI: usd.nameVI || "",
|
||||
muaTm: usd.muaTm || "",
|
||||
muaCk: usd.muaCk || "",
|
||||
currency: usd.currency || "USD",
|
||||
nameEN: usd.nameEN || "",
|
||||
ban: usd.ban || "",
|
||||
};
|
||||
|
||||
const displayDate = format(dateObj, DISPLAY_DATE_FORMAT);
|
||||
const items = currency === "ALL"
|
||||
? rateData.data
|
||||
: rateData.data.filter((item) => item.currency === currency);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return items.map((item) => ({
|
||||
date: displayDate,
|
||||
nameVI: item.nameVI || "",
|
||||
muaTm: item.muaTm || "",
|
||||
muaCk: item.muaCk || "",
|
||||
currency: item.currency || "",
|
||||
nameEN: item.nameEN || "",
|
||||
ban: item.ban || "",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching BIDV rates:", error);
|
||||
console.error("Error fetching BIDV rates:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to fetch TCB exchange rates
|
||||
async function fetchTCBRates(dateObj) {
|
||||
async function fetchTCBRates(dateObj, currency) {
|
||||
const dateStr = format(dateObj, "yyyy-MM-dd");
|
||||
const url = `https://techcombank.com/content/techcombank/web/vn/vi/cong-cu-tien-ich/ty-gia/_jcr_content.exchange-rates.${dateStr}.integration.json`;
|
||||
try {
|
||||
const res = await axiosInstance.get(url);
|
||||
const res = await withRetry(() => axiosInstance.get(url));
|
||||
const data = res.data;
|
||||
if (!data.exchangeRate?.data) return null;
|
||||
// Find USD data
|
||||
const usd = data.exchangeRate.data.find(
|
||||
(item) => item.label === "USD (50,100)"
|
||||
);
|
||||
if (!usd) return null;
|
||||
return {
|
||||
|
||||
const items = currency === "ALL"
|
||||
? data.exchangeRate.data
|
||||
: data.exchangeRate.data.filter((item) => item.sourceCurrency === currency);
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return items.map((item) => ({
|
||||
date: dateStr,
|
||||
label: usd.label || "",
|
||||
askRate: usd.askRate || "",
|
||||
bidRateCK: usd.bidRateCK || "",
|
||||
bidRateTM: usd.bidRateTM || "",
|
||||
sourceCurrency: usd.sourceCurrency || "",
|
||||
targetCurrency: usd.targetCurrency || "",
|
||||
askRateTM: usd.askRateTM || "",
|
||||
};
|
||||
label: item.label || "",
|
||||
askRate: item.askRate || "",
|
||||
bidRateCK: item.bidRateCK || "",
|
||||
bidRateTM: item.bidRateTM || "",
|
||||
sourceCurrency: item.sourceCurrency || "",
|
||||
targetCurrency: item.targetCurrency || "",
|
||||
askRateTM: item.askRateTM || "",
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching TCB rates:", error);
|
||||
console.error("Error fetching TCB rates:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get date range between start and end dates
|
||||
function fetchRateForDate(dateObj, bank, currency) {
|
||||
return bank === "bidv" ? fetchBIDVRates(dateObj, currency) : fetchTCBRates(dateObj, currency);
|
||||
}
|
||||
|
||||
function getDateRange(start, end) {
|
||||
const dates = [];
|
||||
let current = new Date(start);
|
||||
@@ -88,105 +110,82 @@ function getDateRange(start, end) {
|
||||
return dates;
|
||||
}
|
||||
|
||||
// Helper function to get previous business day rate with recursive search
|
||||
async function getPreviousDayRate(date, bank, rateCache, maxAttempts = 30) {
|
||||
// Search backwards for the most recent available rate
|
||||
async function getPreviousDayRate(date, bank, currency, rateCache, maxAttempts = 30) {
|
||||
let currentDate = new Date(date);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
currentDate.setDate(currentDate.getDate() - 1);
|
||||
const dateStr = format(currentDate, 'yyyy-MM-dd');
|
||||
|
||||
|
||||
// Try to find in cache first
|
||||
const dateStr = format(currentDate, "yyyy-MM-dd");
|
||||
|
||||
if (rateCache.has(dateStr)) {
|
||||
return rateCache.get(dateStr);
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
let rate = null;
|
||||
try {
|
||||
if (bank === "bidv") {
|
||||
rate = await fetchBIDVRates(currentDate);
|
||||
} else if (bank === "tcb") {
|
||||
rate = await fetchTCBRates(currentDate);
|
||||
}
|
||||
|
||||
if (rate) {
|
||||
rateCache.set(dateStr, rate);
|
||||
return rate;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching rates for ${dateStr}:`, error);
|
||||
|
||||
const rates = await fetchRateForDate(currentDate, bank, currency);
|
||||
if (rates) {
|
||||
rateCache.set(dateStr, rates);
|
||||
return rates;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch a chunk of dates in parallel, filling gaps with previous day data
|
||||
async function fetchChunk(dates, bank, currency, rateCache) {
|
||||
const settled = await Promise.allSettled(
|
||||
dates.map((date) => fetchRateForDate(date, bank, currency))
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const date = dates[i];
|
||||
const dateStr = format(date, "yyyy-MM-dd");
|
||||
const outcome = settled[i];
|
||||
let rates = outcome.status === "fulfilled" ? outcome.value : null;
|
||||
|
||||
if (rates) {
|
||||
rateCache.set(dateStr, rates);
|
||||
results.push(...rates);
|
||||
} else {
|
||||
// Weekend/holiday/error — fill with most recent available rate
|
||||
const prevRates = await getPreviousDayRate(date, bank, currency, rateCache);
|
||||
if (prevRates) {
|
||||
const displayDate = format(date, DISPLAY_DATE_FORMAT);
|
||||
results.push(...prevRates.map((r) => ({ ...r, date: displayDate })));
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { startDate, endDate, bank } = body;
|
||||
const { startDate, endDate, bank, currency = "USD" } = body;
|
||||
if (!startDate || !endDate || !bank) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing required parameters" }),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } }
|
||||
return Response.json(
|
||||
{ error: "Missing required parameters" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dates = getDateRange(startDate, endDate);
|
||||
const rateCache = new Map();
|
||||
const results = [];
|
||||
const rateCache = new Map(); // Cache to store rates we've fetched
|
||||
|
||||
for (const date of dates) {
|
||||
let rate = null;
|
||||
const dateStr = format(date, 'yyyy-MM-dd');
|
||||
|
||||
|
||||
try {
|
||||
if (bank === "bidv") {
|
||||
rate = await fetchBIDVRates(date);
|
||||
} else if (bank === "tcb") {
|
||||
rate = await fetchTCBRates(date);
|
||||
}
|
||||
|
||||
if (rate) {
|
||||
results.push(rate);
|
||||
rateCache.set(dateStr, rate);
|
||||
} else {
|
||||
// No data for this date (likely weekend), try to get previous day's data
|
||||
const prevRate = await getPreviousDayRate(date, bank, rateCache);
|
||||
if (prevRate) {
|
||||
// Create a new rate object with current date but previous day's data
|
||||
const filledRate = {
|
||||
...prevRate,
|
||||
date: bank === "bidv" ? format(date, 'dd/MM/yyyy') : dateStr
|
||||
};
|
||||
results.push(filledRate);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching rates for ${date}:`, error);
|
||||
// Try to fill with previous day's data even on error
|
||||
const prevRate = await getPreviousDayRate(date, bank, rateCache);
|
||||
if (prevRate) {
|
||||
const filledRate = {
|
||||
...prevRate,
|
||||
date: bank === "bidv" ? format(date, 'dd/MM/yyyy') : dateStr
|
||||
};
|
||||
results.push(filledRate);
|
||||
}
|
||||
}
|
||||
|
||||
// Process dates in parallel chunks to avoid overwhelming upstream APIs
|
||||
for (let i = 0; i < dates.length; i += PARALLEL_CHUNK_SIZE) {
|
||||
const chunk = dates.slice(i, i + PARALLEL_CHUNK_SIZE);
|
||||
const chunkResults = await fetchChunk(chunk, bank, currency, rateCache);
|
||||
results.push(...chunkResults);
|
||||
}
|
||||
return new Response(JSON.stringify({ data: results }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
return Response.json({ data: results, total: dates.length, chunks: Math.ceil(dates.length / PARALLEL_CHUNK_SIZE) });
|
||||
} catch (error) {
|
||||
console.error("Server error:", error);
|
||||
return new Response(JSON.stringify({ error: "Internal server error" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
return Response.json(
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,274 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import DatePicker from "react-datepicker";
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { parse, isDate, format } from "date-fns";
|
||||
import { format, subDays, startOfMonth, isSameDay, differenceInCalendarDays } from "date-fns";
|
||||
import * as XLSX from "xlsx";
|
||||
import { LoadingSpinner, ErrorMessage, EmptyState } from "./exchange-rate-status";
|
||||
import { RateTable, ComparisonTable } from "./exchange-rate-table";
|
||||
import { RateTrendChart } from "./exchange-rate-chart";
|
||||
|
||||
export default function ExchangeRateViewer({
|
||||
defaultStartDate,
|
||||
defaultEndDate,
|
||||
}) {
|
||||
const [startDate, setStartDate] = useState(
|
||||
defaultStartDate
|
||||
? isDate(defaultStartDate)
|
||||
? defaultStartDate
|
||||
: parse(defaultStartDate, "yyyy-MM-dd", new Date())
|
||||
: null
|
||||
// Read state from URL search params (takes priority over localStorage)
|
||||
function loadFromUrl() {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const bank = params.get("bank");
|
||||
const currency = params.get("currency");
|
||||
const start = params.get("start");
|
||||
const end = params.get("end");
|
||||
if (!bank && !currency && !start && !end) return null;
|
||||
return {
|
||||
bank: bank || null,
|
||||
currency: currency || null,
|
||||
startDate: start ? new Date(start) : null,
|
||||
endDate: end ? new Date(end) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function loadSavedSettings() {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const saved = localStorage.getItem("exchangeRateSettings");
|
||||
if (!saved) return null;
|
||||
const parsed = JSON.parse(saved);
|
||||
return {
|
||||
bank: parsed.bank || "bidv",
|
||||
currency: parsed.currency || "USD",
|
||||
startDate: parsed.startDate ? new Date(parsed.startDate) : null,
|
||||
endDate: parsed.endDate ? new Date(parsed.endDate) : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(bank, currency, startDate, endDate) {
|
||||
try {
|
||||
localStorage.setItem("exchangeRateSettings", JSON.stringify({
|
||||
bank,
|
||||
currency,
|
||||
startDate: startDate?.toISOString(),
|
||||
endDate: endDate?.toISOString(),
|
||||
}));
|
||||
} 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() },
|
||||
];
|
||||
|
||||
function getActivePreset(startDate, endDate) {
|
||||
if (!startDate || !endDate) return null;
|
||||
for (const preset of DATE_PRESETS) {
|
||||
if (isSameDay(startDate, preset.start()) && isSameDay(endDate, preset.end())) {
|
||||
return preset.label;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyTheme(value) {
|
||||
const root = document.documentElement;
|
||||
if (value === "system") {
|
||||
root.removeAttribute("data-theme");
|
||||
} else {
|
||||
root.setAttribute("data-theme", value);
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_CYCLE = ["system", "light", "dark"];
|
||||
const THEME_LABELS = { system: "System", light: "Light", dark: "Dark" };
|
||||
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z" />
|
||||
</svg>
|
||||
);
|
||||
const [endDate, setEndDate] = useState(
|
||||
defaultEndDate
|
||||
? isDate(defaultEndDate)
|
||||
? defaultEndDate
|
||||
: parse(defaultEndDate, "yyyy-MM-dd", new Date())
|
||||
: null
|
||||
}
|
||||
|
||||
function MoonIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.72 9.72 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 0 1-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0 1 15 18.257V17.25m6-12V15a2.25 2.25 0 0 1-2.25 2.25H5.25A2.25 2.25 0 0 1 3 15V5.25A2.25 2.25 0 0 1 5.25 3h13.5A2.25 2.25 0 0 1 21 5.25Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline SVG icons (16x16) to avoid adding an icon library dependency
|
||||
function FetchIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ExcelIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h7.5c.621 0 1.125-.504 1.125-1.125m-9.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-7.5A1.125 1.125 0 0 1 12 18.375m9.75-12.75c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125m19.5 0v1.5c0 .621-.504 1.125-1.125 1.125M2.25 5.625v1.5c0 .621.504 1.125 1.125 1.125m0 0h17.25m-17.25 0h7.5c.621 0 1.125.504 1.125 1.125M3.375 8.25c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m17.25-3.75h-7.5c-.621 0-1.125.504-1.125 1.125m8.625-1.125c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h7.5m-7.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M12 10.875v-1.5m0 1.5c0 .621-.504 1.125-1.125 1.125M12 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125M10.875 12c-.621 0-1.125.504-1.125 1.125M12 12c.621 0 1.125.504 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m0 0v1.5c0 .621-.504 1.125-1.125 1.125m0 0c-.621 0-1.125.504-1.125 1.125v1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CsvIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExchangeRateViewer() {
|
||||
const [startDate, setStartDate] = useState(() => subDays(new Date(), 7));
|
||||
const [endDate, setEndDate] = useState(() => new Date());
|
||||
const [selectedBank, setSelectedBank] = useState("bidv");
|
||||
const [selectedCurrency, setSelectedCurrency] = useState("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 [showTable, setShowTable] = useState(false);
|
||||
const [hasFetched, setHasFetched] = useState(false);
|
||||
const [progress, setProgress] = useState(null);
|
||||
const [theme, setTheme] = useState("system");
|
||||
|
||||
const handleFetch = async () => {
|
||||
// Restore saved settings on client mount — URL params take priority over localStorage
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
useEffect(() => {
|
||||
const urlSettings = loadFromUrl();
|
||||
const saved = urlSettings || loadSavedSettings();
|
||||
if (saved) {
|
||||
if (saved.bank) setSelectedBank(saved.bank);
|
||||
if (saved.currency) setSelectedCurrency(saved.currency);
|
||||
if (saved.startDate) setStartDate(saved.startDate);
|
||||
if (saved.endDate) setEndDate(saved.endDate);
|
||||
}
|
||||
const savedTheme = localStorage.getItem("theme") || "system";
|
||||
setTheme(savedTheme);
|
||||
applyTheme(savedTheme);
|
||||
setHydrated(true);
|
||||
}, []);
|
||||
|
||||
// Sync state to both localStorage and URL
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
saveSettings(selectedBank, selectedCurrency, startDate, endDate);
|
||||
const params = new URLSearchParams();
|
||||
params.set("bank", selectedBank);
|
||||
params.set("currency", selectedCurrency);
|
||||
if (startDate) params.set("start", format(startDate, "yyyy-MM-dd"));
|
||||
if (endDate) params.set("end", format(endDate, "yyyy-MM-dd"));
|
||||
window.history.replaceState(null, "", `?${params.toString()}`);
|
||||
}, [selectedBank, selectedCurrency, startDate, endDate, hydrated]);
|
||||
|
||||
const fetchBank = async (bank) => {
|
||||
const response = await fetch("/api/exchange-rates", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
startDate: format(startDate, "yyyy-MM-dd"),
|
||||
endDate: format(endDate, "yyyy-MM-dd"),
|
||||
bank,
|
||||
currency: selectedCurrency,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || `Failed to fetch ${bank} rates`);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const handleFetch = useCallback(async () => {
|
||||
if (!startDate || !endDate) {
|
||||
setError("Please select both start and end dates");
|
||||
return;
|
||||
}
|
||||
if (startDate > endDate) {
|
||||
setError("Start date cannot be after end date");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setIsLoading(true);
|
||||
setResults([]);
|
||||
setShowTable(false);
|
||||
setHasFetched(false);
|
||||
const totalDays = differenceInCalendarDays(endDate, startDate) + 1;
|
||||
setProgress({ current: 0, total: totalDays });
|
||||
try {
|
||||
const start = startDate;
|
||||
const end = endDate;
|
||||
if (start > end) {
|
||||
setError("Start date cannot be after end date");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
if (compareMode) {
|
||||
const [bidvData, tcbData] = await Promise.all([fetchBank("bidv"), fetchBank("tcb")]);
|
||||
const merged = new Map();
|
||||
for (const row of (bidvData.data || [])) {
|
||||
const key = `${row.date}|${row.currency}`;
|
||||
merged.set(key, { date: row.date, currency: row.currency, bidvBuyTm: row.muaTm, bidvSell: row.ban });
|
||||
}
|
||||
for (const row of (tcbData.data || [])) {
|
||||
const key = `${row.date}|${row.sourceCurrency}`;
|
||||
const existing = merged.get(key) || { date: row.date, currency: row.sourceCurrency };
|
||||
existing.tcbBidTm = row.bidRateTM;
|
||||
existing.tcbAsk = row.askRate;
|
||||
merged.set(key, existing);
|
||||
}
|
||||
setResults(Array.from(merged.values()).sort((a, b) => a.date.localeCompare(b.date)));
|
||||
} else {
|
||||
const data = await fetchBank(selectedBank);
|
||||
setResults(data.data || []);
|
||||
}
|
||||
const response = await fetch("/api/exchange-rates", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
startDate: format(start, 'yyyy-MM-dd'),
|
||||
endDate: format(end, 'yyyy-MM-dd'),
|
||||
bank: selectedBank,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "Failed to fetch exchange rates");
|
||||
}
|
||||
const data = await response.json();
|
||||
setResults(data.data || []);
|
||||
setShowTable(true);
|
||||
setHasFetched(true);
|
||||
} catch (err) {
|
||||
setError(
|
||||
`An error occurred while fetching exchange rates: ${err.message}`
|
||||
);
|
||||
setError(`An error occurred while fetching exchange rates: ${err.message}`);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setProgress(null);
|
||||
}
|
||||
}, [startDate, endDate, selectedBank, selectedCurrency, compareMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hydrated) handleFetch();
|
||||
}, [hydrated]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const getExportRows = () => {
|
||||
if (compareMode) {
|
||||
const headers = ["Date", "Currency", "BIDV Buy TM", "BIDV Sell", "TCB Bid TM", "TCB Ask"];
|
||||
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"];
|
||||
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"];
|
||||
return [headers, ...results.map((r) => [r.date, r.label, r.askRate, r.bidRateCK, r.bidRateTM, r.sourceCurrency, r.targetCurrency, r.askRateTM])];
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const baseFilename = `exchange_rates_${compareMode ? "compare" : selectedBank}_${selectedCurrency}_${new Date().toISOString().split("T")[0]}`;
|
||||
|
||||
const handleExportExcel = () => {
|
||||
if (results.length === 0) return;
|
||||
let csvContent = "";
|
||||
let headers = [];
|
||||
if (selectedBank === "bidv") {
|
||||
headers = [
|
||||
"Date",
|
||||
"NameVI",
|
||||
"MuaTm",
|
||||
"MuaCk",
|
||||
"Currency",
|
||||
"NameEN",
|
||||
"Ban",
|
||||
];
|
||||
csvContent = headers.join(",") + "\n";
|
||||
csvContent += results
|
||||
.map(
|
||||
(row) =>
|
||||
`"${row.date}","${row.nameVI}","${row.muaTm}","${row.muaCk}","${row.currency}","${row.nameEN}","${row.ban}"`
|
||||
)
|
||||
.join("\n");
|
||||
} else {
|
||||
headers = [
|
||||
"Date",
|
||||
"Label",
|
||||
"AskRate",
|
||||
"BidRateCK",
|
||||
"BidRateTM",
|
||||
"SourceCurrency",
|
||||
"TargetCurrency",
|
||||
"AskRateTM",
|
||||
];
|
||||
csvContent = headers.join(",") + "\n";
|
||||
csvContent += results
|
||||
.map(
|
||||
(row) =>
|
||||
`"${row.date}","${row.label}","${row.askRate}","${row.bidRateCK}","${row.bidRateTM}","${row.sourceCurrency}","${row.targetCurrency}","${row.askRateTM}"`
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
|
||||
const ws = XLSX.utils.aoa_to_sheet(getExportRows());
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Exchange Rates");
|
||||
XLSX.writeFile(wb, `${baseFilename}.xlsx`);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (results.length === 0) return;
|
||||
const ws = XLSX.utils.aoa_to_sheet(getExportRows());
|
||||
const csv = XLSX.utils.sheet_to_csv(ws);
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `exchange_rates_${selectedBank}_${
|
||||
new Date().toISOString().split("T")[0]
|
||||
}.csv`;
|
||||
link.download = `${baseFilename}.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const activePreset = getActivePreset(startDate, endDate);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 max-w-5xl w-full">
|
||||
<h1 className="text-2xl font-bold mb-4">Exchange Rate Export</h1>
|
||||
<div className="flex flex-col gap-4 mb-4">
|
||||
{/* Row 1: Start & End Date Pickers and Bank Select */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="form-group flex flex-col w-full sm:w-1/3">
|
||||
<label htmlFor="bank" className="mb-1">
|
||||
Bank:
|
||||
</label>
|
||||
<select
|
||||
id="bank"
|
||||
value={selectedBank}
|
||||
onChange={(e) => setSelectedBank(e.target.value)}
|
||||
className="border rounded p-2 w-full theme-select"
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<option value="bidv">BIDV</option>
|
||||
<option value="tcb">Techcombank</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group flex flex-col w-full sm:w-1/3">
|
||||
<label htmlFor="start-date" className="mb-1">
|
||||
Start Date:
|
||||
</label>
|
||||
<DatePicker
|
||||
id="start-date"
|
||||
selected={startDate}
|
||||
onChange={(date) => setStartDate(date)}
|
||||
maxDate={endDate || new Date()}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
className="border rounded p-2 w-full"
|
||||
placeholderText="Select start date"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group flex flex-col w-full sm:w-1/3">
|
||||
<label htmlFor="end-date" className="mb-1">
|
||||
End Date:
|
||||
</label>
|
||||
<DatePicker
|
||||
id="end-date"
|
||||
selected={endDate}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
minDate={startDate}
|
||||
maxDate={new Date()}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
className="border rounded p-2 w-full"
|
||||
placeholderText="Select end date"
|
||||
/>
|
||||
</div>
|
||||
<div className="container mx-auto px-4 py-8 max-w-5xl w-full">
|
||||
<div className="mb-6 flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exchange Rate Export</h1>
|
||||
<p className="text-sm mt-1 text-[var(--muted)]">
|
||||
Fetch exchange rates from Vietnamese banks and export to CSV.
|
||||
</p>
|
||||
</div>
|
||||
{/* Row 2: Fetch & Export Buttons aligned with End Date */}
|
||||
<div className="flex flex-row gap-2 justify-end items-end">
|
||||
<button
|
||||
className="fetch-btn bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||
onClick={handleFetch}
|
||||
disabled={isLoading}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{isLoading ? "Fetching..." : "Fetch Rates"}
|
||||
</button>
|
||||
<button
|
||||
className="export-btn bg-green-600 text-white px-4 py-2 rounded disabled:opacity-50"
|
||||
onClick={handleExport}
|
||||
disabled={results.length === 0 || isLoading}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
Export CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={`Theme: ${THEME_LABELS[theme]}`}
|
||||
onClick={() => {
|
||||
const next = THEME_CYCLE[(THEME_CYCLE.indexOf(theme) + 1) % THEME_CYCLE.length];
|
||||
setTheme(next);
|
||||
applyTheme(next);
|
||||
localStorage.setItem("theme", next);
|
||||
}}
|
||||
className="p-2 rounded-lg border border-[var(--input-border)] text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
{theme === "dark" ? <MoonIcon /> : theme === "light" ? <SunIcon /> : <SystemIcon />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-xl p-5 mb-6 bg-[var(--card-bg)] border border-[var(--card-border)]"
|
||||
onKeyDown={(e) => { if (e.key === "Enter" && !isLoading) handleFetch(); }}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Row 1: Selectors and date pickers */}
|
||||
<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>
|
||||
<select
|
||||
id="bank"
|
||||
value={selectedBank}
|
||||
onChange={(e) => setSelectedBank(e.target.value)}
|
||||
disabled={compareMode}
|
||||
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 disabled:opacity-50 bg-[var(--input-bg)] text-[var(--foreground)] border-[var(--input-border)]"
|
||||
>
|
||||
<option value="bidv">BIDV</option>
|
||||
<option value="tcb">Techcombank</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="currency" className="text-sm font-medium mb-1.5">Currency</label>
|
||||
<select
|
||||
id="currency"
|
||||
value={selectedCurrency}
|
||||
onChange={(e) => setSelectedCurrency(e.target.value)}
|
||||
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 bg-[var(--input-bg)] text-[var(--foreground)] border-[var(--input-border)]"
|
||||
>
|
||||
{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>
|
||||
<DatePicker
|
||||
id="start-date"
|
||||
selected={startDate}
|
||||
onChange={(date) => setStartDate(date)}
|
||||
maxDate={endDate || new Date()}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
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"
|
||||
placeholderText="Select start date"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="end-date" className="text-sm font-medium mb-1.5">End Date</label>
|
||||
<DatePicker
|
||||
id="end-date"
|
||||
selected={endDate}
|
||||
onChange={(date) => setEndDate(date)}
|
||||
minDate={startDate}
|
||||
maxDate={new Date()}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
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"
|
||||
placeholderText="Select end date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Toggles and date presets */}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1 border-t border-[var(--card-border)]">
|
||||
<label className="flex items-center gap-1.5 text-xs cursor-pointer text-[var(--muted)]">
|
||||
<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 text-[var(--muted)]">
|
||||
<input type="checkbox" checked={showChart} onChange={(e) => setShowChart(e.target.checked)} className="rounded" />
|
||||
Show Chart
|
||||
</label>
|
||||
<span className="text-[var(--card-border)]">|</span>
|
||||
{DATE_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
className={`text-xs px-3 py-1 rounded-md border transition-colors ${
|
||||
activePreset === preset.label
|
||||
? "bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 border-blue-300 dark:border-blue-700 font-medium"
|
||||
: "border-[var(--input-border)] text-[var(--muted)] hover:bg-blue-50 dark:hover:bg-blue-900/20"
|
||||
}`}
|
||||
onClick={() => { setStartDate(preset.start()); setEndDate(preset.end()); }}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Row 3: Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm: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 inline-flex items-center justify-center gap-2"
|
||||
onClick={handleFetch}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FetchIcon />
|
||||
{isLoading ? "Fetching..." : "Fetch Rates"}
|
||||
</button>
|
||||
<button
|
||||
className="bg-emerald-600 hover:bg-emerald-700 active:bg-emerald-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-emerald-500/40 inline-flex items-center justify-center gap-2"
|
||||
onClick={handleExportExcel}
|
||||
disabled={results.length === 0 || isLoading}
|
||||
>
|
||||
<ExcelIcon />
|
||||
Export Excel
|
||||
</button>
|
||||
<button
|
||||
className="text-sm font-medium px-5 py-2 rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-blue-500/30 inline-flex items-center justify-center gap-2 border-[var(--input-border)] text-[var(--foreground)]"
|
||||
onClick={handleExportCsv}
|
||||
disabled={results.length === 0 || isLoading}
|
||||
>
|
||||
<CsvIcon />
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error text-red-600 mb-2">{error}</div>}
|
||||
{isLoading && (
|
||||
<div className="loading mb-2">Loading data, please wait...</div>
|
||||
)}
|
||||
{showTable && (
|
||||
<div className="table-container overflow-x-auto mt-4">
|
||||
{selectedBank === "bidv" ? (
|
||||
<table className="min-w-full border border-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1">Date</th>
|
||||
<th className="border px-2 py-1">NameVI</th>
|
||||
<th className="border px-2 py-1">MuaTm</th>
|
||||
<th className="border px-2 py-1">MuaCk</th>
|
||||
<th className="border px-2 py-1">Currency</th>
|
||||
<th className="border px-2 py-1">NameEN</th>
|
||||
<th className="border px-2 py-1">Ban</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border px-2 py-1">{result.date}</td>
|
||||
<td className="border px-2 py-1">{result.nameVI}</td>
|
||||
<td className="border px-2 py-1">{result.muaTm}</td>
|
||||
<td className="border px-2 py-1">{result.muaCk}</td>
|
||||
<td className="border px-2 py-1">{result.currency}</td>
|
||||
<td className="border px-2 py-1">{result.nameEN}</td>
|
||||
<td className="border px-2 py-1">{result.ban}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="min-w-full border border-gray-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border px-2 py-1">Date</th>
|
||||
<th className="border px-2 py-1">Label</th>
|
||||
<th className="border px-2 py-1">AskRate</th>
|
||||
<th className="border px-2 py-1">BidRateCK</th>
|
||||
<th className="border px-2 py-1">BidRateTM</th>
|
||||
<th className="border px-2 py-1">SourceCurrency</th>
|
||||
<th className="border px-2 py-1">TargetCurrency</th>
|
||||
<th className="border px-2 py-1">AskRateTM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((result, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border px-2 py-1">{result.date}</td>
|
||||
<td className="border px-2 py-1">{result.label}</td>
|
||||
<td className="border px-2 py-1">{result.askRate}</td>
|
||||
<td className="border px-2 py-1">{result.bidRateCK}</td>
|
||||
<td className="border px-2 py-1">{result.bidRateTM}</td>
|
||||
<td className="border px-2 py-1">
|
||||
{result.sourceCurrency}
|
||||
</td>
|
||||
<td className="border px-2 py-1">
|
||||
{result.targetCurrency}
|
||||
</td>
|
||||
<td className="border px-2 py-1">{result.askRateTM}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div aria-live="polite" aria-atomic="true">
|
||||
{error && <ErrorMessage message={error} />}
|
||||
{isLoading && <LoadingSpinner progress={progress} />}
|
||||
{!isLoading && hasFetched && results.length === 0 && <EmptyState />}
|
||||
{!isLoading && hasFetched && results.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{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} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"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 }) {
|
||||
const chartData = [];
|
||||
|
||||
if (compareMode) {
|
||||
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;
|
||||
|
||||
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 bg-[var(--card-bg)] border border-[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"]}
|
||||
label={{ value: "VND", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "var(--muted)" } }}
|
||||
/>
|
||||
<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,44 @@
|
||||
"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 w-64">
|
||||
<div className="w-8 h-8 border-3 border-blue-200 border-t-blue-600 rounded-full animate-spin" />
|
||||
<p className="text-[var(--muted)] text-sm">
|
||||
{progress?.total ? `Fetching rates for ${progress.total} day${progress.total > 1 ? "s" : ""}...` : "Fetching exchange rates..."}
|
||||
</p>
|
||||
{progress?.total > 1 && (
|
||||
<div className="w-full h-1.5 rounded-full bg-[var(--card-border)] overflow-hidden">
|
||||
<div className="h-full bg-blue-500 rounded-full animate-pulse" style={{ width: "100%" }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorMessage({ message }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-lg px-4 py-3 text-sm mb-4 bg-red-50 dark:bg-red-950 border border-red-500/20 text-red-500">
|
||||
<svg aria-hidden="true" className="shrink-0 mt-0.5 w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
||||
</svg>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
return (
|
||||
<div className="text-center py-16 text-[var(--muted)]">
|
||||
<svg aria-hidden="true" className="mx-auto mb-4 w-12 h-12 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 0 0 6 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0 1 18 16.5h-2.25m-7.5 0h7.5m-7.5 0-1 3m8.5-3 1 3m0 0 .5 1.5m-.5-1.5h-9.5m0 0-.5 1.5M9 11.25v-5.5m3 5.5V8.25" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium mb-1">No exchange rate data</p>
|
||||
<p className="text-sm">Select a bank and date range, then click Fetch Rates to get started.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export function ComparisonTable({ results }) {
|
||||
const headers = [
|
||||
{ label: "Date", numeric: false },
|
||||
{ label: "Currency", numeric: false },
|
||||
{ label: "BIDV Buy TM", numeric: true },
|
||||
{ label: "BIDV Sell", numeric: true },
|
||||
{ label: "TCB Bid TM", numeric: true },
|
||||
{ label: "TCB Ask", numeric: true },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block overflow-x-auto rounded-lg border border-[var(--card-border)]">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-[var(--table-header-bg)]">
|
||||
{headers.map((h) => (
|
||||
<th
|
||||
key={h.label}
|
||||
className={`px-3 py-2.5 font-semibold whitespace-nowrap border-b border-[var(--card-border)] ${h.numeric ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{h.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((row, idx) => {
|
||||
const cells = [
|
||||
{ value: row.date, numeric: false },
|
||||
{ value: row.currency, numeric: false },
|
||||
{ value: row.bidvBuyTm, numeric: true },
|
||||
{ value: row.bidvSell, numeric: true },
|
||||
{ value: row.tcbBidTm, numeric: true },
|
||||
{ value: row.tcbAsk, numeric: true },
|
||||
];
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<td
|
||||
key={i}
|
||||
className={`px-3 py-2 whitespace-nowrap border-b border-[var(--card-border)] ${cell.numeric ? "text-right tabular-nums" : ""}`}
|
||||
>
|
||||
{cell.value || "-"}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden flex flex-col gap-2">
|
||||
{results.map((row, idx) => (
|
||||
<div key={idx} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
|
||||
<div className="flex justify-between mb-1.5">
|
||||
<span className="font-medium">{row.date}</span>
|
||||
<span className="text-[var(--muted)]">{row.currency}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
<div className="flex justify-between"><span className="text-[var(--muted)]">BIDV Buy</span><span className="tabular-nums">{row.bidvBuyTm || "-"}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--muted)]">TCB Bid</span><span className="tabular-nums">{row.tcbBidTm || "-"}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--muted)]">BIDV Sell</span><span className="tabular-nums">{row.bidvSell || "-"}</span></div>
|
||||
<div className="flex justify-between"><span className="text-[var(--muted)]">TCB Ask</span><span className="tabular-nums">{row.tcbAsk || "-"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RateTable({ results, selectedBank }) {
|
||||
const bidvHeaders = [
|
||||
{ label: "Date", numeric: false },
|
||||
{ label: "Name (VI)", numeric: false },
|
||||
{ label: "Buy TM", numeric: true },
|
||||
{ label: "Buy CK", numeric: true },
|
||||
{ label: "Currency", numeric: false },
|
||||
{ label: "Name (EN)", numeric: false },
|
||||
{ label: "Sell", numeric: true },
|
||||
];
|
||||
const tcbHeaders = [
|
||||
{ label: "Date", numeric: false },
|
||||
{ label: "Label", numeric: false },
|
||||
{ label: "Ask Rate", numeric: true },
|
||||
{ label: "Bid CK", numeric: true },
|
||||
{ label: "Bid TM", numeric: true },
|
||||
{ label: "Source", numeric: false },
|
||||
{ label: "Target", numeric: false },
|
||||
{ label: "Ask TM", numeric: true },
|
||||
];
|
||||
|
||||
const headers = selectedBank === "bidv" ? bidvHeaders : tcbHeaders;
|
||||
|
||||
const getRowCells = (row) => {
|
||||
if (selectedBank === "bidv") {
|
||||
return [
|
||||
{ value: row.date, numeric: false },
|
||||
{ value: row.nameVI, numeric: false },
|
||||
{ value: row.muaTm, numeric: true },
|
||||
{ value: row.muaCk, numeric: true },
|
||||
{ value: row.currency, numeric: false },
|
||||
{ value: row.nameEN, numeric: false },
|
||||
{ value: row.ban, numeric: true },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ value: row.date, numeric: false },
|
||||
{ value: row.label, numeric: false },
|
||||
{ value: row.askRate, numeric: true },
|
||||
{ value: row.bidRateCK, numeric: true },
|
||||
{ value: row.bidRateTM, numeric: true },
|
||||
{ value: row.sourceCurrency, numeric: false },
|
||||
{ value: row.targetCurrency, numeric: false },
|
||||
{ value: row.askRateTM, numeric: true },
|
||||
];
|
||||
};
|
||||
|
||||
const getMobileCard = (row) => {
|
||||
if (selectedBank === "bidv") {
|
||||
return { title: row.nameVI || row.nameEN, subtitle: `${row.currency} - ${row.date}`, fields: [
|
||||
{ label: "Buy TM", value: row.muaTm }, { label: "Buy CK", value: row.muaCk }, { label: "Sell", value: row.ban },
|
||||
]};
|
||||
}
|
||||
return { title: row.label, subtitle: `${row.sourceCurrency} - ${row.date}`, fields: [
|
||||
{ label: "Bid TM", value: row.bidRateTM }, { label: "Bid CK", value: row.bidRateCK },
|
||||
{ label: "Ask", value: row.askRate }, { label: "Ask TM", value: row.askRateTM },
|
||||
]};
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop table */}
|
||||
<div className="hidden sm:block overflow-x-auto rounded-lg border border-[var(--card-border)]">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-[var(--table-header-bg)]">
|
||||
{headers.map((h) => (
|
||||
<th
|
||||
key={h.label}
|
||||
className={`px-3 py-2.5 font-semibold whitespace-nowrap border-b border-[var(--card-border)] ${h.numeric ? "text-right" : "text-left"}`}
|
||||
>
|
||||
{h.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.map((row, idx) => {
|
||||
const cells = getRowCells(row);
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
className={`transition-colors hover:!bg-[var(--table-row-hover)] ${idx % 2 !== 0 ? "bg-[var(--table-row-stripe)]" : ""}`}
|
||||
>
|
||||
{cells.map((cell, i) => (
|
||||
<td
|
||||
key={i}
|
||||
className={`px-3 py-2 whitespace-nowrap border-b border-[var(--card-border)] ${cell.numeric ? "text-right tabular-nums" : ""}`}
|
||||
>
|
||||
{cell.value}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* Mobile cards */}
|
||||
<div className="sm:hidden flex flex-col gap-2">
|
||||
{results.map((row, idx) => {
|
||||
const card = getMobileCard(row);
|
||||
return (
|
||||
<div key={idx} className="rounded-lg border border-[var(--card-border)] p-3 bg-[var(--card-bg)] text-sm">
|
||||
<div className="flex justify-between mb-1.5">
|
||||
<span className="font-medium">{card.title}</span>
|
||||
<span className="text-[var(--muted)] text-xs">{card.subtitle}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{card.fields.map((f, i) => (
|
||||
<div key={i} className="flex justify-between">
|
||||
<span className="text-[var(--muted)]">{f.label}</span>
|
||||
<span className="tabular-nums">{f.value || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+92
-3
@@ -1,21 +1,53 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--background: #f8fafc;
|
||||
--foreground: #171717;
|
||||
--card-bg: #ffffff;
|
||||
--card-border: #e2e8f0;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cbd5e1;
|
||||
--input-focus: #3b82f6;
|
||||
--table-header-bg: #f1f5f9;
|
||||
--table-row-hover: #e2e8f0;
|
||||
--table-row-stripe: #f1f5f9;
|
||||
--muted: #64748b;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
/* Dark mode: explicit toggle OR system preference (unless overridden to light) */
|
||||
:root[data-theme="dark"] {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card-bg: #18181b;
|
||||
--card-border: #27272a;
|
||||
--input-bg: #1e1e22;
|
||||
--input-border: #3f3f46;
|
||||
--input-focus: #60a5fa;
|
||||
--table-header-bg: #1e1e22;
|
||||
--table-row-hover: #27272a;
|
||||
--table-row-stripe: #141416;
|
||||
--muted: #a1a1aa;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
:root:not([data-theme="light"]) {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--card-bg: #18181b;
|
||||
--card-border: #27272a;
|
||||
--input-bg: #1e1e22;
|
||||
--input-border: #3f3f46;
|
||||
--input-focus: #60a5fa;
|
||||
--table-header-bg: #1e1e22;
|
||||
--table-row-hover: #27272a;
|
||||
--table-row-stripe: #141416;
|
||||
--muted: #a1a1aa;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +56,60 @@ body {
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Date picker dark mode support */
|
||||
.react-datepicker-wrapper {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.react-datepicker__input-container input {
|
||||
background: var(--input-bg) !important;
|
||||
color: var(--foreground) !important;
|
||||
border-color: var(--input-border) !important;
|
||||
}
|
||||
|
||||
.react-datepicker {
|
||||
background-color: var(--card-bg) !important;
|
||||
border-color: var(--card-border) !important;
|
||||
color: var(--foreground) !important;
|
||||
font-family: inherit !important;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__header {
|
||||
background-color: var(--table-header-bg) !important;
|
||||
border-bottom-color: var(--card-border) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__current-month,
|
||||
.react-datepicker__day-name {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day {
|
||||
color: var(--foreground) !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day:hover {
|
||||
background-color: var(--table-row-hover) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day--selected,
|
||||
.react-datepicker__day--keyboard-selected {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.react-datepicker__day--disabled {
|
||||
color: var(--muted) !important;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.react-datepicker__navigation-icon::before {
|
||||
border-color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.react-datepicker__triangle {
|
||||
display: none !important;
|
||||
}
|
||||
+5
-10
@@ -1,4 +1,4 @@
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Analytics } from "@vercel/analytics/react";
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next';
|
||||
@@ -8,21 +8,16 @@ const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Exchange Rate Export",
|
||||
description: "Fetch exchange rates from BIDV or Techcombank and export to CSV",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="vi">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Analytics />
|
||||
|
||||
+1
-19
@@ -1,28 +1,10 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import ExchangeRateViewer from "./components/ExchangeRateViewer";
|
||||
import { format, subDays } from "date-fns";
|
||||
|
||||
export default function Home() {
|
||||
const [defaultStartDate, setDefaultStartDate] = useState("");
|
||||
const [defaultEndDate, setDefaultEndDate] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const today = new Date();
|
||||
const weekAgo = subDays(today, 7);
|
||||
setDefaultStartDate(format(weekAgo, "yyyy-MM-dd"));
|
||||
setDefaultEndDate(format(today, "yyyy-MM-dd"));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full flex flex-col items-center justify-center bg-background text-foreground font-[family-name:var(--font-geist-sans)]">
|
||||
<main className="w-full flex flex-col items-center">
|
||||
{defaultStartDate && defaultEndDate && (
|
||||
<ExchangeRateViewer
|
||||
defaultStartDate={defaultStartDate}
|
||||
defaultEndDate={defaultEndDate}
|
||||
/>
|
||||
)}
|
||||
<ExchangeRateViewer />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user