new classification ui

This commit is contained in:
Lars Nolden
2026-09-13 13:52:30 +02:00
parent 10314fb1cd
commit 62a7d6daf4
7 changed files with 494 additions and 115 deletions
+199 -9
View File
@@ -1,5 +1,12 @@
import { useEffect, useRef, useState } from "react";
import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react";
import {
Sparkles,
ShieldCheck,
Check,
X,
ArrowRight,
RotateCcw,
} from "lucide-react";
import type {
Dataset,
Enrichment,
@@ -9,6 +16,7 @@ import type {
} from "./api";
import { categoryPath, money, request } from "./api";
import {
Combobox,
DateField,
Empty,
ErrorMessage,
@@ -39,6 +47,9 @@ export function Classification({
const [confirm, setConfirm] = useState(false);
const [running, setRunning] = useState<PreviewProgress | null>(null);
const runStart = useRef({ time: 0, analysed: 0 });
// Reviewer corrections to proposals, keyed by transaction id. A correction
// that matches the proposal again is dropped, so presence means "edited".
const [edits, setEdits] = useState<Record<string, CorrectionValue>>({});
const finalize = (result: Preview) => {
result.changes ??= [];
result.errors ??= [];
@@ -58,6 +69,7 @@ export function Classification({
(confidenceRank[b.after.classification.confidence || "low"] ?? 0),
);
setPreview(result);
setEdits({});
setSelected(
result.changes
.filter((change) => change.after.classification.confidence !== "low")
@@ -129,6 +141,7 @@ export function Classification({
setRunning(null);
setPreview(null);
setSelected([]);
setEdits({});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
@@ -141,6 +154,34 @@ export function Classification({
merchants: [...state.data.merchants, ...preview.new_merchants],
}
: state.data;
// The value a change will be applied with: the reviewer's correction when
// one exists, otherwise the model's proposal.
const effective = (change: Preview["changes"][number]): CorrectionValue =>
edits[change.id] ?? {
category_id: change.after.category_id || "",
tag_ids: change.after.tag_ids,
};
const correct = (
change: Preview["changes"][number],
value: CorrectionValue,
) => {
const proposal = change.after;
const same =
value.category_id === (proposal.category_id || "") &&
value.tag_ids.length === proposal.tag_ids.length &&
value.tag_ids.every((id) => proposal.tag_ids.includes(id));
setEdits((prev) => {
const next = { ...prev };
if (same) delete next[change.id];
else next[change.id] = value;
return next;
});
// Correcting a row is a decision to apply it.
if (!same)
setSelected((ids) =>
ids.includes(change.id) ? ids : [...ids, change.id],
);
};
return (
<>
<div className="section-heading">
@@ -372,7 +413,9 @@ export function Classification({
<div>
<h3>Review changes</h3>
<p>
{selected.length} of {preview.changes.length} selected
{selected.length} of {preview.changes.length} selected
correct any proposed category or tags in place; corrections
are recorded as manual classifications.
</p>
</div>
<div className="row-actions">
@@ -395,12 +438,13 @@ export function Classification({
{preview.changes.length ? (
<div className="preview-list">
{preview.changes.map((change) => (
<label
<div
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
key={change.id}
>
<input
type="checkbox"
aria-label={`Apply ${change.description || change.counterparty || change.id}`}
checked={selected.includes(change.id)}
disabled={busy}
onChange={(e) =>
@@ -430,14 +474,17 @@ export function Classification({
label="Before"
/>
<ArrowRight size={18} />
<EnrichmentView
<CorrectionEditor
data={previewData}
value={change.after}
label="Proposed"
change={change}
value={effective(change)}
edited={change.id in edits}
disabled={busy}
onChange={(value) => correct(change, value)}
/>
</div>
</div>
</label>
</div>
))}
</div>
) : (
@@ -495,8 +542,19 @@ export function Classification({
<p>
This will replace the selected enrichment fields on{" "}
<strong>{selected.length} transactions</strong> in one journal
commit. Unselected proposals will not be applied. Original bank
facts remain unchanged.
commit.
{selected.filter((id) => id in edits).length > 0 && (
<>
{" "}
<strong>
{selected.filter((id) => id in edits).length}
</strong>{" "}
of them carry your corrections and will be recorded as manual
classifications.
</>
)}{" "}
Unselected proposals will not be applied. Original bank facts
remain unchanged.
</p>
<ErrorMessage error={error} />
</div>
@@ -519,6 +577,9 @@ export function Classification({
id: preview.id,
revision: preview.revision,
transaction_ids: selected,
edits: selected
.filter((id) => id in edits)
.map((id) => ({ id, ...edits[id] })),
});
acceptState(
result,
@@ -532,6 +593,13 @@ export function Classification({
? { ...preview, changes: remaining }
: null,
);
setEdits((prev) =>
Object.fromEntries(
Object.entries(prev).filter(
([id]) => !selected.includes(id),
),
),
);
setSelected([]);
setConfirm(false);
} catch (err) {
@@ -589,6 +657,128 @@ function EnrichmentView({
</div>
);
}
// CorrectionValue is the pair of fields a reviewer may correct on a proposal
// before applying it. Merchants are minted by the model and stay read-only.
interface CorrectionValue {
category_id: string;
tag_ids: string[];
}
// CorrectionEditor is the "Proposed" side of a review row, editable in place.
// Category and tags are free-text inputs that autocomplete against the
// existing taxonomy; the category list is limited to leaves of the change's
// kind because that is what validation will accept.
function CorrectionEditor({
data,
change,
value,
edited,
disabled,
onChange,
}: {
data: Dataset;
change: Preview["changes"][number];
value: CorrectionValue;
edited: boolean;
disabled: boolean;
onChange: (value: CorrectionValue) => void;
}) {
const categories = data.categories
.filter(
(c) =>
c.kind === change.after.kind &&
!data.categories.some((child) => child.parent_id === c.id),
)
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
const addable = data.tags
.filter((t) => !value.tag_ids.includes(t.id))
.map((t) => ({ value: t.id, label: t.name }));
return (
<div className="diff-value">
<div className="diff-edit-head">
<span className="eyebrow">Proposed{edited ? " · edited" : ""}</span>
{edited && (
<button
type="button"
className="button subtle"
disabled={disabled}
onClick={() =>
onChange({
category_id: change.after.category_id || "",
tag_ids: change.after.tag_ids,
})
}
>
<RotateCcw size={12} />
Reset
</button>
)}
</div>
<dl>
<div>
<dt>Merchant</dt>
<dd>
{change.after.merchant_id
? data.merchants.find((m) => m.id === change.after.merchant_id)
?.name || `New merchant (${change.after.merchant_id})`
: "None"}
</dd>
</div>
<div>
<dt>Category</dt>
<dd>
<Combobox
options={categories}
value={value.category_id}
disabled={disabled}
onChange={(category_id) => onChange({ ...value, category_id })}
placeholder="Search categories"
emptyText="No matching category. Create it in Categories first."
/>
</dd>
</div>
<div>
<dt>Tags</dt>
<dd>
<div className="tag-edit">
{value.tag_ids.map((id) => (
<button
type="button"
className="tag-chip"
key={id}
disabled={disabled}
aria-label={`Remove tag ${data.tags.find((t) => t.id === id)?.name || id}`}
onClick={() =>
onChange({
...value,
tag_ids: value.tag_ids.filter((t) => t !== id),
})
}
>
{data.tags.find((t) => t.id === id)?.name || id}
<X size={12} />
</button>
))}
<Combobox
options={addable}
value=""
disabled={disabled || !addable.length}
onChange={(id) =>
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
}
placeholder={
data.tags.length
? "Add tag"
: "No tags yet — create them in Tags"
}
emptyText="No matching tag. Create it in Tags first."
/>
</div>
</dd>
</div>
</dl>
</div>
);
}
// remainingEstimate projects the finish time from the pace observed since
// this page attached to the run; the server paces provider requests, so the
// first sample is meaningless and re-attaching mid-run must not count work