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