Clarify category creation flows

This commit is contained in:
Lars Nolden
2026-09-14 15:44:20 +02:00
parent 71e95917da
commit 8aab21fe9e
4 changed files with 120 additions and 81 deletions
+105 -75
View File
@@ -5,6 +5,7 @@ import {
GitMerge, GitMerge,
Trash2, Trash2,
FolderTree, FolderTree,
FolderPlus,
Tag as TagIcon, Tag as TagIcon,
Store, Store,
CandlestickChart, CandlestickChart,
@@ -32,6 +33,10 @@ import {
import type { Mutate } from "./ui"; import type { Mutate } from "./ui";
type Entity = "category" | "tag" | "merchant" | "instrument"; type Entity = "category" | "tag" | "merchant" | "instrument";
type Item = Category | Tag | Merchant | Instrument; type Item = Category | Tag | Merchant | Instrument;
const protectedCategoryIDs = new Set([
"cat_expenses_unclassified",
"cat_income_unclassified",
]);
const titles = { const titles = {
category: "Categories", category: "Categories",
tag: "Tags", tag: "Tags",
@@ -82,83 +87,105 @@ export function Registry({
? { id: "", isin: "", name: "", currency: "EUR", symbol: "" } ? { id: "", isin: "", name: "", currency: "EUR", symbol: "" }
: { id: "", name: "" }, : { id: "", name: "" },
); );
const row = (item: Item, depth = 0) => ( const createChild = (parent: Category) =>
<div className="registry-row" key={item.id}> setEditing({
<div id: "",
className="registry-label" name: "",
style={{ paddingLeft: `${depth * 23}px` }} parent_id: parent.id,
> kind: parent.kind,
{entity === "category" ? ( hint: "",
<FolderTree size={18} /> });
) : entity === "tag" ? ( const row = (item: Item, depth = 0) => {
<TagIcon size={18} /> const category = entity === "category" && "kind" in item ? item : null;
) : entity === "instrument" ? ( return (
<CandlestickChart size={18} /> <div className="registry-row" key={item.id}>
) : ( <div
<Store size={18} /> className="registry-label"
)} style={{ paddingLeft: `${depth * 23}px` }}
<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} ·{" "}
{item.symbol
? item.quote
? `${item.symbol} at ${item.quote} on ${item.quoted_at}`
: `${item.symbol}, not yet quoted`
: "No market symbol, so unpriced"}
</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} /> {entity === "category" ? (
</button> <FolderTree size={18} />
{entity !== "instrument" && ( ) : 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} ·{" "}
{item.symbol
? item.quote
? `${item.symbol} at ${item.quote} on ${item.quoted_at}`
: `${item.symbol}, not yet quoted`
: "No market symbol, so unpriced"}
</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">
{category && !protectedCategoryIDs.has(category.id) && (
<button
className="button subtle category-child-action"
title={`Add a child category under ${category.name}`}
aria-label={`Add a child category under ${category.name}`}
onClick={() => createChild(category)}
>
<FolderPlus size={15} />
Add child
</button>
)}
<button <button
className="icon-button" className="icon-button"
title={`Merge ${item.name}`} title={`Edit ${item.name}`}
aria-label={`Merge ${item.name}`} aria-label={`Edit ${item.name}`}
onClick={() => setAction({ item, action: "merge" })} onClick={() => setEditing(item)}
> >
<GitMerge size={16} /> <Pencil size={16} />
</button> </button>
)} {entity !== "instrument" && (
<button <button
className="icon-button danger" className="icon-button"
title={`Delete ${item.name}`} title={`Merge ${item.name}`}
aria-label={`Delete ${item.name}`} aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "delete" })} onClick={() => setAction({ item, action: "merge" })}
> >
<Trash2 size={16} /> <GitMerge size={16} />
</button> </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> </div>
</div> );
); };
const tree = ( const tree = (
parent: string | undefined, parent: string | undefined,
depth = 0, depth = 0,
@@ -181,7 +208,7 @@ export function Registry({
<h2>{titles[entity]}</h2> <h2>{titles[entity]}</h2>
<p> <p>
{entity === "category" {entity === "category"
? "A clear home for every transaction. Parent categories roll up their children." ? "Organize spending and income into a tree. Use Add child on any category to create a nested category."
: entity === "tag" : entity === "tag"
? "Flexible labels that work across your accounts and categories." ? "Flexible labels that work across your accounts and categories."
: entity === "instrument" : entity === "instrument"
@@ -549,15 +576,18 @@ function RegistryEditor({
<option value="income">Income</option> <option value="income">Income</option>
</select> </select>
</Field> </Field>
<Field label="Parent category"> <Field
label="Parent category"
hint="Choose an existing category to nest this one. Use Add child on the category list when you want to add a nested category."
>
<CategoryCombobox <CategoryCombobox
data={data} data={data}
kind={kind} kind={kind}
exclude={[...descendants]} exclude={[...descendants]}
emptyLabel="No parent (root)" emptyLabel="No parent (top level)"
mutate={mutate}
value={parent} value={parent}
onChange={setParent} onChange={setParent}
placeholder="Choose a parent category"
/> />
</Field> </Field>
<p className="muted"> <p className="muted">
+5 -1
View File
@@ -496,13 +496,17 @@ function TransactionEditor({
)} )}
</div> </div>
{value.kind !== "transfer" && value.kind !== "investment" && ( {value.kind !== "transfer" && value.kind !== "investment" && (
<Field label="Category"> <Field
label="Category"
hint="Choose a leaf category. To add one now, type a new name and choose “Create category”. Use “Parent / Child” to nest it under an existing category."
>
<CategoryCombobox <CategoryCombobox
data={data} data={data}
kind={value.kind} kind={value.kind}
leavesOnly leavesOnly
required required
mutate={mutate} mutate={mutate}
placeholder="Select or type to add a category"
value={value.category_id || ""} value={value.category_id || ""}
onChange={(category_id) => onChange={(category_id) =>
setValue((v) => ({ ...v, category_id })) setValue((v) => ({ ...v, category_id }))
+5
View File
@@ -903,6 +903,11 @@ tbody tr:hover {
gap: 5px; gap: 5px;
align-items: center; align-items: center;
} }
.category-child-action {
min-height: 32px;
padding: 6px 9px;
font-size: 11px;
}
.modal { .modal {
border: 1px solid #dce5eb; border: 1px solid #dce5eb;
border-radius: 12px; border-radius: 12px;
+5 -5
View File
@@ -730,9 +730,9 @@ export function CategoryOptions({
} }
// CategoryCombobox is the one category picker: options are full paths, and // CategoryCombobox is the one category picker: options are full paths, and
// with a mutate handle an unmatched name can be created in place. A bare name // with a mutate handle an unmatched name can be created in place. A bare name
// lands under the kind's root; "Parent / Name" creates under that parent. // is offered under each matching root; "Parent / Name" creates under that
// leavesOnly matches the server rule that assigned categories must be leaves; // existing parent. leavesOnly matches the server rule that assigned categories
// a freshly created category is always a leaf. // must be leaves; a freshly created category is always a leaf.
export function CategoryCombobox({ export function CategoryCombobox({
data, data,
value, value,
@@ -782,7 +782,7 @@ export function CategoryCombobox({
const name = segments[segments.length - 1]; const name = segments[segments.length - 1];
const row = (parent: Category): ComboCreate => ({ const row = (parent: Category): ComboCreate => ({
key: parent.id, key: parent.id,
label: `Create "${name}" in ${categoryPath(data, parent.id)}`, label: `Create category "${name}" in ${categoryPath(data, parent.id)}`,
run: async () => run: async () =>
onChange( onChange(
await createCategory(mutate, data, { await createCategory(mutate, data, {
@@ -813,7 +813,7 @@ export function CategoryCombobox({
placeholder={placeholder} placeholder={placeholder}
emptyText={ emptyText={
mutate mutate
? "No matching category. Type a name to create it." ? "No match. Type a category name and choose Create category."
: "No matching category." : "No matching category."
} }
create={create} create={create}