Simpl-Resultat/src/pages/AccountsPage.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

473 lines
20 KiB
TypeScript

// 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";
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<Tab>("accounts");
const [showAccountForm, setShowAccountForm] = useState(false);
const [editingAccount, setEditingAccount] =
useState<BalanceAccountWithCategory | null>(null);
const [showCategoryForm, setShowCategoryForm] = useState(false);
const [newCategoryKey, setNewCategoryKey] = useState("");
const [newCategoryLabel, setNewCategoryLabel] = useState("");
const activeCategories = useMemo(
() => state.categories.filter((c) => c.is_active),
[state.categories]
);
const renderCategoryLabel = (cat: BalanceCategory) =>
t(cat.i18n_key, { defaultValue: cat.key });
const closeAccountForm = () => {
setShowAccountForm(false);
setEditingAccount(null);
};
const handleAccountSubmit = async (
payload:
| Parameters<typeof addAccount>[0]
| Parameters<typeof editAccount>[1]
) => {
try {
if (editingAccount) {
await editAccount(editingAccount.id, payload as Parameters<typeof editAccount>[1]);
} else {
await addAccount(payload as Parameters<typeof addAccount>[0]);
}
closeAccountForm();
} catch {
// Error already surfaced via state.error
}
};
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;
try {
await addCategory({
key,
i18n_key: i18nKey,
kind: "simple",
sort_order: 100, // user-created categories sort after seeded ones
});
setNewCategoryKey("");
setNewCategoryLabel("");
setShowCategoryForm(false);
} catch {
// Error already surfaced via state.error
}
};
return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="flex items-center gap-3 mb-6">
<Wallet size={24} className="text-[var(--primary)]" />
<h1 className="text-2xl font-bold">{t("balance.accountsPage.title")}</h1>
</div>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
{state.errorCode
? t(`balance.errors.${state.errorCode}`, {
defaultValue: state.error,
})
: state.error}
</div>
)}
<div className="flex border-b border-[var(--border)] mb-6">
<button
type="button"
onClick={() => setActiveTab("accounts")}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === "accounts"
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{t("balance.accountsPage.tabs.accounts")}
</button>
<button
type="button"
onClick={() => setActiveTab("categories")}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px ${
activeTab === "categories"
? "border-[var(--primary)] text-[var(--primary)]"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{t("balance.accountsPage.tabs.categories")}
</button>
</div>
{activeTab === "accounts" && (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={state.includeArchived}
onChange={(e) => setIncludeArchived(e.target.checked)}
/>
{t("balance.accountsPage.includeArchived")}
</label>
<button
type="button"
onClick={() => {
setEditingAccount(null);
setShowAccountForm(true);
}}
disabled={activeCategories.length === 0}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
<Plus size={16} />
{t("balance.accountsPage.newAccount")}
</button>
</div>
{showAccountForm ? (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
<h2 className="text-lg font-semibold mb-4">
{editingAccount
? t("balance.account.form.editTitle")
: t("balance.account.form.createTitle")}
</h2>
<AccountForm
initialAccount={editingAccount ?? null}
categories={activeCategories}
isSaving={state.isSaving}
onSubmit={handleAccountSubmit}
onCancel={closeAccountForm}
/>
</div>
) : null}
{state.accounts.length === 0 ? (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
{t("balance.accountsPage.empty")}
</div>
) : (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]">
<tr>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.name")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.category")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.symbol")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.currency")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.account.fields.status")}
</th>
<th className="text-right px-4 py-2 font-medium">
{t("balance.account.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{state.accounts.map((acc) => {
const isArchived = !!acc.archived_at;
return (
<tr
key={acc.id}
className="border-t border-[var(--border)]"
>
<td className="px-4 py-2">
<span className={isArchived ? "opacity-60" : ""}>
{acc.name}
</span>
</td>
<td className="px-4 py-2">
{t(acc.category_i18n_key, {
defaultValue: acc.category_key,
})}
</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
{acc.symbol ?? "—"}
</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
{acc.currency}
</td>
<td className="px-4 py-2">
{isArchived ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)] text-[var(--muted-foreground)]">
{t("balance.account.status.archived")}
</span>
) : (
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--positive)]/10 text-[var(--positive)]">
{t("balance.account.status.active")}
</span>
)}
</td>
<td className="px-4 py-2 text-right">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
setEditingAccount(acc);
setShowAccountForm(true);
}}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={t("common.edit")}
>
<Edit2 size={14} />
</button>
{isArchived ? (
<button
type="button"
onClick={() => unarchiveAccount(acc.id)}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={t("balance.account.actions.unarchive")}
>
<ArchiveRestore size={14} />
</button>
) : (
<button
type="button"
onClick={() => archiveAccount(acc.id)}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)]"
title={t("balance.account.actions.archive")}
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === "categories" && (
<div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-4">
<p className="text-sm text-[var(--muted-foreground)]">
{t("balance.category.intro")}
</p>
<button
type="button"
onClick={() => setShowCategoryForm((prev) => !prev)}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
<Plus size={16} />
{t("balance.category.actions.create")}
</button>
</div>
{showCategoryForm && (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
<h2 className="text-lg font-semibold mb-4">
{t("balance.category.form.createTitle")}
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-key"
>
{t("balance.category.form.key")}
</label>
<input
id="category-key"
type="text"
value={newCategoryKey}
onChange={(e) => 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"
/>
</div>
<div>
<label
className="block text-sm font-medium mb-1"
htmlFor="category-label"
>
{t("balance.category.form.label")}
</label>
<input
id="category-label"
type="text"
value={newCategoryLabel}
onChange={(e) => 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"
/>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)] mt-3">
{t("balance.category.form.simpleOnlyNotice")}
</p>
<div className="flex justify-end gap-2 mt-4">
<button
type="button"
onClick={() => {
setShowCategoryForm(false);
setNewCategoryKey("");
setNewCategoryLabel("");
}}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
<button
type="button"
onClick={handleCreateCategory}
disabled={state.isSaving || !newCategoryKey.trim()}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("balance.category.form.create")}
</button>
</div>
</div>
)}
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--muted)]">
<tr>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.name")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.key")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.kind")}
</th>
<th className="text-left px-4 py-2 font-medium">
{t("balance.category.fields.origin")}
</th>
<th className="text-right px-4 py-2 font-medium">
{t("balance.category.fields.actions")}
</th>
</tr>
</thead>
<tbody>
{state.categories.map((cat) => (
<tr key={cat.id} className="border-t border-[var(--border)]">
<td className="px-4 py-2">{renderCategoryLabel(cat)}</td>
<td className="px-4 py-2 text-[var(--muted-foreground)]">
<code className="text-xs">{cat.key}</code>
</td>
<td className="px-4 py-2">
<span className="text-xs px-2 py-0.5 rounded-full bg-[var(--muted)]">
{t(`balance.category.kind.${cat.kind}`)}
</span>
</td>
<td className="px-4 py-2">
{cat.is_seed ? (
<span className="text-xs text-[var(--muted-foreground)]">
{t("balance.category.origin.seeded")}
</span>
) : (
<span className="text-xs text-[var(--muted-foreground)]">
{t("balance.category.origin.user")}
</span>
)}
</td>
<td className="px-4 py-2 text-right">
<div className="inline-flex items-center gap-1">
<button
type="button"
onClick={() => {
const next = window.prompt(
t("balance.category.actions.renamePrompt"),
renderCategoryLabel(cat)
);
if (next && next.trim()) {
editCategory(cat.id, { i18n_key: next.trim() });
}
}}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
title={t("common.edit")}
>
<Edit2 size={14} />
</button>
<button
type="button"
onClick={() => {
if (cat.is_seed) return;
if (
window.confirm(
t("balance.category.actions.deleteConfirm")
)
) {
removeCategory(cat.id);
}
}}
disabled={cat.is_seed}
title={
cat.is_seed
? t("balance.category.actions.deleteSeedHint")
: t("common.delete")
}
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--negative)] disabled:opacity-30 disabled:cursor-not-allowed"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}