- 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>
466 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|