useSnapshotEditor.save now validates all simple/priced lines in-memory before any DB write, then delegates to a new saveSnapshotAtomic helper that wraps INSERT snapshot + INSERT lines in an explicit BEGIN/COMMIT transaction (ROLLBACK on catch). Pattern matches categorizationService. Migration v11 cleans existing orphan snapshots in profiles that hit the old race; new orphans are no longer possible thanks to the transaction. Resolves #176
549 lines
18 KiB
TypeScript
549 lines
18 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,
|
|
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<number, string>;
|
|
/**
|
|
* Map of account_id → string-typed `{quantity, unit_price}` (priced
|
|
* kind only). Same partial-input guarantee as `values`.
|
|
*/
|
|
pricedValues: Record<number, PricedEntry>;
|
|
/** 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>;
|
|
pricedValues: Record<number, PricedEntry>;
|
|
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<number, string>;
|
|
pricedValues: Record<number, PricedEntry>;
|
|
};
|
|
}
|
|
| { 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<number, string> = {};
|
|
let pricedValues: Record<number, PricedEntry> = {};
|
|
let previousLines: BalanceSnapshotLine[] | null = null;
|
|
// Index account kinds for quick line classification.
|
|
const kindByAccountId = new Map<number, BalanceCategory["kind"]>();
|
|
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<number, BalanceCategory["kind"]>();
|
|
for (const acc of state.accounts) {
|
|
accountKindById.set(acc.id, acc.category_kind);
|
|
}
|
|
const nextSimple: Record<number, string> = {};
|
|
const nextPriced: Record<number, PricedEntry> = {};
|
|
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),
|
|
};
|
|
}
|