Files
finance-app/src/components/csv-import-modal.tsx
T
siddharthd 3edcc27781
ci / lint-test (push) Successful in 41s
csv import: map a currency column, or foreign rows land as AUD
Deleting the Frollo importer dropped its currency handling and nothing replaced
it: ColumnMapping had no currency column, so applyMapping could never produce
foreign_currency_code and batchInsertCSVTransactions' support for it was
unreachable from the UI. The USD 10,782 salary imported as A$10,782 — about a
third under, sitting in a column of AUD figures looking entirely normal, which
is the same defect the owner spotted in the first place.

An optional Currency column now sets foreign_currency_code and
foreign_currency_amount when the cell is a three-letter code other than AUD, and
leaves amount_aud NULL rather than inventing a rate. That is the shape order
ingestion already uses and what AMOUNT_UNCONVERTED looks for, so the row renders
as its native figure with "no AUD rate" and the statement supplies the real
number when it arrives.

Caught by reading the imported row rather than the import summary: the summary
said 171 inserted and was right about everything it reported.
2026-08-13 15:08:50 +10:00

479 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 (
<div>
<label className="block text-xs text-zinc-500 mb-1">{label}</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{!required && <option value=""> none </option>}
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</div>
);
}
export function CsvImportModal({ onClose }: { onClose: () => void }) {
const importCSV = useImportCSV();
const fileRef = useRef<HTMLInputElement>(null);
const [step, setStep] = useState<Step>("upload");
const [rawRows, setRawRows] = useState<string[][]>([]);
const [hasHeaders, setHasHeaders] = useState(false);
const [columnLabels, setColumnLabels] = useState<string[]>([]);
const [dataRows, setDataRows] = useState<string[][]>([]);
const [bankName, setBankName] = useState("");
const [dateFormat, setDateFormat] = useState<DateFormat>("DD/MM/YYYY");
const [mapping, setMapping] = useState<ColumnMapping>({
dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "",
});
const [savePreset, setSavePreset] = useState(false);
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
const [error, setError] = useState("");
const [presets, setPresets] = useState<BankPreset[]>([]);
const [insertedCount, setInsertedCount] = useState(0);
const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]);
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<ParsedTransaction>) {
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
<div
className={`bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full ${modalWidth} flex flex-col max-h-[90vh]`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
<div>
<h3 className="font-semibold text-sm text-zinc-200">Import CSV</h3>
<div className="flex gap-2 mt-1">
{(["upload", "map", "review", "done"] as Step[]).map((s, i) => (
<span
key={s}
className={`text-xs ${step === s ? "text-indigo-400 font-medium" : "text-zinc-600"}`}
>
{i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)}
</span>
))}
</div>
</div>
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5">
{/* Step 1: Upload */}
{step === "upload" && (
<div className="space-y-4">
{presets.length > 0 && (
<div>
<label className="block text-xs text-zinc-500 mb-1">Load saved preset</label>
<select
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
defaultValue=""
onChange={(e) => {
const p = presets.find((x) => x.bankName === e.target.value);
if (p) applyPreset(p);
}}
>
<option value=""> select preset </option>
{presets.map((p) => <option key={p.bankName} value={p.bankName}>{p.bankName}</option>)}
</select>
</div>
)}
<div>
<input
ref={fileRef}
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
<button
onClick={() => fileRef.current?.click()}
className="w-full border-2 border-dashed border-zinc-700 hover:border-indigo-500 rounded-xl py-12 text-center transition-colors"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
>
<p className="text-zinc-400 text-sm">Drop a CSV file here, or click to browse</p>
<p className="text-zinc-600 text-xs mt-1">Westpac, ANZ, CBA, NAB and others</p>
</button>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
</div>
)}
{/* Step 2: Map */}
{step === "map" && (
<div className="space-y-4">
{/* Raw preview */}
<div>
<p className="text-xs text-zinc-500 mb-2">First 3 rows from file:</p>
<div className="overflow-x-auto rounded border border-zinc-800">
<table className="text-xs text-zinc-400 w-full">
<thead>
<tr className="border-b border-zinc-800">
{columnLabels.map((h) => (
<th key={h} className="px-2 py-1.5 text-left font-medium text-zinc-300 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{dataRows.slice(0, 3).map((row, i) => (
<tr key={i} className="border-b border-zinc-800/50">
{columnLabels.map((_, ci) => (
<td key={ci} className="px-2 py-1 truncate max-w-[160px]">{row[ci] ?? ""}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Bank Name</label>
<input
value={bankName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Date Format</label>
<select
value={dateFormat}
onChange={(e) => { setDateFormat(e.target.value as DateFormat); refreshColumns(); }}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{DATE_FORMATS.map((f) => <option key={f} value={f}>{f}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Date Column *" value={mapping.dateCol} onChange={(v) => setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
<ColSelect label="Description Column *" value={mapping.descriptionCol} onChange={(v) => setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
</div>
<div>
<label className="block text-xs text-zinc-500 mb-2">Amount</label>
<div className="flex gap-4 mb-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" name="amtmode" value="single" checked={mapping.amountMode === "single"} onChange={() => setMapping((m) => ({ ...m, amountMode: "single" }))} className="accent-indigo-500" />
Single signed column
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="radio" name="amtmode" value="debit_credit" checked={mapping.amountMode === "debit_credit"} onChange={() => setMapping((m) => ({ ...m, amountMode: "debit_credit" }))} className="accent-indigo-500" />
Separate debit / credit columns
</label>
</div>
{mapping.amountMode === "single" ? (
<ColSelect label="Amount Column *" value={mapping.amountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required />
) : (
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Debit Column" value={mapping.debitCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} />
<ColSelect label="Credit Column" value={mapping.creditCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Account Column (optional)" value={mapping.accountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Currency Column (optional)" value={mapping.currencyCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, currencyCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Row ID Column (optional)" value={mapping.sourceRefCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />
</div>
<p className="text-[11px] text-zinc-500 leading-relaxed">
Map <b>Account</b> when the file covers more than one account: rows already
covered by that account&apos;s statements are then left out. Map <b>Row ID</b> to
make re-importing the same file do nothing. Map <b>Currency</b> for a
multi-currency file, or foreign rows are stored as if they were AUD.
</p>
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
Save as preset for {bankName || "this bank"}
</label>
{error && <p className="text-red-400 text-xs">{error}</p>}
</div>
)}
{/* Step 3: Review */}
{step === "review" && (
<div>
<p className="text-xs text-zinc-500 mb-2">
{editedRows.length} transactions to import. Edit or remove rows before importing.
</p>
{/*
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 && (
<div className="mb-3 rounded border border-zinc-800 bg-zinc-800/30 px-3 py-2">
<button
type="button"
onClick={() => setShowCovered((v) => !v)}
className="text-xs text-zinc-300 hover:text-white text-left w-full"
>
{coveredRows.length} row{coveredRows.length === 1 ? "" : "s"} left out already covered by
a statement for that account.{" "}
<span className="text-indigo-400">{showCovered ? "hide" : "show"}</span>
</button>
{showCovered && (
<>
<ul className="mt-2 max-h-40 overflow-y-auto text-[11px] text-zinc-500 font-mono space-y-0.5">
{coveredRows.slice(0, 200).map((r, i) => (
<li key={i}>{r.date} {r.amount.toFixed(2).padStart(10)} {r.description.slice(0, 44)}</li>
))}
</ul>
{coveredRows.length > 200 && (
<p className="mt-1 text-[11px] text-zinc-600">and {coveredRows.length - 200} more.</p>
)}
<p className="mt-2 text-[11px] text-zinc-500">
Statements cover:{" "}
{(coverage.data ?? []).map((c) => `${c.last4} to ${c.coveredTo}`).join(" · ")}
</p>
</>
)}
</div>
)}
{inFileDuplicates(editedRows) > 0 && (
<p className="mb-3 rounded border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
{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.
</p>
)}
<div className="overflow-x-auto rounded border border-zinc-800">
<table className="w-full text-xs">
<thead className="border-b border-zinc-800">
<tr>
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => (
<th key={h} className="px-2 py-2 text-left text-zinc-400 font-medium whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody>
{editedRows.map((row, i) => (
<tr key={i} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-2 py-1">
<input type="date" value={row.date} onChange={(e) => 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" />
</td>
<td className="px-2 py-1">
<input value={row.description} onChange={(e) => 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" />
</td>
<td className="px-2 py-1">
<input type="number" step="0.01" value={row.amount} onChange={(e) => 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" />
</td>
<td className="px-2 py-1">
<select value={row.transaction_type} onChange={(e) => updateRow(i, { transaction_type: e.target.value })}
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
{TX_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</td>
<td className="px-2 py-1">
<input value={row.merchant_name ?? ""} onChange={(e) => 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" />
</td>
<td className="px-2 py-1">
<select value={row.category ?? ""} onChange={(e) => updateRow(i, { category: e.target.value || undefined })}
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
<option value=""></option>
{CATEGORIES.map((c) => <option key={c} value={c}>{formatCategory(c)}</option>)}
</select>
</td>
<td className="px-2 py-1">
<button onClick={() => deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
</div>
)}
{/* Step 4: Done */}
{step === "done" && (
<div className="text-center py-6 space-y-3">
<div className="text-4xl"></div>
<p className="text-zinc-200 font-medium">Imported {insertedCount} transactions</p>
<p className="text-zinc-500 text-sm">Tagged with <span className="text-indigo-400">csv-import</span></p>
<div className="flex gap-2 justify-center pt-2">
<a href="/transactions" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
View Transactions
</a>
<a href="/reconcile" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">
Reconcile
</a>
</div>
</div>
)}
</div>
{/* Footer */}
{step !== "done" && (
<div className="flex gap-2 px-6 py-4 border-t border-zinc-800 flex-shrink-0">
{step === "map" && (
<button onClick={() => setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Back
</button>
)}
{step === "review" && (
<button onClick={() => setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Back
</button>
)}
<div className="flex-1" />
<button onClick={onClose} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Cancel
</button>
{step === "map" && (
<button onClick={handleNext} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium">
Next
</button>
)}
{step === "review" && (
<button
onClick={handleImport}
disabled={importCSV.isPending || editedRows.length === 0}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
</button>
)}
</div>
)}
</div>
</div>
);
}