// 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, deleteSnapshot, listLinesBySnapshot, saveSnapshotAtomic, getPreviousSnapshot, BalanceServiceError, } from "../services/balance.service"; export type SnapshotEditorMode = "new" | "edit"; /** String-typed entry for a priced-kind line being edited. */ export interface PricedEntry { quantity: string; unit_price: string; } 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 (simple kind only). We keep * strings to preserve empty / partial input; conversion to number * happens at save time. */ values: Record; /** * Map of account_id → string-typed `{quantity, unit_price}` (priced * kind only). Same partial-input guarantee as `values`. */ pricedValues: 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; pricedValues: Record; previousSnapshot: BalanceSnapshot | null; previousLines: BalanceSnapshotLine[] | null; }; } | { type: "SET_DATE"; payload: string } | { type: "SET_VALUE"; payload: { accountId: number; value: string } } | { type: "SET_PRICED_FIELD"; payload: { accountId: number; field: "quantity" | "unit_price"; value: string; }; } | { type: "PREFILL"; payload: { values: Record; pricedValues: Record; }; } | { type: "RESET" } | { type: "CLEAR_DIRTY" }; function initialState(initialDate: string): State { return { mode: "new", snapshotDate: initialDate, snapshot: null, accounts: [], categories: [], values: {}, pricedValues: {}, 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, pricedValues: action.payload.pricedValues, 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 "SET_PRICED_FIELD": { const existing = state.pricedValues[action.payload.accountId] ?? { quantity: "", unit_price: "", }; const next: PricedEntry = action.payload.field === "quantity" ? { ...existing, quantity: action.payload.value } : { ...existing, unit_price: action.payload.value }; return { ...state, pricedValues: { ...state.pricedValues, [action.payload.accountId]: next, }, isDirty: true, }; } case "PREFILL": return { ...state, values: { ...state.values, ...action.payload.values }, pricedValues: { ...state.pricedValues, ...action.payload.pricedValues, }, 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: {}, pricedValues: {}, 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 pricedValues: Record = {}; let previousLines: BalanceSnapshotLine[] | null = null; // Index account kinds for quick line classification. const kindByAccountId = new Map(); for (const acc of accounts) { kindByAccountId.set(acc.id, acc.category_kind); } if (existing) { const lines = await listLinesBySnapshot(existing.id); for (const line of lines) { // The line itself carries quantity / unit_price for priced kinds; // we still cross-check against the account kind to decide which // input map this row belongs to (it dictates what the user sees). const kind = kindByAccountId.get(line.account_id); if ( kind === "priced" || (line.quantity !== null && line.unit_price !== null) ) { pricedValues[line.account_id] = { quantity: line.quantity !== null && line.quantity !== undefined ? String(line.quantity) : "", unit_price: line.unit_price !== null && line.unit_price !== undefined ? String(line.unit_price) : "", }; } else { 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, pricedValues, 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 setLineQuantity = useCallback( (accountId: number, value: string) => { dispatch({ type: "SET_PRICED_FIELD", payload: { accountId, field: "quantity", value }, }); }, [] ); const setLineUnitPrice = useCallback( (accountId: number, value: string) => { dispatch({ type: "SET_PRICED_FIELD", payload: { accountId, field: "unit_price", value }, }); }, [] ); const reset = useCallback(() => { dispatch({ type: "RESET" }); }, []); /** * Build the prefill map from the previous snapshot. Per spec-decisions * row "Bouton Pré-remplir": * - simple kind → copy value * - priced kind → copy quantity, leave unit_price blank (the user * must enter or fetch a fresh price each time). */ 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 nextSimple: Record = {}; const nextPriced: Record = {}; for (const line of lines) { const kind = accountKindById.get(line.account_id); if (!kind) continue; // archived account — skip if (kind === "simple") { nextSimple[line.account_id] = String(line.value); } else { // Priced: copy quantity, leave unit_price blank — quantities don't // change unless the user buys / sells, prices always change. nextPriced[line.account_id] = { quantity: line.quantity !== null && line.quantity !== undefined ? String(line.quantity) : "", unit_price: "", }; } } dispatch({ type: "PREFILL", payload: { values: nextSimple, pricedValues: nextPriced }, }); }, [state.previousLines, state.accounts]); /** * Persist the editor state to the database (#176 — atomic). * * Order of operations: * 1. Build & validate `simpleLines` and `pricedLines` arrays from * editor state. Any input parsing error throws BEFORE any DB * mutation happens, so an invalid form never produces an orphan * snapshot row. * 2. Call `saveSnapshotAtomic` which wraps `INSERT INTO * balance_snapshots` (new mode) and the line rewrite in a single * `BEGIN/COMMIT/ROLLBACK` transaction. * * Modes: * - 'new' mode: atomic helper inserts the snapshot row and its lines. * - 'edit' mode: only the lines get rewritten 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 `saveSnapshotAtomic`. */ const save = useCallback(async (): Promise<{ snapshotId: number }> => { dispatch({ type: "SET_SAVING", payload: true }); dispatch({ type: "SET_ERROR", payload: { message: null, code: null } }); try { // Step 1 — build & validate every line in memory. THROW HERE means // no DB mutation has happened yet, so no orphan snapshot can be // left behind by a validation failure (#176). const simpleLines = 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, account_kind: "simple" as const, }; }); const pricedLines = Object.entries(state.pricedValues) .filter( ([, entry]) => entry && String(entry.quantity ?? "").trim().length > 0 && String(entry.unit_price ?? "").trim().length > 0 ) .map(([accountIdStr, entry]) => { const accountId = Number(accountIdStr); const qtyTrim = String(entry.quantity).trim().replace(",", "."); const priceTrim = String(entry.unit_price).trim().replace(",", "."); const qty = Number(qtyTrim); const price = Number(priceTrim); if (!Number.isFinite(qty)) { throw new BalanceServiceError( "snapshot_priced_quantity_required", `Invalid quantity for account ${accountId}: "${entry.quantity}"` ); } if (!Number.isFinite(price)) { throw new BalanceServiceError( "snapshot_priced_unit_price_required", `Invalid unit_price for account ${accountId}: "${entry.unit_price}"` ); } return { account_id: accountId, account_kind: "priced" as const, quantity: qty, unit_price: price, // value = qty * price; the service re-validates the relation // within PRICED_VALUE_TOLERANCE before persisting. value: qty * price, }; }); // Step 2 — atomic write. BEGIN / INSERT snapshot (if 'new') / // INSERT lines / COMMIT, with ROLLBACK on any failure. const existingSnapshotId = state.mode === "edit" && state.snapshot ? state.snapshot.id : null; const { snapshotId } = await saveSnapshotAtomic({ existingSnapshotId, snapshot_date: state.snapshotDate, lines: [...simpleLines, ...pricedLines], }); 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, state.pricedValues, 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, setLineQuantity, setLineUnitPrice, reset, prefillFromPrevious, save, remove, /** Manual reload (e.g. after navigation between dates). */ reload: () => loadForDate(state.snapshotDate), }; }