Simpl-Resultat/src/components/balance/AccountForm.tsx
le king fu fccc8e4fa2 feat(balance): add useBalanceAccounts hook + AccountsPage + AccountForm
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) <noreply@anthropic.com>
2026-04-25 14:37:30 -04:00

229 lines
7.2 KiB
TypeScript

// 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> | 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<AccountFormValues>(() =>
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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-category">
{t("balance.account.form.category")}
</label>
<select
id="account-category"
value={values.balance_category_id}
onChange={(e) =>
setValues({
...values,
balance_category_id: Number(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)]"
>
{categories.length === 0 ? (
<option value={0}>{t("balance.account.form.noCategory")}</option>
) : (
categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{renderCategoryLabel(cat)}
</option>
))
)}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-name">
{t("balance.account.form.name")}
</label>
<input
id="account-name"
type="text"
value={values.name}
onChange={(e) => 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 && (
<p className="mt-1 text-xs text-[var(--negative)]">
{t("balance.account.form.nameRequired")}
</p>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-symbol">
{t("balance.account.form.symbol")}
{isPriced && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
({t("balance.account.form.symbolPricedHint")})
</span>
)}
</label>
<input
id="account-symbol"
type="text"
value={values.symbol}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" htmlFor="account-notes">
{t("balance.account.form.notes")}
</label>
<textarea
id="account-notes"
value={values.notes}
onChange={(e) => setValues({ ...values, notes: e.target.value })}
rows={2}
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)] resize-none"
/>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{t("balance.account.form.currencyMvpNotice")}
</p>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={isSaving}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
>
{t("common.cancel")}
</button>
<button
type="submit"
disabled={isSaving || !trimmedName || categories.length === 0}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{isEditing
? t("balance.account.form.save")
: t("balance.account.form.create")}
</button>
</div>
</form>
);
}