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
+298
View File
@@ -0,0 +1,298 @@
"use client";
import { useState } from "react";
import { usePendingReconciliations, useReconcile } from "@/lib/hooks";
import type { ManualTxWithMatches, PotentialMatch } from "@/lib/queries";
function formatDate(d: string | Date) {
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
}
function formatAmt(amount: number, type: string) {
const f = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(amount);
return ["debit", "fee", "interest"].includes(type) ? f : `+${f}`;
}
const TYPE_COLORS: Record<string, string> = {
debit: "bg-red-900/30 text-red-400",
credit: "bg-green-900/30 text-green-400",
payment: "bg-blue-900/30 text-blue-400",
refund: "bg-emerald-900/30 text-emerald-400",
fee: "bg-yellow-900/30 text-yellow-400",
interest: "bg-orange-900/30 text-orange-400",
transfer: "bg-zinc-800 text-zinc-400",
};
// Selections: manual_id → statement_tx_id or null (skip)
type Selections = Record<number, number | null>;
export default function ReconcilePage() {
const { data: pending = [], isLoading, refetch } = usePendingReconciliations();
const reconcile = useReconcile();
const [selections, setSelections] = useState<Selections>({});
const [error, setError] = useState("");
const [done, setDone] = useState<{ reconciled: number } | null>(null);
const withMatches = pending.filter((tx) => tx.matches.length > 0);
const noMatches = pending.filter((tx) => tx.matches.length === 0);
// Statement tx IDs already chosen in another row this session
const usedStatementIds = new Set(Object.values(selections).filter((v): v is number => v !== null));
function selectMatch(manualId: number, matchId: number) {
setSelections((prev) => {
// If this matchId was previously selected for a different manual tx, clear that
const updated: Selections = { ...prev };
for (const [k, v] of Object.entries(updated)) {
if (v === matchId && Number(k) !== manualId) delete updated[Number(k)];
}
updated[manualId] = matchId;
return updated;
});
}
function skipManual(manualId: number) {
setSelections((prev) => ({ ...prev, [manualId]: null }));
}
const confirmedMatches = Object.entries(selections)
.filter(([, v]) => v !== null)
.map(([k, v]) => ({ manual_id: Number(k), statement_tx_id: v as number }));
async function handleApply() {
if (!confirmedMatches.length) return;
setError("");
try {
const result = await reconcile.mutateAsync(confirmedMatches);
setDone(result);
setSelections({});
refetch();
} catch (e) {
setError(e instanceof Error ? e.message : "Reconcile failed");
}
}
if (isLoading) {
return <div className="p-6 text-zinc-500 text-sm">Loading...</div>;
}
if (done) {
return (
<div className="p-6 max-w-lg">
<div className="bg-emerald-900/20 border border-emerald-700/50 rounded-xl p-6 text-center space-y-2">
<p className="text-emerald-400 font-semibold text-lg"> {done.reconciled} transaction{done.reconciled !== 1 ? "s" : ""} reconciled</p>
<p className="text-zinc-400 text-sm">Overrides, tags, and splits copied to statement transactions.</p>
<button onClick={() => setDone(null)} className="mt-3 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Continue reconciling
</button>
</div>
</div>
);
}
if (pending.length === 0) {
return (
<div className="p-6">
<h2 className="text-xl font-semibold mb-2">Reconcile</h2>
<p className="text-zinc-500 text-sm">No unreconciled manual transactions. Import a CSV to get started.</p>
</div>
);
}
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Reconcile</h2>
<p className="text-xs text-zinc-500 mt-0.5">
{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "}
{withMatches.length} with potential matches
</p>
</div>
{confirmedMatches.length > 0 && (
<button
onClick={handleApply}
disabled={reconcile.isPending}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
{reconcile.isPending ? "Reconciling..." : `Apply ${confirmedMatches.length} match${confirmedMatches.length !== 1 ? "es" : ""}`}
</button>
)}
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
{/* Transactions with matches */}
{withMatches.map((tx) => (
<ReconcileRow
key={tx.id}
tx={tx}
selection={selections[tx.id]}
usedStatementIds={usedStatementIds}
onSelect={(matchId) => selectMatch(tx.id, matchId)}
onSkip={() => skipManual(tx.id)}
onClear={() => setSelections((prev) => { const n = { ...prev }; delete n[tx.id]; return n; })}
/>
))}
{/* Transactions with no matches */}
{noMatches.length > 0 && (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800">
<p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p>
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p>
</div>
<div className="divide-y divide-zinc-800">
{noMatches.map((tx) => (
<div key={tx.id} className="px-4 py-3 flex items-center gap-3">
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(tx.transaction_date)}</span>
<span className="text-zinc-300 text-sm flex-1 truncate">{tx.effective_merchant || tx.description}</span>
<span className={`text-sm font-mono ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
{formatAmt(tx.amount, tx.transaction_type)}
</span>
<div className="flex gap-1">
{(tx.tags as { id: number; name: string; color: string }[]).map((tag) => (
<span key={tag.id} className="w-2 h-2 rounded-full" style={{ backgroundColor: tag.color }} title={tag.name} />
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
}
function ReconcileRow({
tx, selection, usedStatementIds, onSelect, onSkip, onClear,
}: {
tx: ManualTxWithMatches;
selection: number | null | undefined;
usedStatementIds: Set<number>;
onSelect: (matchId: number) => void;
onSkip: () => void;
onClear: () => void;
}) {
const isSkipped = selection === null;
const selectedMatchId = typeof selection === "number" ? selection : null;
const tags = tx.tags as { id: number; name: string; color: string }[];
return (
<div className={`border rounded-xl overflow-hidden transition-colors ${
selectedMatchId ? "border-emerald-700/60" : isSkipped ? "border-zinc-700/40 opacity-60" : "border-zinc-700"
}`}>
{/* Manual tx header */}
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800 flex items-center gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-zinc-500 text-xs flex-shrink-0">{formatDate(tx.transaction_date)}</span>
<span className="text-zinc-200 text-sm truncate">{tx.effective_merchant || tx.description}</span>
{tx.effective_merchant && (
<span className="text-zinc-600 text-xs truncate hidden sm:inline">{tx.description}</span>
)}
</div>
{tags.length > 0 && (
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<span key={tag.id} className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs"
style={{ backgroundColor: tag.color + "33", color: tag.color }}>
{tag.name}
</span>
))}
</div>
)}
</div>
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(tx.transaction_type) ? "text-red-400" : "text-green-400"}`}>
{formatAmt(tx.amount, tx.transaction_type)}
</span>
<span className={`px-2 py-0.5 rounded text-xs font-medium flex-shrink-0 ${TYPE_COLORS[tx.transaction_type] || "bg-zinc-800 text-zinc-400"}`}>
{tx.transaction_type}
</span>
</div>
{/* Matches */}
<div className="divide-y divide-zinc-800/50">
{tx.matches.map((match) => {
const isSelected = selectedMatchId === match.id;
const isUsedElsewhere = !isSelected && usedStatementIds.has(match.id);
return (
<MatchRow
key={match.id}
match={match}
isSelected={isSelected}
isDisabled={isUsedElsewhere}
onSelect={() => onSelect(match.id)}
/>
);
})}
{/* Skip / status row */}
<div className="px-4 py-2.5 flex items-center justify-between bg-zinc-950/30">
{selectedMatchId ? (
<span className="text-emerald-400 text-xs font-medium"> Match selected</span>
) : isSkipped ? (
<span className="text-zinc-500 text-xs">Skipped will stay as manual transaction</span>
) : (
<span className="text-zinc-600 text-xs">Select a match above, or skip</span>
)}
<div className="flex gap-2">
{(selectedMatchId || isSkipped) && (
<button onClick={onClear} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
Clear
</button>
)}
{!isSkipped && (
<button onClick={onSkip} className="text-xs text-zinc-500 hover:text-zinc-300 px-2 py-1 rounded hover:bg-zinc-800">
Skip (no match)
</button>
)}
</div>
</div>
</div>
</div>
);
}
function MatchRow({
match, isSelected, isDisabled, onSelect,
}: {
match: PotentialMatch;
isSelected: boolean;
isDisabled: boolean;
onSelect: () => void;
}) {
return (
<button
onClick={onSelect}
disabled={isDisabled}
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
isSelected
? "bg-emerald-900/20 hover:bg-emerald-900/30"
: isDisabled
? "opacity-30 cursor-not-allowed"
: "hover:bg-zinc-800/50"
}`}
>
<div className={`w-4 h-4 rounded-full border-2 flex-shrink-0 flex items-center justify-center ${
isSelected ? "border-emerald-500 bg-emerald-500" : "border-zinc-600"
}`}>
{isSelected && <div className="w-2 h-2 rounded-full bg-white" />}
</div>
<span className="text-zinc-500 text-xs w-20 flex-shrink-0">{formatDate(match.transaction_date)}</span>
<span className="flex-1 min-w-0">
<span className="text-zinc-300 text-sm truncate block">
{match.effective_merchant || match.description}
</span>
{match.effective_merchant && (
<span className="text-zinc-600 text-xs truncate block">{match.description}</span>
)}
</span>
<span className="text-zinc-500 text-xs flex-shrink-0">{match.bank_name}</span>
<span className={`text-sm font-mono flex-shrink-0 ${["debit","fee","interest"].includes(match.transaction_type) ? "text-red-400" : "text-green-400"}`}>
{formatAmt(match.amount, match.transaction_type)}
</span>
</button>
);
}