diff --git a/bun.lock b/bun.lock index 9c737603..a00a70b0 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index a9c044c1..49ec204d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/resources/js/components/app-sidebar-header.tsx b/resources/js/components/app-sidebar-header.tsx index f8038af8..e2883a6a 100644 --- a/resources/js/components/app-sidebar-header.tsx +++ b/resources/js/components/app-sidebar-header.tsx @@ -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({
+
diff --git a/resources/js/components/sync-status-button.tsx b/resources/js/components/sync-status-button.tsx index eb15e8b3..f506a68d 100644 --- a/resources/js/components/sync-status-button.tsx +++ b/resources/js/components/sync-status-button.tsx @@ -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()} @@ -73,7 +71,7 @@ export function SyncStatusButton() {

{getTooltipText()}

- + ); } diff --git a/resources/js/components/transactions/import-step-account.tsx b/resources/js/components/transactions/import-step-account.tsx new file mode 100644 index 00000000..6b1d62f5 --- /dev/null +++ b/resources/js/components/transactions/import-step-account.tsx @@ -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([]); + 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 ( +
+

Loading accounts...

+
+ ); + } + + if (accounts.length === 0) { + return ( +
+

+ No accounts found. Please create an account first. +

+
+ ); + } + + return ( +
+ onAccountSelect(Number(value))} + > +
+ {accounts.map((account) => ( + + ))} +
+
+ +
+ +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-step-mapping.tsx b/resources/js/components/transactions/import-step-mapping.tsx new file mode 100644 index 00000000..25722da3 --- /dev/null +++ b/resources/js/components/transactions/import-step-mapping.tsx @@ -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 ( +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {isValid && previewTransactions.length > 0 && ( +
+ +
+ {previewTransactions.map((transaction, index) => ( +
+
+ {transaction.date} + {transaction.description} +
+ {transaction.amount} +
+ ))} +
+
+ )} + + {!dateFormatDetected && ( +
+ + onDateFormatChange(value as DateFormat)} + > +
+ + +
+
+ + +
+
+ + +
+
+
+ )} +
+ +
+ + +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-step-preview.tsx b/resources/js/components/transactions/import-step-preview.tsx new file mode 100644 index 00000000..4706f348 --- /dev/null +++ b/resources/js/components/transactions/import-step-preview.tsx @@ -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 ( +
+
+
+

Total

+

{stats.total}

+
+
+

New

+

+ {stats.newCount} +

+
+
+

Duplicates

+

+ {stats.duplicateCount} +

+
+
+ + {stats.newCount === 0 && stats.duplicateCount > 0 && ( +
+

+ All transactions appear to be duplicates. No new transactions will be imported. +

+
+ )} + +
+ + + + Date + Description + Amount + Status + + + + {transactions.length === 0 ? ( + + + No valid transactions found + + + ) : ( + transactions.map((transaction, index) => ( + + + {formatDate(transaction.transaction_date)} + + + {transaction.description} + + + {formatAmount(transaction.amount)} + + + {transaction.isDuplicate ? ( + Duplicate + ) : ( + New + )} + + + )) + )} + +
+
+ +
+ + +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-step-upload.tsx b/resources/js/components/transactions/import-step-upload.tsx new file mode 100644 index 00000000..3ee3f780 --- /dev/null +++ b/resources/js/components/transactions/import-step-upload.tsx @@ -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) => { + 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 ( +
+
+ {!file ? ( + <> + +

+ Drop your file here, or click to browse +

+

+ Supports CSV, XLS, and XLSX files +

+ + + ) : ( +
+ +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
+ )} +
+ +
+ + +
+
+ ); +} + diff --git a/resources/js/components/transactions/import-transactions-button.tsx b/resources/js/components/transactions/import-transactions-button.tsx new file mode 100644 index 00000000..f1941bb4 --- /dev/null +++ b/resources/js/components/transactions/import-transactions-button.tsx @@ -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 ( + <> + + + + + + Import transactions from CSV/Excel + + + + + + ); +} + diff --git a/resources/js/components/transactions/import-transactions-drawer.tsx b/resources/js/components/transactions/import-transactions-drawer.tsx new file mode 100644 index 00000000..b562536d --- /dev/null +++ b/resources/js/components/transactions/import-transactions-drawer.tsx @@ -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(null); + const [selectedAccount, setSelectedAccount] = useState(null); + const [state, setState] = useState({ + 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 ( + moveToStep(ImportStep.UploadFile)} + /> + ); + case ImportStep.UploadFile: + return ( + moveToStep(ImportStep.MapColumns)} + onBack={() => moveToStep(ImportStep.SelectAccount)} + /> + ); + case ImportStep.MapColumns: + return ( + moveToStep(ImportStep.UploadFile)} + /> + ); + case ImportStep.Preview: + return ( + moveToStep(ImportStep.MapColumns)} + isImporting={isImporting} + /> + ); + default: + return null; + } + }; + + const stepInfo = getStepInfo(); + + return ( + + +
+ + {stepInfo.title} + {stepInfo.description} + + {error && ( +
+ +
+ )} +
{renderStep()}
+
+
+
+ ); +} + diff --git a/resources/js/components/ui/drawer.tsx b/resources/js/components/ui/drawer.tsx new file mode 100644 index 00000000..580c3e37 --- /dev/null +++ b/resources/js/components/ui/drawer.tsx @@ -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) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/resources/js/components/ui/radio-group.tsx b/resources/js/components/ui/radio-group.tsx new file mode 100644 index 00000000..dc682eb9 --- /dev/null +++ b/resources/js/components/ui/radio-group.tsx @@ -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) { + return ( + + ) +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { RadioGroup, RadioGroupItem } diff --git a/resources/js/lib/file-parser.ts b/resources/js/lib/file-parser.ts new file mode 100644 index 00000000..c3c67356 --- /dev/null +++ b/resources/js/lib/file-parser.ts @@ -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.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; +} + diff --git a/resources/js/lib/indexeddb.ts b/resources/js/lib/indexeddb.ts index 0a95a37b..1f574eee 100644 --- a/resources/js/lib/indexeddb.ts +++ b/resources/js/lib/indexeddb.ts @@ -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 { + 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 | null = null; + async checkStoreExists(storeName: StoreName): Promise { + const db = await this.init(); + return db.objectStoreNames.contains(storeName); + } + async init(): Promise { 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 { 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 { 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 { 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); + } }); } diff --git a/resources/js/services/transaction-sync.ts b/resources/js/services/transaction-sync.ts new file mode 100644 index 00000000..74c9938d --- /dev/null +++ b/resources/js/services/transaction-sync.ts @@ -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 { + return await this.syncManager.getAll(); + } + + async getById(id: number): Promise { + return await this.syncManager.getById(id); + } + + async getByAccountId(accountId: number): Promise { + 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): Promise { + return await this.syncManager.createLocal(data as Omit & { id?: number; created_at?: string; updated_at?: string }); + } + + async createMany(transactions: Omit[]): Promise { + 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): Promise { + await this.syncManager.updateLocal(id, data); + } + + async delete(id: number): Promise { + await this.syncManager.deleteLocal(id); + } + + async isDuplicate( + accountId: number, + transactionDate: string, + amount: number, + description: string + ): Promise { + 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 { + return await this.syncManager.getLastSyncTime(); + } + + isSyncing(): boolean { + return this.syncManager.isSyncing(); + } +} + +export const transactionSyncService = new TransactionSyncService(); + diff --git a/resources/js/types/import.ts b/resources/js/types/import.ts new file mode 100644 index 00000000..f5c7b595 --- /dev/null +++ b/resources/js/types/import.ts @@ -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[]; +} + + diff --git a/storage/local/movements-3112025.xls b/storage/local/movements-3112025.xls new file mode 100644 index 00000000..d97f0058 Binary files /dev/null and b/storage/local/movements-3112025.xls differ