new classification ui
This commit is contained in:
@@ -242,7 +242,7 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
|
||||
}
|
||||
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")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
@@ -334,7 +334,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
||||
t.Fatal("preview mutated canonical records")
|
||||
}
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -356,7 +356,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -375,7 +375,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
|
||||
if err != nil {
|
||||
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")
|
||||
}
|
||||
after, err := a.Snapshot(context.Background())
|
||||
@@ -410,13 +410,13 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
|
||||
}); err != nil {
|
||||
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 {
|
||||
t.Fatalf("unrelated commit invalidated the preview: %v", err)
|
||||
}
|
||||
// The partial apply moved the revision again; the remaining proposal must
|
||||
// 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 {
|
||||
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.
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// category lands on the editable fallback while the merchant link and the
|
||||
// recorded confidence survive for review in Analyse.
|
||||
|
||||
@@ -34,6 +34,16 @@ type Change struct {
|
||||
Before domain.Enrichment `json:"before"`
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
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
|
||||
// changed since the preview snapshot conflicts. Applied changes are pruned so
|
||||
// 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()
|
||||
defer a.mu.Unlock()
|
||||
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 {
|
||||
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
|
||||
needed := map[string]bool{}
|
||||
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) {
|
||||
return State{}, errors.New("revision conflict: a selected transaction changed after the preview; analyse it again")
|
||||
}
|
||||
s.Data.Transactions[i].Enrichment = c.After
|
||||
needed[c.After.MerchantID] = true
|
||||
after := c.After
|
||||
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++
|
||||
}
|
||||
if applied != len(selected) {
|
||||
|
||||
@@ -508,14 +508,15 @@ func (s *Server) previewProgress(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
func (s *Server) apply(w http.ResponseWriter, r *http.Request) {
|
||||
var b struct {
|
||||
ID string `json:"id"`
|
||||
Revision string `json:"revision"`
|
||||
TransactionIDs []string `json:"transaction_ids"`
|
||||
ID string `json:"id"`
|
||||
Revision string `json:"revision"`
|
||||
TransactionIDs []string `json:"transaction_ids"`
|
||||
Edits []app.EnrichmentEdit `json:"edits"`
|
||||
}
|
||||
if !decode(w, r, &b) {
|
||||
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)
|
||||
}
|
||||
func (s *Server) cancel(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+21
-81
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user