Clarify category creation flows
This commit is contained in:
+35
-5
@@ -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">
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user