Clarify category creation flows
This commit is contained in:
+105
-75
@@ -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">
|
||||||
|
|||||||
@@ -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