4a49add277
- 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
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
"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>
|
|
);
|
|
}
|