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>
276 lines
8.1 KiB
TypeScript
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,
|
|
};
|
|
}
|