Import CSV
This commit is contained in:
parent
5b558716c8
commit
dd0f1380d4
27
bun.lock
27
bun.lock
|
|
@ -14,7 +14,8 @@
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
|
|
@ -40,7 +41,9 @@
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"vite": "^7.0.4",
|
"vite": "^7.0.4",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.19.0",
|
||||||
|
|
@ -269,6 +272,8 @@
|
||||||
|
|
||||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||||
|
|
||||||
|
"@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
|
||||||
|
|
||||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||||
|
|
||||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
|
||||||
|
|
@ -449,6 +454,8 @@
|
||||||
|
|
||||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="],
|
||||||
|
|
||||||
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
|
||||||
|
|
||||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
@ -501,6 +508,8 @@
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001735", "", {}, "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w=="],
|
||||||
|
|
||||||
|
"cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="],
|
||||||
|
|
||||||
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||||
|
|
||||||
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
|
||||||
|
|
@ -513,6 +522,8 @@
|
||||||
|
|
||||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||||
|
|
||||||
|
"codepage": ["codepage@1.15.0", "", {}, "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA=="],
|
||||||
|
|
||||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
@ -525,6 +536,8 @@
|
||||||
|
|
||||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||||
|
|
||||||
|
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
|
||||||
|
|
||||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
@ -633,6 +646,8 @@
|
||||||
|
|
||||||
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
|
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
|
||||||
|
|
||||||
|
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
@ -959,6 +974,8 @@
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"ssf": ["ssf@0.11.2", "", { "dependencies": { "frac": "~1.1.2" } }, "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g=="],
|
||||||
|
|
||||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||||
|
|
||||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
@ -1031,6 +1048,8 @@
|
||||||
|
|
||||||
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
"use-sync-external-store": ["use-sync-external-store@1.5.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A=="],
|
||||||
|
|
||||||
|
"vaul": ["vaul@1.1.2", "", { "dependencies": { "@radix-ui/react-dialog": "^1.1.1" }, "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA=="],
|
||||||
|
|
||||||
"vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="],
|
"vite": ["vite@7.1.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": "bin/vite.js" }, "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ=="],
|
||||||
|
|
||||||
"vite-plugin-full-reload": ["vite-plugin-full-reload@1.2.0", "", { "dependencies": { "picocolors": "^1.0.0", "picomatch": "^2.3.1" } }, "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA=="],
|
"vite-plugin-full-reload": ["vite-plugin-full-reload@1.2.0", "", { "dependencies": { "picocolors": "^1.0.0", "picomatch": "^2.3.1" } }, "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA=="],
|
||||||
|
|
@ -1045,10 +1064,16 @@
|
||||||
|
|
||||||
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
"which-typed-array": ["which-typed-array@1.1.19", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw=="],
|
||||||
|
|
||||||
|
"wmf": ["wmf@1.0.2", "", {}, "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw=="],
|
||||||
|
|
||||||
|
"word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="],
|
||||||
|
|
||||||
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
|
"xlsx": ["xlsx@0.18.5", "", { "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" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.5",
|
"@radix-ui/react-navigation-menu": "^1.2.5",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-separator": "^1.1.2",
|
"@radix-ui/react-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^1.2.3",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"@radix-ui/react-toggle": "^1.1.2",
|
"@radix-ui/react-toggle": "^1.1.2",
|
||||||
|
|
@ -63,7 +64,9 @@
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^7.0.4"
|
"vaul": "^1.1.2",
|
||||||
|
"vite": "^7.0.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||||
import { EncryptionKeyButton } from '@/components/encryption-key-button';
|
import { EncryptionKeyButton } from '@/components/encryption-key-button';
|
||||||
import { SyncStatusButton } from '@/components/sync-status-button';
|
import { SyncStatusButton } from '@/components/sync-status-button';
|
||||||
|
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
|
||||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ export function AppSidebarHeader({
|
||||||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<ImportTransactionsButton />
|
||||||
<SyncStatusButton />
|
<SyncStatusButton />
|
||||||
<EncryptionKeyButton />
|
<EncryptionKeyButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -50,9 +50,6 @@ export function SyncStatusButton() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getVariant = () => {
|
const getVariant = () => {
|
||||||
if (syncStatus === 'error' || !isOnline) {
|
|
||||||
return 'destructive' as const;
|
|
||||||
}
|
|
||||||
return 'ghost' as const;
|
return 'ghost' as const;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -65,6 +62,7 @@ export function SyncStatusButton() {
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={sync}
|
onClick={sync}
|
||||||
disabled={syncStatus === 'syncing' || !isOnline}
|
disabled={syncStatus === 'syncing' || !isOnline}
|
||||||
|
className={`${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
|
||||||
>
|
>
|
||||||
{getIcon()}
|
{getIcon()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -73,7 +71,7 @@ export function SyncStatusButton() {
|
||||||
<p>{getTooltipText()}</p>
|
<p>{getTooltipText()}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider >
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { accountSyncService } from '@/services/account-sync';
|
||||||
|
import { type Account } from '@/types/account';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { EncryptedText } from '@/components/encrypted-text';
|
||||||
|
import { Building2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ImportStepAccountProps {
|
||||||
|
selectedAccountId: number | null;
|
||||||
|
onAccountSelect: (accountId: number) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportStepAccount({
|
||||||
|
selectedAccountId,
|
||||||
|
onAccountSelect,
|
||||||
|
onNext,
|
||||||
|
}: ImportStepAccountProps) {
|
||||||
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAccounts = async () => {
|
||||||
|
try {
|
||||||
|
const data = await accountSyncService.getAll();
|
||||||
|
setAccounts(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load accounts:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadAccounts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading accounts...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No accounts found. Please create an account first.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<RadioGroup
|
||||||
|
value={selectedAccountId?.toString()}
|
||||||
|
onValueChange={(value) => onAccountSelect(Number(value))}
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{accounts.map((account) => (
|
||||||
|
<Label
|
||||||
|
htmlFor={`account-${account.id}`}
|
||||||
|
key={account.id}
|
||||||
|
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={account.id.toString()}
|
||||||
|
id={`account-${account.id}`}
|
||||||
|
/>
|
||||||
|
{account.bank.logo ? (
|
||||||
|
<img
|
||||||
|
src={account.bank.logo}
|
||||||
|
alt={account.bank.name}
|
||||||
|
className="h-10 w-10 rounded-md object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted">
|
||||||
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="flex flex-1 flex-col gap-1"
|
||||||
|
>
|
||||||
|
<span className="font-medium">
|
||||||
|
<EncryptedText
|
||||||
|
encryptedText={account.name}
|
||||||
|
iv={account.name_iv}
|
||||||
|
fallback="[Encrypted Account]"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{account.bank.name} • {account.currency_code}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!selectedAccountId}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { DateFormat, type ColumnMapping, type ColumnOption, type ParsedRow } from '@/types/import';
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
|
import { parseDate, parseAmount } from '@/lib/file-parser';
|
||||||
|
|
||||||
|
interface ImportStepMappingProps {
|
||||||
|
columnOptions: ColumnOption[];
|
||||||
|
columnMapping: ColumnMapping;
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
dateFormatDetected: boolean;
|
||||||
|
parsedData: ParsedRow[];
|
||||||
|
currencyCode: string;
|
||||||
|
onMappingChange: (field: keyof ColumnMapping, value: string) => void;
|
||||||
|
onDateFormatChange: (format: DateFormat) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportStepMapping({
|
||||||
|
columnOptions,
|
||||||
|
columnMapping,
|
||||||
|
dateFormat,
|
||||||
|
dateFormatDetected,
|
||||||
|
parsedData,
|
||||||
|
currencyCode,
|
||||||
|
onMappingChange,
|
||||||
|
onDateFormatChange,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: ImportStepMappingProps) {
|
||||||
|
const isValid =
|
||||||
|
columnMapping.transaction_date &&
|
||||||
|
columnMapping.description &&
|
||||||
|
columnMapping.amount;
|
||||||
|
|
||||||
|
const previewTransactions = parsedData.slice(0, 3).map((row) => {
|
||||||
|
const date = columnMapping.transaction_date
|
||||||
|
? parseDate(row[columnMapping.transaction_date] as string | number, dateFormat)
|
||||||
|
: null;
|
||||||
|
const description = columnMapping.description
|
||||||
|
? String(row[columnMapping.description] || '')
|
||||||
|
: '';
|
||||||
|
const amount = columnMapping.amount
|
||||||
|
? parseAmount(row[columnMapping.amount] as string | number)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: date ? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Invalid date',
|
||||||
|
description: description || 'No description',
|
||||||
|
amount: amount !== null
|
||||||
|
? new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyCode,
|
||||||
|
}).format(amount)
|
||||||
|
: 'Invalid amount',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date-column">
|
||||||
|
Transaction Date <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={columnMapping.transaction_date || ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onMappingChange('transaction_date', value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="date-column">
|
||||||
|
<SelectValue placeholder="Select date column" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description-column">
|
||||||
|
Description <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={columnMapping.description || ''}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onMappingChange('description', value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="description-column">
|
||||||
|
<SelectValue placeholder="Select description column" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="amount-column">
|
||||||
|
Amount <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={columnMapping.amount || ''}
|
||||||
|
onValueChange={(value) => onMappingChange('amount', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="amount-column">
|
||||||
|
<SelectValue placeholder="Select amount column" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{columnOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isValid && previewTransactions.length > 0 && (
|
||||||
|
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
|
||||||
|
<Label className="text-xs opacity-50 pl-2 font-light uppercase tracking-widest">Preview (first 3 rows)</Label>
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
{previewTransactions.map((transaction, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between rounded-md bg-background p-3 text-sm">
|
||||||
|
<div className="flex flex-1 items-center gap-3">
|
||||||
|
<span className="text-muted-foreground">{transaction.date}</span>
|
||||||
|
<span className="flex-1 truncate">{transaction.description}</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono font-medium">{transaction.amount}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!dateFormatDetected && (
|
||||||
|
<div className="space-y-3 rounded-lg border p-4">
|
||||||
|
<Label>Date Format</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={dateFormat}
|
||||||
|
onValueChange={(value) => onDateFormatChange(value as DateFormat)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={DateFormat.YearMonthDay}
|
||||||
|
id="format-ymd"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="format-ymd" className="cursor-pointer font-normal">
|
||||||
|
YYYY-MM-DD (e.g., 2024-12-31)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={DateFormat.MonthDayYear}
|
||||||
|
id="format-mdy"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="format-mdy" className="cursor-pointer font-normal">
|
||||||
|
MM-DD-YYYY (e.g., 12-31-2024)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value={DateFormat.DayMonthYear}
|
||||||
|
id="format-dmy"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="format-dmy" className="cursor-pointer font-normal">
|
||||||
|
DD-MM-YYYY (e.g., 31-12-2024)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onNext} disabled={!isValid}>
|
||||||
|
Preview Transactions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { type ParsedTransaction } from '@/types/import';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
interface ImportStepPreviewProps {
|
||||||
|
transactions: ParsedTransaction[];
|
||||||
|
currencyCode: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
isImporting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportStepPreview({
|
||||||
|
transactions,
|
||||||
|
currencyCode,
|
||||||
|
onConfirm,
|
||||||
|
onBack,
|
||||||
|
isImporting,
|
||||||
|
}: ImportStepPreviewProps) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const newCount = transactions.filter((t) => !t.isDuplicate).length;
|
||||||
|
const duplicateCount = transactions.filter((t) => t.isDuplicate).length;
|
||||||
|
return { newCount, duplicateCount, total: transactions.length };
|
||||||
|
}, [transactions]);
|
||||||
|
|
||||||
|
const formatAmount = (amount: number): string => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currencyCode,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string): string => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex gap-4 rounded-lg border bg-muted/50 p-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Total</p>
|
||||||
|
<p className="text-2xl font-bold">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">New</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{stats.newCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-muted-foreground">Duplicates</p>
|
||||||
|
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">
|
||||||
|
{stats.duplicateCount}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.newCount === 0 && stats.duplicateCount > 0 && (
|
||||||
|
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
All transactions appear to be duplicates. No new transactions will be imported.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="max-h-[400px] overflow-auto rounded-lg border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Description</TableHead>
|
||||||
|
<TableHead className="text-right">Amount</TableHead>
|
||||||
|
<TableHead className="text-center">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{transactions.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
No valid transactions found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
transactions.map((transaction, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={index}
|
||||||
|
className={
|
||||||
|
transaction.isDuplicate ? 'opacity-60' : ''
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className="whitespace-nowrap">
|
||||||
|
{formatDate(transaction.transaction_date)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">
|
||||||
|
{transaction.description}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{formatAmount(transaction.amount)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{transaction.isDuplicate ? (
|
||||||
|
<Badge variant="secondary">Duplicate</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="default">New</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={onBack} disabled={isImporting}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isImporting || stats.newCount === 0}
|
||||||
|
>
|
||||||
|
{isImporting
|
||||||
|
? 'Importing...'
|
||||||
|
: `Import ${stats.newCount} Transaction${stats.newCount !== 1 ? 's' : ''}`}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Upload, FileSpreadsheet, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ImportStepUploadProps {
|
||||||
|
file: File | null;
|
||||||
|
onFileSelect: (file: File) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportStepUpload({
|
||||||
|
file,
|
||||||
|
onFileSelect,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
}: ImportStepUploadProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isValidFile = useCallback((file: File | null | undefined): boolean => {
|
||||||
|
if (!file || !file.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const validExtensions = ['.csv', '.xls', '.xlsx'];
|
||||||
|
const lastDotIndex = file.name.lastIndexOf('.');
|
||||||
|
if (lastDotIndex === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const extension = file.name.toLowerCase().slice(lastDotIndex);
|
||||||
|
return validExtensions.includes(extension);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const droppedFile = e.dataTransfer.files[0];
|
||||||
|
if (droppedFile && isValidFile(droppedFile)) {
|
||||||
|
onFileSelect(droppedFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFileSelect, isValidFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleFileInput = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = e.target.files?.[0];
|
||||||
|
if (selectedFile && isValidFile(selectedFile)) {
|
||||||
|
onFileSelect(selectedFile);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onFileSelect, isValidFile]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
|
||||||
|
isDragging && 'border-primary bg-accent',
|
||||||
|
!isDragging && 'border-border hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
{!file ? (
|
||||||
|
<>
|
||||||
|
<Upload className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||||
|
<p className="mb-2 text-sm font-medium">
|
||||||
|
Drop your file here, or click to browse
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 text-xs text-muted-foreground">
|
||||||
|
Supports CSV, XLS, and XLSX files
|
||||||
|
</p>
|
||||||
|
<Button asChild variant="secondary">
|
||||||
|
<label className="cursor-pointer">
|
||||||
|
Browse Files
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".csv,.xls,.xlsx"
|
||||||
|
onChange={handleFileInput}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex w-full items-center gap-4 rounded-lg border bg-card p-4">
|
||||||
|
<FileSpreadsheet className="h-8 w-8 text-primary" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="font-medium">{file.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
|
||||||
|
if (input) input.value = '';
|
||||||
|
onFileSelect(undefined as unknown as File);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onNext} disabled={!file}>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Upload } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { ImportTransactionsDrawer } from './import-transactions-drawer';
|
||||||
|
|
||||||
|
export function ImportTransactionsButton() {
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="h-9"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
aria-label="Import transactions"
|
||||||
|
>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Import transactions from CSV/Excel</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<ImportTransactionsDrawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onOpenChange={setDrawerOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,386 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
} from '@/components/ui/drawer';
|
||||||
|
import { ImportStepAccount } from './import-step-account';
|
||||||
|
import { ImportStepUpload } from './import-step-upload';
|
||||||
|
import { ImportStepMapping } from './import-step-mapping';
|
||||||
|
import { ImportStepPreview } from './import-step-preview';
|
||||||
|
import {
|
||||||
|
ImportStep,
|
||||||
|
DateFormat,
|
||||||
|
type ImportState,
|
||||||
|
type ColumnMapping,
|
||||||
|
} from '@/types/import';
|
||||||
|
import { type Account } from '@/types/account';
|
||||||
|
import {
|
||||||
|
parseFile,
|
||||||
|
autoDetectColumns,
|
||||||
|
convertRowsToTransactions,
|
||||||
|
} from '@/lib/file-parser';
|
||||||
|
import { transactionSyncService } from '@/services/transaction-sync';
|
||||||
|
import { accountSyncService } from '@/services/account-sync';
|
||||||
|
import { useSyncContext } from '@/contexts/sync-context';
|
||||||
|
import { useEncryptionKey } from '@/contexts/encryption-key-context';
|
||||||
|
import AlertError from '@/components/alert-error';
|
||||||
|
|
||||||
|
interface ImportTransactionsDrawerProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportTransactionsDrawer({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ImportTransactionsDrawerProps) {
|
||||||
|
const { sync } = useSyncContext();
|
||||||
|
const { isKeySet } = useEncryptionKey();
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
|
||||||
|
const [state, setState] = useState<ImportState>({
|
||||||
|
step: ImportStep.SelectAccount,
|
||||||
|
selectedAccountId: null,
|
||||||
|
file: null,
|
||||||
|
parsedData: [],
|
||||||
|
columnHeaders: [],
|
||||||
|
columnOptions: [],
|
||||||
|
columnMapping: {
|
||||||
|
transaction_date: null,
|
||||||
|
description: null,
|
||||||
|
amount: null,
|
||||||
|
},
|
||||||
|
dateFormat: DateFormat.YearMonthDay,
|
||||||
|
dateFormatDetected: false,
|
||||||
|
transactions: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.selectedAccountId) {
|
||||||
|
accountSyncService.getById(state.selectedAccountId).then((account) => {
|
||||||
|
if (account) {
|
||||||
|
setSelectedAccount(account);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [state.selectedAccountId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setState({
|
||||||
|
step: ImportStep.SelectAccount,
|
||||||
|
selectedAccountId: null,
|
||||||
|
file: null,
|
||||||
|
parsedData: [],
|
||||||
|
columnHeaders: [],
|
||||||
|
columnOptions: [],
|
||||||
|
columnMapping: {
|
||||||
|
transaction_date: null,
|
||||||
|
description: null,
|
||||||
|
amount: null,
|
||||||
|
},
|
||||||
|
dateFormat: DateFormat.YearMonthDay,
|
||||||
|
dateFormatDetected: false,
|
||||||
|
transactions: [],
|
||||||
|
});
|
||||||
|
setIsImporting(false);
|
||||||
|
setError(null);
|
||||||
|
setSelectedAccount(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleAccountSelect = (accountId: number) => {
|
||||||
|
setState((prev) => ({ ...prev, selectedAccountId: accountId }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = async (file: File) => {
|
||||||
|
if (!file) {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
file: null,
|
||||||
|
parsedData: [],
|
||||||
|
columnHeaders: [],
|
||||||
|
columnOptions: [],
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { headers, data, columns, headerRowIndex } = await parseFile(file);
|
||||||
|
const autoMapping = autoDetectColumns(headers);
|
||||||
|
|
||||||
|
const columnOptions = headers.map((header, index) => {
|
||||||
|
const columnData = columns[index] || [];
|
||||||
|
const middleIndex = Math.floor(columnData.length / 2);
|
||||||
|
const examples = columnData
|
||||||
|
.slice(Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3)
|
||||||
|
.filter(cell => cell !== null && cell !== undefined && String(cell).trim() !== '')
|
||||||
|
.map(cell => String(cell))
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: header,
|
||||||
|
label: header,
|
||||||
|
examples,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let detectedFormat = DateFormat.YearMonthDay;
|
||||||
|
let formatDetected = false;
|
||||||
|
if (autoMapping.transaction_date) {
|
||||||
|
const { autoDetectDateFormat } = await import('@/lib/file-parser');
|
||||||
|
const detected = autoDetectDateFormat(data, autoMapping.transaction_date);
|
||||||
|
if (detected) {
|
||||||
|
detectedFormat = detected;
|
||||||
|
formatDetected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
file,
|
||||||
|
parsedData: data,
|
||||||
|
columnHeaders: headers,
|
||||||
|
columnOptions,
|
||||||
|
columnMapping: autoMapping,
|
||||||
|
dateFormat: detectedFormat,
|
||||||
|
dateFormatDetected: formatDetected,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to parse file'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMappingChange = (
|
||||||
|
field: keyof ColumnMapping,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
columnMapping: {
|
||||||
|
...prev.columnMapping,
|
||||||
|
[field]: value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateFormatChange = (format: DateFormat) => {
|
||||||
|
setState((prev) => ({ ...prev, dateFormat: format }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreviewTransactions = async () => {
|
||||||
|
try {
|
||||||
|
const parsedTransactions = convertRowsToTransactions(
|
||||||
|
state.parsedData,
|
||||||
|
state.columnMapping,
|
||||||
|
state.dateFormat
|
||||||
|
);
|
||||||
|
|
||||||
|
const account = await accountSyncService.getById(
|
||||||
|
state.selectedAccountId!
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
setError('Selected account not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transactionsWithDuplicateCheck = await Promise.all(
|
||||||
|
parsedTransactions.map(async (transaction) => {
|
||||||
|
const isDuplicate = await transactionSyncService.isDuplicate(
|
||||||
|
account.id,
|
||||||
|
transaction.transaction_date,
|
||||||
|
transaction.amount,
|
||||||
|
transaction.description
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...transaction,
|
||||||
|
isDuplicate,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
transactions: transactionsWithDuplicateCheck,
|
||||||
|
step: ImportStep.Preview,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to process transactions'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmImport = async () => {
|
||||||
|
if (!isKeySet) {
|
||||||
|
setError('Please unlock your encryption key first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsImporting(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!selectedAccount) {
|
||||||
|
throw new Error('Selected account not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTransactions = state.transactions.filter(
|
||||||
|
(t) => !t.isDuplicate
|
||||||
|
);
|
||||||
|
|
||||||
|
const transactionsToImport = await Promise.all(
|
||||||
|
newTransactions.map(async (transaction) => {
|
||||||
|
const { encrypted, iv } =
|
||||||
|
await transactionSyncService.encryptDescription(
|
||||||
|
transaction.description
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user_id: (selectedAccount as Account & { user_id?: number }).user_id || 0,
|
||||||
|
account_id: selectedAccount.id,
|
||||||
|
category_id: null,
|
||||||
|
description: encrypted,
|
||||||
|
description_iv: iv,
|
||||||
|
transaction_date: transaction.transaction_date,
|
||||||
|
amount: transaction.amount.toString(),
|
||||||
|
currency_code: selectedAccount.currency_code,
|
||||||
|
notes: null,
|
||||||
|
notes_iv: null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await transactionSyncService.createMany(transactionsToImport);
|
||||||
|
|
||||||
|
sync();
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: 'Failed to import transactions'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveToStep = (step: ImportStep) => {
|
||||||
|
setState((prev) => ({ ...prev, step }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepInfo = () => {
|
||||||
|
switch (state.step) {
|
||||||
|
case ImportStep.SelectAccount:
|
||||||
|
return {
|
||||||
|
title: 'Select Account',
|
||||||
|
description: 'Choose the account where transactions will be imported',
|
||||||
|
};
|
||||||
|
case ImportStep.UploadFile:
|
||||||
|
return {
|
||||||
|
title: 'Upload File',
|
||||||
|
description: 'Drop your CSV or Excel file here, or click to browse',
|
||||||
|
};
|
||||||
|
case ImportStep.MapColumns:
|
||||||
|
return {
|
||||||
|
title: 'Map Columns',
|
||||||
|
description: 'Match your file columns to transaction fields',
|
||||||
|
};
|
||||||
|
case ImportStep.Preview:
|
||||||
|
return {
|
||||||
|
title: 'Preview Transactions',
|
||||||
|
description: 'Review transactions before importing',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: 'Import Transactions',
|
||||||
|
description: 'Import transactions from CSV or Excel files',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (state.step) {
|
||||||
|
case ImportStep.SelectAccount:
|
||||||
|
return (
|
||||||
|
<ImportStepAccount
|
||||||
|
selectedAccountId={state.selectedAccountId}
|
||||||
|
onAccountSelect={handleAccountSelect}
|
||||||
|
onNext={() => moveToStep(ImportStep.UploadFile)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case ImportStep.UploadFile:
|
||||||
|
return (
|
||||||
|
<ImportStepUpload
|
||||||
|
file={state.file}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onNext={() => moveToStep(ImportStep.MapColumns)}
|
||||||
|
onBack={() => moveToStep(ImportStep.SelectAccount)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case ImportStep.MapColumns:
|
||||||
|
return (
|
||||||
|
<ImportStepMapping
|
||||||
|
columnOptions={state.columnOptions}
|
||||||
|
columnMapping={state.columnMapping}
|
||||||
|
dateFormat={state.dateFormat}
|
||||||
|
dateFormatDetected={state.dateFormatDetected}
|
||||||
|
parsedData={state.parsedData}
|
||||||
|
currencyCode={selectedAccount?.currency_code || 'USD'}
|
||||||
|
onMappingChange={handleMappingChange}
|
||||||
|
onDateFormatChange={handleDateFormatChange}
|
||||||
|
onNext={handlePreviewTransactions}
|
||||||
|
onBack={() => moveToStep(ImportStep.UploadFile)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case ImportStep.Preview:
|
||||||
|
return (
|
||||||
|
<ImportStepPreview
|
||||||
|
transactions={state.transactions}
|
||||||
|
currencyCode={selectedAccount?.currency_code || 'USD'}
|
||||||
|
onConfirm={handleConfirmImport}
|
||||||
|
onBack={() => moveToStep(ImportStep.MapColumns)}
|
||||||
|
isImporting={isImporting}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepInfo = getStepInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
|
||||||
|
<div className="mx-auto w-full max-w-3xl overflow-y-auto p-6">
|
||||||
|
<DrawerHeader className="px-0">
|
||||||
|
<DrawerTitle>{stepInfo.title}</DrawerTitle>
|
||||||
|
<DrawerDescription>{stepInfo.description}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<AlertError errors={[error]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4">{renderStep()}</div>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import { Drawer as DrawerPrimitive } from "vaul"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Drawer({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||||
|
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||||
|
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||||
|
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||||
|
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
data-slot="drawer-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<DrawerPortal data-slot="drawer-portal">
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
data-slot="drawer-content"
|
||||||
|
className={cn(
|
||||||
|
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||||
|
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||||
|
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||||
|
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||||
|
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-header"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="drawer-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
data-slot="drawer-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DrawerDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
data-slot="drawer-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import * as React from "react"
|
||||||
|
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||||
|
import { CircleIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import';
|
||||||
|
|
||||||
|
function detectHeaderRow(columns: unknown[][]): number {
|
||||||
|
if (!columns || columns.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstRowWithValue = columns.map((column) =>
|
||||||
|
column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1)
|
||||||
|
);
|
||||||
|
|
||||||
|
const percentages = [0.95, 0.75];
|
||||||
|
|
||||||
|
for (const minPercentage of percentages) {
|
||||||
|
const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
for (const rowNumber of uniqueRows) {
|
||||||
|
if (rowNumber === -1) continue;
|
||||||
|
|
||||||
|
const columnsWithValues = columns.filter((column) => {
|
||||||
|
return column[rowNumber] !== undefined &&
|
||||||
|
column[rowNumber] !== null &&
|
||||||
|
String(column[rowNumber]).length > 1;
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
if (columnsWithValues / columns.length >= minPercentage) {
|
||||||
|
return rowNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
try {
|
||||||
|
const data = e.target?.result;
|
||||||
|
if (!data) {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workbook = XLSX.read(data, { type: 'binary' });
|
||||||
|
const firstSheetName = workbook.SheetNames[0];
|
||||||
|
const worksheet = workbook.Sheets[firstSheetName];
|
||||||
|
|
||||||
|
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][];
|
||||||
|
|
||||||
|
if (jsonData.length === 0) {
|
||||||
|
reject(new Error('File is empty'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0));
|
||||||
|
const columns: unknown[][] = [];
|
||||||
|
|
||||||
|
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
|
||||||
|
const columnData = jsonData.map(row =>
|
||||||
|
Array.isArray(row) ? row[colIndex] : undefined
|
||||||
|
);
|
||||||
|
columns.push(columnData);
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerRowIndex = detectHeaderRow(columns);
|
||||||
|
|
||||||
|
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
|
||||||
|
|
||||||
|
const headers = columns.map((column, index) => {
|
||||||
|
const headerValue = column[headerRowIndex];
|
||||||
|
const headerStr = String(headerValue || '').trim();
|
||||||
|
|
||||||
|
if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) {
|
||||||
|
return headerStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
return letters[index] || `Column ${index + 1}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][];
|
||||||
|
|
||||||
|
const parsedData: ParsedRow[] = dataRows
|
||||||
|
.filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== ''))
|
||||||
|
.map(row => {
|
||||||
|
const obj: ParsedRow = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
if (header) {
|
||||||
|
const value = row[index];
|
||||||
|
obj[header] = (value === null || value === undefined) ? null : value as string | number;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return obj;
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve({ headers, data: parsedData, columns, headerRowIndex });
|
||||||
|
} catch {
|
||||||
|
reject(new Error('Failed to parse file'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
reject(new Error('Failed to read file'));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null {
|
||||||
|
if (!data || data.length === 0 || !dateColumnName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear];
|
||||||
|
const sampleSize = Math.min(10, data.length);
|
||||||
|
const scores: Record<DateFormat, number> = {
|
||||||
|
[DateFormat.YearMonthDay]: 0,
|
||||||
|
[DateFormat.DayMonthYear]: 0,
|
||||||
|
[DateFormat.MonthDayYear]: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < sampleSize; i++) {
|
||||||
|
const dateValue = data[i][dateColumnName];
|
||||||
|
if (!dateValue) continue;
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
const parsedDate = parseDate(dateValue as string | number, format);
|
||||||
|
if (parsedDate) {
|
||||||
|
scores[format]++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxScore = Math.max(...Object.values(scores));
|
||||||
|
|
||||||
|
if (maxScore === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bestFormat = formats.find(format => scores[format] === maxScore);
|
||||||
|
|
||||||
|
if (maxScore >= sampleSize * 0.8) {
|
||||||
|
return bestFormat || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function autoDetectColumns(headers: string[]): ColumnMapping {
|
||||||
|
const mapping: ColumnMapping = {
|
||||||
|
transaction_date: null,
|
||||||
|
description: null,
|
||||||
|
amount: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!headers || headers.length === 0) {
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerHeaders = headers.map(h => {
|
||||||
|
if (h === null || h === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(h).toLowerCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor'];
|
||||||
|
const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción'];
|
||||||
|
const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad'];
|
||||||
|
|
||||||
|
for (let i = 0; i < lowerHeaders.length; i++) {
|
||||||
|
const header = lowerHeaders[i];
|
||||||
|
const originalHeader = headers[i];
|
||||||
|
|
||||||
|
if (!header || typeof header !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) {
|
||||||
|
mapping.transaction_date = originalHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) {
|
||||||
|
mapping.description = originalHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping.amount && amountPatterns.some(p => header.includes(p))) {
|
||||||
|
mapping.amount = originalHeader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseDate(dateStr: string | number, format: DateFormat): Date | null {
|
||||||
|
if (!dateStr) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof dateStr === 'number') {
|
||||||
|
const excelDate = XLSX.SSF.parse_date_code(dateStr);
|
||||||
|
if (excelDate) {
|
||||||
|
return new Date(excelDate.y, excelDate.m - 1, excelDate.d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let str = String(dateStr).trim();
|
||||||
|
str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, '');
|
||||||
|
|
||||||
|
let year: number | undefined, month: number | undefined, day: number | undefined;
|
||||||
|
|
||||||
|
if (str.length === 5) {
|
||||||
|
const dateRegex = /^(\d{1,2})-(\d{1,2})$/;
|
||||||
|
const dateArray = dateRegex.exec(str);
|
||||||
|
if (dateArray) {
|
||||||
|
month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]);
|
||||||
|
day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const parts = str.split('-').filter(p => p.length > 0);
|
||||||
|
|
||||||
|
if (parts.length === 3) {
|
||||||
|
switch (format) {
|
||||||
|
case DateFormat.YearMonthDay:
|
||||||
|
[year, month, day] = parts.map(Number);
|
||||||
|
break;
|
||||||
|
case DateFormat.MonthDayYear:
|
||||||
|
[month, day, year] = parts.map(Number);
|
||||||
|
break;
|
||||||
|
case DateFormat.DayMonthYear:
|
||||||
|
[day, month, year] = parts.map(Number);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
month = Number(parts[format === DateFormat.DayMonthYear ? 1 : 0]);
|
||||||
|
day = Number(parts[format === DateFormat.DayMonthYear ? 0 : 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year === undefined) {
|
||||||
|
year = new Date().getFullYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year < 100) {
|
||||||
|
year += year < 50 ? 2000 : 1900;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (year === undefined || month === undefined || day === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
|
||||||
|
if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAmount(amountStr: string | number): number | null {
|
||||||
|
if (typeof amountStr === 'number') {
|
||||||
|
return amountStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!amountStr) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let str = String(amountStr).trim();
|
||||||
|
|
||||||
|
const dotPos = str.lastIndexOf('.');
|
||||||
|
const commaPos = str.lastIndexOf(',');
|
||||||
|
|
||||||
|
const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos :
|
||||||
|
commaPos > dotPos && commaPos !== -1 ? commaPos :
|
||||||
|
-1;
|
||||||
|
|
||||||
|
if (decimalSep !== -1) {
|
||||||
|
const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, '');
|
||||||
|
const decimalPart = str.substring(decimalSep + 1);
|
||||||
|
str = integerPart + '.' + decimalPart;
|
||||||
|
} else {
|
||||||
|
str = str.replace(/[^\d]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = parseFloat(str);
|
||||||
|
|
||||||
|
if (isNaN(amount)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateTransaction(
|
||||||
|
row: ParsedRow,
|
||||||
|
mapping: ColumnMapping,
|
||||||
|
dateFormat: DateFormat
|
||||||
|
): { isValid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
if (!mapping.transaction_date || !row[mapping.transaction_date]) {
|
||||||
|
errors.push('Missing transaction date');
|
||||||
|
} else {
|
||||||
|
const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat);
|
||||||
|
if (!date) {
|
||||||
|
errors.push('Invalid date format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping.description || !row[mapping.description]) {
|
||||||
|
errors.push('Missing description');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) {
|
||||||
|
errors.push('Missing amount');
|
||||||
|
} else {
|
||||||
|
const amount = parseAmount(row[mapping.amount] as string | number);
|
||||||
|
if (amount === null) {
|
||||||
|
errors.push('Invalid amount format');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isValid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertRowsToTransactions(
|
||||||
|
rows: ParsedRow[],
|
||||||
|
mapping: ColumnMapping,
|
||||||
|
dateFormat: DateFormat
|
||||||
|
): ParsedTransaction[] {
|
||||||
|
const results: ParsedTransaction[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const validation = validateTransaction(row, mapping, dateFormat);
|
||||||
|
|
||||||
|
if (!validation.isValid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat);
|
||||||
|
const amount = parseAmount(row[mapping.amount!] as string | number);
|
||||||
|
const description = String(row[mapping.description!] || '').trim();
|
||||||
|
|
||||||
|
if (!date || amount === null || !description) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedDate = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
transaction_date: formattedDate,
|
||||||
|
description,
|
||||||
|
amount,
|
||||||
|
validationErrors: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
const DB_NAME = 'whisper_money';
|
const DB_NAME = 'whisper_money';
|
||||||
const DB_VERSION = 1;
|
const DB_VERSION = 2;
|
||||||
|
|
||||||
|
// Force database recreation if needed
|
||||||
|
export async function resetDatabase(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
|
||||||
|
|
||||||
|
deleteRequest.onsuccess = () => {
|
||||||
|
console.log('Database deleted successfully');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteRequest.onerror = () => {
|
||||||
|
reject(new Error('Failed to delete database'));
|
||||||
|
};
|
||||||
|
|
||||||
|
deleteRequest.onblocked = () => {
|
||||||
|
console.warn('Database deletion blocked. Please close all other tabs.');
|
||||||
|
reject(new Error('Database deletion blocked'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface IndexedDBRecord {
|
export interface IndexedDBRecord {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -9,12 +30,17 @@ export interface IndexedDBRecord {
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StoreName = 'categories' | 'accounts' | 'banks';
|
export type StoreName = 'categories' | 'accounts' | 'banks' | 'transactions';
|
||||||
|
|
||||||
class IndexedDBService {
|
class IndexedDBService {
|
||||||
private db: IDBDatabase | null = null;
|
private db: IDBDatabase | null = null;
|
||||||
private initPromise: Promise<IDBDatabase> | null = null;
|
private initPromise: Promise<IDBDatabase> | null = null;
|
||||||
|
|
||||||
|
async checkStoreExists(storeName: StoreName): Promise<boolean> {
|
||||||
|
const db = await this.init();
|
||||||
|
return db.objectStoreNames.contains(storeName);
|
||||||
|
}
|
||||||
|
|
||||||
async init(): Promise<IDBDatabase> {
|
async init(): Promise<IDBDatabase> {
|
||||||
if (this.db) {
|
if (this.db) {
|
||||||
return this.db;
|
return this.db;
|
||||||
|
|
@ -75,6 +101,21 @@ class IndexedDBService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('transactions')) {
|
||||||
|
const transactionStore = db.createObjectStore('transactions', {
|
||||||
|
keyPath: 'id',
|
||||||
|
});
|
||||||
|
transactionStore.createIndex('user_id', 'user_id', {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
|
transactionStore.createIndex('account_id', 'account_id', {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
|
transactionStore.createIndex('updated_at', 'updated_at', {
|
||||||
|
unique: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!db.objectStoreNames.contains('sync_metadata')) {
|
if (!db.objectStoreNames.contains('sync_metadata')) {
|
||||||
db.createObjectStore('sync_metadata', { keyPath: 'key' });
|
db.createObjectStore('sync_metadata', { keyPath: 'key' });
|
||||||
}
|
}
|
||||||
|
|
@ -102,17 +143,28 @@ class IndexedDBService {
|
||||||
): Promise<T[]> {
|
): Promise<T[]> {
|
||||||
const db = await this.init();
|
const db = await this.init();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction(storeName, 'readonly');
|
try {
|
||||||
const store = transaction.objectStore(storeName);
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
const request = store.getAll();
|
console.warn(`Object store '${storeName}' not found. Returning empty array.`);
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
const transaction = db.transaction(storeName, 'readonly');
|
||||||
resolve(request.result as T[]);
|
const store = transaction.objectStore(storeName);
|
||||||
};
|
const request = store.getAll();
|
||||||
|
|
||||||
request.onerror = () => {
|
request.onsuccess = () => {
|
||||||
reject(new Error(`Failed to get all from ${storeName}`));
|
resolve(request.result as T[]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to get all from ${storeName}`));
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error accessing ${storeName}:`, error);
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,17 +194,26 @@ class IndexedDBService {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = await this.init();
|
const db = await this.init();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction(storeName, 'readwrite');
|
try {
|
||||||
const store = transaction.objectStore(storeName);
|
if (!db.objectStoreNames.contains(storeName)) {
|
||||||
const request = store.put(record);
|
reject(new Error(`Object store '${storeName}' not found. Please refresh the page.`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
request.onsuccess = () => {
|
const transaction = db.transaction(storeName, 'readwrite');
|
||||||
resolve();
|
const store = transaction.objectStore(storeName);
|
||||||
};
|
const request = store.put(record);
|
||||||
|
|
||||||
request.onerror = () => {
|
request.onsuccess = () => {
|
||||||
reject(new Error(`Failed to put to ${storeName}`));
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
reject(new Error(`Failed to put to ${storeName}`));
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,22 +330,26 @@ class IndexedDBService {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const db = await this.init();
|
const db = await this.init();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const transaction = db.transaction('pending_changes', 'readwrite');
|
try {
|
||||||
const store = transaction.objectStore('pending_changes');
|
const transaction = db.transaction('pending_changes', 'readwrite');
|
||||||
const request = store.add({
|
const store = transaction.objectStore('pending_changes');
|
||||||
store: storeName,
|
const request = store.add({
|
||||||
operation,
|
store: storeName,
|
||||||
data,
|
operation,
|
||||||
timestamp: new Date().toISOString(),
|
data,
|
||||||
});
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
request.onsuccess = () => {
|
request.onsuccess = () => {
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
|
|
||||||
request.onerror = () => {
|
request.onerror = () => {
|
||||||
reject(new Error('Failed to add pending change'));
|
reject(new Error('Failed to add pending change'));
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { SyncManager } from '@/lib/sync-manager';
|
||||||
|
import { indexedDBService } from '@/lib/indexeddb';
|
||||||
|
import { encrypt, importKey } from '@/lib/crypto';
|
||||||
|
import { getStoredKey } from '@/lib/key-storage';
|
||||||
|
|
||||||
|
export interface Transaction {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
account_id: number;
|
||||||
|
category_id: number | null;
|
||||||
|
description: string;
|
||||||
|
description_iv: string;
|
||||||
|
transaction_date: string;
|
||||||
|
amount: string;
|
||||||
|
currency_code: string;
|
||||||
|
notes: string | null;
|
||||||
|
notes_iv: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class TransactionSyncService {
|
||||||
|
private syncManager: SyncManager;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.syncManager = new SyncManager({
|
||||||
|
storeName: 'transactions',
|
||||||
|
endpoint: '/api/sync/transactions',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sync() {
|
||||||
|
return await this.syncManager.sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAll(): Promise<Transaction[]> {
|
||||||
|
return await this.syncManager.getAll<Transaction>();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: number): Promise<Transaction | null> {
|
||||||
|
return await this.syncManager.getById<Transaction>(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByAccountId(accountId: number): Promise<Transaction[]> {
|
||||||
|
try {
|
||||||
|
const allTransactions = await this.getAll();
|
||||||
|
return allTransactions.filter(t => t.account_id === accountId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to get transactions from IndexedDB:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>): Promise<Transaction> {
|
||||||
|
return await this.syncManager.createLocal<Transaction>(data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & { id?: number; created_at?: string; updated_at?: string });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMany(transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[]): Promise<Transaction[]> {
|
||||||
|
try {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const created: Transaction[] = [];
|
||||||
|
|
||||||
|
for (const data of transactions) {
|
||||||
|
const tempId = Date.now() + created.length;
|
||||||
|
const record = {
|
||||||
|
...data,
|
||||||
|
id: tempId,
|
||||||
|
created_at: timestamp,
|
||||||
|
updated_at: timestamp,
|
||||||
|
} as Transaction;
|
||||||
|
|
||||||
|
await indexedDBService.put('transactions', record);
|
||||||
|
await indexedDBService.addPendingChange(
|
||||||
|
'transactions',
|
||||||
|
'create',
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
|
||||||
|
created.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create transactions in IndexedDB:', error);
|
||||||
|
throw new Error('Failed to save transactions locally. Please refresh the page and try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, data: Partial<Transaction>): Promise<void> {
|
||||||
|
await this.syncManager.updateLocal<Transaction>(id, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: number): Promise<void> {
|
||||||
|
await this.syncManager.deleteLocal(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async isDuplicate(
|
||||||
|
accountId: number,
|
||||||
|
transactionDate: string,
|
||||||
|
amount: number,
|
||||||
|
description: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const existingTransactions = await this.getByAccountId(accountId);
|
||||||
|
|
||||||
|
const keyString = getStoredKey();
|
||||||
|
if (!keyString) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await importKey(keyString);
|
||||||
|
|
||||||
|
for (const existing of existingTransactions) {
|
||||||
|
if (
|
||||||
|
existing.transaction_date === transactionDate &&
|
||||||
|
parseFloat(existing.amount) === amount
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { decrypt } = await import('@/lib/crypto');
|
||||||
|
const decryptedExisting = await decrypt(
|
||||||
|
existing.description,
|
||||||
|
key,
|
||||||
|
existing.description_iv
|
||||||
|
);
|
||||||
|
|
||||||
|
if (decryptedExisting.toLowerCase() === description.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to decrypt description for duplicate check:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Duplicate check failed, assuming no duplicates:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async encryptDescription(description: string): Promise<{ encrypted: string; iv: string }> {
|
||||||
|
const keyString = getStoredKey();
|
||||||
|
if (!keyString) {
|
||||||
|
throw new Error('Encryption key not set');
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await importKey(keyString);
|
||||||
|
return await encrypt(description, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLastSyncTime(): Promise<string | null> {
|
||||||
|
return await this.syncManager.getLastSyncTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
isSyncing(): boolean {
|
||||||
|
return this.syncManager.isSyncing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transactionSyncService = new TransactionSyncService();
|
||||||
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
export enum ImportStep {
|
||||||
|
SelectAccount = 'select-account',
|
||||||
|
UploadFile = 'upload-file',
|
||||||
|
MapColumns = 'map-columns',
|
||||||
|
Preview = 'preview',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DateFormat {
|
||||||
|
YearMonthDay = 'YYYY-MM-DD',
|
||||||
|
MonthDayYear = 'MM-DD-YYYY',
|
||||||
|
DayMonthYear = 'DD-MM-YYYY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnMapping {
|
||||||
|
transaction_date: string | null;
|
||||||
|
description: string | null;
|
||||||
|
amount: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedRow {
|
||||||
|
[key: string]: string | number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTransaction {
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
isDuplicate?: boolean;
|
||||||
|
validationErrors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
examples: (string | number)[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportState {
|
||||||
|
step: ImportStep;
|
||||||
|
selectedAccountId: number | null;
|
||||||
|
file: File | null;
|
||||||
|
parsedData: ParsedRow[];
|
||||||
|
columnHeaders: string[];
|
||||||
|
columnOptions: ColumnOption[];
|
||||||
|
columnMapping: ColumnMapping;
|
||||||
|
dateFormat: DateFormat;
|
||||||
|
dateFormatDetected: boolean;
|
||||||
|
transactions: ParsedTransaction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Binary file not shown.
Loading…
Reference in New Issue