Import CSV

This commit is contained in:
Víctor Falcón 2025-11-08 12:47:16 +00:00
parent 5b558716c8
commit dd0f1380d4
17 changed files with 1928 additions and 41 deletions

View File

@ -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=="],

View File

@ -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",

View File

@ -1,6 +1,7 @@
import { Breadcrumbs } from '@/components/breadcrumbs';
import { EncryptionKeyButton } from '@/components/encryption-key-button';
import { SyncStatusButton } from '@/components/sync-status-button';
import { ImportTransactionsButton } from '@/components/transactions/import-transactions-button';
import { SidebarTrigger } from '@/components/ui/sidebar';
import { type BreadcrumbItem as BreadcrumbItemType } from '@/types';
@ -16,6 +17,7 @@ export function AppSidebarHeader({
<Breadcrumbs breadcrumbs={breadcrumbs} />
</div>
<div className="flex items-center gap-2">
<ImportTransactionsButton />
<SyncStatusButton />
<EncryptionKeyButton />
</div>

View File

@ -50,9 +50,6 @@ export function SyncStatusButton() {
};
const getVariant = () => {
if (syncStatus === 'error' || !isOnline) {
return 'destructive' as const;
}
return 'ghost' as const;
};
@ -65,6 +62,7 @@ export function SyncStatusButton() {
size="icon"
onClick={sync}
disabled={syncStatus === 'syncing' || !isOnline}
className={`${syncStatus === 'error' || !isOnline ? 'bg-red-100 dark:bg-red-900' : ''}`}
>
{getIcon()}
</Button>
@ -73,7 +71,7 @@ export function SyncStatusButton() {
<p>{getTooltipText()}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TooltipProvider >
);
}

View File

@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { accountSyncService } from '@/services/account-sync';
import { type Account } from '@/types/account';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Button } from '@/components/ui/button';
import { EncryptedText } from '@/components/encrypted-text';
import { Building2 } from 'lucide-react';
interface ImportStepAccountProps {
selectedAccountId: number | null;
onAccountSelect: (accountId: number) => void;
onNext: () => void;
}
export function ImportStepAccount({
selectedAccountId,
onAccountSelect,
onNext,
}: ImportStepAccountProps) {
const [accounts, setAccounts] = useState<Account[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadAccounts = async () => {
try {
const data = await accountSyncService.getAll();
setAccounts(data);
} catch (error) {
console.error('Failed to load accounts:', error);
} finally {
setLoading(false);
}
};
loadAccounts();
}, []);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-sm text-muted-foreground">Loading accounts...</p>
</div>
);
}
if (accounts.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm text-muted-foreground">
No accounts found. Please create an account first.
</p>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<RadioGroup
value={selectedAccountId?.toString()}
onValueChange={(value) => onAccountSelect(Number(value))}
>
<div className="space-y-3">
{accounts.map((account) => (
<Label
htmlFor={`account-${account.id}`}
key={account.id}
className="flex items-center space-x-3 rounded-lg border p-4 hover:bg-accent"
>
<RadioGroupItem
value={account.id.toString()}
id={`account-${account.id}`}
/>
{account.bank.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="h-10 w-10 rounded-md object-contain"
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div
className="flex flex-1 flex-col gap-1"
>
<span className="font-medium">
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
fallback="[Encrypted Account]"
/>
</span>
<span className="text-sm text-muted-foreground">
{account.bank.name} {account.currency_code}
</span>
</div>
</Label>
))}
</div>
</RadioGroup>
<div className="flex justify-end">
<Button
onClick={onNext}
disabled={!selectedAccountId}
>
Next
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,208 @@
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DateFormat, type ColumnMapping, type ColumnOption, type ParsedRow } from '@/types/import';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { parseDate, parseAmount } from '@/lib/file-parser';
interface ImportStepMappingProps {
columnOptions: ColumnOption[];
columnMapping: ColumnMapping;
dateFormat: DateFormat;
dateFormatDetected: boolean;
parsedData: ParsedRow[];
currencyCode: string;
onMappingChange: (field: keyof ColumnMapping, value: string) => void;
onDateFormatChange: (format: DateFormat) => void;
onNext: () => void;
onBack: () => void;
}
export function ImportStepMapping({
columnOptions,
columnMapping,
dateFormat,
dateFormatDetected,
parsedData,
currencyCode,
onMappingChange,
onDateFormatChange,
onNext,
onBack,
}: ImportStepMappingProps) {
const isValid =
columnMapping.transaction_date &&
columnMapping.description &&
columnMapping.amount;
const previewTransactions = parsedData.slice(0, 3).map((row) => {
const date = columnMapping.transaction_date
? parseDate(row[columnMapping.transaction_date] as string | number, dateFormat)
: null;
const description = columnMapping.description
? String(row[columnMapping.description] || '')
: '';
const amount = columnMapping.amount
? parseAmount(row[columnMapping.amount] as string | number)
: null;
return {
date: date ? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) : 'Invalid date',
description: description || 'No description',
amount: amount !== null
? new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount)
: 'Invalid amount',
};
});
return (
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="date-column">
Transaction Date <span className="text-destructive">*</span>
</Label>
<Select
value={columnMapping.transaction_date || ''}
onValueChange={(value) =>
onMappingChange('transaction_date', value)
}
>
<SelectTrigger id="date-column">
<SelectValue placeholder="Select date column" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description-column">
Description <span className="text-destructive">*</span>
</Label>
<Select
value={columnMapping.description || ''}
onValueChange={(value) =>
onMappingChange('description', value)
}
>
<SelectTrigger id="description-column">
<SelectValue placeholder="Select description column" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col">
<span>{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="amount-column">
Amount <span className="text-destructive">*</span>
</Label>
<Select
value={columnMapping.amount || ''}
onValueChange={(value) => onMappingChange('amount', value)}
>
<SelectTrigger id="amount-column">
<SelectValue placeholder="Select amount column" />
</SelectTrigger>
<SelectContent>
{columnOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isValid && previewTransactions.length > 0 && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<Label className="text-xs opacity-50 pl-2 font-light uppercase tracking-widest">Preview (first 3 rows)</Label>
<div className="space-y-2 pt-2">
{previewTransactions.map((transaction, index) => (
<div key={index} className="flex items-center justify-between rounded-md bg-background p-3 text-sm">
<div className="flex flex-1 items-center gap-3">
<span className="text-muted-foreground">{transaction.date}</span>
<span className="flex-1 truncate">{transaction.description}</span>
</div>
<span className="font-mono font-medium">{transaction.amount}</span>
</div>
))}
</div>
</div>
)}
{!dateFormatDetected && (
<div className="space-y-3 rounded-lg border p-4">
<Label>Date Format</Label>
<RadioGroup
value={dateFormat}
onValueChange={(value) => onDateFormatChange(value as DateFormat)}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.YearMonthDay}
id="format-ymd"
/>
<Label htmlFor="format-ymd" className="cursor-pointer font-normal">
YYYY-MM-DD (e.g., 2024-12-31)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.MonthDayYear}
id="format-mdy"
/>
<Label htmlFor="format-mdy" className="cursor-pointer font-normal">
MM-DD-YYYY (e.g., 12-31-2024)
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value={DateFormat.DayMonthYear}
id="format-dmy"
/>
<Label htmlFor="format-dmy" className="cursor-pointer font-normal">
DD-MM-YYYY (e.g., 31-12-2024)
</Label>
</div>
</RadioGroup>
</div>
)}
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button onClick={onNext} disabled={!isValid}>
Preview Transactions
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,144 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { type ParsedTransaction } from '@/types/import';
import { useMemo } from 'react';
interface ImportStepPreviewProps {
transactions: ParsedTransaction[];
currencyCode: string;
onConfirm: () => void;
onBack: () => void;
isImporting: boolean;
}
export function ImportStepPreview({
transactions,
currencyCode,
onConfirm,
onBack,
isImporting,
}: ImportStepPreviewProps) {
const stats = useMemo(() => {
const newCount = transactions.filter((t) => !t.isDuplicate).length;
const duplicateCount = transactions.filter((t) => t.isDuplicate).length;
return { newCount, duplicateCount, total: transactions.length };
}, [transactions]);
const formatAmount = (amount: number): string => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
}).format(amount);
};
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
return (
<div className="flex flex-col gap-6">
<div className="flex gap-4 rounded-lg border bg-muted/50 p-4">
<div className="flex-1">
<p className="text-sm text-muted-foreground">Total</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">New</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">
{stats.newCount}
</p>
</div>
<div className="flex-1">
<p className="text-sm text-muted-foreground">Duplicates</p>
<p className="text-2xl font-bold text-amber-600 dark:text-amber-400">
{stats.duplicateCount}
</p>
</div>
</div>
{stats.newCount === 0 && stats.duplicateCount > 0 && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 p-4">
<p className="text-sm text-amber-700 dark:text-amber-300">
All transactions appear to be duplicates. No new transactions will be imported.
</p>
</div>
)}
<div className="max-h-[400px] overflow-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Description</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead className="text-center">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground">
No valid transactions found
</TableCell>
</TableRow>
) : (
transactions.map((transaction, index) => (
<TableRow
key={index}
className={
transaction.isDuplicate ? 'opacity-60' : ''
}
>
<TableCell className="whitespace-nowrap">
{formatDate(transaction.transaction_date)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{transaction.description}
</TableCell>
<TableCell className="text-right font-mono">
{formatAmount(transaction.amount)}
</TableCell>
<TableCell className="text-center">
{transaction.isDuplicate ? (
<Badge variant="secondary">Duplicate</Badge>
) : (
<Badge variant="default">New</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isImporting}>
Back
</Button>
<Button
onClick={onConfirm}
disabled={isImporting || stats.newCount === 0}
>
{isImporting
? 'Importing...'
: `Import ${stats.newCount} Transaction${stats.newCount !== 1 ? 's' : ''}`}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,142 @@
import { useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Upload, FileSpreadsheet, X } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ImportStepUploadProps {
file: File | null;
onFileSelect: (file: File) => void;
onNext: () => void;
onBack: () => void;
}
export function ImportStepUpload({
file,
onFileSelect,
onNext,
onBack,
}: ImportStepUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const isValidFile = useCallback((file: File | null | undefined): boolean => {
if (!file || !file.name) {
return false;
}
const validExtensions = ['.csv', '.xls', '.xlsx'];
const lastDotIndex = file.name.lastIndexOf('.');
if (lastDotIndex === -1) {
return false;
}
const extension = file.name.toLowerCase().slice(lastDotIndex);
return validExtensions.includes(extension);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const droppedFile = e.dataTransfer.files[0];
if (droppedFile && isValidFile(droppedFile)) {
onFileSelect(droppedFile);
}
},
[onFileSelect, isValidFile]
);
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile && isValidFile(selectedFile)) {
onFileSelect(selectedFile);
}
},
[onFileSelect, isValidFile]
);
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
};
return (
<div className="flex flex-col gap-6">
<div
className={cn(
'flex min-h-[200px] flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
isDragging && 'border-primary bg-accent',
!isDragging && 'border-border hover:border-primary/50'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{!file ? (
<>
<Upload className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="mb-2 text-sm font-medium">
Drop your file here, or click to browse
</p>
<p className="mb-4 text-xs text-muted-foreground">
Supports CSV, XLS, and XLSX files
</p>
<Button asChild variant="secondary">
<label className="cursor-pointer">
Browse Files
<input
type="file"
className="hidden"
accept=".csv,.xls,.xlsx"
onChange={handleFileInput}
/>
</label>
</Button>
</>
) : (
<div className="flex w-full items-center gap-4 rounded-lg border bg-card p-4">
<FileSpreadsheet className="h-8 w-8 text-primary" />
<div className="flex-1">
<p className="font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.preventDefault();
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
if (input) input.value = '';
onFileSelect(undefined as unknown as File);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Back
</Button>
<Button onClick={onNext} disabled={!file}>
Next
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Upload } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
export function ImportTransactionsButton() {
const [drawerOpen, setDrawerOpen] = useState(false);
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
className="h-9"
onClick={() => setDrawerOpen(true)}
aria-label="Import transactions"
>
<Upload className="h-5 w-5" />
Import
</Button>
</TooltipTrigger>
<TooltipContent>Import transactions from CSV/Excel</TooltipContent>
</Tooltip>
</TooltipProvider>
<ImportTransactionsDrawer
open={drawerOpen}
onOpenChange={setDrawerOpen}
/>
</>
);
}

View File

@ -0,0 +1,386 @@
import { useState, useEffect } from 'react';
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from '@/components/ui/drawer';
import { ImportStepAccount } from './import-step-account';
import { ImportStepUpload } from './import-step-upload';
import { ImportStepMapping } from './import-step-mapping';
import { ImportStepPreview } from './import-step-preview';
import {
ImportStep,
DateFormat,
type ImportState,
type ColumnMapping,
} from '@/types/import';
import { type Account } from '@/types/account';
import {
parseFile,
autoDetectColumns,
convertRowsToTransactions,
} from '@/lib/file-parser';
import { transactionSyncService } from '@/services/transaction-sync';
import { accountSyncService } from '@/services/account-sync';
import { useSyncContext } from '@/contexts/sync-context';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import AlertError from '@/components/alert-error';
interface ImportTransactionsDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImportTransactionsDrawer({
open,
onOpenChange,
}: ImportTransactionsDrawerProps) {
const { sync } = useSyncContext();
const { isKeySet } = useEncryptionKey();
const [isImporting, setIsImporting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedAccount, setSelectedAccount] = useState<Account | null>(null);
const [state, setState] = useState<ImportState>({
step: ImportStep.SelectAccount,
selectedAccountId: null,
file: null,
parsedData: [],
columnHeaders: [],
columnOptions: [],
columnMapping: {
transaction_date: null,
description: null,
amount: null,
},
dateFormat: DateFormat.YearMonthDay,
dateFormatDetected: false,
transactions: [],
});
useEffect(() => {
if (state.selectedAccountId) {
accountSyncService.getById(state.selectedAccountId).then((account) => {
if (account) {
setSelectedAccount(account);
}
});
}
}, [state.selectedAccountId]);
useEffect(() => {
if (!open) {
setState({
step: ImportStep.SelectAccount,
selectedAccountId: null,
file: null,
parsedData: [],
columnHeaders: [],
columnOptions: [],
columnMapping: {
transaction_date: null,
description: null,
amount: null,
},
dateFormat: DateFormat.YearMonthDay,
dateFormatDetected: false,
transactions: [],
});
setIsImporting(false);
setError(null);
setSelectedAccount(null);
}
}, [open]);
const handleAccountSelect = (accountId: number) => {
setState((prev) => ({ ...prev, selectedAccountId: accountId }));
};
const handleFileSelect = async (file: File) => {
if (!file) {
setState((prev) => ({
...prev,
file: null,
parsedData: [],
columnHeaders: [],
columnOptions: [],
}));
return;
}
try {
const { headers, data, columns, headerRowIndex } = await parseFile(file);
const autoMapping = autoDetectColumns(headers);
const columnOptions = headers.map((header, index) => {
const columnData = columns[index] || [];
const middleIndex = Math.floor(columnData.length / 2);
const examples = columnData
.slice(Math.max(headerRowIndex + 1, middleIndex), Math.max(headerRowIndex + 1, middleIndex) + 3)
.filter(cell => cell !== null && cell !== undefined && String(cell).trim() !== '')
.map(cell => String(cell))
.slice(0, 3);
return {
value: header,
label: header,
examples,
};
});
let detectedFormat = DateFormat.YearMonthDay;
let formatDetected = false;
if (autoMapping.transaction_date) {
const { autoDetectDateFormat } = await import('@/lib/file-parser');
const detected = autoDetectDateFormat(data, autoMapping.transaction_date);
if (detected) {
detectedFormat = detected;
formatDetected = true;
}
}
setState((prev) => ({
...prev,
file,
parsedData: data,
columnHeaders: headers,
columnOptions,
columnMapping: autoMapping,
dateFormat: detectedFormat,
dateFormatDetected: formatDetected,
}));
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to parse file'
);
}
};
const handleMappingChange = (
field: keyof ColumnMapping,
value: string
) => {
setState((prev) => ({
...prev,
columnMapping: {
...prev.columnMapping,
[field]: value,
},
}));
};
const handleDateFormatChange = (format: DateFormat) => {
setState((prev) => ({ ...prev, dateFormat: format }));
};
const handlePreviewTransactions = async () => {
try {
const parsedTransactions = convertRowsToTransactions(
state.parsedData,
state.columnMapping,
state.dateFormat
);
const account = await accountSyncService.getById(
state.selectedAccountId!
);
if (!account) {
setError('Selected account not found');
return;
}
const transactionsWithDuplicateCheck = await Promise.all(
parsedTransactions.map(async (transaction) => {
const isDuplicate = await transactionSyncService.isDuplicate(
account.id,
transaction.transaction_date,
transaction.amount,
transaction.description
);
return {
...transaction,
isDuplicate,
};
})
);
setState((prev) => ({
...prev,
transactions: transactionsWithDuplicateCheck,
step: ImportStep.Preview,
}));
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to process transactions'
);
}
};
const handleConfirmImport = async () => {
if (!isKeySet) {
setError('Please unlock your encryption key first');
return;
}
setIsImporting(true);
setError(null);
try {
if (!selectedAccount) {
throw new Error('Selected account not found');
}
const newTransactions = state.transactions.filter(
(t) => !t.isDuplicate
);
const transactionsToImport = await Promise.all(
newTransactions.map(async (transaction) => {
const { encrypted, iv } =
await transactionSyncService.encryptDescription(
transaction.description
);
return {
user_id: (selectedAccount as Account & { user_id?: number }).user_id || 0,
account_id: selectedAccount.id,
category_id: null,
description: encrypted,
description_iv: iv,
transaction_date: transaction.transaction_date,
amount: transaction.amount.toString(),
currency_code: selectedAccount.currency_code,
notes: null,
notes_iv: null,
};
})
);
await transactionSyncService.createMany(transactionsToImport);
sync();
onOpenChange(false);
} catch (err) {
setError(
err instanceof Error
? err.message
: 'Failed to import transactions'
);
} finally {
setIsImporting(false);
}
};
const moveToStep = (step: ImportStep) => {
setState((prev) => ({ ...prev, step }));
};
const getStepInfo = () => {
switch (state.step) {
case ImportStep.SelectAccount:
return {
title: 'Select Account',
description: 'Choose the account where transactions will be imported',
};
case ImportStep.UploadFile:
return {
title: 'Upload File',
description: 'Drop your CSV or Excel file here, or click to browse',
};
case ImportStep.MapColumns:
return {
title: 'Map Columns',
description: 'Match your file columns to transaction fields',
};
case ImportStep.Preview:
return {
title: 'Preview Transactions',
description: 'Review transactions before importing',
};
default:
return {
title: 'Import Transactions',
description: 'Import transactions from CSV or Excel files',
};
}
};
const renderStep = () => {
switch (state.step) {
case ImportStep.SelectAccount:
return (
<ImportStepAccount
selectedAccountId={state.selectedAccountId}
onAccountSelect={handleAccountSelect}
onNext={() => moveToStep(ImportStep.UploadFile)}
/>
);
case ImportStep.UploadFile:
return (
<ImportStepUpload
file={state.file}
onFileSelect={handleFileSelect}
onNext={() => moveToStep(ImportStep.MapColumns)}
onBack={() => moveToStep(ImportStep.SelectAccount)}
/>
);
case ImportStep.MapColumns:
return (
<ImportStepMapping
columnOptions={state.columnOptions}
columnMapping={state.columnMapping}
dateFormat={state.dateFormat}
dateFormatDetected={state.dateFormatDetected}
parsedData={state.parsedData}
currencyCode={selectedAccount?.currency_code || 'USD'}
onMappingChange={handleMappingChange}
onDateFormatChange={handleDateFormatChange}
onNext={handlePreviewTransactions}
onBack={() => moveToStep(ImportStep.UploadFile)}
/>
);
case ImportStep.Preview:
return (
<ImportStepPreview
transactions={state.transactions}
currencyCode={selectedAccount?.currency_code || 'USD'}
onConfirm={handleConfirmImport}
onBack={() => moveToStep(ImportStep.MapColumns)}
isImporting={isImporting}
/>
);
default:
return null;
}
};
const stepInfo = getStepInfo();
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<div className="mx-auto w-full max-w-3xl overflow-y-auto p-6">
<DrawerHeader className="px-0">
<DrawerTitle>{stepInfo.title}</DrawerTitle>
<DrawerDescription>{stepInfo.description}</DrawerDescription>
</DrawerHeader>
{error && (
<div className="mt-4">
<AlertError errors={[error]} />
</div>
)}
<div className="mt-4">{renderStep()}</div>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@ -0,0 +1,133 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,369 @@
import * as XLSX from 'xlsx';
import { DateFormat, type ParsedRow, type ColumnMapping, type ParsedTransaction } from '@/types/import';
function detectHeaderRow(columns: unknown[][]): number {
if (!columns || columns.length === 0) {
return 0;
}
const firstRowWithValue = columns.map((column) =>
column.findIndex((cell) => cell !== undefined && cell !== null && String(cell).length > 1)
);
const percentages = [0.95, 0.75];
for (const minPercentage of percentages) {
const uniqueRows = [...new Set(firstRowWithValue)].sort((a, b) => a - b);
for (const rowNumber of uniqueRows) {
if (rowNumber === -1) continue;
const columnsWithValues = columns.filter((column) => {
return column[rowNumber] !== undefined &&
column[rowNumber] !== null &&
String(column[rowNumber]).length > 1;
}).length;
if (columnsWithValues / columns.length >= minPercentage) {
return rowNumber;
}
}
}
return 0;
}
export async function parseFile(file: File): Promise<{ headers: string[]; data: ParsedRow[]; columns: unknown[][]; headerRowIndex: number }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = e.target?.result;
if (!data) {
reject(new Error('Failed to read file'));
return;
}
const workbook = XLSX.read(data, { type: 'binary' });
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as unknown[][];
if (jsonData.length === 0) {
reject(new Error('File is empty'));
return;
}
const maxColumns = Math.max(...jsonData.map(row => Array.isArray(row) ? row.length : 0));
const columns: unknown[][] = [];
for (let colIndex = 0; colIndex < maxColumns; colIndex++) {
const columnData = jsonData.map(row =>
Array.isArray(row) ? row[colIndex] : undefined
);
columns.push(columnData);
}
const headerRowIndex = detectHeaderRow(columns);
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'];
const headers = columns.map((column, index) => {
const headerValue = column[headerRowIndex];
const headerStr = String(headerValue || '').trim();
if (headerStr && headerStr.length > 1 && isNaN(Number(headerStr))) {
return headerStr;
}
return letters[index] || `Column ${index + 1}`;
});
const dataRows = jsonData.slice(headerRowIndex + 1) as unknown[][];
const parsedData: ParsedRow[] = dataRows
.filter(row => Array.isArray(row) && row.some(cell => cell !== null && cell !== undefined && cell !== ''))
.map(row => {
const obj: ParsedRow = {};
headers.forEach((header, index) => {
if (header) {
const value = row[index];
obj[header] = (value === null || value === undefined) ? null : value as string | number;
}
});
return obj;
});
resolve({ headers, data: parsedData, columns, headerRowIndex });
} catch {
reject(new Error('Failed to parse file'));
}
};
reader.onerror = () => {
reject(new Error('Failed to read file'));
};
reader.readAsBinaryString(file);
});
}
export function autoDetectDateFormat(data: ParsedRow[], dateColumnName: string): DateFormat | null {
if (!data || data.length === 0 || !dateColumnName) {
return null;
}
const formats = [DateFormat.YearMonthDay, DateFormat.DayMonthYear, DateFormat.MonthDayYear];
const sampleSize = Math.min(10, data.length);
const scores: Record<DateFormat, number> = {
[DateFormat.YearMonthDay]: 0,
[DateFormat.DayMonthYear]: 0,
[DateFormat.MonthDayYear]: 0,
};
for (let i = 0; i < sampleSize; i++) {
const dateValue = data[i][dateColumnName];
if (!dateValue) continue;
for (const format of formats) {
const parsedDate = parseDate(dateValue as string | number, format);
if (parsedDate) {
scores[format]++;
}
}
}
const maxScore = Math.max(...Object.values(scores));
if (maxScore === 0) {
return null;
}
const bestFormat = formats.find(format => scores[format] === maxScore);
if (maxScore >= sampleSize * 0.8) {
return bestFormat || null;
}
return null;
}
export function autoDetectColumns(headers: string[]): ColumnMapping {
const mapping: ColumnMapping = {
transaction_date: null,
description: null,
amount: null,
};
if (!headers || headers.length === 0) {
return mapping;
}
const lowerHeaders = headers.map(h => {
if (h === null || h === undefined) {
return '';
}
return String(h).toLowerCase();
});
const datePatterns = ['date', 'transaction date', 'fecha', 'transaction_date', 'trans date', 'trans_date', 'f. valor'];
const descriptionPatterns = ['description', 'desc', 'descripcion', 'concept', 'concepto', 'details', 'detalles', 'memo', 'descripción'];
const amountPatterns = ['amount', 'monto', 'value', 'valor', 'total', 'importe', 'quantity', 'cantidad'];
for (let i = 0; i < lowerHeaders.length; i++) {
const header = lowerHeaders[i];
const originalHeader = headers[i];
if (!header || typeof header !== 'string') {
continue;
}
if (!mapping.transaction_date && datePatterns.some(p => header.includes(p))) {
mapping.transaction_date = originalHeader;
}
if (!mapping.description && descriptionPatterns.some(p => header.includes(p))) {
mapping.description = originalHeader;
}
if (!mapping.amount && amountPatterns.some(p => header.includes(p))) {
mapping.amount = originalHeader;
}
}
return mapping;
}
export function parseDate(dateStr: string | number, format: DateFormat): Date | null {
if (!dateStr) {
return null;
}
if (typeof dateStr === 'number') {
const excelDate = XLSX.SSF.parse_date_code(dateStr);
if (excelDate) {
return new Date(excelDate.y, excelDate.m - 1, excelDate.d);
}
}
let str = String(dateStr).trim();
str = str.replace(/\//g, '-').replace(/\./g, '-').replace(/[^\d-]/g, '');
let year: number | undefined, month: number | undefined, day: number | undefined;
if (str.length === 5) {
const dateRegex = /^(\d{1,2})-(\d{1,2})$/;
const dateArray = dateRegex.exec(str);
if (dateArray) {
month = Number(dateArray[format === DateFormat.DayMonthYear ? 2 : 1]);
day = Number(dateArray[format === DateFormat.DayMonthYear ? 1 : 2]);
}
} else {
const parts = str.split('-').filter(p => p.length > 0);
if (parts.length === 3) {
switch (format) {
case DateFormat.YearMonthDay:
[year, month, day] = parts.map(Number);
break;
case DateFormat.MonthDayYear:
[month, day, year] = parts.map(Number);
break;
case DateFormat.DayMonthYear:
[day, month, year] = parts.map(Number);
break;
}
} else if (parts.length === 2) {
month = Number(parts[format === DateFormat.DayMonthYear ? 1 : 0]);
day = Number(parts[format === DateFormat.DayMonthYear ? 0 : 1]);
}
}
if (year === undefined) {
year = new Date().getFullYear();
}
if (year < 100) {
year += year < 50 ? 2000 : 1900;
}
if (year === undefined || month === undefined || day === undefined) {
return null;
}
const date = new Date(year, month - 1, day);
if (isNaN(date.getTime()) || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) {
return null;
}
return date;
}
export function parseAmount(amountStr: string | number): number | null {
if (typeof amountStr === 'number') {
return amountStr;
}
if (!amountStr) {
return null;
}
let str = String(amountStr).trim();
const dotPos = str.lastIndexOf('.');
const commaPos = str.lastIndexOf(',');
const decimalSep = dotPos > commaPos && dotPos !== -1 ? dotPos :
commaPos > dotPos && commaPos !== -1 ? commaPos :
-1;
if (decimalSep !== -1) {
const integerPart = str.substring(0, decimalSep).replace(/[^\d]/g, '');
const decimalPart = str.substring(decimalSep + 1);
str = integerPart + '.' + decimalPart;
} else {
str = str.replace(/[^\d]/g, '');
}
const amount = parseFloat(str);
if (isNaN(amount)) {
return null;
}
return amount;
}
export function validateTransaction(
row: ParsedRow,
mapping: ColumnMapping,
dateFormat: DateFormat
): { isValid: boolean; errors: string[] } {
const errors: string[] = [];
if (!mapping.transaction_date || !row[mapping.transaction_date]) {
errors.push('Missing transaction date');
} else {
const date = parseDate(row[mapping.transaction_date] as string | number, dateFormat);
if (!date) {
errors.push('Invalid date format');
}
}
if (!mapping.description || !row[mapping.description]) {
errors.push('Missing description');
}
if (!mapping.amount || row[mapping.amount] === null || row[mapping.amount] === undefined) {
errors.push('Missing amount');
} else {
const amount = parseAmount(row[mapping.amount] as string | number);
if (amount === null) {
errors.push('Invalid amount format');
}
}
return {
isValid: errors.length === 0,
errors,
};
}
export function convertRowsToTransactions(
rows: ParsedRow[],
mapping: ColumnMapping,
dateFormat: DateFormat
): ParsedTransaction[] {
const results: ParsedTransaction[] = [];
for (const row of rows) {
const validation = validateTransaction(row, mapping, dateFormat);
if (!validation.isValid) {
continue;
}
const date = parseDate(row[mapping.transaction_date!] as string | number, dateFormat);
const amount = parseAmount(row[mapping.amount!] as string | number);
const description = String(row[mapping.description!] || '').trim();
if (!date || amount === null || !description) {
continue;
}
const formattedDate = date.toISOString().split('T')[0];
results.push({
transaction_date: formattedDate,
description,
amount,
validationErrors: [],
});
}
return results;
}

View File

@ -1,5 +1,26 @@
const DB_NAME = 'whisper_money';
const DB_VERSION = 1;
const DB_VERSION = 2;
// Force database recreation if needed
export async function resetDatabase(): Promise<void> {
return new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(DB_NAME);
deleteRequest.onsuccess = () => {
console.log('Database deleted successfully');
resolve();
};
deleteRequest.onerror = () => {
reject(new Error('Failed to delete database'));
};
deleteRequest.onblocked = () => {
console.warn('Database deletion blocked. Please close all other tabs.');
reject(new Error('Database deletion blocked'));
};
});
}
export interface IndexedDBRecord {
id: number;
@ -9,12 +30,17 @@ export interface IndexedDBRecord {
[key: string]: any;
}
export type StoreName = 'categories' | 'accounts' | 'banks';
export type StoreName = 'categories' | 'accounts' | 'banks' | 'transactions';
class IndexedDBService {
private db: IDBDatabase | null = null;
private initPromise: Promise<IDBDatabase> | null = null;
async checkStoreExists(storeName: StoreName): Promise<boolean> {
const db = await this.init();
return db.objectStoreNames.contains(storeName);
}
async init(): Promise<IDBDatabase> {
if (this.db) {
return this.db;
@ -75,6 +101,21 @@ class IndexedDBService {
});
}
if (!db.objectStoreNames.contains('transactions')) {
const transactionStore = db.createObjectStore('transactions', {
keyPath: 'id',
});
transactionStore.createIndex('user_id', 'user_id', {
unique: false,
});
transactionStore.createIndex('account_id', 'account_id', {
unique: false,
});
transactionStore.createIndex('updated_at', 'updated_at', {
unique: false,
});
}
if (!db.objectStoreNames.contains('sync_metadata')) {
db.createObjectStore('sync_metadata', { keyPath: 'key' });
}
@ -102,17 +143,28 @@ class IndexedDBService {
): Promise<T[]> {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
try {
if (!db.objectStoreNames.contains(storeName)) {
console.warn(`Object store '${storeName}' not found. Returning empty array.`);
resolve([]);
return;
}
request.onsuccess = () => {
resolve(request.result as T[]);
};
const transaction = db.transaction(storeName, 'readonly');
const store = transaction.objectStore(storeName);
const request = store.getAll();
request.onerror = () => {
reject(new Error(`Failed to get all from ${storeName}`));
};
request.onsuccess = () => {
resolve(request.result as T[]);
};
request.onerror = () => {
reject(new Error(`Failed to get all from ${storeName}`));
};
} catch (error) {
console.error(`Error accessing ${storeName}:`, error);
resolve([]);
}
});
}
@ -142,17 +194,26 @@ class IndexedDBService {
): Promise<void> {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(record);
try {
if (!db.objectStoreNames.contains(storeName)) {
reject(new Error(`Object store '${storeName}' not found. Please refresh the page.`));
return;
}
request.onsuccess = () => {
resolve();
};
const transaction = db.transaction(storeName, 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.put(record);
request.onerror = () => {
reject(new Error(`Failed to put to ${storeName}`));
};
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error(`Failed to put to ${storeName}`));
};
} catch (error) {
reject(error);
}
});
}
@ -269,22 +330,26 @@ class IndexedDBService {
): Promise<void> {
const db = await this.init();
return new Promise((resolve, reject) => {
const transaction = db.transaction('pending_changes', 'readwrite');
const store = transaction.objectStore('pending_changes');
const request = store.add({
store: storeName,
operation,
data,
timestamp: new Date().toISOString(),
});
try {
const transaction = db.transaction('pending_changes', 'readwrite');
const store = transaction.objectStore('pending_changes');
const request = store.add({
store: storeName,
operation,
data,
timestamp: new Date().toISOString(),
});
request.onsuccess = () => {
resolve();
};
request.onsuccess = () => {
resolve();
};
request.onerror = () => {
reject(new Error('Failed to add pending change'));
};
request.onerror = () => {
reject(new Error('Failed to add pending change'));
};
} catch (error) {
reject(error);
}
});
}

View File

@ -0,0 +1,162 @@
import { SyncManager } from '@/lib/sync-manager';
import { indexedDBService } from '@/lib/indexeddb';
import { encrypt, importKey } from '@/lib/crypto';
import { getStoredKey } from '@/lib/key-storage';
export interface Transaction {
id: number;
user_id: number;
account_id: number;
category_id: number | null;
description: string;
description_iv: string;
transaction_date: string;
amount: string;
currency_code: string;
notes: string | null;
notes_iv: string | null;
created_at: string;
updated_at: string;
}
class TransactionSyncService {
private syncManager: SyncManager;
constructor() {
this.syncManager = new SyncManager({
storeName: 'transactions',
endpoint: '/api/sync/transactions',
});
}
async sync() {
return await this.syncManager.sync();
}
async getAll(): Promise<Transaction[]> {
return await this.syncManager.getAll<Transaction>();
}
async getById(id: number): Promise<Transaction | null> {
return await this.syncManager.getById<Transaction>(id);
}
async getByAccountId(accountId: number): Promise<Transaction[]> {
try {
const allTransactions = await this.getAll();
return allTransactions.filter(t => t.account_id === accountId);
} catch (error) {
console.warn('Failed to get transactions from IndexedDB:', error);
return [];
}
}
async create(data: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>): Promise<Transaction> {
return await this.syncManager.createLocal<Transaction>(data as Omit<Transaction, 'id' | 'created_at' | 'updated_at'> & { id?: number; created_at?: string; updated_at?: string });
}
async createMany(transactions: Omit<Transaction, 'id' | 'created_at' | 'updated_at'>[]): Promise<Transaction[]> {
try {
const timestamp = new Date().toISOString();
const created: Transaction[] = [];
for (const data of transactions) {
const tempId = Date.now() + created.length;
const record = {
...data,
id: tempId,
created_at: timestamp,
updated_at: timestamp,
} as Transaction;
await indexedDBService.put('transactions', record);
await indexedDBService.addPendingChange(
'transactions',
'create',
record,
);
created.push(record);
}
return created;
} catch (error) {
console.error('Failed to create transactions in IndexedDB:', error);
throw new Error('Failed to save transactions locally. Please refresh the page and try again.');
}
}
async update(id: number, data: Partial<Transaction>): Promise<void> {
await this.syncManager.updateLocal<Transaction>(id, data);
}
async delete(id: number): Promise<void> {
await this.syncManager.deleteLocal(id);
}
async isDuplicate(
accountId: number,
transactionDate: string,
amount: number,
description: string
): Promise<boolean> {
try {
const existingTransactions = await this.getByAccountId(accountId);
const keyString = getStoredKey();
if (!keyString) {
return false;
}
const key = await importKey(keyString);
for (const existing of existingTransactions) {
if (
existing.transaction_date === transactionDate &&
parseFloat(existing.amount) === amount
) {
try {
const { decrypt } = await import('@/lib/crypto');
const decryptedExisting = await decrypt(
existing.description,
key,
existing.description_iv
);
if (decryptedExisting.toLowerCase() === description.toLowerCase()) {
return true;
}
} catch (error) {
console.error('Failed to decrypt description for duplicate check:', error);
}
}
}
return false;
} catch (error) {
console.warn('Duplicate check failed, assuming no duplicates:', error);
return false;
}
}
async encryptDescription(description: string): Promise<{ encrypted: string; iv: string }> {
const keyString = getStoredKey();
if (!keyString) {
throw new Error('Encryption key not set');
}
const key = await importKey(keyString);
return await encrypt(description, key);
}
async getLastSyncTime(): Promise<string | null> {
return await this.syncManager.getLastSyncTime();
}
isSyncing(): boolean {
return this.syncManager.isSyncing();
}
}
export const transactionSyncService = new TransactionSyncService();

View File

@ -0,0 +1,51 @@
export enum ImportStep {
SelectAccount = 'select-account',
UploadFile = 'upload-file',
MapColumns = 'map-columns',
Preview = 'preview',
}
export enum DateFormat {
YearMonthDay = 'YYYY-MM-DD',
MonthDayYear = 'MM-DD-YYYY',
DayMonthYear = 'DD-MM-YYYY',
}
export interface ColumnMapping {
transaction_date: string | null;
description: string | null;
amount: string | null;
}
export interface ParsedRow {
[key: string]: string | number | null;
}
export interface ParsedTransaction {
transaction_date: string;
description: string;
amount: number;
isDuplicate?: boolean;
validationErrors?: string[];
}
export interface ColumnOption {
value: string;
label: string;
examples: (string | number)[];
}
export interface ImportState {
step: ImportStep;
selectedAccountId: number | null;
file: File | null;
parsedData: ParsedRow[];
columnHeaders: string[];
columnOptions: ColumnOption[];
columnMapping: ColumnMapping;
dateFormat: DateFormat;
dateFormatDetected: boolean;
transactions: ParsedTransaction[];
}

Binary file not shown.