Implement classification redesign
This commit is contained in:
+219
-8
@@ -10,8 +10,16 @@ import {
|
||||
CandlestickChart,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { Category, Dataset, Instrument, Merchant, Tag } from "./api";
|
||||
import { categoryPath } from "./api";
|
||||
import type {
|
||||
Category,
|
||||
Dataset,
|
||||
Instrument,
|
||||
Merchant,
|
||||
State,
|
||||
Tag,
|
||||
TaxonomyPreview,
|
||||
} from "./api";
|
||||
import { categoryPath, request } from "./api";
|
||||
import {
|
||||
CategoryOptions,
|
||||
Empty,
|
||||
@@ -41,10 +49,16 @@ export function Registry({
|
||||
entity,
|
||||
data,
|
||||
mutate,
|
||||
acceptState,
|
||||
revision,
|
||||
model,
|
||||
}: {
|
||||
entity: Entity;
|
||||
data: Dataset;
|
||||
mutate: Mutate;
|
||||
acceptState?: (state: State, message?: string) => void;
|
||||
revision?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<Item | null>(null);
|
||||
const [action, setAction] = useState<{
|
||||
@@ -97,6 +111,7 @@ export function Registry({
|
||||
{item.use_defaults ? " · Defaults enabled" : ""}
|
||||
</small>
|
||||
)}
|
||||
{"hint" in item && item.hint && <small>{item.hint}</small>}
|
||||
{"isin" in item && (
|
||||
<small>
|
||||
{item.isin} · {item.currency}
|
||||
@@ -169,10 +184,19 @@ export function Registry({
|
||||
: "Recognize familiar names and choose explicit classification defaults."}
|
||||
</p>
|
||||
</div>
|
||||
<button className="button primary" onClick={create}>
|
||||
<Plus size={17} />
|
||||
New {entity}
|
||||
</button>
|
||||
<div className="row-actions">
|
||||
{entity === "category" && acceptState && revision && model && (
|
||||
<TaxonomyPanel
|
||||
revision={revision}
|
||||
model={model}
|
||||
acceptState={acceptState}
|
||||
/>
|
||||
)}
|
||||
<button className="button primary" onClick={create}>
|
||||
<Plus size={17} />
|
||||
New {entity}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<section className="panel registry">
|
||||
{items.length ? (
|
||||
@@ -210,6 +234,176 @@ export function Registry({
|
||||
</>
|
||||
);
|
||||
}
|
||||
function TaxonomyPanel({
|
||||
revision,
|
||||
model,
|
||||
acceptState,
|
||||
}: {
|
||||
revision: string;
|
||||
model: string;
|
||||
acceptState: (state: State, message?: string) => void;
|
||||
}) {
|
||||
const [preview, setPreview] = useState<TaxonomyPreview | null>(null);
|
||||
const [selectedCategories, setSelectedCategories] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [selectedTags, setSelectedTags] = useState<Set<number>>(new Set());
|
||||
const [selectedMerchants, setSelectedMerchants] = useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const toggle = (
|
||||
setSelected: React.Dispatch<React.SetStateAction<Set<number>>>,
|
||||
index: number,
|
||||
) =>
|
||||
setSelected((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
const close = () => {
|
||||
setPreview(null);
|
||||
setError("");
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await request<TaxonomyPreview>(
|
||||
"/api/taxonomy/propose",
|
||||
{ revision, model },
|
||||
);
|
||||
setPreview(result);
|
||||
setSelectedCategories(new Set());
|
||||
setSelectedTags(new Set());
|
||||
setSelectedMerchants(new Set());
|
||||
} catch (err) {
|
||||
setError(String(err instanceof Error ? err.message : err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "Sampling…" : "Propose categories and tags"}
|
||||
</button>
|
||||
{preview && (
|
||||
<Modal title="Review taxonomy proposal" close={close}>
|
||||
<div className="form-body">
|
||||
<ErrorMessage error={error} />
|
||||
<p className="muted">
|
||||
Select each item to write. Existing categories, tags, and
|
||||
merchants are never changed by this proposal.
|
||||
</p>
|
||||
<h3>Categories</h3>
|
||||
{preview.proposal.categories.map((category, index) => (
|
||||
<label className="checkbox-row" key={`${category.name}-${index}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCategories.has(index)}
|
||||
onChange={() => toggle(setSelectedCategories, index)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{category.name}</strong>
|
||||
<small>
|
||||
{category.kind}
|
||||
{category.parent ? ` · ${category.parent}` : ""}
|
||||
{category.hint ? ` · ${category.hint}` : ""}
|
||||
</small>
|
||||
{category.because.length > 0 && (
|
||||
<small>Seen in: {category.because.join(" · ")}</small>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<h3>Tags</h3>
|
||||
{preview.proposal.tags.map((tag, index) => (
|
||||
<label className="checkbox-row" key={`${tag.name}-${index}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTags.has(index)}
|
||||
onChange={() => toggle(setSelectedTags, index)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{tag.name}</strong>
|
||||
{tag.hint && <small>{tag.hint}</small>}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<h3>Merchants</h3>
|
||||
{preview.proposal.merchants.map((merchant, index) => (
|
||||
<label className="checkbox-row" key={`${merchant.name}-${index}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMerchants.has(index)}
|
||||
onChange={() => toggle(setSelectedMerchants, index)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{merchant.name}</strong>
|
||||
<small>
|
||||
{merchant.aliases.length
|
||||
? merchant.aliases.join(" · ")
|
||||
: "No aliases"}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="form-actions">
|
||||
<button className="button" type="button" onClick={close}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="button primary"
|
||||
type="button"
|
||||
disabled={
|
||||
busy ||
|
||||
(selectedCategories.size === 0 &&
|
||||
selectedTags.size === 0 &&
|
||||
selectedMerchants.size === 0)
|
||||
}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
const applied = await request<State>("/api/taxonomy/apply", {
|
||||
id: preview.id,
|
||||
revision: preview.revision,
|
||||
approved: {
|
||||
categories: preview.proposal.categories.filter((_, i) =>
|
||||
selectedCategories.has(i),
|
||||
),
|
||||
tags: preview.proposal.tags.filter((_, i) =>
|
||||
selectedTags.has(i),
|
||||
),
|
||||
merchants: preview.proposal.merchants.filter((_, i) =>
|
||||
selectedMerchants.has(i),
|
||||
),
|
||||
},
|
||||
});
|
||||
acceptState(applied, "Approved taxonomy written");
|
||||
close();
|
||||
} catch (err) {
|
||||
setError(String(err instanceof Error ? err.message : err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Apply selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function RegistryEditor({
|
||||
entity,
|
||||
item,
|
||||
@@ -228,6 +422,7 @@ function RegistryEditor({
|
||||
const [parent, setParent] = useState(
|
||||
"parent_id" in item ? item.parent_id || "" : "",
|
||||
);
|
||||
const [hint, setHint] = useState("hint" in item ? item.hint || "" : "");
|
||||
const merchant = "aliases" in item ? item : null;
|
||||
const [aliases, setAliases] = useState(merchant?.aliases.join("\n") || "");
|
||||
const [category, setCategory] = useState(merchant?.default_category_id || "");
|
||||
@@ -262,7 +457,13 @@ function RegistryEditor({
|
||||
try {
|
||||
const result =
|
||||
entity === "category"
|
||||
? { id: item.id, name: name.trim(), kind, parent_id: parent }
|
||||
? {
|
||||
id: item.id,
|
||||
name: name.trim(),
|
||||
kind,
|
||||
parent_id: parent,
|
||||
hint: hint.trim(),
|
||||
}
|
||||
: entity === "merchant"
|
||||
? {
|
||||
id: item.id,
|
||||
@@ -286,7 +487,7 @@ function RegistryEditor({
|
||||
name: name.trim(),
|
||||
currency: currency.toUpperCase(),
|
||||
}
|
||||
: { id: item.id, name: name.trim() };
|
||||
: { id: item.id, name: name.trim(), hint: hint.trim() };
|
||||
await mutate(
|
||||
`/api/${plurals[entity]}`,
|
||||
{ [entity]: result },
|
||||
@@ -311,6 +512,16 @@ function RegistryEditor({
|
||||
autoFocus
|
||||
/>
|
||||
</Field>
|
||||
{(entity === "category" || entity === "tag") && (
|
||||
<Field label="Hint" hint="Explain when this category or tag applies to the AI classifier.">
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
value={hint}
|
||||
onChange={(e) => setHint(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{entity === "category" && (
|
||||
<>
|
||||
<Field label="Kind">
|
||||
|
||||
Reference in New Issue
Block a user