new classification ui
This commit is contained in:
@@ -242,7 +242,7 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
|
|||||||
}
|
}
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
}
|
}
|
||||||
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
|
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}, nil); err == nil {
|
||||||
t.Fatal("cancelled run produced an applicable preview")
|
t.Fatal("cancelled run produced an applicable preview")
|
||||||
}
|
}
|
||||||
after, err := a.Snapshot(context.Background())
|
after, err := a.Snapshot(context.Background())
|
||||||
@@ -334,7 +334,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
|||||||
t.Fatal("preview mutated canonical records")
|
t.Fatal("preview mutated canonical records")
|
||||||
}
|
}
|
||||||
id := preview.Changes[0].ID
|
id := preview.Changes[0].ID
|
||||||
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id})
|
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -356,7 +356,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
|||||||
t.Fatal("unselected transaction changed")
|
t.Fatal("unselected transaction changed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil {
|
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil); err == nil {
|
||||||
t.Fatal("consumed preview applied twice")
|
t.Fatal("consumed preview applied twice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -375,7 +375,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
|
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
|
||||||
t.Fatal("stale preview overwrote manual edit")
|
t.Fatal("stale preview overwrote manual edit")
|
||||||
}
|
}
|
||||||
after, err := a.Snapshot(context.Background())
|
after, err := a.Snapshot(context.Background())
|
||||||
@@ -410,13 +410,13 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID})
|
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unrelated commit invalidated the preview: %v", err)
|
t.Fatalf("unrelated commit invalidated the preview: %v", err)
|
||||||
}
|
}
|
||||||
// The partial apply moved the revision again; the remaining proposal must
|
// The partial apply moved the revision again; the remaining proposal must
|
||||||
// still apply without another paced provider run.
|
// still apply without another paced provider run.
|
||||||
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID})
|
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("partial apply consumed the remaining proposals: %v", err)
|
t.Fatalf("partial apply consumed the remaining proposals: %v", err)
|
||||||
}
|
}
|
||||||
@@ -429,11 +429,62 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Both changes are consumed now; re-applying must fail, not double-write.
|
// Both changes are consumed now; re-applying must fail, not double-write.
|
||||||
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
|
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
|
||||||
t.Fatal("consumed change applied twice")
|
t.Fatal("consumed change applied twice")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A reviewer can correct a proposal before applying it: the corrected fields
|
||||||
|
// land instead of the model's, provenance becomes manual, and an invalid or
|
||||||
|
// unselected correction rejects the whole apply.
|
||||||
|
func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
a, s := testApp(t)
|
||||||
|
s = seed(t, a, s)
|
||||||
|
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
|
||||||
|
d.Categories = append(d.Categories, domain.Category{ID: "dining", Name: "Dining", ParentID: "cat_expenses", Kind: "expense"})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mockClassifier(t, a)
|
||||||
|
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(p.Changes) != 2 {
|
||||||
|
t.Fatalf("expected two proposed changes: %+v", p)
|
||||||
|
}
|
||||||
|
edited, other := p.Changes[0], p.Changes[1]
|
||||||
|
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "nonexistent", TagIDs: []string{}}}); err == nil {
|
||||||
|
t.Fatal("edit naming an unknown category was applied")
|
||||||
|
}
|
||||||
|
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: other.ID, CategoryID: "dining", TagIDs: []string{}}}); err == nil {
|
||||||
|
t.Fatal("edit for an unselected transaction was accepted")
|
||||||
|
}
|
||||||
|
applied, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID, other.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "dining", TagIDs: []string{"home"}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, tx := range applied.Data.Transactions {
|
||||||
|
e := tx.Enrichment
|
||||||
|
switch tx.Facts.ID {
|
||||||
|
case edited.ID:
|
||||||
|
if e.CategoryID != "dining" || !reflect.DeepEqual(e.TagIDs, []string{"home"}) {
|
||||||
|
t.Fatalf("reviewer correction lost: %+v", e)
|
||||||
|
}
|
||||||
|
if e.Classification.Source != "manual" {
|
||||||
|
t.Fatalf("corrected change kept model provenance: %+v", e.Classification)
|
||||||
|
}
|
||||||
|
case other.ID:
|
||||||
|
if e.CategoryID != "groceries" || e.Classification.Source == "manual" {
|
||||||
|
t.Fatalf("uncorrected change altered: %+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Imports auto-apply only what the model is sure about: a low-confidence
|
// Imports auto-apply only what the model is sure about: a low-confidence
|
||||||
// category lands on the editable fallback while the merchant link and the
|
// category lands on the editable fallback while the merchant link and the
|
||||||
// recorded confidence survive for review in Analyse.
|
// recorded confidence survive for review in Analyse.
|
||||||
|
|||||||
@@ -34,6 +34,16 @@ type Change struct {
|
|||||||
Before domain.Enrichment `json:"before"`
|
Before domain.Enrichment `json:"before"`
|
||||||
After domain.Enrichment `json:"after"`
|
After domain.Enrichment `json:"after"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnrichmentEdit is a reviewer's correction to one proposal: it replaces the
|
||||||
|
// proposed category and tags before the change is applied. A corrected
|
||||||
|
// transaction is classified by the human, not the model, so its provenance
|
||||||
|
// becomes manual and later runs treat it accordingly.
|
||||||
|
type EnrichmentEdit struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
CategoryID string `json:"category_id"`
|
||||||
|
TagIDs []string `json:"tag_ids"`
|
||||||
|
}
|
||||||
type ClassificationError struct {
|
type ClassificationError struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
@@ -329,7 +339,7 @@ func enrichmentEqual(a, b domain.Enrichment) bool {
|
|||||||
// invalidate the review; only a selected transaction whose own enrichment
|
// invalidate the review; only a selected transaction whose own enrichment
|
||||||
// changed since the preview snapshot conflicts. Applied changes are pruned so
|
// changed since the preview snapshot conflicts. Applied changes are pruned so
|
||||||
// the remaining proposals stay appliable without another paced provider run.
|
// the remaining proposals stay appliable without another paced provider run.
|
||||||
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (State, error) {
|
func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string, edits []EnrichmentEdit) (State, error) {
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
p, ok := a.previews[id]
|
p, ok := a.previews[id]
|
||||||
@@ -357,6 +367,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
|||||||
if len(selected) == 0 {
|
if len(selected) == 0 {
|
||||||
return State{}, errors.New("select at least one change")
|
return State{}, errors.New("select at least one change")
|
||||||
}
|
}
|
||||||
|
edited := map[string]EnrichmentEdit{}
|
||||||
|
for _, e := range edits {
|
||||||
|
if !selected[e.ID] {
|
||||||
|
return State{}, errors.New("edited transaction is not selected")
|
||||||
|
}
|
||||||
|
edited[e.ID] = e
|
||||||
|
}
|
||||||
|
// Edits are validated against the dataset the change will land in, which
|
||||||
|
// includes merchants this preview mints only when the change is applied.
|
||||||
|
validation := s.Data
|
||||||
|
validation.Merchants = append(append([]domain.Merchant{}, s.Data.Merchants...), p.NewMerchants...)
|
||||||
applied := 0
|
applied := 0
|
||||||
needed := map[string]bool{}
|
needed := map[string]bool{}
|
||||||
for i, t := range s.Data.Transactions {
|
for i, t := range s.Data.Transactions {
|
||||||
@@ -367,8 +388,17 @@ func (a *App) ApplyPreview(ctx context.Context, id, rev string, ids []string) (S
|
|||||||
if !enrichmentEqual(t.Enrichment, c.Before) {
|
if !enrichmentEqual(t.Enrichment, c.Before) {
|
||||||
return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again")
|
return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again")
|
||||||
}
|
}
|
||||||
s.Data.Transactions[i].Enrichment = c.After
|
after := c.After
|
||||||
needed[c.After.MerchantID] = true
|
if e, ok := edited[t.Facts.ID]; ok {
|
||||||
|
after.CategoryID = e.CategoryID
|
||||||
|
after.TagIDs = append([]string{}, e.TagIDs...)
|
||||||
|
after.Classification = domain.Provenance{Source: "manual", Timestamp: time.Now().UTC().Format(time.RFC3339)}
|
||||||
|
if err := domain.ValidateEnrichment(validation, t.Facts, after); err != nil {
|
||||||
|
return State{}, fmt.Errorf("edited classification for %s is invalid: %w", t.Facts.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.Data.Transactions[i].Enrichment = after
|
||||||
|
needed[after.MerchantID] = true
|
||||||
applied++
|
applied++
|
||||||
}
|
}
|
||||||
if applied != len(selected) {
|
if applied != len(selected) {
|
||||||
|
|||||||
@@ -511,11 +511,12 @@ func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Revision string `json:"revision"`
|
Revision string `json:"revision"`
|
||||||
TransactionIDs []string `json:"transaction_ids"`
|
TransactionIDs []string `json:"transaction_ids"`
|
||||||
|
Edits []app.EnrichmentEdit `json:"edits"`
|
||||||
}
|
}
|
||||||
if !decode(w, r, &b) {
|
if !decode(w, r, &b) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs)
|
v, e := s.app.ApplyPreview(r.Context(), b.ID, b.Revision, b.TransactionIDs, b.Edits)
|
||||||
respond(w, v, e)
|
respond(w, v, e)
|
||||||
}
|
}
|
||||||
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+16
-76
@@ -12,7 +12,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Account, Institution, PreparedImport, State } from "./api";
|
import type { Account, Institution, PreparedImport, State } from "./api";
|
||||||
import { localInstant, money, request } 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";
|
import type { Mutate } from "./ui";
|
||||||
interface Balance {
|
interface Balance {
|
||||||
amount: string;
|
amount: string;
|
||||||
@@ -1110,8 +1110,6 @@ function InstitutionSelect({
|
|||||||
}) {
|
}) {
|
||||||
const [institutions, setInstitutions] = useState<Institution[] | null>(null);
|
const [institutions, setInstitutions] = useState<Institution[] | null>(null);
|
||||||
const [loadError, setLoadError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInstitutions(null);
|
setInstitutions(null);
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
@@ -1147,90 +1145,32 @@ function InstitutionSelect({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</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);
|
const selected = institutions?.find((i) => i.name === value);
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
label="Institution"
|
label="Institution"
|
||||||
hint="Choose your bank as listed by Enable Banking."
|
hint="Choose your bank as listed by Enable Banking."
|
||||||
>
|
>
|
||||||
<div className="bank-select">
|
<Combobox
|
||||||
<input
|
|
||||||
required
|
required
|
||||||
role="combobox"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-autocomplete="list"
|
|
||||||
disabled={!institutions}
|
disabled={!institutions}
|
||||||
value={open ? query : value}
|
options={(institutions ?? []).map((i) => ({
|
||||||
placeholder={institutions ? "Search your bank" : "Loading banks…"}
|
value: i.name,
|
||||||
onFocus={() => {
|
label: i.name,
|
||||||
setQuery("");
|
icon: i.logo ? (
|
||||||
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" />
|
<img src={i.logo} alt="" loading="lazy" />
|
||||||
) : (
|
) : (
|
||||||
<Landmark size={16} />
|
<Landmark size={16} />
|
||||||
)}
|
),
|
||||||
<span>{i.name}</span>
|
}))}
|
||||||
</button>
|
value={value}
|
||||||
</li>
|
onChange={(name) =>
|
||||||
))}
|
onChange(name, institutions?.find((i) => i.name === name)?.psu_types)
|
||||||
{shown.length === 0 && (
|
}
|
||||||
<li className="bank-empty">No banks match “{query}”.</li>
|
placeholder={institutions ? "Search your bank" : "Loading banks…"}
|
||||||
)}
|
adornment={selected?.logo ? <img src={selected.logo} alt="" /> : null}
|
||||||
{matches.length > shown.length && (
|
emptyText="No banks match your search."
|
||||||
<li className="bank-empty">
|
/>
|
||||||
{matches.length - shown.length} more — keep typing to narrow
|
|
||||||
down.
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+199
-9
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
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 {
|
import type {
|
||||||
Dataset,
|
Dataset,
|
||||||
Enrichment,
|
Enrichment,
|
||||||
@@ -9,6 +16,7 @@ import type {
|
|||||||
} from "./api";
|
} from "./api";
|
||||||
import { categoryPath, money, request } from "./api";
|
import { categoryPath, money, request } from "./api";
|
||||||
import {
|
import {
|
||||||
|
Combobox,
|
||||||
DateField,
|
DateField,
|
||||||
Empty,
|
Empty,
|
||||||
ErrorMessage,
|
ErrorMessage,
|
||||||
@@ -39,6 +47,9 @@ export function Classification({
|
|||||||
const [confirm, setConfirm] = useState(false);
|
const [confirm, setConfirm] = useState(false);
|
||||||
const [running, setRunning] = useState<PreviewProgress | null>(null);
|
const [running, setRunning] = useState<PreviewProgress | null>(null);
|
||||||
const runStart = useRef({ time: 0, analysed: 0 });
|
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) => {
|
const finalize = (result: Preview) => {
|
||||||
result.changes ??= [];
|
result.changes ??= [];
|
||||||
result.errors ??= [];
|
result.errors ??= [];
|
||||||
@@ -58,6 +69,7 @@ export function Classification({
|
|||||||
(confidenceRank[b.after.classification.confidence || "low"] ?? 0),
|
(confidenceRank[b.after.classification.confidence || "low"] ?? 0),
|
||||||
);
|
);
|
||||||
setPreview(result);
|
setPreview(result);
|
||||||
|
setEdits({});
|
||||||
setSelected(
|
setSelected(
|
||||||
result.changes
|
result.changes
|
||||||
.filter((change) => change.after.classification.confidence !== "low")
|
.filter((change) => change.after.classification.confidence !== "low")
|
||||||
@@ -129,6 +141,7 @@ export function Classification({
|
|||||||
setRunning(null);
|
setRunning(null);
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
|
setEdits({});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : String(err));
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -141,6 +154,34 @@ export function Classification({
|
|||||||
merchants: [...state.data.merchants, ...preview.new_merchants],
|
merchants: [...state.data.merchants, ...preview.new_merchants],
|
||||||
}
|
}
|
||||||
: state.data;
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
@@ -372,7 +413,9 @@ export function Classification({
|
|||||||
<div>
|
<div>
|
||||||
<h3>Review changes</h3>
|
<h3>Review changes</h3>
|
||||||
<p>
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="row-actions">
|
<div className="row-actions">
|
||||||
@@ -395,12 +438,13 @@ export function Classification({
|
|||||||
{preview.changes.length ? (
|
{preview.changes.length ? (
|
||||||
<div className="preview-list">
|
<div className="preview-list">
|
||||||
{preview.changes.map((change) => (
|
{preview.changes.map((change) => (
|
||||||
<label
|
<div
|
||||||
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
|
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
|
||||||
key={change.id}
|
key={change.id}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
aria-label={`Apply ${change.description || change.counterparty || change.id}`}
|
||||||
checked={selected.includes(change.id)}
|
checked={selected.includes(change.id)}
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -430,14 +474,17 @@ export function Classification({
|
|||||||
label="Before"
|
label="Before"
|
||||||
/>
|
/>
|
||||||
<ArrowRight size={18} />
|
<ArrowRight size={18} />
|
||||||
<EnrichmentView
|
<CorrectionEditor
|
||||||
data={previewData}
|
data={previewData}
|
||||||
value={change.after}
|
change={change}
|
||||||
label="Proposed"
|
value={effective(change)}
|
||||||
|
edited={change.id in edits}
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(value) => correct(change, value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -495,8 +542,19 @@ export function Classification({
|
|||||||
<p>
|
<p>
|
||||||
This will replace the selected enrichment fields on{" "}
|
This will replace the selected enrichment fields on{" "}
|
||||||
<strong>{selected.length} transactions</strong> in one journal
|
<strong>{selected.length} transactions</strong> in one journal
|
||||||
commit. Unselected proposals will not be applied. Original bank
|
commit.
|
||||||
facts remain unchanged.
|
{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>
|
</p>
|
||||||
<ErrorMessage error={error} />
|
<ErrorMessage error={error} />
|
||||||
</div>
|
</div>
|
||||||
@@ -519,6 +577,9 @@ export function Classification({
|
|||||||
id: preview.id,
|
id: preview.id,
|
||||||
revision: preview.revision,
|
revision: preview.revision,
|
||||||
transaction_ids: selected,
|
transaction_ids: selected,
|
||||||
|
edits: selected
|
||||||
|
.filter((id) => id in edits)
|
||||||
|
.map((id) => ({ id, ...edits[id] })),
|
||||||
});
|
});
|
||||||
acceptState(
|
acceptState(
|
||||||
result,
|
result,
|
||||||
@@ -532,6 +593,13 @@ export function Classification({
|
|||||||
? { ...preview, changes: remaining }
|
? { ...preview, changes: remaining }
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
setEdits((prev) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(prev).filter(
|
||||||
|
([id]) => !selected.includes(id),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
setConfirm(false);
|
setConfirm(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -589,6 +657,128 @@ function EnrichmentView({
|
|||||||
</div>
|
</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
|
// remainingEstimate projects the finish time from the pace observed since
|
||||||
// this page attached to the run; the server paces provider requests, so the
|
// 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
|
// first sample is meaningless and re-attaching mid-run must not count work
|
||||||
|
|||||||
+75
-11
@@ -1997,24 +1997,38 @@ footer span:first-child {
|
|||||||
.callback-details code {
|
.callback-details code {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
.bank-select {
|
.combo {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
.bank-select > input {
|
.combo > input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-right: 40px;
|
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;
|
position: absolute;
|
||||||
right: 11px;
|
right: 11px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.combo-adornment img,
|
||||||
|
.combo-adornment svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
.bank-options {
|
.combo-options {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
top: calc(100% + 4px);
|
top: calc(100% + 4px);
|
||||||
@@ -2030,7 +2044,7 @@ footer span:first-child {
|
|||||||
max-height: 264px;
|
max-height: 264px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
.bank-option {
|
.combo-option {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2044,23 +2058,73 @@ footer span:first-child {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
.bank-option:hover,
|
.combo-option:hover,
|
||||||
.bank-option[aria-selected="true"] {
|
.combo-option[aria-selected="true"] {
|
||||||
background: #f0f7f4;
|
background: #f0f7f4;
|
||||||
}
|
}
|
||||||
.bank-option img,
|
.combo-option img,
|
||||||
.bank-option svg {
|
.combo-option svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
.bank-empty {
|
.combo-empty {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 12px;
|
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 {
|
.date-select {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
+103
@@ -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
|
// 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
|
// 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.
|
// booking date. "Sept" follows the four-letter form used in the journal UI.
|
||||||
|
|||||||
Reference in New Issue
Block a user