"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 = { 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; export default function ReconcilePage() { const { data: pending = [], isLoading, refetch } = usePendingReconciliations(); const reconcile = useReconcile(); const [selections, setSelections] = useState({}); 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
Loading...
; } if (done) { return (

✓ {done.reconciled} transaction{done.reconciled !== 1 ? "s" : ""} reconciled

Overrides, tags, and splits copied to statement transactions.

); } if (pending.length === 0) { return (

Reconcile

No unreconciled manual transactions. Import a CSV to get started.

); } return (

Reconcile

{pending.length} manual transaction{pending.length !== 1 ? "s" : ""} ·{" "} {withMatches.length} with potential matches

{confirmedMatches.length > 0 && ( )}
{error &&

{error}

} {/* Transactions with matches */} {withMatches.map((tx) => ( 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 && (

No statement matches found ({noMatches.length})

These may not have hit a statement yet, or the statement hasn't been imported.

{noMatches.map((tx) => (
{formatDate(tx.transaction_date)} {tx.effective_merchant || tx.description} {formatAmt(tx.amount, tx.transaction_type)}
{(tx.tags as { id: number; name: string; color: string }[]).map((tag) => ( ))}
))}
)}
); } function ReconcileRow({ tx, selection, usedStatementIds, onSelect, onSkip, onClear, }: { tx: ManualTxWithMatches; selection: number | null | undefined; usedStatementIds: Set; 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 (
{/* Manual tx header */}
{formatDate(tx.transaction_date)} {tx.effective_merchant || tx.description} {tx.effective_merchant && ( {tx.description} )}
{tags.length > 0 && (
{tags.map((tag) => ( {tag.name} ))}
)}
{formatAmt(tx.amount, tx.transaction_type)} {tx.transaction_type}
{/* Matches */}
{tx.matches.map((match) => { const isSelected = selectedMatchId === match.id; const isUsedElsewhere = !isSelected && usedStatementIds.has(match.id); return ( onSelect(match.id)} /> ); })} {/* Skip / status row */}
{selectedMatchId ? ( ✓ Match selected ) : isSkipped ? ( Skipped — will stay as manual transaction ) : ( Select a match above, or skip )}
{(selectedMatchId || isSkipped) && ( )} {!isSkipped && ( )}
); } function MatchRow({ match, isSelected, isDisabled, onSelect, }: { match: PotentialMatch; isSelected: boolean; isDisabled: boolean; onSelect: () => void; }) { return ( ); }