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 (
+
+ );
+ }
+
+ 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