import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Upload, FileText, AlertTriangle, CheckCircle2, Loader2, ArrowLeft } from "lucide-react"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; import { useFinanceData } from "@/context/FinanceDataContext"; import { useMoney } from "@/hooks/useMoney"; import { parseCsv, detectMapping, buildTransactions, EMPTY_MAPPING, type ColumnMapping, type ColumnKey, type ImportRow, type ParsedFile, } from "@/lib/import/parse"; import { extractPdfTransactions, pdfRowsAsRecords, PDF_HEADERS } from "@/lib/import/pdf"; import { CategoryBadge } from "@/components/elements/CategoryIcon"; import { accountDisplayName, canHoldTransactions } from "@/lib/accounts"; type Step = "choose" | "map" | "done"; const REQUIRED: ColumnKey[] = ["date", "description"]; const FIELD_LABELS: Record = { date: "Date", description: "Description", amount: "Amount", category: "Category (optional)", debit: "Debit / money out", credit: "Credit / money in", }; /** * Import transactions from a CSV or bank export. * * `lib/csv-parser.ts` and `lib/pdf-parser.ts` shipped in this repo, fully * written and referenced by nothing, while every empty state in the app invited * the user to "Import Data" and then offered only Plaid. This is the flow those * modules were missing. */ export function ImportDialog({ open, onOpenChange, }: { open: boolean; onOpenChange: (open: boolean) => void; }) { const { accounts: allAccounts, transactions, addTransactions, refreshData, dateFormat } = useFinanceData(); const accounts = useMemo(() => allAccounts.filter((a) => canHoldTransactions(a.type)), [allAccounts]); const { money } = useMoney(); const [step, setStep] = useState("choose"); const [fileName, setFileName] = useState(""); const [parsed, setParsed] = useState(null); const [mapping, setMapping] = useState(EMPTY_MAPPING); const [accountId, setAccountId] = useState(accounts[0]?.id ?? ""); const [allPositiveAreExpenses, setAllPositiveAreExpenses] = useState(false); const [skipDuplicates, setSkipDuplicates] = useState(true); const [busy, setBusy] = useState(false); const [dragging, setDragging] = useState(false); const [pdfNote, setPdfNote] = useState(""); const [imported, setImported] = useState(0); const inputRef = useRef(null); // `accounts` is empty on first render while the context loads, so seeding the // selection from useState's initial value leaves it blank forever, and with // no account the preview can build nothing and the import button never // enables. Adopt the first account as soon as one exists. useEffect(() => { if (!accountId && accounts.length > 0) setAccountId(accounts[0].id); }, [accounts, accountId]); const reset = useCallback(() => { setStep("choose"); setFileName(""); setParsed(null); setMapping(EMPTY_MAPPING); setAllPositiveAreExpenses(false); setSkipDuplicates(true); setImported(0); setPdfNote(""); setAccountId(accounts[0]?.id ?? ""); }, [accounts]); const handleFile = useCallback( async (file: File) => { const isPdf = /\.pdf$/i.test(file.name); if (!isPdf && !/\.(csv|tsv|txt)$/i.test(file.name)) { toast.error("Choose a .csv or .pdf statement"); return; } setBusy(true); try { let result: ParsedFile; if (isPdf) { // PDF statements have no schema, so this is a guess, which is exactly // why it lands on the same preview step for confirmation. const extracted = await extractPdfTransactions(file, dateFormat); if (extracted.rows.length === 0) { toast.error("Couldn't find any transactions in that PDF"); return; } result = { headers: PDF_HEADERS, rows: pdfRowsAsRecords(extracted.rows) }; setPdfNote( extracted.skipped > 0 ? `Read ${extracted.rows.length} of ${extracted.scanned} statement lines, check the preview.` : `Read ${extracted.rows.length} lines, check the preview before importing.` ); } else { result = await parseCsv(file); setPdfNote(""); if (result.rows.length === 0) { toast.error("That file has no rows"); return; } } setParsed(result); setMapping(detectMapping(result.headers)); setFileName(file.name); setStep("map"); } catch { toast.error("Couldn't read that file"); } finally { setBusy(false); } }, [dateFormat] ); const account = accounts.find((a) => a.id === accountId); /** Preview is computed live, so changing a mapping updates it immediately. */ const rows: ImportRow[] = useMemo(() => { if (!parsed || !account) return []; return buildTransactions(parsed.rows, { mapping, dateFormat, accountId, accountName: accountDisplayName(account), existing: transactions, treatAmountsAsExpenses: allPositiveAreExpenses, }); }, [parsed, mapping, dateFormat, accountId, account, transactions, allPositiveAreExpenses]); const valid = rows.filter((r) => r.transaction); const failed = rows.filter((r) => !r.transaction); const duplicates = valid.filter((r) => r.duplicate); const toImport = skipDuplicates ? valid.filter((r) => !r.duplicate) : valid; const hasAmountSource = !!mapping.amount || !!mapping.debit || !!mapping.credit; const missing = REQUIRED.filter((k) => !mapping[k]); const canImport = missing.length === 0 && hasAmountSource && !!accountId && toImport.length > 0; const doImport = async () => { setBusy(true); try { await addTransactions(toImport.map((r) => r.transaction!)); setImported(toImport.length); setStep("done"); refreshData(); } catch { toast.error("Import failed"); } finally { setBusy(false); } }; const close = (o: boolean) => { if (!o) reset(); onOpenChange(o); }; return ( {step === "choose" && "Import transactions"} {step === "map" && "Check the columns"} {step === "done" && "Import complete"} {step === "choose" && (
{accounts.length === 0 ? (

Add or connect an account first, imported transactions need somewhere to live.

) : ( <>
inputRef.current?.click()} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && inputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files?.[0]; if (f) void handleFile(f); }} className={cn( "border-2 border-dashed rounded-xl p-10 text-center transition-colors cursor-pointer", dragging ? "border-primary bg-primary/5" : "border-border hover:border-primary/50" )} > {busy ? ( ) : ( )}

Drop a CSV or PDF statement here, or click to choose

CSV is most reliable; PDF statements are read best-effort and shown for review

{ const f = e.target.files?.[0]; if (f) void handleFile(f); e.target.value = ""; }} /> )}
)} {step === "map" && parsed && (
{fileName} · {parsed.rows.length} rows
{pdfNote && (

{pdfNote}

)}
{(Object.keys(FIELD_LABELS) as ColumnKey[]).map((key) => (

{FIELD_LABELS[key]} {REQUIRED.includes(key) && *}

))}
{!hasAmountSource && (

Map an Amount column, or a Debit/Credit pair.

)}

Ambiguous dates like 01/02/2026 are read as {dateFormat}, change that in Settings → Preferences.

{/* Preview, what will actually be created. */}
{toImport.length} to import {duplicates.length > 0 && ( {duplicates.length} duplicate{duplicates.length === 1 ? "" : "s"} )} {failed.length > 0 && ( {failed.length} unreadable )}
{toImport.slice(0, 40).map((r) => (
{r.transaction!.date} {r.transaction!.description} {money(r.transaction!.amount)}
))} {toImport.length === 0 && (

Nothing to import with these settings.

)}
{/* Failures are listed rather than silently dropped, so a mis-mapped column is obvious instead of looking like data loss. */} {failed.length > 0 && (
{failed.length} row{failed.length === 1 ? "" : "s"} couldn't be read
{failed.slice(0, 20).map((r) => (

Row {r.index + 2}: {r.error}

))}
)}
)} {step === "done" && (

Imported {imported} transaction{imported === 1 ? "" : "s"}

They're in your transaction list and counted towards your budgets.

)} {step === "map" ? ( ) : }
{step === "map" && ( )} {step === "done" && }
); }