Create categories and tags in place from every assignment picker
Category and tag inputs across the transaction editor, Analyse corrections, and merchant defaults now mint missing entries without a detour through the registry pages. A bare name lands under the kind's root, "Parent / Name" targets that parent, and typing an existing name selects it instead of duplicating. Enter only creates when nothing matches, server rejections surface inline in the dropdown, and assignment pickers offer leaf categories only — the shape the server validates. Mutations now return the accepted state so callers can select the id the server just minted, and the revision-keyed remounts on Transactions and the registry pages are gone: they closed the open modal and threw away pending edits the moment any in-modal creation committed.
This commit is contained in:
+40
-19
@@ -16,7 +16,9 @@ import type {
|
||||
} from "./api";
|
||||
import { categoryPath, money, request } from "./api";
|
||||
import {
|
||||
CategoryCombobox,
|
||||
Combobox,
|
||||
createTag,
|
||||
DateField,
|
||||
Empty,
|
||||
ErrorMessage,
|
||||
@@ -24,12 +26,15 @@ import {
|
||||
Modal,
|
||||
ModelOptions,
|
||||
} from "./ui";
|
||||
import type { Mutate } from "./ui";
|
||||
export function Classification({
|
||||
state,
|
||||
acceptState,
|
||||
mutate,
|
||||
}: {
|
||||
state: State;
|
||||
acceptState: (state: State, message?: string) => void;
|
||||
mutate: Mutate;
|
||||
}) {
|
||||
const dates = state.data.transactions.map((t) => t.facts.booking_date).sort();
|
||||
const [from, setFrom] = useState(dates[0] || "");
|
||||
@@ -482,6 +487,7 @@ export function Classification({
|
||||
value={effective(change)}
|
||||
edited={change.id in edits}
|
||||
disabled={busy}
|
||||
mutate={mutate}
|
||||
onChange={(value) => correct(change, value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -667,14 +673,18 @@ interface CorrectionValue {
|
||||
}
|
||||
// CorrectionEditor is the "Proposed" side of a review row, editable in place.
|
||||
// Category and tags are free-text inputs that autocomplete against the
|
||||
// existing taxonomy; the category list is limited to leaves of the change's
|
||||
// kind because that is what validation will accept.
|
||||
// existing taxonomy and can create a missing entry in place; the category
|
||||
// list is limited to leaves of the change's kind because that is what
|
||||
// validation will accept. Creating mid-review bumps the journal revision,
|
||||
// which the apply path tolerates as long as the transactions themselves are
|
||||
// untouched.
|
||||
function CorrectionEditor({
|
||||
data,
|
||||
change,
|
||||
value,
|
||||
edited,
|
||||
disabled,
|
||||
mutate,
|
||||
onChange,
|
||||
}: {
|
||||
data: Dataset;
|
||||
@@ -682,15 +692,9 @@ function CorrectionEditor({
|
||||
value: CorrectionValue;
|
||||
edited: boolean;
|
||||
disabled: boolean;
|
||||
mutate: Mutate;
|
||||
onChange: (value: CorrectionValue) => void;
|
||||
}) {
|
||||
const categories = data.categories
|
||||
.filter(
|
||||
(c) =>
|
||||
c.kind === change.after.kind &&
|
||||
!data.categories.some((child) => child.parent_id === c.id),
|
||||
)
|
||||
.map((c) => ({ value: c.id, label: categoryPath(data, c.id) }));
|
||||
const addable = data.tags
|
||||
.filter((t) => !value.tag_ids.includes(t.id))
|
||||
.map((t) => ({ value: t.id, label: t.name }));
|
||||
@@ -728,13 +732,14 @@ function CorrectionEditor({
|
||||
<div>
|
||||
<dt>Category</dt>
|
||||
<dd>
|
||||
<Combobox
|
||||
options={categories}
|
||||
<CategoryCombobox
|
||||
data={data}
|
||||
kind={change.after.kind}
|
||||
leavesOnly
|
||||
mutate={mutate}
|
||||
value={value.category_id}
|
||||
disabled={disabled}
|
||||
onChange={(category_id) => onChange({ ...value, category_id })}
|
||||
placeholder="Search categories"
|
||||
emptyText="No matching category. Create it in Categories first."
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
@@ -763,16 +768,32 @@ function CorrectionEditor({
|
||||
<Combobox
|
||||
options={addable}
|
||||
value=""
|
||||
disabled={disabled || !addable.length}
|
||||
disabled={disabled}
|
||||
onChange={(id) =>
|
||||
onChange({ ...value, tag_ids: [...value.tag_ids, id] })
|
||||
}
|
||||
placeholder={
|
||||
data.tags.length
|
||||
? "Add tag"
|
||||
: "No tags yet — create them in Tags"
|
||||
placeholder={data.tags.length ? "Add tag" : "Add or create tag"}
|
||||
emptyText="No matching tag. Type a name to create it."
|
||||
create={(text) =>
|
||||
data.tags.some(
|
||||
(t) => t.name.toLowerCase() === text.toLowerCase(),
|
||||
)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "tag",
|
||||
label: `Create tag "${text}"`,
|
||||
run: async () =>
|
||||
onChange({
|
||||
...value,
|
||||
tag_ids: [
|
||||
...value.tag_ids,
|
||||
await createTag(mutate, data, text),
|
||||
],
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
emptyText="No matching tag. Create it in Tags first."
|
||||
/>
|
||||
</div>
|
||||
</dd>
|
||||
|
||||
Reference in New Issue
Block a user