Files
finance-duck/web/src/Registry.tsx
T
2026-09-11 23:36:52 +02:00

763 lines
24 KiB
TypeScript

import { useState } from "react";
import {
Plus,
Pencil,
GitMerge,
Trash2,
FolderTree,
Tag as TagIcon,
Store,
CandlestickChart,
ChevronRight,
} from "lucide-react";
import type {
Category,
Dataset,
Instrument,
Merchant,
State,
Tag,
TaxonomyPreview,
} from "./api";
import { categoryPath, request } from "./api";
import {
CategoryOptions,
Empty,
ErrorMessage,
Field,
FormActions,
Modal,
TagPicker,
} from "./ui";
import type { Mutate } from "./ui";
type Entity = "category" | "tag" | "merchant" | "instrument";
type Item = Category | Tag | Merchant | Instrument;
const titles = {
category: "Categories",
tag: "Tags",
merchant: "Merchants",
instrument: "Instruments",
};
const plurals = {
category: "categories",
tag: "tags",
merchant: "merchants",
instrument: "instruments",
};
type Plural = "categories" | "tags" | "merchants" | "instruments";
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<{
item: Item;
action: "merge" | "delete";
} | null>(null);
const items: Item[] = data[plurals[entity] as Plural];
const create = () =>
setEditing(
entity === "category"
? { id: "", name: "", parent_id: "cat_expenses", kind: "expense" }
: entity === "merchant"
? {
id: "",
name: "",
aliases: [],
default_tag_ids: [],
use_defaults: false,
}
: entity === "instrument"
? { id: "", isin: "", name: "", currency: "EUR" }
: { id: "", name: "" },
);
const row = (item: Item, depth = 0) => (
<div className="registry-row" key={item.id}>
<div
className="registry-label"
style={{ paddingLeft: `${depth * 23}px` }}
>
{entity === "category" ? (
<FolderTree size={18} />
) : entity === "tag" ? (
<TagIcon size={18} />
) : entity === "instrument" ? (
<CandlestickChart size={18} />
) : (
<Store size={18} />
)}
<div>
<strong>{item.name}</strong>
{"kind" in item && (
<small>
{item.kind}
{!item.parent_id ? " root" : ""}
</small>
)}
{"aliases" in item && (
<small>
{item.aliases.length ? item.aliases.join(" · ") : "No aliases"}
{item.use_defaults ? " · Defaults enabled" : ""}
</small>
)}
{"hint" in item && item.hint && <small>{item.hint}</small>}
{"isin" in item && (
<small>
{item.isin} · {item.currency}
</small>
)}
</div>
</div>
{"default_category_id" in item && item.default_category_id && (
<span className="muted registry-detail">
{categoryPath(data, item.default_category_id)}
</span>
)}
<div className="row-actions">
<button
className="icon-button"
title={`Edit ${item.name}`}
aria-label={`Edit ${item.name}`}
onClick={() => setEditing(item)}
>
<Pencil size={16} />
</button>
{entity !== "instrument" && (
<button
className="icon-button"
title={`Merge ${item.name}`}
aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "merge" })}
>
<GitMerge size={16} />
</button>
)}
<button
className="icon-button danger"
title={`Delete ${item.name}`}
aria-label={`Delete ${item.name}`}
onClick={() => setAction({ item, action: "delete" })}
>
<Trash2 size={16} />
</button>
</div>
</div>
);
const tree = (
parent: string | undefined,
depth = 0,
visited = new Set<string>(),
): React.ReactNode =>
data.categories
.filter(
(c) => (c.parent_id || "") === (parent || "") && !visited.has(c.id),
)
.map((c) => (
<div key={c.id}>
{row(c, depth)}
{tree(c.id, depth + 1, new Set([...visited, c.id]))}
</div>
));
return (
<>
<div className="section-heading">
<div>
<h2>{titles[entity]}</h2>
<p>
{entity === "category"
? "A clear home for every transaction. Parent categories roll up their children."
: entity === "tag"
? "Flexible labels that work across your accounts and categories."
: entity === "instrument"
? "The securities your broker rows trade. The ISIN is the identity; the name is yours to correct."
: "Recognize familiar names and choose explicit classification defaults."}
</p>
</div>
<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 ? (
entity === "category" ? (
tree(undefined)
) : (
items.map((item) => row(item))
)
) : (
<Empty title={`No ${titles[entity].toLowerCase()} yet`}>
Create your first {entity} to organize transactions.
</Empty>
)}
</section>
{editing && (
<RegistryEditor
key={editing.id}
entity={entity}
item={editing}
data={data}
mutate={mutate}
close={() => setEditing(null)}
/>
)}{" "}
{action && (
<ManageDialog
entity={entity}
item={action.item}
action={action.action}
data={data}
mutate={mutate}
close={() => setAction(null)}
/>
)}
</>
);
}
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,
data,
mutate,
close,
}: {
entity: Entity;
item: Item;
data: Dataset;
mutate: Mutate;
close: () => void;
}) {
const [name, setName] = useState(item.name);
const [kind, setKind] = useState("kind" in item ? item.kind : "expense");
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 || "");
const [tags, setTags] = useState(merchant?.default_tag_ids || []);
const [defaults, setDefaults] = useState(merchant?.use_defaults || false);
const instrument = "isin" in item ? item : null;
const [isin, setIsin] = useState(instrument?.isin || "");
const [currency, setCurrency] = useState(instrument?.currency || "EUR");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const descendants = new Set([item.id]);
let changed = true;
while (changed) {
changed = false;
for (const c of data.categories)
if (
c.parent_id &&
descendants.has(c.parent_id) &&
!descendants.has(c.id)
) {
descendants.add(c.id);
changed = true;
}
}
return (
<Modal title={`${item.id ? "Edit" : "New"} ${entity}`} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
const result =
entity === "category"
? {
id: item.id,
name: name.trim(),
kind,
parent_id: parent,
hint: hint.trim(),
}
: entity === "merchant"
? {
id: item.id,
name: name.trim(),
aliases: Array.from(
new Set(
aliases
.split("\n")
.map((a) => a.trim())
.filter(Boolean),
),
),
default_category_id: category,
default_tag_ids: tags,
use_defaults: defaults,
}
: entity === "instrument"
? {
id: item.id,
isin: isin.replaceAll(" ", "").toUpperCase(),
name: name.trim(),
currency: currency.toUpperCase(),
}
: { id: item.id, name: name.trim(), hint: hint.trim() };
await mutate(
`/api/${plurals[entity]}`,
{ [entity]: result },
`${name.trim()} saved`,
);
close();
} catch (err) {
setError(String(err instanceof Error ? err.message : err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<Field label="Name">
<input
required
maxLength={200}
value={name}
onChange={(e) => setName(e.target.value)}
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">
<select
value={kind}
onChange={(e) => {
setKind(e.target.value);
setParent("");
}}
>
<option value="expense">Expense</option>
<option value="income">Income</option>
</select>
</Field>
<Field label="Parent category">
<select
value={parent}
onChange={(e) => setParent(e.target.value)}
>
<option value="">No parent (root)</option>
<CategoryOptions
data={data}
kind={kind}
exclude={[...descendants]}
/>
</select>
</Field>
<p className="muted">
Changing the parent moves this category and its entire subtree.
The server protects fallback categories and validates
references.
</p>
</>
)}
{entity === "merchant" && (
<>
<Field
label="Aliases"
hint="One exact merchant alias per line. These help recognize future transactions."
>
<textarea
rows={4}
value={aliases}
onChange={(e) => setAliases(e.target.value)}
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={defaults}
onChange={(e) => setDefaults(e.target.checked)}
/>
Use these defaults when this merchant is recognized
</label>
<Field label="Default category">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">No default category</option>
<CategoryOptions data={data} />
</select>
</Field>
<TagPicker data={data} value={tags} onChange={setTags} />
<p className="muted">
Defaults are only used when explicitly enabled. Editing defaults
does not rewrite existing transactions.
</p>
</>
)}
{entity === "instrument" && (
<>
<Field
label="ISIN"
hint={
item.id
? "An instrument's ISIN is its identity: the trades were imported under it and the server refuses to change it. Register a different security separately."
: "Twelve characters: two country letters, nine alphanumerics and a check digit."
}
>
<input
required
readOnly={!!item.id}
maxLength={12}
value={isin}
onChange={(e) => setIsin(e.target.value.toUpperCase())}
/>
</Field>
<Field
label="Currency"
hint="The currency the broker prices this security in."
>
<input
required
pattern="[A-Z]{3}"
maxLength={3}
value={currency}
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
/>
</Field>
<p className="muted">
The broker's own description for one ISIN changes over time, so
the name is display text you can correct. Renaming does not
touch a single imported trade.
</p>
</>
)}
</div>
<FormActions busy={busy} close={close} />
</form>
</Modal>
);
}
function ManageDialog({
entity,
item,
action,
data,
mutate,
close,
}: {
entity: Entity;
item: Item;
action: "delete" | "merge";
data: Dataset;
mutate: Mutate;
close: () => void;
}) {
const [target, setTarget] = useState("");
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const items: Item[] = data[plurals[entity] as Plural];
return (
<Modal
title={`${action === "merge" ? "Merge" : "Delete"} ${item.name}`}
close={close}
>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/manage",
{ entity, action, id: item.id, target_id: target },
`${item.name} ${action === "merge" ? "merged" : "deleted"}`,
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p>
{action === "merge"
? "References will move to the destination and the source will be removed. Review the destination carefully."
: entity === "tag"
? "This tag will be removed from every transaction and merchant default. The original bank facts will not change."
: entity === "category"
? "Referenced categories need a replacement. Protected roots and unsafe tree changes cannot be deleted."
: entity === "instrument"
? "Remove this security from your registry. An instrument any imported trade still references cannot be deleted: the server refuses it and says so."
: "Remove this merchant from your registry. Referenced merchants may require a merge instead."}
</p>
{(action === "merge" || entity === "category") && (
<Field
label={
action === "merge"
? "Merge into"
: "Replacement category (if referenced)"
}
>
<select
required={action === "merge"}
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">Choose destination</option>
{items
.filter((i) => i.id !== item.id)
.map((i) => (
<option value={i.id} key={i.id}>
{entity === "category"
? categoryPath(data, i.id)
: i.name}
</option>
))}
</select>
</Field>
)}
<label className="checkbox">
<input
required
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
I understand this changes all references and cannot be undone here.
</label>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
disabled={busy}
onClick={close}
>
Cancel
</button>
<button className="button destructive" disabled={busy || !confirm}>
{action === "merge" ? (
<ChevronRight size={16} />
) : (
<Trash2 size={16} />
)}{" "}
{busy
? "Working…"
: action === "merge"
? "Merge permanently"
: "Delete permanently"}
</button>
</div>
</form>
</Modal>
);
}