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>
229 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|