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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user