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.
173 lines
7.3 KiB
TypeScript
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>
|
|
);
|
|
}
|