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
+21 -81
View File
@@ -12,7 +12,7 @@ import {
} from "lucide-react";
import type { Account, Institution, PreparedImport, State } from "./api";
import { localInstant, money, request } from "./api";
import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import { Combobox, Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import type { Mutate } from "./ui";
interface Balance {
amount: string;
@@ -1110,8 +1110,6 @@ function InstitutionSelect({
}) {
const [institutions, setInstitutions] = useState<Institution[] | null>(null);
const [loadError, setLoadError] = useState("");
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
useEffect(() => {
setInstitutions(null);
setLoadError("");
@@ -1147,90 +1145,32 @@ function InstitutionSelect({
/>
</Field>
);
const filter = query.trim().toLowerCase();
const matches = (institutions ?? []).filter((i) =>
i.name.toLowerCase().includes(filter),
);
const exact = filter
? matches.find((i) => i.name.toLowerCase() === filter)
: undefined;
const shown = exact
? [exact, ...matches.filter((i) => i !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = institutions?.find((i) => i.name === value);
return (
<Field
label="Institution"
hint="Choose your bank as listed by Enable Banking."
>
<div className="bank-select">
<input
required
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
disabled={!institutions}
value={open ? query : value}
placeholder={institutions ? "Search your bank" : "Loading banks…"}
onFocus={() => {
setQuery("");
setOpen(true);
}}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Enter" && open) {
e.preventDefault();
if (shown.length === 1) {
onChange(shown[0].name, shown[0].psu_types);
setOpen(false);
}
}
}}
/>
{selected?.logo && !open && (
<img className="bank-selected-logo" src={selected.logo} alt="" />
)}
{open && institutions && (
<ul className="bank-options" role="listbox">
{shown.map((i) => (
<li key={i.name}>
<button
type="button"
className="bank-option"
role="option"
aria-selected={i.name === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
onChange(i.name, i.psu_types);
setOpen(false);
}}
>
{i.logo ? (
<img src={i.logo} alt="" loading="lazy" />
) : (
<Landmark size={16} />
)}
<span>{i.name}</span>
</button>
</li>
))}
{shown.length === 0 && (
<li className="bank-empty">No banks match “{query}”.</li>
)}
{matches.length > shown.length && (
<li className="bank-empty">
{matches.length - shown.length} more — keep typing to narrow
down.
</li>
)}
</ul>
)}
</div>
<Combobox
required
disabled={!institutions}
options={(institutions ?? []).map((i) => ({
value: i.name,
label: i.name,
icon: i.logo ? (
<img src={i.logo} alt="" loading="lazy" />
) : (
<Landmark size={16} />
),
}))}
value={value}
onChange={(name) =>
onChange(name, institutions?.find((i) => i.name === name)?.psu_types)
}
placeholder={institutions ? "Search your bank" : "Loading banks…"}
adornment={selected?.logo ? <img src={selected.logo} alt="" /> : null}
emptyText="No banks match your search."
/>
</Field>
);
}
+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
+75 -11
View File
@@ -1997,24 +1997,38 @@ footer span:first-child {
.callback-details code {
font-size: 10px;
}
.bank-select {
.combo {
position: relative;
}
.bank-select > input {
.combo > input {
width: 100%;
padding-right: 40px;
border: 1px solid #dbe2ea;
border-radius: 5px;
min-height: 39px;
padding-top: 10px;
padding-bottom: 10px;
padding-left: 11px;
min-width: 0;
color: #33445a;
background: #fff;
font-weight: 400;
}
.bank-selected-logo {
.combo-adornment {
position: absolute;
right: 11px;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
display: flex;
}
.combo-adornment img,
.combo-adornment svg {
width: 22px;
height: 22px;
object-fit: contain;
pointer-events: none;
}
.bank-options {
.combo-options {
position: absolute;
z-index: 30;
top: calc(100% + 4px);
@@ -2030,7 +2044,7 @@ footer span:first-child {
max-height: 264px;
overflow-y: auto;
}
.bank-option {
.combo-option {
display: flex;
width: 100%;
align-items: center;
@@ -2044,23 +2058,73 @@ footer span:first-child {
font-size: 13px;
color: inherit;
}
.bank-option:hover,
.bank-option[aria-selected="true"] {
.combo-option:hover,
.combo-option[aria-selected="true"] {
background: #f0f7f4;
}
.bank-option img,
.bank-option svg {
.combo-option img,
.combo-option svg {
width: 22px;
height: 22px;
object-fit: contain;
flex: none;
color: var(--muted);
}
.bank-empty {
.combo-empty {
padding: 8px 10px;
color: var(--muted);
font-size: 12px;
}
/* The proposed side of a review row is editable in place: compact combobox
inputs so a correction fits the diff card, removable chips for tags. */
.diff-value .combo > input {
min-height: 31px;
padding: 6px 24px 6px 9px;
font-size: 12px;
}
.diff-value .combo-option {
font-size: 12px;
padding: 6px 9px;
}
.diff-edit-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 22px;
}
.diff-edit-head .button {
padding: 2px 8px;
font-size: 10px;
}
.tag-edit {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.tag-edit .combo {
flex: 1;
min-width: 130px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 5px;
border: 1px solid #cfe4da;
background: #fff;
color: #2c6d57;
border-radius: 20px;
padding: 3px 5px 3px 10px;
font-size: 11px;
font-weight: 600;
}
.tag-chip svg {
color: #7fa295;
}
.tag-chip:hover svg {
color: var(--danger);
}
.date-select {
position: relative;
}
+103
View File
@@ -98,6 +98,109 @@ export function Field({
);
}
export interface ComboOption {
value: string;
label: string;
icon?: ReactNode;
}
// Combobox is a free-text input that autocompletes against a fixed option
// list: typing filters by label, Enter takes the exact or only match, and
// picking an option reports its value. The caller keeps working with stable
// ids while the user only ever sees names.
export function Combobox({
options,
value,
onChange,
placeholder,
disabled = false,
required = false,
adornment,
emptyText = "No matches.",
}: {
options: ComboOption[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
adornment?: ReactNode;
emptyText?: string;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const filter = query.trim().toLowerCase();
const matches = options.filter((o) => o.label.toLowerCase().includes(filter));
const exact = filter
? matches.find((o) => o.label.toLowerCase() === filter)
: undefined;
const shown = exact
? [exact, ...matches.filter((o) => o !== exact).slice(0, 59)]
: matches.slice(0, 60);
const selected = options.find((o) => o.value === value);
const pick = (v: string) => {
onChange(v);
setOpen(false);
};
return (
<div className="combo">
<input
required={required}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
disabled={disabled}
value={open ? query : (selected?.label ?? value)}
placeholder={placeholder}
onFocus={() => {
setQuery("");
setOpen(true);
}}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onBlur={() => setOpen(false)}
onKeyDown={(e) => {
if (e.key === "Escape") setOpen(false);
if (e.key === "Enter" && open) {
e.preventDefault();
const hit = exact ?? (shown.length === 1 ? shown[0] : undefined);
if (hit) pick(hit.value);
}
}}
/>
{adornment && !open && (
<span className="combo-adornment">{adornment}</span>
)}
{open && (
<ul className="combo-options" role="listbox">
{shown.map((o) => (
<li key={o.value}>
<button
type="button"
className="combo-option"
role="option"
aria-selected={o.value === value}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(o.value)}
>
{o.icon}
<span>{o.label}</span>
</button>
</li>
))}
{shown.length === 0 && <li className="combo-empty">{emptyText}</li>}
{matches.length > shown.length && (
<li className="combo-empty">
{matches.length - shown.length} more keep typing to narrow down.
</li>
)}
</ul>
)}
</div>
);
}
// Dates are handled as calendar days, never as instants: every helper works on
// the ISO string's integer parts so a browser time zone can never shift a
// booking date. "Sept" follows the four-letter form used in the journal UI.