feat: CSV import and batch reconciliation UI

- Add reconciled_with_id column to transactions (links manual → statement tx)
- CSV import wizard: 4-step modal (upload → map columns → review → done)
  - Handles any bank format via column mapping with localStorage presets
  - Single signed or separate debit/credit column modes
  - Editable preview table before committing
  - Auto-tags all imported rows with 'csv-import'
- Batch reconcile page: shows all unreconciled manual transactions with
  potential statement matches (date ±3 days, amount ±1%) pre-fetched
  - Select matches across multiple rows, apply all at once
  - Copies overrides/tags/splits from manual → statement tx atomically
  - Manual tx marked reconciled (linked), hidden from main transactions view
  - Transactions with no matches shown separately
- Import CSV button on transactions page
- Reconcile nav item in sidebar
This commit is contained in:
2026-04-13 06:23:08 +10:00
parent 07b8c1ef16
commit 4a49add277
11 changed files with 1263 additions and 7 deletions
+413
View File
@@ -0,0 +1,413 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { useImportCSV } from "@/lib/hooks";
import {
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
saveBankPreset, loadBankPresets,
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);
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 });
}
setEditedRows(parsed);
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, 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} />
</div>
<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-3">
{editedRows.length} transactions parsed. Edit or remove rows 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>
);
}