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.
790 lines
25 KiB
TypeScript
790 lines
25 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Plus,
|
|
Pencil,
|
|
GitMerge,
|
|
Trash2,
|
|
FolderTree,
|
|
Tag as TagIcon,
|
|
Store,
|
|
CandlestickChart,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import type {
|
|
Category,
|
|
Dataset,
|
|
Instrument,
|
|
Merchant,
|
|
State,
|
|
Tag,
|
|
TaxonomyPreview,
|
|
} from "./api";
|
|
import { categoryPath, request } from "./api";
|
|
import {
|
|
CategoryCombobox,
|
|
Empty,
|
|
ErrorMessage,
|
|
Field,
|
|
FormActions,
|
|
Modal,
|
|
TagPicker,
|
|
} from "./ui";
|
|
import type { Mutate } from "./ui";
|
|
type Entity = "category" | "tag" | "merchant" | "instrument";
|
|
type Item = Category | Tag | Merchant | Instrument;
|
|
const titles = {
|
|
category: "Categories",
|
|
tag: "Tags",
|
|
merchant: "Merchants",
|
|
instrument: "Instruments",
|
|
};
|
|
const plurals = {
|
|
category: "categories",
|
|
tag: "tags",
|
|
merchant: "merchants",
|
|
instrument: "instruments",
|
|
};
|
|
type Plural = "categories" | "tags" | "merchants" | "instruments";
|
|
export function Registry({
|
|
entity,
|
|
data,
|
|
mutate,
|
|
acceptState,
|
|
revision,
|
|
model,
|
|
}: {
|
|
entity: Entity;
|
|
data: Dataset;
|
|
mutate: Mutate;
|
|
acceptState?: (state: State, message?: string) => void;
|
|
revision?: string;
|
|
model?: string;
|
|
}) {
|
|
const [editing, setEditing] = useState<Item | null>(null);
|
|
const [action, setAction] = useState<{
|
|
item: Item;
|
|
action: "merge" | "delete";
|
|
} | null>(null);
|
|
const items: Item[] = data[plurals[entity] as Plural];
|
|
const create = () =>
|
|
setEditing(
|
|
entity === "category"
|
|
? { id: "", name: "", parent_id: "cat_expenses", kind: "expense" }
|
|
: entity === "merchant"
|
|
? {
|
|
id: "",
|
|
name: "",
|
|
aliases: [],
|
|
default_tag_ids: [],
|
|
use_defaults: false,
|
|
}
|
|
: entity === "instrument"
|
|
? { id: "", isin: "", name: "", currency: "EUR", symbol: "" }
|
|
: { id: "", name: "" },
|
|
);
|
|
const row = (item: Item, depth = 0) => (
|
|
<div className="registry-row" key={item.id}>
|
|
<div
|
|
className="registry-label"
|
|
style={{ paddingLeft: `${depth * 23}px` }}
|
|
>
|
|
{entity === "category" ? (
|
|
<FolderTree size={18} />
|
|
) : 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">
|
|
<button
|
|
className="icon-button"
|
|
title={`Edit ${item.name}`}
|
|
aria-label={`Edit ${item.name}`}
|
|
onClick={() => setEditing(item)}
|
|
>
|
|
<Pencil size={16} />
|
|
</button>
|
|
{entity !== "instrument" && (
|
|
<button
|
|
className="icon-button"
|
|
title={`Merge ${item.name}`}
|
|
aria-label={`Merge ${item.name}`}
|
|
onClick={() => setAction({ item, action: "merge" })}
|
|
>
|
|
<GitMerge size={16} />
|
|
</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>
|
|
);
|
|
const tree = (
|
|
parent: string | undefined,
|
|
depth = 0,
|
|
visited = new Set<string>(),
|
|
): React.ReactNode =>
|
|
data.categories
|
|
.filter(
|
|
(c) => (c.parent_id || "") === (parent || "") && !visited.has(c.id),
|
|
)
|
|
.map((c) => (
|
|
<div key={c.id}>
|
|
{row(c, depth)}
|
|
{tree(c.id, depth + 1, new Set([...visited, c.id]))}
|
|
</div>
|
|
));
|
|
return (
|
|
<>
|
|
<div className="section-heading">
|
|
<div>
|
|
<h2>{titles[entity]}</h2>
|
|
<p>
|
|
{entity === "category"
|
|
? "A clear home for every transaction. Parent categories roll up their children."
|
|
: entity === "tag"
|
|
? "Flexible labels that work across your accounts and categories."
|
|
: entity === "instrument"
|
|
? "The securities your broker rows trade. The ISIN is the identity; the name is yours to correct."
|
|
: "Recognize familiar names and choose explicit classification defaults."}
|
|
</p>
|
|
</div>
|
|
<div className="row-actions">
|
|
{entity === "category" && acceptState && revision && model && (
|
|
<TaxonomyPanel
|
|
revision={revision}
|
|
model={model}
|
|
acceptState={acceptState}
|
|
/>
|
|
)}
|
|
<button className="button primary" onClick={create}>
|
|
<Plus size={17} />
|
|
New {entity}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<section className="panel registry">
|
|
{items.length ? (
|
|
entity === "category" ? (
|
|
tree(undefined)
|
|
) : (
|
|
items.map((item) => row(item))
|
|
)
|
|
) : (
|
|
<Empty title={`No ${titles[entity].toLowerCase()} yet`}>
|
|
Create your first {entity} to organize transactions.
|
|
</Empty>
|
|
)}
|
|
</section>
|
|
{editing && (
|
|
<RegistryEditor
|
|
key={editing.id}
|
|
entity={entity}
|
|
item={editing}
|
|
data={data}
|
|
mutate={mutate}
|
|
close={() => setEditing(null)}
|
|
/>
|
|
)}{" "}
|
|
{action && (
|
|
<ManageDialog
|
|
entity={entity}
|
|
item={action.item}
|
|
action={action.action}
|
|
data={data}
|
|
mutate={mutate}
|
|
close={() => setAction(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
function TaxonomyPanel({
|
|
revision,
|
|
model,
|
|
acceptState,
|
|
}: {
|
|
revision: string;
|
|
model: string;
|
|
acceptState: (state: State, message?: string) => void;
|
|
}) {
|
|
const [preview, setPreview] = useState<TaxonomyPreview | null>(null);
|
|
const [selectedCategories, setSelectedCategories] = useState<Set<number>>(
|
|
new Set(),
|
|
);
|
|
const [selectedTags, setSelectedTags] = useState<Set<number>>(new Set());
|
|
const [selectedMerchants, setSelectedMerchants] = useState<Set<number>>(
|
|
new Set(),
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const toggle = (
|
|
setSelected: React.Dispatch<React.SetStateAction<Set<number>>>,
|
|
index: number,
|
|
) =>
|
|
setSelected((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(index)) next.delete(index);
|
|
else next.add(index);
|
|
return next;
|
|
});
|
|
const close = () => {
|
|
setPreview(null);
|
|
setError("");
|
|
};
|
|
return (
|
|
<>
|
|
<button
|
|
className="button secondary"
|
|
disabled={busy}
|
|
onClick={async () => {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const result = await request<TaxonomyPreview>(
|
|
"/api/taxonomy/propose",
|
|
{ revision, model },
|
|
);
|
|
setPreview(result);
|
|
setSelectedCategories(new Set());
|
|
setSelectedTags(new Set());
|
|
setSelectedMerchants(new Set());
|
|
} catch (err) {
|
|
setError(String(err instanceof Error ? err.message : err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
{busy ? "Sampling…" : "Propose categories and tags"}
|
|
</button>
|
|
{preview && (
|
|
<Modal title="Review taxonomy proposal" close={close}>
|
|
<div className="form-body">
|
|
<ErrorMessage error={error} />
|
|
<p className="muted">
|
|
Select each item to write. Existing categories, tags, and
|
|
merchants are never changed by this proposal.
|
|
</p>
|
|
<h3>Categories</h3>
|
|
{preview.proposal.categories.map((category, index) => (
|
|
<label className="checkbox-row" key={`${category.name}-${index}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedCategories.has(index)}
|
|
onChange={() => toggle(setSelectedCategories, index)}
|
|
/>
|
|
<span>
|
|
<strong>{category.name}</strong>
|
|
<small>
|
|
{category.kind}
|
|
{category.parent ? ` · ${category.parent}` : ""}
|
|
{category.hint ? ` · ${category.hint}` : ""}
|
|
</small>
|
|
{category.because.length > 0 && (
|
|
<small>Seen in: {category.because.join(" · ")}</small>
|
|
)}
|
|
</span>
|
|
</label>
|
|
))}
|
|
<h3>Tags</h3>
|
|
{preview.proposal.tags.map((tag, index) => (
|
|
<label className="checkbox-row" key={`${tag.name}-${index}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedTags.has(index)}
|
|
onChange={() => toggle(setSelectedTags, index)}
|
|
/>
|
|
<span>
|
|
<strong>{tag.name}</strong>
|
|
{tag.hint && <small>{tag.hint}</small>}
|
|
</span>
|
|
</label>
|
|
))}
|
|
<h3>Merchants</h3>
|
|
{preview.proposal.merchants.map((merchant, index) => (
|
|
<label className="checkbox-row" key={`${merchant.name}-${index}`}>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedMerchants.has(index)}
|
|
onChange={() => toggle(setSelectedMerchants, index)}
|
|
/>
|
|
<span>
|
|
<strong>{merchant.name}</strong>
|
|
<small>
|
|
{merchant.aliases.length
|
|
? merchant.aliases.join(" · ")
|
|
: "No aliases"}
|
|
</small>
|
|
</span>
|
|
</label>
|
|
))}
|
|
<div className="form-actions">
|
|
<button className="button" type="button" onClick={close}>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="button primary"
|
|
type="button"
|
|
disabled={
|
|
busy ||
|
|
(selectedCategories.size === 0 &&
|
|
selectedTags.size === 0 &&
|
|
selectedMerchants.size === 0)
|
|
}
|
|
onClick={async () => {
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const applied = await request<State>(
|
|
"/api/taxonomy/apply",
|
|
{
|
|
id: preview.id,
|
|
revision: preview.revision,
|
|
approved: {
|
|
categories: preview.proposal.categories.filter(
|
|
(_, i) => selectedCategories.has(i),
|
|
),
|
|
tags: preview.proposal.tags.filter((_, i) =>
|
|
selectedTags.has(i),
|
|
),
|
|
merchants: preview.proposal.merchants.filter((_, i) =>
|
|
selectedMerchants.has(i),
|
|
),
|
|
},
|
|
},
|
|
);
|
|
acceptState(applied, "Approved taxonomy written");
|
|
close();
|
|
} catch (err) {
|
|
setError(String(err instanceof Error ? err.message : err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
Apply selected
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
function RegistryEditor({
|
|
entity,
|
|
item,
|
|
data,
|
|
mutate,
|
|
close,
|
|
}: {
|
|
entity: Entity;
|
|
item: Item;
|
|
data: Dataset;
|
|
mutate: Mutate;
|
|
close: () => void;
|
|
}) {
|
|
const [name, setName] = useState(item.name);
|
|
const [kind, setKind] = useState("kind" in item ? item.kind : "expense");
|
|
const [parent, setParent] = useState(
|
|
"parent_id" in item ? item.parent_id || "" : "",
|
|
);
|
|
const [hint, setHint] = useState("hint" in item ? item.hint || "" : "");
|
|
const merchant = "aliases" in item ? item : null;
|
|
const [aliases, setAliases] = useState(merchant?.aliases.join("\n") || "");
|
|
const [category, setCategory] = useState(merchant?.default_category_id || "");
|
|
const [tags, setTags] = useState(merchant?.default_tag_ids || []);
|
|
const [defaults, setDefaults] = useState(merchant?.use_defaults || false);
|
|
const instrument = "isin" in item ? item : null;
|
|
const [isin, setIsin] = useState(instrument?.isin || "");
|
|
const [currency, setCurrency] = useState(instrument?.currency || "EUR");
|
|
const [symbol, setSymbol] = useState(instrument?.symbol || "");
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const descendants = new Set([item.id]);
|
|
let changed = true;
|
|
while (changed) {
|
|
changed = false;
|
|
for (const c of data.categories)
|
|
if (
|
|
c.parent_id &&
|
|
descendants.has(c.parent_id) &&
|
|
!descendants.has(c.id)
|
|
) {
|
|
descendants.add(c.id);
|
|
changed = true;
|
|
}
|
|
}
|
|
return (
|
|
<Modal title={`${item.id ? "Edit" : "New"} ${entity}`} close={close}>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
const result =
|
|
entity === "category"
|
|
? {
|
|
id: item.id,
|
|
name: name.trim(),
|
|
kind,
|
|
parent_id: parent,
|
|
hint: hint.trim(),
|
|
}
|
|
: entity === "merchant"
|
|
? {
|
|
id: item.id,
|
|
name: name.trim(),
|
|
aliases: Array.from(
|
|
new Set(
|
|
aliases
|
|
.split("\n")
|
|
.map((a) => a.trim())
|
|
.filter(Boolean),
|
|
),
|
|
),
|
|
default_category_id: category,
|
|
default_tag_ids: tags,
|
|
use_defaults: defaults,
|
|
}
|
|
: entity === "instrument"
|
|
? {
|
|
id: item.id,
|
|
isin: isin.replaceAll(" ", "").toUpperCase(),
|
|
name: name.trim(),
|
|
currency: currency.toUpperCase(),
|
|
symbol: symbol.trim(),
|
|
}
|
|
: { id: item.id, name: name.trim(), hint: hint.trim() };
|
|
await mutate(
|
|
`/api/${plurals[entity]}`,
|
|
{ [entity]: result },
|
|
`${name.trim()} saved`,
|
|
);
|
|
close();
|
|
} catch (err) {
|
|
setError(String(err instanceof Error ? err.message : err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="form-body">
|
|
<ErrorMessage error={error} />
|
|
<Field label="Name">
|
|
<input
|
|
required
|
|
maxLength={200}
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</Field>
|
|
{(entity === "category" || entity === "tag") && (
|
|
<Field
|
|
label="Hint"
|
|
hint="Explain when this category or tag applies to the AI classifier."
|
|
>
|
|
<textarea
|
|
rows={2}
|
|
maxLength={200}
|
|
value={hint}
|
|
onChange={(e) => setHint(e.target.value)}
|
|
/>
|
|
</Field>
|
|
)}
|
|
{entity === "category" && (
|
|
<>
|
|
<Field label="Kind">
|
|
<select
|
|
value={kind}
|
|
onChange={(e) => {
|
|
setKind(e.target.value);
|
|
setParent("");
|
|
}}
|
|
>
|
|
<option value="expense">Expense</option>
|
|
<option value="income">Income</option>
|
|
</select>
|
|
</Field>
|
|
<Field label="Parent category">
|
|
<CategoryCombobox
|
|
data={data}
|
|
kind={kind}
|
|
exclude={[...descendants]}
|
|
emptyLabel="No parent (root)"
|
|
mutate={mutate}
|
|
value={parent}
|
|
onChange={setParent}
|
|
/>
|
|
</Field>
|
|
<p className="muted">
|
|
Changing the parent moves this category and its entire subtree.
|
|
The server protects fallback categories and validates
|
|
references.
|
|
</p>
|
|
</>
|
|
)}
|
|
{entity === "merchant" && (
|
|
<>
|
|
<Field
|
|
label="Aliases"
|
|
hint="One exact merchant alias per line. These help recognize future transactions."
|
|
>
|
|
<textarea
|
|
rows={4}
|
|
value={aliases}
|
|
onChange={(e) => setAliases(e.target.value)}
|
|
/>
|
|
</Field>
|
|
<label className="checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={defaults}
|
|
onChange={(e) => setDefaults(e.target.checked)}
|
|
/>
|
|
Use these defaults when this merchant is recognized
|
|
</label>
|
|
<Field label="Default category">
|
|
<CategoryCombobox
|
|
data={data}
|
|
leavesOnly
|
|
emptyLabel="No default category"
|
|
mutate={mutate}
|
|
value={category}
|
|
onChange={setCategory}
|
|
/>
|
|
</Field>
|
|
<TagPicker
|
|
data={data}
|
|
value={tags}
|
|
onChange={setTags}
|
|
mutate={mutate}
|
|
/>
|
|
<p className="muted">
|
|
Defaults are only used when explicitly enabled. Editing defaults
|
|
does not rewrite existing transactions.
|
|
</p>
|
|
</>
|
|
)}
|
|
{entity === "instrument" && (
|
|
<>
|
|
<Field
|
|
label="ISIN"
|
|
hint={
|
|
item.id
|
|
? "An instrument's ISIN is its identity: the trades were imported under it and the server refuses to change it. Register a different security separately."
|
|
: "Twelve characters: two country letters, nine alphanumerics and a check digit."
|
|
}
|
|
>
|
|
<input
|
|
required
|
|
readOnly={!!item.id}
|
|
maxLength={12}
|
|
value={isin}
|
|
onChange={(e) => setIsin(e.target.value.toUpperCase())}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Currency"
|
|
hint="The currency the broker prices this security in."
|
|
>
|
|
<input
|
|
required
|
|
pattern="[A-Z]{3}"
|
|
maxLength={3}
|
|
value={currency}
|
|
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
|
/>
|
|
</Field>
|
|
<Field
|
|
label="Market symbol"
|
|
hint="The listing the daily price job quotes this security under, for example EUNL.DE. One ISIN lists on several exchanges in different currencies, so the listing has to match the currency above; the wrong one misstates your wealth. Leave it empty and the holding is reported as unpriced rather than guessed at cost."
|
|
>
|
|
<input
|
|
value={symbol}
|
|
placeholder="Unpriced"
|
|
onChange={(e) => setSymbol(e.target.value.trim())}
|
|
/>
|
|
</Field>
|
|
{instrument?.quote && (
|
|
<p className="muted">
|
|
Last quote {instrument.quote} {instrument.currency} from{" "}
|
|
{instrument.quoted_at}.
|
|
</p>
|
|
)}
|
|
<p className="muted">
|
|
The broker's own description for one ISIN changes over time, so
|
|
the name is display text you can correct. Renaming does not
|
|
touch a single imported trade.
|
|
</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
<FormActions busy={busy} close={close} />
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|
|
function ManageDialog({
|
|
entity,
|
|
item,
|
|
action,
|
|
data,
|
|
mutate,
|
|
close,
|
|
}: {
|
|
entity: Entity;
|
|
item: Item;
|
|
action: "delete" | "merge";
|
|
data: Dataset;
|
|
mutate: Mutate;
|
|
close: () => void;
|
|
}) {
|
|
const [target, setTarget] = useState("");
|
|
const [confirm, setConfirm] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const items: Item[] = data[plurals[entity] as Plural];
|
|
return (
|
|
<Modal
|
|
title={`${action === "merge" ? "Merge" : "Delete"} ${item.name}`}
|
|
close={close}
|
|
>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
setBusy(true);
|
|
setError("");
|
|
try {
|
|
await mutate(
|
|
"/api/manage",
|
|
{ entity, action, id: item.id, target_id: target },
|
|
`${item.name} ${action === "merge" ? "merged" : "deleted"}`,
|
|
);
|
|
close();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="form-body">
|
|
<ErrorMessage error={error} />
|
|
<p>
|
|
{action === "merge"
|
|
? "References will move to the destination and the source will be removed. Review the destination carefully."
|
|
: entity === "tag"
|
|
? "This tag will be removed from every transaction and merchant default. The original bank facts will not change."
|
|
: entity === "category"
|
|
? "Referenced categories need a replacement. Protected roots and unsafe tree changes cannot be deleted."
|
|
: entity === "instrument"
|
|
? "Remove this security from your registry. An instrument any imported trade still references cannot be deleted: the server refuses it and says so."
|
|
: "Remove this merchant from your registry. Referenced merchants may require a merge instead."}
|
|
</p>
|
|
{(action === "merge" || entity === "category") && (
|
|
<Field
|
|
label={
|
|
action === "merge"
|
|
? "Merge into"
|
|
: "Replacement category (if referenced)"
|
|
}
|
|
>
|
|
<select
|
|
required={action === "merge"}
|
|
value={target}
|
|
onChange={(e) => setTarget(e.target.value)}
|
|
>
|
|
<option value="">Choose destination</option>
|
|
{items
|
|
.filter((i) => i.id !== item.id)
|
|
.map((i) => (
|
|
<option value={i.id} key={i.id}>
|
|
{entity === "category"
|
|
? categoryPath(data, i.id)
|
|
: i.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
)}
|
|
<label className="checkbox">
|
|
<input
|
|
required
|
|
type="checkbox"
|
|
checked={confirm}
|
|
onChange={(e) => setConfirm(e.target.checked)}
|
|
/>
|
|
I understand this changes all references and cannot be undone here.
|
|
</label>
|
|
</div>
|
|
<div className="form-actions">
|
|
<button
|
|
type="button"
|
|
className="button secondary"
|
|
disabled={busy}
|
|
onClick={close}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button className="button destructive" disabled={busy || !confirm}>
|
|
{action === "merge" ? (
|
|
<ChevronRight size={16} />
|
|
) : (
|
|
<Trash2 size={16} />
|
|
)}{" "}
|
|
{busy
|
|
? "Working…"
|
|
: action === "merge"
|
|
? "Merge permanently"
|
|
: "Delete permanently"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
);
|
|
}
|