Files
finance-app/src/components/rule-preview-modal.tsx
T
siddharthd fc9ffaba25 chore(ci): clear the lint debt and make lint blocking (#79)
CI ran lint with continue-on-error because 23 errors predated it. All are
fixed, so the flag is gone and a typecheck step is added beside it — nothing
else in CI compiles the test files, so a type error in a fixture could only
ever be found on a developer's machine.

Real fixes, no blanket disables and no suppressions:

- react-hooks/set-state-in-effect (6): each was "reset or seed state when an
  input changes", so each becomes a state adjustment during render behind a
  previous-value guard — the documented React pattern for it. Two were also
  latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter()
  every render, so the effect already ran every render, one commit late.
  shared/page.tsx depended on the `tagIds` array identity, so the row window
  was reset more often than "changing any filter" claims — including after
  "Show more", the one interaction it must not undo; the key now joins by
  value. csv-import-modal reads localStorage once at mount, which is a lazy
  useState initializer, not an effect (SSR-guarded, though the modal is
  mounted from client state and never prerenders).
- react-hooks/static-components (3): SortIcon was declared inside SharedPage,
  making it a new component type on every render. Hoisted to module scope with
  the sort state passed as props.
- @typescript-eslint/no-explicit-any (9): the dynamically imported route
  handlers in two integration tests. Typed as their real signatures with one
  `as unknown as NextRequest` cast at the fixture boundary, so the call sites
  and response bodies get checked instead of opted out.
- react/no-unescaped-entities (5): exact-equivalent entities (', "),
  so no rendered text changes.

Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511):
`options` is optional, and `?? 0` keeps the assertion failing on an absent
list rather than silencing it with a non-null bang.

9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on
warnings, so lint blocks on errors only — deliberately, since none of the
remaining warnings should stop a push.
2026-08-21 00:52:14 +10:00

173 lines
7.3 KiB
TypeScript

"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<Set<number>>(new Set());
const [done, setDone] = useState<string | null>(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<typeof data>(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 (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-zinc-900 border border-zinc-700 rounded-lg w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="px-5 py-4 border-b border-zinc-800">
<h2 className="text-lg font-medium">Preview: {ruleName}</h2>
{data && (
<p className="text-xs text-zinc-500 mt-1">
{data.total_matched} matched · <span className="text-amber-400">{data.would_change} would change</span>
{data.already_correct > 0 && ` · ${data.already_correct} already correct`}
</p>
)}
{data?.rule.matches_everything && (
<p className="text-xs text-red-400 mt-1">
This rule has no conditions — it matches every transaction.
</p>
)}
</div>
<div className="flex-1 overflow-auto px-5 py-3">
{isLoading && <p className="text-sm text-zinc-500">Checking…</p>}
{error && <p className="text-sm text-red-400">Failed to load matches.</p>}
{data && rows.length === 0 && (
<p className="text-sm text-zinc-500">
Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}.
</p>
)}
{rows.length > 0 && (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-900">
<tr className="border-b border-zinc-800 text-zinc-500">
<th className="p-2 w-8">
<input
type="checkbox"
checked={allSelected}
onChange={(e) =>
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
}
/>
</th>
<th className="text-left p-2">Date</th>
<th className="text-left p-2">Description</th>
<th className="text-right p-2">Amount</th>
<th className="text-left p-2">Would change</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-zinc-800/40 hover:bg-zinc-800/30">
<td className="p-2">
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={(e) => {
const next = new Set(selected);
if (e.target.checked) next.add(r.id); else next.delete(r.id);
setSelected(next);
}}
/>
</td>
<td className="p-2 text-zinc-500 whitespace-nowrap">{String(r.transaction_date).slice(0, 10)}</td>
<td className="p-2 text-zinc-300 max-w-[280px] truncate" title={r.description}>
{r.effective_merchant || r.description}
<span className="text-zinc-600 ml-2">{r.bank_name}</span>
</td>
<td className="p-2 text-right tabular-nums text-zinc-400">
{fmt(Number(r.amount_aud ?? r.amount))}
</td>
<td className="p-2 text-amber-400">
{r.changes.map((c, i) => <div key={i}>{describe(c)}</div>)}
</td>
</tr>
))}
</tbody>
</table>
)}
{data?.truncated && (
<p className="text-xs text-zinc-500 mt-2">Showing the first {rows.length} — narrow the rule to see the rest.</p>
)}
</div>
<div className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
<span className="text-xs text-zinc-500">
{done ?? `${selected.size} selected · ${fmt(totalValue)}`}
</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-3 py-1.5 text-sm text-zinc-400 hover:text-white">
Close
</button>
<button
disabled={selected.size === 0 || apply.isPending}
onClick={() =>
apply.mutate(
{ ruleId, ids: Array.from(selected) },
{
onSuccess: () => {
setDone(`Applied to ${selected.size} transaction${selected.size !== 1 ? "s" : ""}`);
setSelected(new Set());
},
}
)
}
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-sm"
>
{apply.isPending ? "Applying…" : `Apply to ${selected.size}`}
</button>
</div>
</div>
</div>
</div>
);
}