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 { CategoryCombobox, 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(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", symbol: "" } : { id: "", name: "" }, ); const row = (item: Item, depth = 0) => (
{entity === "category" ? ( ) : entity === "tag" ? ( ) : entity === "instrument" ? ( ) : ( )}
{item.name} {"kind" in item && ( {item.kind} {!item.parent_id ? " root" : ""} )} {"aliases" in item && ( {item.aliases.length ? item.aliases.join(" · ") : "No aliases"} {item.use_defaults ? " · Defaults enabled" : ""} )} {"hint" in item && item.hint && {item.hint}} {"isin" in item && ( {item.isin} · {item.currency} ·{" "} {item.symbol ? item.quote ? `${item.symbol} at ${item.quote} on ${item.quoted_at}` : `${item.symbol}, not yet quoted` : "No market symbol, so unpriced"} )}
{"default_category_id" in item && item.default_category_id && ( {categoryPath(data, item.default_category_id)} )}
{entity !== "instrument" && ( )}
); const tree = ( parent: string | undefined, depth = 0, visited = new Set(), ): React.ReactNode => data.categories .filter( (c) => (c.parent_id || "") === (parent || "") && !visited.has(c.id), ) .map((c) => (
{row(c, depth)} {tree(c.id, depth + 1, new Set([...visited, c.id]))}
)); return ( <>

{titles[entity]}

{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."}

{entity === "category" && acceptState && revision && model && ( )}
{items.length ? ( entity === "category" ? ( tree(undefined) ) : ( items.map((item) => row(item)) ) ) : ( Create your first {entity} to organize transactions. )}
{editing && ( setEditing(null)} /> )}{" "} {action && ( setAction(null)} /> )} ); } function TaxonomyPanel({ revision, model, acceptState, }: { revision: string; model: string; acceptState: (state: State, message?: string) => void; }) { const [preview, setPreview] = useState(null); const [selectedCategories, setSelectedCategories] = useState>( new Set(), ); const [selectedTags, setSelectedTags] = useState>(new Set()); const [selectedMerchants, setSelectedMerchants] = useState>( new Set(), ); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const toggle = ( setSelected: React.Dispatch>>, 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 ( <> {preview && (

Select each item to write. Existing categories, tags, and merchants are never changed by this proposal.

Categories

{preview.proposal.categories.map((category, index) => ( ))}

Tags

{preview.proposal.tags.map((tag, index) => ( ))}

Merchants

{preview.proposal.merchants.map((merchant, index) => ( ))}
)} ); } 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 [symbol, setSymbol] = useState(instrument?.symbol || ""); 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 (
{ 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(), symbol: symbol.trim(), } : { 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); } }} >
setName(e.target.value)} autoFocus /> {(entity === "category" || entity === "tag") && (