Simpl-Resultat/src/pages/AccountsPage.tsx
le king fu 344c27ee6d
All checks were successful
PR Check / rust (pull_request) Successful in 22m29s
PR Check / frontend (pull_request) Successful in 2m23s
feat(balance): account form vehicle field + category rename via custom_label (#203)
- AccountForm: optional vehicle_type dropdown (account mode, 6 fiscal
  envelopes + none) wired into create/update; custom_label field (category
  mode) written to custom_label, never i18n_key.
- AccountsPage rename: writes custom_label, leaves i18n_key intact (fixes
  bug I where renaming clobbered the translation key).
- New shared renderCategoryLabel helper (+ shape adapters) applied at the 5
  sites: AccountsPage, AccountForm, SnapshotEditor, BalanceAccountsTable,
  BalancePage.
- Hide v13-deactivated seeds: useBalanceAccounts passes includeInactive=false
  (keeps #202 behavior-neutral default + tests green).
- BALANCE_VEHICLE_TYPES exported from shared/types as single source of truth
  (service reuses it).
- i18n FR+EN: vehicleType.*, category.form.customLabel*, errors.vehicle_type_invalid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:50:40 -04:00

466 lines
19 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";
import type { CreateBalanceCategoryInput } from "../services/balance.service";
import {
renderCategoryLabelFromAccount,
renderCategoryLabelFromCategory,
} from "../utils/renderCategoryLabel";
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);
/** Local error string for category deletion guard (count + names of linked accounts). */
const [categoryDeleteError, setCategoryDeleteError] = useState<string | null>(
null
);
const activeCategories = useMemo(
() => state.categories.filter((c) => c.is_active),
[state.categories]
);
/** Map category id → array of accounts linked to it (active + archived). */
const accountsByCategory = useMemo(() => {
const m = new Map<number, BalanceAccountWithCategory[]>();
for (const acc of state.accounts) {
const list = m.get(acc.balance_category_id) ?? [];
list.push(acc);
m.set(acc.balance_category_id, list);
}
return m;
}, [state.accounts]);
const renderCategoryLabel = (cat: BalanceCategory) =>
renderCategoryLabelFromCategory(cat, t);
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 handleCategorySubmit = async (input: CreateBalanceCategoryInput) => {
try {
await addCategory(input);
setShowCategoryForm(false);
} catch {
// Error already surfaced via state.error
}
};
/**
* Delete-guard for categories. The service refuses to delete a seeded
* category or one with linked accounts, but we pre-check at the UI to
* surface a richer message that lists the linked-account names.
*/
const handleDeleteCategory = (cat: BalanceCategory) => {
setCategoryDeleteError(null);
if (cat.is_seed) return;
const linked = accountsByCategory.get(cat.id) ?? [];
if (linked.length > 0) {
const sample = linked.slice(0, 3).map((a) => a.name).join(", ");
const more = linked.length > 3 ? ", …" : "";
setCategoryDeleteError(
t("balance.category.error.has_accounts", {
count: linked.length,
names: `${sample}${more}`,
})
);
return;
}
if (!window.confirm(t("balance.category.actions.deleteConfirm"))) return;
removeCategory(cat.id);
};
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
mode="account"
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">
{renderCategoryLabelFromAccount(acc, t)}
</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>
<AccountForm
mode="category"
isSaving={state.isSaving}
onSubmit={handleCategorySubmit}
onCancel={() => setShowCategoryForm(false)}
/>
</div>
)}
{categoryDeleteError && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20 flex items-start justify-between gap-2">
<span>{categoryDeleteError}</span>
<button
type="button"
onClick={() => setCategoryDeleteError(null)}
className="text-xs underline shrink-0"
>
{t("common.dismiss", { defaultValue: "OK" })}
</button>
</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)
);
// Write the human label to custom_label, never to
// i18n_key — preserves the bundled translation
// (fixes bug I). A blank/empty answer clears the
// override and falls back to t(i18n_key).
if (next !== null) {
editCategory(cat.id, {
custom_label: next.trim() || null,
});
}
}}
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>
{(() => {
const linkedCount =
accountsByCategory.get(cat.id)?.length ?? 0;
const blocked = cat.is_seed || linkedCount > 0;
const titleKey = cat.is_seed
? t("balance.category.actions.deleteSeedHint")
: linkedCount > 0
? t("balance.category.actions.deleteHasAccountsHint", {
count: linkedCount,
})
: t("common.delete");
return (
<button
type="button"
onClick={() => handleDeleteCategory(cat)}
disabled={blocked}
title={titleKey}
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>
);
}