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