This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+473
View File
@@ -0,0 +1,473 @@
import { useState } from "react";
import {
Plus,
Pencil,
GitMerge,
Trash2,
FolderTree,
Tag as TagIcon,
Store,
ChevronRight,
} from "lucide-react";
import type { Category, Dataset, Merchant, Tag } from "./api";
import { categoryPath } from "./api";
import {
CategoryOptions,
Empty,
ErrorMessage,
Field,
FormActions,
Modal,
TagPicker,
} from "./ui";
import type { Mutate } from "./ui";
type Entity = "category" | "tag" | "merchant";
type Item = Category | Tag | Merchant;
const titles = { category: "Categories", tag: "Tags", merchant: "Merchants" };
const plurals = { category: "categories", tag: "tags", merchant: "merchants" };
export function Registry({
entity,
data,
mutate,
}: {
entity: Entity;
data: Dataset;
mutate: Mutate;
}) {
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 "categories" | "tags" | "merchants"];
const create = () =>
setEditing(
entity === "category"
? { id: "", name: "", parent_id: "cat_expenses", kind: "expense" }
: entity === "merchant"
? {
id: "",
name: "",
aliases: [],
default_tag_ids: [],
use_defaults: false,
}
: { 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} />
) : (
<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>
)}
</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>
<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."
: "Recognize familiar names and choose explicit classification defaults."}
</p>
</div>
<button className="button primary" onClick={create}>
<Plus size={17} />
New {entity}
</button>
</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 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 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 [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 }
: 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,
}
: { id: item.id, name: name.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" && (
<>
<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>
</>
)}
</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 "categories" | "tags" | "merchants"];
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."
: "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>
);
}