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-navigation-menu": "^1.2.5",
|
||||
"@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-slot": "^1.2.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
|
|
@ -40,7 +41,9 @@
|
|||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.0.4",
|
||||
"xlsx": "^0.18.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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-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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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-navigation-menu": "^1.2.5",
|
||||
"@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-slot": "^1.2.3",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
|
|
@ -63,7 +64,9 @@
|
|||
"tailwindcss": "^4.0.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.0.4"
|
||||
"vaul": "^1.1.2",
|
||||
"vite": "^7.0.4",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { EncryptionKeyButton } from '@/components/encryption-key-button';
|
||||
import { SyncStatusButton } from '@/components/sync-status-button';
|
||||
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ export function AppSidebarHeader({
|
|||
<Breadcrumbs breadcrumbs={breadcrumbs} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ImportTransactionsButton />
|
||||
<SyncStatusButton />
|
||||
<EncryptionKeyButton />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,9 +50,6 @@ export function SyncStatusButton() {
|
|||
};
|
||||
|
||||
const getVariant = () => {
|
||||
if (syncStatus === 'error' || !isOnline) {
|
||||
return 'destructive' as const;
|
||||
}
|
||||
return 'ghost' as const;
|
||||
};
|
||||
|
||||
|
|
@ -65,6 +62,7 @@ export function SyncStatusButton() {
|
|||
size="icon"
|
||||
onClick={sync}
|
||||
disabled={syncStatus === 'syncing' || !isOnline}
|
||||
className={`${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
|
||||
>
|
||||
{getIcon()}
|
||||
</Button>
|
||||
|
|
@ -73,7 +71,7 @@ export function SyncStatusButton() {
|
|||
<p>{getTooltipText()}</p>
|
||||
</TooltipContent>
|
||||
</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_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 {
|
||||
id: number;
|
||||
|
|
@ -9,12 +30,17 @@ export interface IndexedDBRecord {
|
|||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks';
|
||||
export type StoreName = 'categories' | 'accounts' | 'banks' | 'transactions';
|
||||
|
||||
class IndexedDBService {
|
||||
private db: 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> {
|
||||
if (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')) {
|
||||
db.createObjectStore('sync_metadata', { keyPath: 'key' });
|
||||
}
|
||||
|
|
@ -102,17 +143,28 @@ class IndexedDBService {
|
|||
): Promise<T[]> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.getAll();
|
||||
try {
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
console.warn(`Object store '${storeName}' not found. Returning empty array.`);
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve(request.result as T[]);
|
||||
};
|
||||
const transaction = db.transaction(storeName, 'readonly');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.getAll();
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to get all from ${storeName}`));
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
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> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.put(record);
|
||||
try {
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
reject(new Error(`Object store '${storeName}' not found. Please refresh the page.`));
|
||||
return;
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
const transaction = db.transaction(storeName, 'readwrite');
|
||||
const store = transaction.objectStore(storeName);
|
||||
const request = store.put(record);
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to put to ${storeName}`));
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Failed to put to ${storeName}`));
|
||||
};
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -269,22 +330,26 @@ class IndexedDBService {
|
|||
): Promise<void> {
|
||||
const db = await this.init();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction('pending_changes', 'readwrite');
|
||||
const store = transaction.objectStore('pending_changes');
|
||||
const request = store.add({
|
||||
store: storeName,
|
||||
operation,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
try {
|
||||
const transaction = db.transaction('pending_changes', 'readwrite');
|
||||
const store = transaction.objectStore('pending_changes');
|
||||
const request = store.add({
|
||||
store: storeName,
|
||||
operation,
|
||||
data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error('Failed to add pending change'));
|
||||
};
|
||||
request.onerror = () => {
|
||||
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