Simpl-Resultat/src/hooks/useSnapshotEditor.ts
le king fu fdc6cc6c38 feat(balance): add useSnapshotEditor hook + SnapshotEditPage + components
New scoped useReducer hook covering the full single-snapshot lifecycle —
LOAD_FOR_DATE / SET_LINE_VALUE / SAVE / DELETE / PREFILL_FROM_PREVIOUS /
RESET — with the following semantics:
- 'new' mode (?date= absent or no snapshot at that date) creates the row
  at save time only, so abandoning the form does not leave an empty
  snapshot behind;
- 'edit' mode loads existing lines + prefills the values map;
- prefillFromPrevious copies simple-kind values from the most recent
  earlier snapshot (priced branch is a no-op + TODO Issue #140);
- save() flips 'new' -> 'edit' on success and updates the URL ?date=
  so refresh keeps the user in edit mode;
- snapshotDate is immutable in edit mode (UI guard, matches spec).

New SnapshotEditPage at /balance/snapshot:
- date picker (native input type=date — matches the AdjustmentForm /
  TransactionFilterBar / PeriodSelector pattern, no new dep)
- per-category groups of accounts with one value field each
- prefill button (disabled when no earlier snapshot exists, with
  tooltip explaining why)
- delete button with double-confirmation modal that requires retyping
  the snapshot date before the destructive action enables.

New SnapshotEditor (groups by category sort_order) and SnapshotLineRow
(simple variant — single value field per account) components.

Route /balance/snapshot wired in App.tsx.

Refs #146

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:49:33 -04:00

387 lines
13 KiB
TypeScript

// 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<number, string>;
/** 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<number, string>;
previousSnapshot: BalanceSnapshot | null;
previousLines: BalanceSnapshotLine[] | null;
};
}
| { type: "SET_DATE"; payload: string }
| { type: "SET_VALUE"; payload: { accountId: number; value: string } }
| { type: "PREFILL"; payload: Record<number, string> }
| { 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<number, string> = {};
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<number, BalanceCategory["kind"]>();
for (const acc of state.accounts) {
accountKindById.set(acc.id, acc.category_kind);
}
const next: Record<number, string> = {};
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),
};
}