Simpl-Resultat/src/hooks/useBalanceAccounts.ts
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

276 lines
8.1 KiB
TypeScript

// 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,
};
}