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>
473 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|