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, 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,7 +87,17 @@ 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) =>
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-row" key={item.id}>
<div <div
className="registry-label" className="registry-label"
@@ -130,6 +145,17 @@ export function Registry({
</span> </span>
)} )}
<div className="row-actions"> <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={`Edit ${item.name}`} title={`Edit ${item.name}`}
@@ -159,6 +185,7 @@ export function Registry({
</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}