// AccountsPage — CRUD UI for balance accounts and balance categories. // // Issue #138 (Bilan #1a) ships the route `/balance/accounts` with two tabs: // - Comptes : full CRUD over balance_accounts (create/edit/archive) // - Catégories : list of seeded + user-created categories. Users can add // simple-kind categories (the priced toggle lands in #140), // rename them, and delete the ones they created (the seeded // ones are protected at the service layer). // // The sidebar entry "Bilan" is intentionally NOT added here — per spec-plan // v2 it lands in Issue #141 (Bilan #3) when the `/balance` overview page // becomes navigable. Until then the route is reachable directly via URL. import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { ArchiveRestore, Edit2, Plus, Trash2, Wallet } from "lucide-react"; import type { BalanceAccountWithCategory, BalanceCategory, } from "../shared/types"; import { useBalanceAccounts } from "../hooks/useBalanceAccounts"; import AccountForm from "../components/balance/AccountForm"; import type { CreateBalanceCategoryInput } from "../services/balance.service"; import { renderCategoryLabelFromAccount, renderCategoryLabelFromCategory, } from "../utils/renderCategoryLabel"; type Tab = "accounts" | "categories"; export default function AccountsPage() { const { t } = useTranslation(); const { state, setIncludeArchived, addAccount, editAccount, archiveAccount, unarchiveAccount, addCategory, editCategory, removeCategory, } = useBalanceAccounts(); const [activeTab, setActiveTab] = useState("accounts"); const [showAccountForm, setShowAccountForm] = useState(false); const [editingAccount, setEditingAccount] = useState(null); const [showCategoryForm, setShowCategoryForm] = useState(false); /** 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) => renderCategoryLabelFromCategory(cat, t); const closeAccountForm = () => { setShowAccountForm(false); setEditingAccount(null); }; const handleAccountSubmit = async ( payload: | Parameters[0] | Parameters[1] ) => { try { if (editingAccount) { await editAccount(editingAccount.id, payload as Parameters[1]); } else { await addAccount(payload as Parameters[0]); } closeAccountForm(); } catch { // Error already surfaced via state.error } }; const handleCategorySubmit = async (input: CreateBalanceCategoryInput) => { try { 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 (

{t("balance.accountsPage.title")}

{state.error && (
{state.errorCode ? t(`balance.errors.${state.errorCode}`, { defaultValue: state.error, }) : state.error}
)}
{activeTab === "accounts" && (
{showAccountForm ? (

{editingAccount ? t("balance.account.form.editTitle") : t("balance.account.form.createTitle")}

) : null} {state.accounts.length === 0 ? (
{t("balance.accountsPage.empty")}
) : (
{state.accounts.map((acc) => { const isArchived = !!acc.archived_at; return ( ); })}
{t("balance.account.fields.name")} {t("balance.account.fields.category")} {t("balance.account.fields.symbol")} {t("balance.account.fields.currency")} {t("balance.account.fields.status")} {t("balance.account.fields.actions")}
{acc.name} {renderCategoryLabelFromAccount(acc, t)} {acc.symbol ?? "—"} {acc.currency} {isArchived ? ( {t("balance.account.status.archived")} ) : ( {t("balance.account.status.active")} )}
{isArchived ? ( ) : ( )}
)}
)} {activeTab === "categories" && (

{t("balance.category.intro")}

{showCategoryForm && (

{t("balance.category.form.createTitle")}

setShowCategoryForm(false)} />
)} {categoryDeleteError && (
{categoryDeleteError}
)}
{state.categories.map((cat) => ( ))}
{t("balance.category.fields.name")} {t("balance.category.fields.key")} {t("balance.category.fields.kind")} {t("balance.category.fields.origin")} {t("balance.category.fields.actions")}
{renderCategoryLabel(cat)} {cat.key} {t(`balance.category.kind.${cat.kind}`)} {cat.is_seed ? ( {t("balance.category.origin.seeded")} ) : ( {t("balance.category.origin.user")} )}
{(() => { 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 ( ); })()}
)}
); }