// useSnapshotEditor — scoped useReducer hook backing SnapshotEditPage. // // Lifecycle of a single snapshot (Issue #146 / Bilan #1b — simple kind only): // 1. mount in 'new' mode (no `?date=` query param) → user picks a date, // types values, hits Save → service.createSnapshot + upsertLines; // 2. mount in 'edit' mode (`?date=YYYY-MM-DD`) → load snapshot + lines, // user edits values, hits Save → upsertLines on the existing snapshot; // 3. delete → service.deleteSnapshot (the page wraps this in a // double-confirm modal that requires retyping the snapshot date). // // Priced-kind UI lands in #140 (Bilan #2). Until then values are scalar // numbers keyed by account_id and quantity/unit_price are forced to NULL by // `upsertSnapshotLines` (the SQL CHECK guards the invariant too). import { useReducer, useCallback, useEffect, useRef, } from "react"; import type { BalanceAccountWithCategory, BalanceCategory, BalanceSnapshot, BalanceSnapshotLine, } from "../shared/types"; import { listBalanceAccounts, listBalanceCategories, getSnapshotByDate, createSnapshot, deleteSnapshot, listLinesBySnapshot, upsertSnapshotLines, getPreviousSnapshot, BalanceServiceError, } from "../services/balance.service"; export type SnapshotEditorMode = "new" | "edit"; interface State { mode: SnapshotEditorMode; /** ISO YYYY-MM-DD; controlled in 'new' mode, frozen in 'edit'. */ snapshotDate: string; /** Current snapshot row in 'edit' mode (has the id needed for upsert). */ snapshot: BalanceSnapshot | null; /** All active accounts (with category metadata) — drives the line list. */ accounts: BalanceAccountWithCategory[]; /** Used to group lines by category in the editor view. */ categories: BalanceCategory[]; /** * Map of account_id → string-typed value. We keep strings to preserve * empty / partial input the user is typing; conversion to number happens * at save time (and at validation when needed). */ values: Record; /** Snapshot whose values would prefill if the user clicks "Prefill". */ previousSnapshot: BalanceSnapshot | null; /** Lines from `previousSnapshot` (loaded lazily when needed). */ previousLines: BalanceSnapshotLine[] | null; isLoading: boolean; isSaving: boolean; isDirty: boolean; error: string | null; 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: "LOADED"; payload: { mode: SnapshotEditorMode; snapshotDate: string; snapshot: BalanceSnapshot | null; accounts: BalanceAccountWithCategory[]; categories: BalanceCategory[]; values: Record; previousSnapshot: BalanceSnapshot | null; previousLines: BalanceSnapshotLine[] | null; }; } | { type: "SET_DATE"; payload: string } | { type: "SET_VALUE"; payload: { accountId: number; value: string } } | { type: "PREFILL"; payload: Record } | { type: "RESET" } | { type: "CLEAR_DIRTY" }; function initialState(initialDate: string): State { return { mode: "new", snapshotDate: initialDate, snapshot: null, accounts: [], categories: [], values: {}, previousSnapshot: null, previousLines: null, isLoading: false, isSaving: false, isDirty: 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 "LOADED": return { ...state, mode: action.payload.mode, snapshotDate: action.payload.snapshotDate, snapshot: action.payload.snapshot, accounts: action.payload.accounts, categories: action.payload.categories, values: action.payload.values, previousSnapshot: action.payload.previousSnapshot, previousLines: action.payload.previousLines, isLoading: false, isDirty: false, error: null, errorCode: null, }; case "SET_DATE": // Only meaningful in 'new' mode — the page guards against this in 'edit'. return { ...state, snapshotDate: action.payload, isDirty: true }; case "SET_VALUE": return { ...state, values: { ...state.values, [action.payload.accountId]: action.payload.value, }, isDirty: true, }; case "PREFILL": return { ...state, values: { ...state.values, ...action.payload }, isDirty: true, }; case "RESET": return { ...state, // Keep the loaded structure (accounts, categories, snapshot) but wipe // user input back to a clean slate sourced from the saved lines. values: {}, isDirty: true, }; case "CLEAR_DIRTY": return { ...state, isDirty: false }; 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, }; } function todayISO(): string { // Avoid timezone drift: use local YYYY-MM-DD, not toISOString() which is UTC. const d = new Date(); const yyyy = d.getFullYear(); const mm = String(d.getMonth() + 1).padStart(2, "0"); const dd = String(d.getDate()).padStart(2, "0"); return `${yyyy}-${mm}-${dd}`; } interface Options { /** ISO date from the route query string. `undefined` means 'new' mode. */ dateParam?: string | null; } export function useSnapshotEditor(options: Options = {}) { const { dateParam } = options; const [state, dispatch] = useReducer( reducer, undefined, () => initialState(dateParam ?? todayISO()) ); const fetchIdRef = useRef(0); /** * Load the editor state from the database. In 'new' mode we still load * accounts + categories + the previous snapshot (so the prefill button * can be enabled); we do NOT pre-create a snapshot row — that happens at * save time so the user can abandon the form without leaving an empty * snapshot behind. */ const loadForDate = useCallback(async (date: string | null | undefined) => { const fetchId = ++fetchIdRef.current; dispatch({ type: "SET_LOADING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); const targetDate = date && date.length > 0 ? date : todayISO(); try { const [accounts, categories] = await Promise.all([ listBalanceAccounts(), listBalanceCategories(), ]); const existing = await getSnapshotByDate(targetDate); const isEdit = !!existing; let values: Record = {}; let previousLines: BalanceSnapshotLine[] | null = null; if (existing) { const lines = await listLinesBySnapshot(existing.id); for (const line of lines) { values[line.account_id] = String(line.value); } } const previous = await getPreviousSnapshot(targetDate); if (previous) { previousLines = await listLinesBySnapshot(previous.id); } if (fetchId !== fetchIdRef.current) return; dispatch({ type: "LOADED", payload: { mode: isEdit ? "edit" : "new", snapshotDate: targetDate, snapshot: existing, accounts, categories, values, previousSnapshot: previous, previousLines, }, }); } catch (e) { if (fetchId !== fetchIdRef.current) return; dispatch({ type: "SET_ERROR", payload: describeError(e) }); } }, []); // Load on mount + whenever the route's `?date=` changes. useEffect(() => { loadForDate(dateParam); }, [dateParam, loadForDate]); const setDate = useCallback((next: string) => { dispatch({ type: "SET_DATE", payload: next }); }, []); const setLineValue = useCallback((accountId: number, value: string) => { dispatch({ type: "SET_VALUE", payload: { accountId, value }, }); }, []); const reset = useCallback(() => { dispatch({ type: "RESET" }); }, []); /** * Build the prefill map from the previous snapshot. Per spec-decisions * row "Bouton Pré-remplir" (Issue 1b decision): * - simple kind → copy value * - priced kind → copy quantity, leave unit_price blank → effectively * no-op at Issue #146 because priced UI ships in #140. * We add a TODO so the priced branch is explicit. */ const prefillFromPrevious = useCallback(() => { const lines = state.previousLines; if (!lines || lines.length === 0) return; const accountKindById = new Map(); for (const acc of state.accounts) { accountKindById.set(acc.id, acc.category_kind); } const next: Record = {}; for (const line of lines) { const kind = accountKindById.get(line.account_id); if (!kind) continue; // archived account — skip if (kind === "simple") { next[line.account_id] = String(line.value); } else { // TODO Issue #140 — implement priced prefill (quantity copy, leave // unit_price blank). For Issue #146 the priced UI does not exist yet. } } dispatch({ type: "PREFILL", payload: next }); }, [state.previousLines, state.accounts]); /** * Persist the editor state to the database. * - 'new' mode: create the snapshot row (UNIQUE per date), then upsert * its lines. If creation fails because a snapshot was created at this * same date concurrently (snapshot_date_taken), the page is expected * to redirect to edit mode. * - 'edit' mode: upsert lines on the existing snapshot. * * Only accounts with a non-empty value (after trim) are persisted; empty * fields mean "no entry for this account at this date" — they're cleared * by the rewrite-all strategy in `upsertSnapshotLines`. */ const save = useCallback(async (): Promise<{ snapshotId: number }> => { dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); try { let snapshotId: number; if (state.mode === "edit" && state.snapshot) { snapshotId = state.snapshot.id; } else { snapshotId = await createSnapshot({ snapshot_date: state.snapshotDate, }); } const lines = Object.entries(state.values) .filter(([, v]) => v !== undefined && String(v).trim().length > 0) .map(([accountIdStr, raw]) => { const accountId = Number(accountIdStr); const trimmed = String(raw).trim().replace(",", "."); const num = Number(trimmed); if (!Number.isFinite(num)) { throw new BalanceServiceError( "snapshot_value_invalid", `Invalid value for account ${accountId}: "${raw}"` ); } return { account_id: accountId, value: num }; }); await upsertSnapshotLines(snapshotId, lines); dispatch({ type: "CLEAR_DIRTY" }); // Reload so 'new' mode flips to 'edit' and the snapshot row is in state. await loadForDate(state.snapshotDate); return { snapshotId }; } catch (e) { dispatch({ type: "SET_ERROR", payload: describeError(e) }); throw e; } finally { dispatch({ type: "SET_SAVING", payload: false }); } }, [ state.mode, state.snapshot, state.snapshotDate, state.values, loadForDate, ]); const remove = useCallback(async () => { if (!state.snapshot) return; dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); try { await deleteSnapshot(state.snapshot.id); } catch (e) { dispatch({ type: "SET_ERROR", payload: describeError(e) }); throw e; } finally { dispatch({ type: "SET_SAVING", payload: false }); } }, [state.snapshot]); return { state, setDate, setLineValue, reset, prefillFromPrevious, save, remove, /** Manual reload (e.g. after navigation between dates). */ reload: () => loadForDate(state.snapshotDate), }; }