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
+60
View File
@@ -727,6 +727,66 @@ export interface MerchantTxnRow {
statement_id: number;
}
// --- CSV Import & Reconcile ---
export function useImportCSV() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: {
bank_name: string;
transactions: {
date: string; description: string; amount: number; transaction_type: string;
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
}[];
}) => {
const res = await fetch("/api/import/csv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error((await res.json()).error || "Import failed");
return res.json() as Promise<{ inserted: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["tags"] });
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
},
});
}
import type { ManualTxWithMatches } from "./queries";
export function usePendingReconciliations() {
return useQuery<ManualTxWithMatches[]>({
queryKey: ["reconcile-pending"],
queryFn: async () => {
const res = await fetch("/api/reconcile/pending");
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
},
});
}
export function useReconcile() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (matches: { manual_id: number; statement_tx_id: number }[]) => {
const res = await fetch("/api/transactions/reconcile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ matches }),
});
if (!res.ok) throw new Error((await res.json()).error || "Reconcile failed");
return res.json() as Promise<{ reconciled: number }>;
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["reconcile-pending"] });
},
});
}
export function useMerchantTransactions(merchant: string | null) {
return useQuery<{ transactions: MerchantTxnRow[] }>({
queryKey: ["analytics", "merchant-txns", merchant],