new classification ui
This commit is contained in:
+199
-9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user