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>
This commit is contained in:
le king fu 2026-04-25 14:37:30 -04:00
parent 58d3c86336
commit fccc8e4fa2
4 changed files with 980 additions and 0 deletions

View file

@ -16,6 +16,7 @@ import ReportsComparePage from "./pages/ReportsComparePage";
import ReportsCategoryPage from "./pages/ReportsCategoryPage"; import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage"; import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import AccountsPage from "./pages/AccountsPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage"; import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage"; import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
import DocsPage from "./pages/DocsPage"; import DocsPage from "./pages/DocsPage";
@ -114,6 +115,7 @@ export default function App() {
<Route path="/reports/category" element={<ReportsCategoryPage />} /> <Route path="/reports/category" element={<ReportsCategoryPage />} />
<Route path="/reports/cartes" element={<ReportsCartesPage />} /> <Route path="/reports/cartes" element={<ReportsCartesPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/balance/accounts" element={<AccountsPage />} />
<Route <Route
path="/settings/categories/standard" path="/settings/categories/standard"
element={<CategoriesStandardGuidePage />} element={<CategoriesStandardGuidePage />}

View file

@ -0,0 +1,229 @@
// 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>
);
}

View file

@ -0,0 +1,276 @@
// useBalanceAccounts — scoped useReducer hook backing AccountsPage.
//
// Domain coverage (per spec-plan-bilan.md v2): the AccountsPage CRUD over
// `balance_accounts` AND `balance_categories`. Snapshots, lines, transfers,
// and returns are out of scope here — they belong to `useSnapshotEditor`
// (Issue #146 / Bilan #1b) and `useBalanceOverview` (Issue #141 / Bilan #3).
import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
BalanceAccountWithCategory,
BalanceCategory,
BalanceCategoryKind,
} from "../shared/types";
import {
listBalanceAccounts,
listBalanceCategories,
createBalanceAccount,
updateBalanceAccount,
archiveBalanceAccount,
unarchiveBalanceAccount,
createBalanceCategory,
updateBalanceCategory,
deleteBalanceCategory,
BalanceServiceError,
type CreateBalanceAccountInput,
type CreateBalanceCategoryInput,
type UpdateBalanceAccountInput,
type UpdateBalanceCategoryInput,
} from "../services/balance.service";
interface State {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
includeArchived: boolean;
isLoading: boolean;
isSaving: boolean;
error: string | null;
/** Stable error code for UIs that want to localize via i18n (e.g. seed protection). */
errorCode: string | null;
}
type Action =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
| {
type: "SET_DATA";
payload: {
accounts: BalanceAccountWithCategory[];
categories: BalanceCategory[];
};
}
| { type: "SET_INCLUDE_ARCHIVED"; payload: boolean };
function initialState(): State {
return {
accounts: [],
categories: [],
includeArchived: false,
isLoading: false,
isSaving: false,
error: null,
errorCode: null,
};
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_LOADING":
return { ...state, isLoading: action.payload };
case "SET_SAVING":
return { ...state, isSaving: action.payload };
case "SET_ERROR":
return {
...state,
error: action.payload.message,
errorCode: action.payload.code,
isLoading: false,
isSaving: false,
};
case "SET_DATA":
return {
...state,
accounts: action.payload.accounts,
categories: action.payload.categories,
isLoading: false,
error: null,
errorCode: null,
};
case "SET_INCLUDE_ARCHIVED":
return { ...state, includeArchived: action.payload };
default:
return state;
}
}
function describeError(e: unknown): { message: string; code: string | null } {
if (e instanceof BalanceServiceError) {
return { message: e.message, code: e.code };
}
return {
message: e instanceof Error ? e.message : String(e),
code: null,
};
}
export function useBalanceAccounts() {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0);
const refreshData = useCallback(async (includeArchived: boolean) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
try {
const [accounts, categories] = await Promise.all([
listBalanceAccounts({ includeArchived }),
listBalanceCategories(),
]);
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_DATA", payload: { accounts, categories } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: describeError(e) });
}
}, []);
useEffect(() => {
refreshData(state.includeArchived);
}, [state.includeArchived, refreshData]);
const setIncludeArchived = useCallback((next: boolean) => {
dispatch({ type: "SET_INCLUDE_ARCHIVED", payload: next });
}, []);
// ---------------------------------------------------------------------------
// Account mutations
// ---------------------------------------------------------------------------
const addAccount = useCallback(
async (input: CreateBalanceAccountInput) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await createBalanceAccount(input);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const editAccount = useCallback(
async (id: number, input: UpdateBalanceAccountInput) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await updateBalanceAccount(id, input);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const archiveAccount = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await archiveBalanceAccount(id);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const unarchiveAccount = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await unarchiveBalanceAccount(id);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
// ---------------------------------------------------------------------------
// Category mutations
// ---------------------------------------------------------------------------
/**
* Issue #138 keeps the AccountsPage Categories tab to user-created
* `simple` kind only. The priced creation UI lands in #140 until then,
* callers should pass kind = 'simple'.
*/
const addCategory = useCallback(
async (input: CreateBalanceCategoryInput) => {
const kind: BalanceCategoryKind = input.kind ?? "simple";
dispatch({ type: "SET_SAVING", payload: true });
try {
await createBalanceCategory({ ...input, kind });
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const editCategory = useCallback(
async (id: number, input: UpdateBalanceCategoryInput) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await updateBalanceCategory(id, input);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
const removeCategory = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteBalanceCategory(id);
await refreshData(state.includeArchived);
} catch (e) {
dispatch({ type: "SET_ERROR", payload: describeError(e) });
throw e;
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.includeArchived, refreshData]
);
return {
state,
setIncludeArchived,
refresh: () => refreshData(state.includeArchived),
// Account ops
addAccount,
editAccount,
archiveAccount,
unarchiveAccount,
// Category ops
addCategory,
editCategory,
removeCategory,
};
}

473
src/pages/AccountsPage.tsx Normal file
View file

@ -0,0 +1,473 @@
// 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>
);
}