init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user