feat(rules): show what an apply run changed, and which rule ran
ci / lint-test (push) Successful in 38s
ci / lint-test (push) Successful in 38s
Apply History listed only counts - '13 matches · 13 transactions' - which reads identically whether the run renamed a merchant or split every transaction with another participant. Revert is destructive, so that is not enough to decide on. Two additions. Migration 0017 records rule_id, rule_name and source on each run: rule_name is denormalised so history stays readable after a rule is edited or deleted, and there is no FK so deleting a rule cannot cascade away the audit trail. Both write paths now populate it - the condition-matched run and the selection-based quick action. And rows expand to show the run's snapshot set against current values: which transactions were touched and what changed on each. Rows changed by something else since the run are called out, because reverting restores the pre-run value and would discard that later edit. Runs recorded before this show 'Unknown rule' - the rule they came from is not recoverable.
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
-- Record which rule a run came from.
|
||||||
|
--
|
||||||
|
-- rule_apply_runs stored only counts ("13 matches · 13 transactions"), which is
|
||||||
|
-- not enough to decide whether to revert: you cannot tell a merchant rename from
|
||||||
|
-- a 50/50 split of your entire history. The snapshot column already holds the
|
||||||
|
-- before-state, but nothing said what was applied or why.
|
||||||
|
--
|
||||||
|
-- rule_name is denormalised deliberately. A run must stay readable after the
|
||||||
|
-- rule it came from is edited or deleted -- the history is a record of what
|
||||||
|
-- happened, not a pointer to what the rule says today.
|
||||||
|
|
||||||
|
ALTER TABLE rule_apply_runs
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_id INTEGER,
|
||||||
|
ADD COLUMN IF NOT EXISTS rule_name TEXT,
|
||||||
|
-- 'all' = bulk run over every enabled rule; 'rule' = one rule by conditions;
|
||||||
|
-- 'selection' = one rule against hand-picked transactions (preview → apply).
|
||||||
|
ADD COLUMN IF NOT EXISTS source TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE rule_apply_runs DROP CONSTRAINT IF EXISTS rule_apply_runs_source_check;
|
||||||
|
ALTER TABLE rule_apply_runs ADD CONSTRAINT rule_apply_runs_source_check
|
||||||
|
CHECK (source IS NULL OR source IN ('all', 'rule', 'selection'));
|
||||||
|
|
||||||
|
-- No FK to rules: deleting a rule must not cascade away the audit trail.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_rule_apply_runs_rule
|
||||||
|
ON rule_apply_runs (rule_id) WHERE rule_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Existing rows stay NULL. There is no way to recover which rule they ran;
|
||||||
|
-- the UI shows them as "unknown rule" rather than guessing.
|
||||||
@@ -17,8 +17,12 @@ export async function GET(req: NextRequest) {
|
|||||||
matched: number;
|
matched: number;
|
||||||
transactions_affected: number;
|
transactions_affected: number;
|
||||||
reverted_at: string | null;
|
reverted_at: string | null;
|
||||||
|
rule_id: number | null;
|
||||||
|
rule_name: string | null;
|
||||||
|
source: string | null;
|
||||||
}>(
|
}>(
|
||||||
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at
|
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at,
|
||||||
|
rule_id, rule_name, source
|
||||||
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
|
||||||
[user.id]
|
[user.id]
|
||||||
);
|
);
|
||||||
@@ -37,11 +41,11 @@ export async function POST(req: NextRequest) {
|
|||||||
// Manual-only rules ("quick actions") never take part in a condition-matched
|
// Manual-only rules ("quick actions") never take part in a condition-matched
|
||||||
// run — their conditions are typically empty, so they would match every
|
// run — their conditions are typically empty, so they would match every
|
||||||
// transaction. They are fired from the transactions page against a selection.
|
// transaction. They are fired from the transactions page against a selection.
|
||||||
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
|
const rules = await queryRaw<{ id: number; name: string; conditions: unknown; actions: unknown }>(
|
||||||
ruleId
|
ruleId
|
||||||
? `SELECT id, conditions, actions FROM rules
|
? `SELECT id, name, conditions, actions FROM rules
|
||||||
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
WHERE owner_id = $1 AND id = $2 AND manual_only = false`
|
||||||
: `SELECT id, conditions, actions FROM rules
|
: `SELECT id, name, conditions, actions FROM rules
|
||||||
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
WHERE owner_id = $1 AND enabled = true AND manual_only = false
|
||||||
ORDER BY priority DESC`,
|
ORDER BY priority DESC`,
|
||||||
ruleId ? [user.id, ruleId] : [user.id]
|
ruleId ? [user.id, ruleId] : [user.id]
|
||||||
@@ -92,9 +96,11 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
// --- Save run record ---
|
// --- Save run record ---
|
||||||
const run = await queryRaw<{ id: number }>(
|
const run = await queryRaw<{ id: number }>(
|
||||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
|
||||||
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
rule_id, rule_name, source)
|
||||||
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||||
|
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot),
|
||||||
|
ruleId, ruleId ? (rules[0]?.name ?? null) : null, ruleId ? "rule" : "all"]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
|
import { queryRaw } from "@/lib/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a rule run actually did — the before-state from its snapshot set against
|
||||||
|
* the values now.
|
||||||
|
*
|
||||||
|
* The run list only had counts, which is not enough to decide whether to revert:
|
||||||
|
* "13 matches · 13 transactions" reads the same whether it renamed a merchant or
|
||||||
|
* split your history with someone. Reverting is destructive, so the detail has
|
||||||
|
* to be visible before the button is pressed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SnapshotEntry {
|
||||||
|
transaction_id: number;
|
||||||
|
had_override: boolean;
|
||||||
|
prev_category_override: string | null;
|
||||||
|
prev_merchant_normalized: string | null;
|
||||||
|
prev_tag_ids: number[];
|
||||||
|
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const user = await getCurrentUser(req);
|
||||||
|
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const runs = await queryRaw<{
|
||||||
|
id: number; applied_at: string; reverted_at: string | null;
|
||||||
|
split_from: string | null; matched: number; transactions_affected: number;
|
||||||
|
rule_id: number | null; rule_name: string | null; source: string | null;
|
||||||
|
snapshot: unknown;
|
||||||
|
}>(
|
||||||
|
`SELECT id, applied_at, reverted_at, split_from, matched, transactions_affected,
|
||||||
|
rule_id, rule_name, source, snapshot
|
||||||
|
FROM rule_apply_runs WHERE id = $1 AND owner_id = $2`,
|
||||||
|
[Number(id), user.id]
|
||||||
|
);
|
||||||
|
if (!runs.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const run = runs[0];
|
||||||
|
const snapshot = (typeof run.snapshot === "string"
|
||||||
|
? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[];
|
||||||
|
const byId = new Map(snapshot.map((s) => [s.transaction_id, s]));
|
||||||
|
const ids = snapshot.map((s) => s.transaction_id);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return NextResponse.json({ run: { ...run, snapshot: undefined }, transactions: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = await queryRaw<{
|
||||||
|
id: number; transaction_date: string; description: string;
|
||||||
|
amount: number; amount_aud: number | null; bank_name: string;
|
||||||
|
category: string | null; category_override: string | null;
|
||||||
|
merchant_normalized: string | null; merchant_override: string | null;
|
||||||
|
merchant_name: string | null;
|
||||||
|
tag_ids: number[]; splits: { participant_id: number; share_percent: number }[];
|
||||||
|
}>(
|
||||||
|
`SELECT t.id, t.transaction_date::text, t.description, t.amount, t.amount_aud,
|
||||||
|
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||||
|
t.category, o.category_override,
|
||||||
|
t.merchant_normalized, o.merchant_normalized as merchant_override, t.merchant_name,
|
||||||
|
COALESCE((SELECT json_agg(tt.tag_id) FROM transaction_tags tt WHERE tt.transaction_id = t.id), '[]'::json) as tag_ids,
|
||||||
|
COALESCE((SELECT json_agg(json_build_object('participant_id', ts.participant_id, 'share_percent', ts.share_percent))
|
||||||
|
FROM transaction_splits ts WHERE ts.transaction_id = t.id), '[]'::json) as splits
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
WHERE t.id = ANY($1::int[])
|
||||||
|
ORDER BY t.transaction_date DESC`,
|
||||||
|
[ids]
|
||||||
|
);
|
||||||
|
|
||||||
|
const parse = <T,>(v: unknown, fallback: T): T =>
|
||||||
|
(typeof v === "string" ? JSON.parse(v) : v) ?? fallback;
|
||||||
|
|
||||||
|
const transactions = current.map((t) => {
|
||||||
|
const prev = byId.get(t.id);
|
||||||
|
const nowTags: number[] = parse(t.tag_ids, []);
|
||||||
|
const nowSplits = parse<{ participant_id: number; share_percent: number }[]>(t.splits, []);
|
||||||
|
const prevTags = prev?.prev_tag_ids ?? [];
|
||||||
|
const prevSplits = prev?.prev_splits ?? [];
|
||||||
|
|
||||||
|
const nowCategory = t.category_override ?? t.category;
|
||||||
|
const prevCategory = prev?.prev_category_override ?? t.category;
|
||||||
|
const nowMerchant = t.merchant_override ?? t.merchant_normalized ?? t.merchant_name;
|
||||||
|
const prevMerchant = prev?.prev_merchant_normalized ?? t.merchant_normalized ?? t.merchant_name;
|
||||||
|
|
||||||
|
const changes: { field: string; from: string | null; to: string | null }[] = [];
|
||||||
|
if (nowCategory !== prevCategory) {
|
||||||
|
changes.push({ field: "category", from: prevCategory, to: nowCategory });
|
||||||
|
}
|
||||||
|
if (nowMerchant !== prevMerchant) {
|
||||||
|
changes.push({ field: "merchant", from: prevMerchant, to: nowMerchant });
|
||||||
|
}
|
||||||
|
const addedTags = nowTags.filter((x) => !prevTags.includes(x));
|
||||||
|
if (addedTags.length) {
|
||||||
|
changes.push({ field: "tags", from: null, to: addedTags.join(",") });
|
||||||
|
}
|
||||||
|
const fmtSplits = (arr: { participant_id: number; share_percent: number }[]) =>
|
||||||
|
arr.length ? arr.map((s) => `${s.participant_id}:${Number(s.share_percent)}%`).sort().join(" ") : null;
|
||||||
|
if (fmtSplits(nowSplits) !== fmtSplits(prevSplits)) {
|
||||||
|
changes.push({ field: "split", from: fmtSplits(prevSplits), to: fmtSplits(nowSplits) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
transaction_date: t.transaction_date,
|
||||||
|
description: t.description,
|
||||||
|
amount: t.amount,
|
||||||
|
amount_aud: t.amount_aud,
|
||||||
|
bank_name: t.bank_name,
|
||||||
|
merchant: nowMerchant,
|
||||||
|
changes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
run: {
|
||||||
|
id: run.id, applied_at: run.applied_at, reverted_at: run.reverted_at,
|
||||||
|
split_from: run.split_from, matched: run.matched,
|
||||||
|
transactions_affected: run.transactions_affected,
|
||||||
|
rule_id: run.rule_id, rule_name: run.rule_name, source: run.source,
|
||||||
|
},
|
||||||
|
// A reverted run still lists its transactions, but they will show no changes
|
||||||
|
// because the values are back where they started.
|
||||||
|
transactions,
|
||||||
|
still_changed: transactions.filter((t) => t.changes.length > 0).length,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -110,9 +110,11 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const run = await queryRaw<{ id: number }>(
|
const run = await queryRaw<{ id: number }>(
|
||||||
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
|
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
|
||||||
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
|
rule_id, rule_name, source)
|
||||||
[user.id, ids.length, ids.length, JSON.stringify(snapshot)]
|
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
|
||||||
|
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
|
||||||
|
rules[0].id, rules[0].name]
|
||||||
);
|
);
|
||||||
|
|
||||||
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
|
||||||
|
|||||||
+28
-4
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
|
||||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||||
import { RulePreviewModal } from "@/components/rule-preview-modal";
|
import { RulePreviewModal } from "@/components/rule-preview-modal";
|
||||||
|
import { RuleRunDetail } from "@/components/rule-run-detail";
|
||||||
|
|
||||||
const FIELDS = [
|
const FIELDS = [
|
||||||
{ value: "merchant_normalized", label: "Merchant" },
|
{ value: "merchant_normalized", label: "Merchant" },
|
||||||
@@ -84,6 +85,7 @@ export default function RulesPage() {
|
|||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||||
const [preview, setPreview] = useState<{ id: number; name: string } | null>(null);
|
const [preview, setPreview] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [expandedRun, setExpandedRun] = useState<number | null>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [conditions, setConditions] = useState<Condition[]>([]);
|
const [conditions, setConditions] = useState<Condition[]>([]);
|
||||||
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
|
||||||
@@ -451,11 +453,31 @@ export default function RulesPage() {
|
|||||||
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
|
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{runs.map((run) => (
|
{runs.map((run) => (
|
||||||
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
<div key={run.id} className={`rounded-lg border text-sm overflow-hidden ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center justify-between px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
|
||||||
|
className="text-zinc-500 hover:text-zinc-200 w-4 text-left"
|
||||||
|
title="Show what this run changed"
|
||||||
|
>
|
||||||
|
{expandedRun === run.id ? "▾" : "▸"}
|
||||||
|
</button>
|
||||||
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
|
||||||
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
|
{/* Which rule ran matters most: a merchant rename and a
|
||||||
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
|
50/50 split of everything look identical as counts. */}
|
||||||
|
<span className="text-zinc-200">
|
||||||
|
{run.rule_name ?? (run.source === "all" ? "All rules" : "Unknown rule")}
|
||||||
|
</span>
|
||||||
|
{run.source === "selection" && (
|
||||||
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">
|
||||||
|
selection
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-zinc-500">{run.transactions_affected} transactions</span>
|
||||||
|
{run.split_from && (
|
||||||
|
<span className="text-zinc-600 text-xs">splits from {String(run.split_from).slice(0, 10)}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{run.reverted_at ? (
|
{run.reverted_at ? (
|
||||||
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
|
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
|
||||||
@@ -473,6 +495,8 @@ export default function RulesPage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{expandedRun === run.id && <RuleRunDetail runId={run.id} />}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRuleRunDetail } from "@/lib/hooks";
|
||||||
|
import { formatCategory } from "@/lib/categories";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The expanded body of an Apply History row: which transactions the run touched
|
||||||
|
* and what it changed on each, so Revert is an informed decision rather than a
|
||||||
|
* guess from a count.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fmt(n: number) {
|
||||||
|
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(c: { field: string; from: string | null; to: string | null }) {
|
||||||
|
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 `+ tag ${c.to}`;
|
||||||
|
return `split ${c.from ?? "none"} → ${c.to ?? "none"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuleRunDetail({ runId }: { runId: number }) {
|
||||||
|
const { data, isLoading, error } = useRuleRunDetail(runId);
|
||||||
|
|
||||||
|
if (isLoading) return <p className="px-4 py-3 text-xs text-zinc-500">Loading…</p>;
|
||||||
|
if (error) return <p className="px-4 py-3 text-xs text-red-400">Failed to load detail.</p>;
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const { transactions, still_changed, run } = data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-zinc-800 bg-zinc-950/60">
|
||||||
|
<div className="px-4 py-2 text-xs text-zinc-500 flex gap-4 flex-wrap">
|
||||||
|
<span>{transactions.length} transactions</span>
|
||||||
|
{run.reverted_at ? (
|
||||||
|
<span className="text-zinc-500">already reverted — values are back to their originals</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-400">{still_changed} still showing this run's changes</span>
|
||||||
|
)}
|
||||||
|
{still_changed < transactions.length && !run.reverted_at && (
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
{transactions.length - still_changed} since changed by something else — reverting restores the pre-run value
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 overflow-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 bg-zinc-950">
|
||||||
|
<tr className="text-zinc-600 border-b border-zinc-800">
|
||||||
|
<th className="text-left px-4 py-1.5">Date</th>
|
||||||
|
<th className="text-left px-3 py-1.5">Transaction</th>
|
||||||
|
<th className="text-right px-3 py-1.5">Amount</th>
|
||||||
|
<th className="text-left px-3 py-1.5">Changed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{transactions.map((t) => (
|
||||||
|
<tr key={t.id} className="border-b border-zinc-800/40">
|
||||||
|
<td className="px-4 py-1.5 text-zinc-500 whitespace-nowrap">
|
||||||
|
{String(t.transaction_date).slice(0, 10)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-zinc-300 max-w-[300px] truncate" title={t.description}>
|
||||||
|
{t.merchant || t.description}
|
||||||
|
<span className="text-zinc-600 ml-2">{t.bank_name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-right tabular-nums text-zinc-400">
|
||||||
|
{fmt(Number(t.amount_aud ?? t.amount))}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{t.changes.length === 0 ? (
|
||||||
|
<span className="text-zinc-600">no change</span>
|
||||||
|
) : (
|
||||||
|
t.changes.map((c, i) => (
|
||||||
|
<div key={i} className="text-amber-400">{describe(c)}</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -617,6 +617,38 @@ export interface RuleRun {
|
|||||||
matched: number;
|
matched: number;
|
||||||
transactions_affected: number;
|
transactions_affected: number;
|
||||||
reverted_at: string | null;
|
reverted_at: string | null;
|
||||||
|
// Provenance (migration 0017). NULL on runs recorded before it existed.
|
||||||
|
rule_id: number | null;
|
||||||
|
rule_name: string | null;
|
||||||
|
source: "all" | "rule" | "selection" | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleRunDetail {
|
||||||
|
run: RuleRun & { transactions_affected: number };
|
||||||
|
transactions: {
|
||||||
|
id: number;
|
||||||
|
transaction_date: string;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
amount_aud: number | null;
|
||||||
|
bank_name: string;
|
||||||
|
merchant: string | null;
|
||||||
|
changes: { field: string; from: string | null; to: string | null }[];
|
||||||
|
}[];
|
||||||
|
still_changed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a run actually did — loaded on demand when a row is expanded. */
|
||||||
|
export function useRuleRunDetail(runId: number | null) {
|
||||||
|
return useQuery<RuleRunDetail>({
|
||||||
|
queryKey: ["rule-run-detail", runId],
|
||||||
|
enabled: runId != null,
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await fetch(`/api/rules/runs/${runId}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load run detail");
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRuleRuns() {
|
export function useRuleRuns() {
|
||||||
|
|||||||
Reference in New Issue
Block a user