"use client"; import { useState, useRef, useEffect } from "react"; import { CATEGORIES, formatCategory } from "@/lib/categories"; import { useImportCSV, useStatementCoverage } from "@/lib/hooks"; import { parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping, saveBankPreset, loadBankPresets, splitByCoverage, inFileDuplicates, type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset, } from "@/lib/csv-parser"; const DATE_FORMATS: DateFormat[] = ["DD/MM/YYYY", "YYYY-MM-DD", "MM/DD/YYYY", "M/D/YYYY"]; const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"]; type Step = "upload" | "map" | "review" | "done"; function ColSelect({ label, value, onChange, options, required, }: { label: string; value: string; onChange: (v: string) => void; options: string[]; required?: boolean; }) { return (
); } export function CsvImportModal({ onClose }: { onClose: () => void }) { const importCSV = useImportCSV(); const fileRef = useRef(null); const [step, setStep] = useState("upload"); const [rawRows, setRawRows] = useState([]); const [hasHeaders, setHasHeaders] = useState(false); const [columnLabels, setColumnLabels] = useState([]); const [dataRows, setDataRows] = useState([]); const [bankName, setBankName] = useState(""); const [dateFormat, setDateFormat] = useState("DD/MM/YYYY"); const [mapping, setMapping] = useState({ dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "", }); const [savePreset, setSavePreset] = useState(false); const [editedRows, setEditedRows] = useState([]); const [error, setError] = useState(""); const [presets, setPresets] = useState([]); const [insertedCount, setInsertedCount] = useState(0); const [coveredRows, setCoveredRows] = useState([]); const [showCovered, setShowCovered] = useState(false); const coverage = useStatementCoverage(); useEffect(() => { setPresets(loadBankPresets()); }, []); function handleFile(file: File) { setError(""); const reader = new FileReader(); reader.onload = (e) => { const text = e.target?.result as string; const rows = parseCSVRows(text); if (rows.length === 0) { setError("No data found in file"); return; } setRawRows(rows); const headers = detectHasHeaders(rows, dateFormat); setHasHeaders(headers); const labels = getColumnLabels(rows, headers); setColumnLabels(labels); setDataRows(getDataRows(rows, headers)); // auto-set first columns as defaults setMapping((m) => ({ ...m, dateCol: labels[0] ?? "", descriptionCol: labels[1] ?? "", amountCol: labels[2] ?? "", })); setStep("map"); }; reader.readAsText(file); } function applyPreset(preset: BankPreset) { setBankName(preset.bankName); setDateFormat(preset.dateFormat); setMapping(preset.mapping); } function refreshColumns() { if (!rawRows.length) return; const headers = detectHasHeaders(rawRows, dateFormat); setHasHeaders(headers); const labels = getColumnLabels(rawRows, headers); setColumnLabels(labels); setDataRows(getDataRows(rawRows, headers)); } function handleNext() { setError(""); if (!bankName.trim()) { setError("Bank name is required"); return; } if (!mapping.dateCol) { setError("Date column is required"); return; } if (!mapping.descriptionCol) { setError("Description column is required"); return; } if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; } if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) { setError("At least one of debit/credit columns is required"); return; } const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat); if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; } if (savePreset) { saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat }); } const { keep, covered } = splitByCoverage(parsed, coverage.data ?? []); setEditedRows(keep); setCoveredRows(covered); setStep("review"); } async function handleImport() { setError(""); const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description); if (!valid.length) { setError("No valid rows to import"); return; } try { const result = await importCSV.mutateAsync({ bank_name: bankName, // Provenance, so a second import of the same file inserts nothing — // paired with each row's source_ref where the file carries an id. source: bankName.trim().toLowerCase().replace(/\s+/g, "-"), transactions: valid, }); setInsertedCount(result.inserted); setStep("done"); } catch (e) { setError(e instanceof Error ? e.message : "Import failed"); } } function updateRow(i: number, patch: Partial) { setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r)); } function deleteRow(i: number) { setEditedRows((rows) => rows.filter((_, idx) => idx !== i)); } const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md"; return (
e.stopPropagation()} > {/* Header */}

Import CSV

{(["upload", "map", "review", "done"] as Step[]).map((s, i) => ( {i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)} ))}
{/* Body */}
{/* Step 1: Upload */} {step === "upload" && (
{presets.length > 0 && (
)}
{ const f = e.target.files?.[0]; if (f) handleFile(f); }} />
{error &&

{error}

}
)} {/* Step 2: Map */} {step === "map" && (
{/* Raw preview */}

First 3 rows from file:

{columnLabels.map((h) => ( ))} {dataRows.slice(0, 3).map((row, i) => ( {columnLabels.map((_, ci) => ( ))} ))}
{h}
{row[ci] ?? ""}
setBankName(e.target.value)} placeholder="e.g. Westpac" className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm" />
setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required /> setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
{mapping.amountMode === "single" ? ( setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required /> ) : (
setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} /> setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
)}
setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} /> setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} /> setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} /> setMapping((m) => ({ ...m, currencyCol: v || undefined }))} options={columnLabels} /> setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />

Map Account when the file covers more than one account: rows already covered by that account's statements are then left out. Map Row ID to make re-importing the same file do nothing. Map Currency for a multi-currency file, or foreign rows are stored as if they were AUD.

{error &&

{error}

}
)} {/* Step 3: Review */} {step === "review" && (

{editedRows.length} transactions to import. Edit or remove rows before importing.

{/* Reported, never silent. A row that disappears can mean several things and one of them is worth following up, so the count is always shown and the rows themselves are one click away. */} {coveredRows.length > 0 && (
{showCovered && ( <>
    {coveredRows.slice(0, 200).map((r, i) => (
  • {r.date} {r.amount.toFixed(2).padStart(10)} {r.description.slice(0, 44)}
  • ))}
{coveredRows.length > 200 && (

…and {coveredRows.length - 200} more.

)}

Statements cover:{" "} {(coverage.data ?? []).map((c) => `${c.last4} to ${c.coveredTo}`).join(" · ")}

)}
)} {inFileDuplicates(editedRows) > 0 && (

{inFileDuplicates(editedRows)} row{inFileDuplicates(editedRows) === 1 ? " duplicates another row" : "s duplicate other rows"} in this same file (same date, amount and description). A re-consent can make an aggregator re-export history under new ids — check before importing.

)}
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => ( ))} {editedRows.map((row, i) => ( ))}
{h}
updateRow(i, { date: e.target.value })} className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" /> updateRow(i, { description: e.target.value })} className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" /> updateRow(i, { amount: parseFloat(e.target.value) || 0 })} className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" /> updateRow(i, { merchant_name: e.target.value || undefined })} className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
{error &&

{error}

}
)} {/* Step 4: Done */} {step === "done" && (

Imported {insertedCount} transactions

Tagged with csv-import

)}
{/* Footer */} {step !== "done" && (
{step === "map" && ( )} {step === "review" && ( )}
{step === "map" && ( )} {step === "review" && ( )}
)}
); }