diff --git a/web/src/Registry.tsx b/web/src/Registry.tsx index 0d288f9..3ac90c2 100644 --- a/web/src/Registry.tsx +++ b/web/src/Registry.tsx @@ -5,6 +5,7 @@ import { GitMerge, Trash2, FolderTree, + FolderPlus, Tag as TagIcon, Store, CandlestickChart, @@ -32,6 +33,10 @@ import { import type { Mutate } from "./ui"; type Entity = "category" | "tag" | "merchant" | "instrument"; type Item = Category | Tag | Merchant | Instrument; +const protectedCategoryIDs = new Set([ + "cat_expenses_unclassified", + "cat_income_unclassified", +]); const titles = { category: "Categories", tag: "Tags", @@ -82,83 +87,105 @@ export function Registry({ ? { 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" && ( + {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)} + + )} +
+ {category && !protectedCategoryIDs.has(category.id) && ( + + )} - )} - + {entity !== "instrument" && ( + + )} + +
- - ); + ); + }; const tree = ( parent: string | undefined, depth = 0, @@ -181,7 +208,7 @@ export function Registry({

{titles[entity]}

{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" ? "Flexible labels that work across your accounts and categories." : entity === "instrument" @@ -549,15 +576,18 @@ function RegistryEditor({ - +

diff --git a/web/src/Transactions.tsx b/web/src/Transactions.tsx index 612ce9b..5b0e5f6 100644 --- a/web/src/Transactions.tsx +++ b/web/src/Transactions.tsx @@ -496,13 +496,17 @@ function TransactionEditor({ )} {value.kind !== "transfer" && value.kind !== "investment" && ( - + setValue((v) => ({ ...v, category_id })) diff --git a/web/src/styles.css b/web/src/styles.css index d704a17..ef3ff4a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -903,6 +903,11 @@ tbody tr:hover { gap: 5px; align-items: center; } +.category-child-action { + min-height: 32px; + padding: 6px 9px; + font-size: 11px; +} .modal { border: 1px solid #dce5eb; border-radius: 12px; diff --git a/web/src/ui.tsx b/web/src/ui.tsx index 9e02156..0edde76 100644 --- a/web/src/ui.tsx +++ b/web/src/ui.tsx @@ -730,9 +730,9 @@ export function CategoryOptions({ } // 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 -// lands under the kind's root; "Parent / Name" creates under that parent. -// leavesOnly matches the server rule that assigned categories must be leaves; -// a freshly created category is always a leaf. +// is offered under each matching root; "Parent / Name" creates under that +// existing parent. leavesOnly matches the server rule that assigned categories +// must be leaves; a freshly created category is always a leaf. export function CategoryCombobox({ data, value, @@ -782,7 +782,7 @@ export function CategoryCombobox({ const name = segments[segments.length - 1]; const row = (parent: Category): ComboCreate => ({ key: parent.id, - label: `Create "${name}" in ${categoryPath(data, parent.id)}`, + label: `Create category "${name}" in ${categoryPath(data, parent.id)}`, run: async () => onChange( await createCategory(mutate, data, { @@ -813,7 +813,7 @@ export function CategoryCombobox({ placeholder={placeholder} emptyText={ mutate - ? "No matching category. Type a name to create it." + ? "No match. Type a category name and choose Create category." : "No matching category." } create={create}