763 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|