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
+35 -5
View File
@@ -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,7 +87,17 @@ export function Registry({
? { id: "", isin: "", name: "", currency: "EUR", symbol: "" }
: { id: "", name: "" },
);
const row = (item: Item, depth = 0) => (
const createChild = (parent: Category) =>
setEditing({
id: "",
name: "",
parent_id: parent.id,
kind: parent.kind,
hint: "",
});
const row = (item: Item, depth = 0) => {
const category = entity === "category" && "kind" in item ? item : null;
return (
<div className="registry-row" key={item.id}>
<div
className="registry-label"
@@ -130,6 +145,17 @@ export function Registry({
</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
className="icon-button"
title={`Edit ${item.name}`}
@@ -159,6 +185,7 @@ export function Registry({
</div>
</div>
);
};
const tree = (
parent: string | undefined,
depth = 0,
@@ -181,7 +208,7 @@ export function Registry({
<h2>{titles[entity]}</h2>
<p>
{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({
<option value="income">Income</option>
</select>
</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
data={data}
kind={kind}
exclude={[...descendants]}
emptyLabel="No parent (root)"
mutate={mutate}
emptyLabel="No parent (top level)"
value={parent}
onChange={setParent}
placeholder="Choose a parent category"
/>
</Field>
<p className="muted">
+5 -1
View File
@@ -496,13 +496,17 @@ function TransactionEditor({
)}
</div>
{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
data={data}
kind={value.kind}
leavesOnly
required
mutate={mutate}
placeholder="Select or type to add a category"
value={value.category_id || ""}
onChange={(category_id) =>
setValue((v) => ({ ...v, category_id }))
+5
View File
@@ -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;
+5 -5
View File
@@ -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}