"use client"; import { useMemo, useState } from "react"; import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks"; import { formatCategory } from "@/lib/categories"; /** * Preview what a rule would do, then apply it to a chosen subset. * * The apply path takes explicit transaction ids rather than re-running the * conditions, so what you tick is exactly what changes — a rule whose * conditions are too broad cannot quietly reach further than the preview showed. */ function fmt(amount: number, currency = "AUD") { return new Intl.NumberFormat("en-AU", { style: "currency", currency }).format(amount); } function describe(c: RuleMatchChange) { if (c.field === "category") return `${formatCategory(c.from)} → ${formatCategory(c.to)}`; if (c.field === "merchant") return `${c.from ?? "—"} → ${c.to}`; if (c.field === "tags") return `+ tags`; return c.from ? `split ${c.from} → ${c.to}` : `split → ${c.to}`; } export function RulePreviewModal({ ruleId, ruleName, onClose, }: { ruleId: number; ruleName: string; onClose: () => void; }) { const { data, isLoading, error } = useRuleMatches(ruleId); const apply = useApplyRuleToSelection(); const [selected, setSelected] = useState>(new Set()); const [done, setDone] = useState(null); // Default to everything the rule would change — the common case is "yes, all // of these", with deselection as the exception. // // Adjusted during render behind a previous-value guard rather than in an // effect. In an effect the modal painted once with nothing ticked and then // again with everything ticked; here the seeded selection is part of the // first commit. The guard is on `data` identity, so a user deselecting a row // is not undone on the next render — only a genuinely new preview reseeds. const [seededFrom, setSeededFrom] = useState(undefined); if (data !== seededFrom) { setSeededFrom(data); setSelected(new Set(data?.transactions?.map((t) => t.id) ?? [])); } const rows = data?.transactions ?? []; const allSelected = rows.length > 0 && selected.size === rows.length; const totalValue = useMemo( () => rows.filter((r) => selected.has(r.id)) .reduce((a, r) => a + Number(r.amount_aud ?? r.amount), 0), [rows, selected] ); return (

Preview: {ruleName}

{data && (

{data.total_matched} matched · {data.would_change} would change {data.already_correct > 0 && ` · ${data.already_correct} already correct`}

)} {data?.rule.matches_everything && (

This rule has no conditions — it matches every transaction.

)}
{isLoading &&

Checking…

} {error &&

Failed to load matches.

} {data && rows.length === 0 && (

Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}.

)} {rows.length > 0 && ( {rows.map((r) => ( ))}
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set()) } /> Date Description Amount Would change
{ const next = new Set(selected); if (e.target.checked) next.add(r.id); else next.delete(r.id); setSelected(next); }} /> {String(r.transaction_date).slice(0, 10)} {r.effective_merchant || r.description} {r.bank_name} {fmt(Number(r.amount_aud ?? r.amount))} {r.changes.map((c, i) =>
{describe(c)}
)}
)} {data?.truncated && (

Showing the first {rows.length} — narrow the rule to see the rest.

)}
{done ?? `${selected.size} selected · ${fmt(totalValue)}`}
); }