From fccc8e4fa29a4096c8296dfa0ba67d1ce2407bf7 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:37:30 -0400 Subject: [PATCH] feat(balance): add useBalanceAccounts hook + AccountsPage + AccountForm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the AccountsPage end-to-end with a new scoped useReducer hook, the page itself (accessible at /balance/accounts) and the account form. useBalanceAccounts (src/hooks/useBalanceAccounts.ts): - Loads accounts (excludes archived by default) + categories in parallel - Surfaces typed errors from balance.service via state.errorCode so the UI can localize them (e.g. seed protection, currency rejection) - CRUD operations on both domains: addAccount/editAccount/archive/ unarchiveAccount + addCategory/editCategory/removeCategory AccountsPage (src/pages/AccountsPage.tsx): - Two tabs: Comptes + Catégories - Accounts tab: archive toggle, table of (name, category, symbol, currency, status), inline edit/archive/restore - Categories tab: full list of seeded + user categories. Add new simple-kind category (priced creation lands in #140). Rename via inline prompt; delete disabled on seeded rows. Errors surfaced via i18n keys keyed on BalanceErrorCode. AccountForm (src/components/balance/AccountForm.tsx): - Variant=account only (category variant lands in #140) - Auto-detects priced category to hint the symbol field - Full FR/EN coverage of labels and validation messages Per spec-plan-bilan.md v2 the sidebar entry "Bilan" is intentionally not added in this issue — it lands in #141 (Bilan #3) when the /balance overview becomes navigable. Until then the route is reachable directly via URL. Refs #138 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 2 + src/components/balance/AccountForm.tsx | 229 ++++++++++++ src/hooks/useBalanceAccounts.ts | 276 +++++++++++++++ src/pages/AccountsPage.tsx | 473 +++++++++++++++++++++++++ 4 files changed, 980 insertions(+) create mode 100644 src/components/balance/AccountForm.tsx create mode 100644 src/hooks/useBalanceAccounts.ts create mode 100644 src/pages/AccountsPage.tsx diff --git a/src/App.tsx b/src/App.tsx index dd6677b..10a099a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCartesPage from "./pages/ReportsCartesPage"; import SettingsPage from "./pages/SettingsPage"; +import AccountsPage from "./pages/AccountsPage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; import DocsPage from "./pages/DocsPage"; @@ -114,6 +115,7 @@ export default function App() { } /> } /> } /> + } /> } diff --git a/src/components/balance/AccountForm.tsx b/src/components/balance/AccountForm.tsx new file mode 100644 index 0000000..a56efb0 --- /dev/null +++ b/src/components/balance/AccountForm.tsx @@ -0,0 +1,229 @@ +// AccountForm — variant=account (Issue #138 / Bilan #1a). +// +// The category variant lands in Issue #140 (Bilan #2) when the priced-kind +// switch becomes available. For now this component focuses on creating / +// editing a `balance_account` record bound to an existing category. + +import { FormEvent, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + BalanceAccount, + BalanceCategory, +} from "../../shared/types"; +import type { + CreateBalanceAccountInput, + UpdateBalanceAccountInput, +} from "../../services/balance.service"; + +export interface AccountFormValues { + balance_category_id: number; + name: string; + symbol: string; + notes: string; +} + +interface Props { + /** When provided, the form is in edit mode; otherwise creation. */ + initialAccount?: BalanceAccount | null; + categories: BalanceCategory[]; + isSaving: boolean; + onSubmit: ( + values: CreateBalanceAccountInput | UpdateBalanceAccountInput + ) => Promise | void; + onCancel: () => void; +} + +function defaultValues( + initial: BalanceAccount | null | undefined, + categories: BalanceCategory[] +): AccountFormValues { + if (initial) { + return { + balance_category_id: initial.balance_category_id, + name: initial.name, + symbol: initial.symbol ?? "", + notes: initial.notes ?? "", + }; + } + // First active category as a sane default + const first = categories.find((c) => c.is_active) ?? categories[0]; + return { + balance_category_id: first?.id ?? 0, + name: "", + symbol: "", + notes: "", + }; +} + +export default function AccountForm({ + initialAccount, + categories, + isSaving, + onSubmit, + onCancel, +}: Props) { + const { t } = useTranslation(); + const [values, setValues] = useState(() => + defaultValues(initialAccount, categories) + ); + const [touched, setTouched] = useState(false); + + // Reset form when target account changes (edit different row). + useEffect(() => { + setValues(defaultValues(initialAccount, categories)); + setTouched(false); + }, [initialAccount, categories]); + + const isEditing = !!initialAccount; + const selectedCategory = categories.find( + (c) => c.id === values.balance_category_id + ); + const isPriced = selectedCategory?.kind === "priced"; + const trimmedName = values.name.trim(); + const nameInvalid = touched && trimmedName.length === 0; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setTouched(true); + if (!trimmedName) return; + + const payload: CreateBalanceAccountInput = { + balance_category_id: values.balance_category_id, + name: trimmedName, + symbol: values.symbol.trim() || null, + notes: values.notes.trim() || null, + }; + + if (isEditing) { + const updatePayload: UpdateBalanceAccountInput = { + balance_category_id: payload.balance_category_id, + name: payload.name, + symbol: payload.symbol, + notes: payload.notes, + }; + await onSubmit(updatePayload); + } else { + await onSubmit(payload); + } + }; + + const renderCategoryLabel = (cat: BalanceCategory) => + t(cat.i18n_key, { defaultValue: cat.key }); + + return ( +
+
+ + +
+ +
+ + setValues({ ...values, name: e.target.value })} + onBlur={() => setTouched(true)} + className={`w-full px-3 py-2 rounded-lg border bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] ${ + nameInvalid + ? "border-[var(--negative)]" + : "border-[var(--border)]" + }`} + autoFocus + autoComplete="off" + /> + {nameInvalid && ( +

+ {t("balance.account.form.nameRequired")} +

+ )} +
+ +
+ + setValues({ ...values, symbol: e.target.value })} + placeholder={ + isPriced + ? t("balance.account.form.symbolPlaceholderPriced") + : t("balance.account.form.symbolPlaceholderSimple") + } + 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)]" + autoComplete="off" + /> +
+ +
+ +