Files
finance-app/src/components/csv-import-modal.tsx
T
siddharthd 4a49add277 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
2026-04-13 06:23:08 +10:00

414 lines
20 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 } 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>
);
}