"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 (
{label}
onChange(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{!required && — none — }
{options.map((o) => {o} )}
);
}
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 && (
Load saved preset
{
const p = presets.find((x) => x.bankName === e.target.value);
if (p) applyPreset(p);
}}
>
— select preset —
{presets.map((p) => {p.bankName} )}
)}
{ const f = e.target.files?.[0]; if (f) handleFile(f); }}
/>
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); }}
>
Drop a CSV file here, or click to browse
Westpac, ANZ, CBA, NAB and others
{error &&
{error}
}
)}
{/* Step 2: Map */}
{step === "map" && (
{/* Raw preview */}
First 3 rows from file:
{columnLabels.map((h) => (
{h}
))}
{dataRows.slice(0, 3).map((row, i) => (
{columnLabels.map((_, ci) => (
{row[ci] ?? ""}
))}
))}
setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
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.
setSavePreset(e.target.checked)} className="accent-indigo-500" />
Save as preset for {bankName || "this bank"}
{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 && (
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.{" "}
{showCovered ? "hide" : "show"}
{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) => (
{h}
))}
{editedRows.map((row, i) => (
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, { 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) => {t} )}
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" />
updateRow(i, { category: e.target.value || undefined })}
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
—
{CATEGORIES.map((c) => {formatCategory(c)} )}
deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×
))}
{error &&
{error}
}
)}
{/* Step 4: Done */}
{step === "done" && (
✓
Imported {insertedCount} transactions
Tagged with csv-import
)}
{/* Footer */}
{step !== "done" && (
{step === "map" && (
setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Back
)}
{step === "review" && (
setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Back
)}
Cancel
{step === "map" && (
Next →
)}
{step === "review" && (
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
)}
)}
);
}