From 5bc7fe80b1f116a6844291c7b70d191a915d9863 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 15:01:44 -0400 Subject: [PATCH] feat(balance): improve category deletion UX with linked-accounts message - AccountsPage Categories tab now uses the new AccountForm 'category' variant for creation (with kind selector). - Delete button is disabled when the category has linked accounts; the disabled tooltip surfaces the count. - Clicking the delete button on a category with linked accounts now shows a dismissable error banner listing up to the first 3 names (with ellipsis when more) so the user knows exactly which accounts to archive first. The service-level FK RESTRICT remains the ultimate guard. Refs #140 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/pages/AccountsPage.tsx | 185 +++++++++++++++++-------------------- 1 file changed, 85 insertions(+), 100 deletions(-) diff --git a/src/pages/AccountsPage.tsx b/src/pages/AccountsPage.tsx index 78f6336..cc00613 100644 --- a/src/pages/AccountsPage.tsx +++ b/src/pages/AccountsPage.tsx @@ -20,6 +20,7 @@ import type { } from "../shared/types"; import { useBalanceAccounts } from "../hooks/useBalanceAccounts"; import AccountForm from "../components/balance/AccountForm"; +import type { CreateBalanceCategoryInput } from "../services/balance.service"; type Tab = "accounts" | "categories"; @@ -43,14 +44,27 @@ export default function AccountsPage() { useState(null); const [showCategoryForm, setShowCategoryForm] = useState(false); - const [newCategoryKey, setNewCategoryKey] = useState(""); - const [newCategoryLabel, setNewCategoryLabel] = useState(""); + /** Local error string for category deletion guard (count + names of linked accounts). */ + const [categoryDeleteError, setCategoryDeleteError] = useState( + null + ); const activeCategories = useMemo( () => state.categories.filter((c) => c.is_active), [state.categories] ); + /** Map category id → array of accounts linked to it (active + archived). */ + const accountsByCategory = useMemo(() => { + const m = new Map(); + for (const acc of state.accounts) { + const list = m.get(acc.balance_category_id) ?? []; + list.push(acc); + m.set(acc.balance_category_id, list); + } + return m; + }, [state.accounts]); + const renderCategoryLabel = (cat: BalanceCategory) => t(cat.i18n_key, { defaultValue: cat.key }); @@ -76,29 +90,39 @@ export default function AccountsPage() { } }; - const handleCreateCategory = async () => { - const key = newCategoryKey.trim(); - const label = newCategoryLabel.trim(); - if (!key) return; - // For user-created categories we use the literal label as the i18n_key - // fallback — they don't ship in the locale bundle, so renderers default - // to this string. (The CategoryCombobox does the same for legacy v2 rows.) - const i18nKey = label || key; + const handleCategorySubmit = async (input: CreateBalanceCategoryInput) => { try { - await addCategory({ - key, - i18n_key: i18nKey, - kind: "simple", - sort_order: 100, // user-created categories sort after seeded ones - }); - setNewCategoryKey(""); - setNewCategoryLabel(""); + await addCategory(input); setShowCategoryForm(false); } catch { // Error already surfaced via state.error } }; + /** + * Delete-guard for categories. The service refuses to delete a seeded + * category or one with linked accounts, but we pre-check at the UI to + * surface a richer message that lists the linked-account names. + */ + const handleDeleteCategory = (cat: BalanceCategory) => { + setCategoryDeleteError(null); + if (cat.is_seed) return; + const linked = accountsByCategory.get(cat.id) ?? []; + if (linked.length > 0) { + const sample = linked.slice(0, 3).map((a) => a.name).join(", "); + const more = linked.length > 3 ? ", …" : ""; + setCategoryDeleteError( + t("balance.category.error.has_accounts", { + count: linked.length, + names: `${sample}${more}`, + }) + ); + return; + } + if (!window.confirm(t("balance.category.actions.deleteConfirm"))) return; + removeCategory(cat.id); + }; + return (
@@ -174,6 +198,7 @@ export default function AccountsPage() { : t("balance.account.form.createTitle")} {t("balance.category.form.createTitle")} -
-
- - setNewCategoryKey(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" - placeholder={t("balance.category.form.keyPlaceholder")} - autoComplete="off" - /> -
-
- - setNewCategoryLabel(e.target.value)} - className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" - placeholder={t("balance.category.form.labelPlaceholder")} - autoComplete="off" - /> -
-
-

- {t("balance.category.form.simpleOnlyNotice")} -

-
- - -
+ setShowCategoryForm(false)} + /> +
+ )} + + {categoryDeleteError && ( +
+ {categoryDeleteError} +
)} @@ -437,28 +421,29 @@ export default function AccountsPage() { > - + {(() => { + const linkedCount = + accountsByCategory.get(cat.id)?.length ?? 0; + const blocked = cat.is_seed || linkedCount > 0; + const titleKey = cat.is_seed + ? t("balance.category.actions.deleteSeedHint") + : linkedCount > 0 + ? t("balance.category.actions.deleteHasAccountsHint", { + count: linkedCount, + }) + : t("common.delete"); + return ( + + ); + })()}