Structural rewrite of useSnapshotEditor for N holdings per detailed account, and switch ALL simple/detailed dispatch from category_kind to the account's own kind. This is the state/plumbing layer; the full multi-security entry UI (SecurityPicker, rich sub-rows) lands in #214. Simple accounts behave identically. Reducer state shape: - values: Record<accountId, string> (simple accounts, scalar) - holdings: Record<accountId, HoldingDraft[]> (detailed accounts, one per title) HoldingDraft is a string-typed, editable mirror of SnapshotHoldingInput with a stable client-side rowId for React keys. Actions: ADD_HOLDING / REMOVE_HOLDING / SET_HOLDING_FIELD (plus existing SET_VALUE/PREFILL/RESET). The legacy priced scalar path (SET_PRICED_FIELD / pricedValues) is removed: after migration v16 (#211) every former-priced account is kind='detailed' with one holding, so those accounts flow through the holdings path. Dispatch: - LOADED hydrates detailed baskets via listHoldingsBySnapshotLine (edit) keeping the saved price, or getHoldingsForLatestSnapshot (new) dropping the price (qty-0 excluded server-side). Simple accounts keep the scalar value path. - SnapshotLineRow / SnapshotEditor / AccountForm now gate on account.kind, not category_kind. category.kind survives ONLY as the suggested seed default for a NEW account in AccountForm. Save: detailed accounts pass their holdings array into SnapshotLineInput.holdings (presence marks the line detailed; value = rounded-cent SUM); simple accounts pass a scalar value with no holdings. Blank holding rows are skipped; a partial row throws a typed error before any DB mutation. AccountForm: adds an entry-mode selector (defaulting to the category-mapped kind). New accounts persist as 'simple' (CreateBalanceAccountInput carries no kind, and the service is out of this issue's scope); converting a fresh account to detailed + pivot date is #215. Editing locks the selector for an already- detailed account (the detailed->simple downgrade is service-guarded). Tests: 19 new reducer/helper unit tests (pure exports; the project has no renderHook harness) covering ADD/REMOVE/SET_HOLDING_FIELD, LOADED-vs-PREFILL hydration (price drop, book_cost), qty-0 already excluded upstream, the build*Lines save builders, and the dispatch-on-account.kind regression (detailed account under a 'simple' category). Resolves #213. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
772 lines
26 KiB
TypeScript
772 lines
26 KiB
TypeScript
// useSnapshotEditor — scoped useReducer hook backing SnapshotEditPage.
|
|
//
|
|
// Lifecycle of a single snapshot (Issue #146 / Bilan #1b; reworked for
|
|
// per-title detail in Issue #213 / Bilan détail par titre):
|
|
// 1. mount in 'new' mode (no `?date=` query param) → user picks a date,
|
|
// types values, hits Save → service.saveSnapshotAtomic;
|
|
// 2. mount in 'edit' mode (`?date=YYYY-MM-DD`) → load snapshot + lines,
|
|
// user edits values, hits Save → upsert on the existing snapshot;
|
|
// 3. delete → service.deleteSnapshot (the page wraps this in a
|
|
// double-confirm modal that requires retyping the snapshot date).
|
|
//
|
|
// ENTRY MODE DISPATCH (#213) — the editor classifies each account by its OWN
|
|
// `account.kind` (simple | detailed), NOT by `category_kind` (simple | priced).
|
|
// The category kind is only a *suggested default* for a brand-new account in
|
|
// AccountForm; once an account exists, its stored `kind` is authoritative.
|
|
// - simple : one scalar value per account, kept as a string in `values`.
|
|
// - detailed: a basket of per-security holdings in `holdings` — one
|
|
// `HoldingDraft` per title. On save the aggregated line carries
|
|
// NO scalar qty/price; the service recomputes value = SUM(holdings)
|
|
// and writes the holdings in the same transaction.
|
|
//
|
|
// The legacy "priced scalar" path (one security per account via account.symbol
|
|
// + scalar quantity/unit_price on the line) is SUPERSEDED: after migration v16
|
|
// (#211) every former-priced account is now `kind='detailed'` with one holding,
|
|
// so those accounts flow through the holdings path. There is no scalar-priced
|
|
// editor branch anymore.
|
|
|
|
import {
|
|
useReducer,
|
|
useCallback,
|
|
useEffect,
|
|
useRef,
|
|
} from "react";
|
|
import type {
|
|
BalanceAccountWithCategory,
|
|
BalanceAssetType,
|
|
BalanceCategory,
|
|
BalanceSnapshot,
|
|
BalanceSnapshotLine,
|
|
BalanceSnapshotHoldingWithSecurity,
|
|
} from "../shared/types";
|
|
import {
|
|
listBalanceAccounts,
|
|
listBalanceCategories,
|
|
getSnapshotByDate,
|
|
deleteSnapshot,
|
|
listLinesBySnapshot,
|
|
saveSnapshotAtomic,
|
|
getPreviousSnapshot,
|
|
getHoldingsForLatestSnapshot,
|
|
listHoldingsBySnapshotLine,
|
|
BalanceServiceError,
|
|
type SnapshotLineInput,
|
|
type SnapshotHoldingInput,
|
|
} from "../services/balance.service";
|
|
|
|
export type SnapshotEditorMode = "new" | "edit";
|
|
|
|
/**
|
|
* String-typed, editable mirror of one position inside a detailed account
|
|
* (Issue #213). All numeric fields are kept as strings to preserve empty /
|
|
* partial input; conversion to numbers happens at save time. `rowId` is a
|
|
* stable client-side identity so React can key the sub-rows even before the
|
|
* holding is persisted (a fresh holding has no DB id yet).
|
|
*/
|
|
export interface HoldingDraft {
|
|
/** Stable client-side row identity for React keys (NOT a DB id). */
|
|
rowId: string;
|
|
/** Security symbol (normalized server-side). */
|
|
symbol: string;
|
|
asset_type: BalanceAssetType;
|
|
/** ISO 4217; defaults to 'CAD'. */
|
|
currency: string;
|
|
/** Optional human-readable security name. */
|
|
security_name: string;
|
|
quantity: string;
|
|
unit_price: string;
|
|
/** Acquisition cost basis for the unrealized-gain column; optional. */
|
|
book_cost: string;
|
|
/** Carried through from a fetched price so save can attribute the source. */
|
|
price_source: string | null;
|
|
price_fetched_at: string | null;
|
|
}
|
|
|
|
let holdingRowSeq = 0;
|
|
/** Monotonic client-side row id for holding drafts. */
|
|
function nextRowId(): string {
|
|
holdingRowSeq += 1;
|
|
return `h${holdingRowSeq}`;
|
|
}
|
|
|
|
/**
|
|
* Build an empty holding draft (used by ADD_HOLDING). `asset_type` defaults to
|
|
* the account's category asset_type when known, else 'stock'.
|
|
*/
|
|
export function makeEmptyHolding(
|
|
assetType: BalanceAssetType = "stock"
|
|
): HoldingDraft {
|
|
return {
|
|
rowId: nextRowId(),
|
|
symbol: "",
|
|
asset_type: assetType,
|
|
currency: "CAD",
|
|
security_name: "",
|
|
quantity: "",
|
|
unit_price: "",
|
|
book_cost: "",
|
|
price_source: null,
|
|
price_fetched_at: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Map server holdings (from `getHoldingsForLatestSnapshot` for prefill, or
|
|
* `listHoldingsBySnapshotLine` for an edited snapshot) into editable string
|
|
* drafts. `keepPrice` controls whether the unit_price is carried over:
|
|
* - LOADED (editing an existing snapshot) keeps the saved price.
|
|
* - PREFILL of the NEXT snapshot drops the price (the user re-enters or
|
|
* re-fetches it) but keeps quantity + book_cost. Titles with quantity 0 are
|
|
* already excluded by `getHoldingsForLatestSnapshot`; a title sold then
|
|
* re-bought reappears because its latest non-zero holding wins server-side.
|
|
*/
|
|
export function holdingsFromServiceHoldings(
|
|
rows: BalanceSnapshotHoldingWithSecurity[],
|
|
opts: { keepPrice: boolean }
|
|
): HoldingDraft[] {
|
|
return rows.map((h) => ({
|
|
rowId: nextRowId(),
|
|
symbol: h.security_symbol,
|
|
asset_type: h.security_asset_type,
|
|
// The joined holdings view doesn't carry the security currency; CAD is the
|
|
// MVP currency and the save path defaults it server-side anyway.
|
|
currency: "CAD",
|
|
security_name: h.security_name ?? "",
|
|
quantity:
|
|
h.quantity !== null && h.quantity !== undefined ? String(h.quantity) : "",
|
|
unit_price: opts.keepPrice
|
|
? h.unit_price !== null && h.unit_price !== undefined
|
|
? String(h.unit_price)
|
|
: ""
|
|
: "",
|
|
book_cost:
|
|
h.book_cost !== null && h.book_cost !== undefined
|
|
? String(h.book_cost)
|
|
: "",
|
|
// Prefill drops the source (the carried price is stale); LOADED keeps it.
|
|
price_source: opts.keepPrice ? h.price_source : null,
|
|
price_fetched_at: opts.keepPrice ? h.price_fetched_at : null,
|
|
}));
|
|
}
|
|
|
|
interface State {
|
|
mode: SnapshotEditorMode;
|
|
/** ISO YYYY-MM-DD; editable in both modes (a change in 'edit' moves the snapshot). */
|
|
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 accounts only). We keep
|
|
* strings to preserve empty / partial input; conversion to number happens
|
|
* at save time.
|
|
*/
|
|
values: Record<number, string>;
|
|
/**
|
|
* Map of account_id → array of `HoldingDraft` (detailed accounts only —
|
|
* dispatched on `account.kind === 'detailed'`). One entry per security held.
|
|
* Same partial-input guarantee as `values`.
|
|
*/
|
|
holdings: Record<number, HoldingDraft[]>;
|
|
/** 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>;
|
|
holdings: Record<number, HoldingDraft[]>;
|
|
previousSnapshot: BalanceSnapshot | null;
|
|
previousLines: BalanceSnapshotLine[] | null;
|
|
};
|
|
}
|
|
| { type: "SET_DATE"; payload: string }
|
|
| { type: "SET_VALUE"; payload: { accountId: number; value: string } }
|
|
| {
|
|
type: "ADD_HOLDING";
|
|
payload: { accountId: number; holding: HoldingDraft };
|
|
}
|
|
| { type: "REMOVE_HOLDING"; payload: { accountId: number; rowId: string } }
|
|
| {
|
|
type: "SET_HOLDING_FIELD";
|
|
payload: {
|
|
accountId: number;
|
|
rowId: string;
|
|
field: keyof Omit<HoldingDraft, "rowId">;
|
|
value: string;
|
|
};
|
|
}
|
|
| {
|
|
type: "PREFILL";
|
|
payload: {
|
|
values: Record<number, string>;
|
|
holdings: Record<number, HoldingDraft[]>;
|
|
};
|
|
}
|
|
| { type: "RESET" }
|
|
| { type: "CLEAR_DIRTY" };
|
|
|
|
export function initialState(initialDate: string): State {
|
|
return {
|
|
mode: "new",
|
|
snapshotDate: initialDate,
|
|
snapshot: null,
|
|
accounts: [],
|
|
categories: [],
|
|
values: {},
|
|
holdings: {},
|
|
previousSnapshot: null,
|
|
previousLines: null,
|
|
isLoading: false,
|
|
isSaving: false,
|
|
isDirty: false,
|
|
error: null,
|
|
errorCode: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pure reducer — exported so the editor's state machine can be unit-tested
|
|
* without rendering the hook (the project has no jsdom/renderHook harness).
|
|
*/
|
|
export 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,
|
|
holdings: action.payload.holdings,
|
|
previousSnapshot: action.payload.previousSnapshot,
|
|
previousLines: action.payload.previousLines,
|
|
isLoading: false,
|
|
isDirty: false,
|
|
error: null,
|
|
errorCode: null,
|
|
};
|
|
case "SET_DATE":
|
|
// Editable in both modes now (#200): in 'edit' mode a changed date
|
|
// triggers a snapshot move on save (lines preserved).
|
|
return { ...state, snapshotDate: action.payload, isDirty: true };
|
|
case "SET_VALUE":
|
|
return {
|
|
...state,
|
|
values: {
|
|
...state.values,
|
|
[action.payload.accountId]: action.payload.value,
|
|
},
|
|
isDirty: true,
|
|
};
|
|
case "ADD_HOLDING": {
|
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
|
return {
|
|
...state,
|
|
holdings: {
|
|
...state.holdings,
|
|
[action.payload.accountId]: [...existing, action.payload.holding],
|
|
},
|
|
isDirty: true,
|
|
};
|
|
}
|
|
case "REMOVE_HOLDING": {
|
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
|
return {
|
|
...state,
|
|
holdings: {
|
|
...state.holdings,
|
|
[action.payload.accountId]: existing.filter(
|
|
(h) => h.rowId !== action.payload.rowId
|
|
),
|
|
},
|
|
isDirty: true,
|
|
};
|
|
}
|
|
case "SET_HOLDING_FIELD": {
|
|
const existing = state.holdings[action.payload.accountId] ?? [];
|
|
const next = existing.map((h) =>
|
|
h.rowId === action.payload.rowId
|
|
? { ...h, [action.payload.field]: action.payload.value }
|
|
: h
|
|
);
|
|
return {
|
|
...state,
|
|
holdings: {
|
|
...state.holdings,
|
|
[action.payload.accountId]: next,
|
|
},
|
|
isDirty: true,
|
|
};
|
|
}
|
|
case "PREFILL":
|
|
return {
|
|
...state,
|
|
values: { ...state.values, ...action.payload.values },
|
|
holdings: { ...state.holdings, ...action.payload.holdings },
|
|
isDirty: true,
|
|
};
|
|
case "RESET":
|
|
return {
|
|
...state,
|
|
// Keep the loaded structure (accounts, categories, snapshot) but wipe
|
|
// user input back to a clean slate.
|
|
values: {},
|
|
holdings: {},
|
|
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}`;
|
|
}
|
|
|
|
/** Parse "12.34" / "12,34" → finite number, or null when empty/invalid. */
|
|
function parseDecimal(raw: string | null | undefined): number | null {
|
|
if (raw === null || raw === undefined) return null;
|
|
const trimmed = String(raw).trim().replace(",", ".");
|
|
if (!trimmed) return null;
|
|
const n = Number(trimmed);
|
|
return Number.isFinite(n) ? n : null;
|
|
}
|
|
|
|
/**
|
|
* Build the simple-account `SnapshotLineInput[]` from the editor's `values`
|
|
* map. Only accounts whose own kind is NOT detailed contribute here; detailed
|
|
* accounts go through `buildDetailedLines`. THROWS a typed BalanceServiceError
|
|
* on the first invalid value so no DB mutation happens on bad input (#176).
|
|
* Exported for unit tests.
|
|
*/
|
|
export function buildSimpleLines(
|
|
values: Record<number, string>,
|
|
detailedAccountIds: ReadonlySet<number>
|
|
): SnapshotLineInput[] {
|
|
return Object.entries(values)
|
|
.filter(
|
|
([accountIdStr, v]) =>
|
|
!detailedAccountIds.has(Number(accountIdStr)) &&
|
|
v !== undefined &&
|
|
String(v).trim().length > 0
|
|
)
|
|
.map(([accountIdStr, raw]) => {
|
|
const accountId = Number(accountIdStr);
|
|
const num = parseDecimal(raw);
|
|
if (num === null) {
|
|
throw new BalanceServiceError(
|
|
"snapshot_value_invalid",
|
|
`Invalid value for account ${accountId}: "${raw}"`
|
|
);
|
|
}
|
|
return {
|
|
account_id: accountId,
|
|
value: num,
|
|
account_kind: "simple" as const,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build the detailed-account `SnapshotLineInput[]` (one per account, each
|
|
* carrying its `holdings` array) from the editor's `holdings` map. The presence
|
|
* of the `holdings` field — even an empty array — marks the line detailed for
|
|
* the service. Empty / blank holding rows (no symbol AND no qty AND no price)
|
|
* are dropped before save so a half-typed row doesn't fail validation. THROWS a
|
|
* typed error on a partially-filled row. The aggregated `value` is the SUM of
|
|
* the rounded-cent holding values; the service re-rounds and re-validates it.
|
|
* Exported for unit tests.
|
|
*/
|
|
export function buildDetailedLines(
|
|
holdings: Record<number, HoldingDraft[]>,
|
|
detailedAccountIds: ReadonlySet<number>
|
|
): SnapshotLineInput[] {
|
|
const lines: SnapshotLineInput[] = [];
|
|
for (const accountId of detailedAccountIds) {
|
|
const drafts = holdings[accountId] ?? [];
|
|
const built: SnapshotHoldingInput[] = [];
|
|
for (const d of drafts) {
|
|
const symbol = d.symbol.trim();
|
|
const qtyRaw = String(d.quantity ?? "").trim();
|
|
const priceRaw = String(d.unit_price ?? "").trim();
|
|
const isBlank = symbol.length === 0 && qtyRaw.length === 0 && priceRaw.length === 0;
|
|
if (isBlank) continue; // skip an untouched / freshly-added empty row
|
|
if (symbol.length === 0) {
|
|
throw new BalanceServiceError(
|
|
"snapshot_holding_invalid",
|
|
`A holding for account ${accountId} is missing its symbol`
|
|
);
|
|
}
|
|
const qty = parseDecimal(d.quantity);
|
|
const price = parseDecimal(d.unit_price);
|
|
if (qty === null) {
|
|
throw new BalanceServiceError(
|
|
"snapshot_priced_quantity_required",
|
|
`Invalid quantity for ${symbol} (account ${accountId}): "${d.quantity}"`
|
|
);
|
|
}
|
|
if (price === null) {
|
|
throw new BalanceServiceError(
|
|
"snapshot_priced_unit_price_required",
|
|
`Invalid unit price for ${symbol} (account ${accountId}): "${d.unit_price}"`
|
|
);
|
|
}
|
|
const bookCost = parseDecimal(d.book_cost);
|
|
const value = Math.round(qty * price * 100) / 100;
|
|
built.push({
|
|
symbol,
|
|
asset_type: d.asset_type,
|
|
currency: d.currency || "CAD",
|
|
security_name: d.security_name.trim() || null,
|
|
quantity: qty,
|
|
unit_price: price,
|
|
value,
|
|
book_cost: bookCost,
|
|
price_source: d.price_source,
|
|
price_fetched_at: d.price_fetched_at,
|
|
});
|
|
}
|
|
// Aggregated value = rounded-cent SUM of the holdings' rounded-cent values.
|
|
const total =
|
|
Math.round(built.reduce((s, h) => s + h.value, 0) * 100) / 100;
|
|
lines.push({
|
|
account_id: accountId,
|
|
value: total,
|
|
// `holdings` present (even empty) ⇒ detailed save path; qty/price omitted.
|
|
holdings: built,
|
|
});
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
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 values: Record<number, string> = {};
|
|
const holdings: Record<number, HoldingDraft[]> = {};
|
|
let previousLines: BalanceSnapshotLine[] | null = null;
|
|
// Index each account's OWN kind (simple|detailed) — this, not the
|
|
// category kind, decides which input map a line belongs to (#213).
|
|
const accountById = new Map<number, BalanceAccountWithCategory>();
|
|
for (const acc of accounts) accountById.set(acc.id, acc);
|
|
|
|
const existing = await getSnapshotByDate(targetDate);
|
|
const isEdit = !!existing;
|
|
if (existing) {
|
|
const lines = await listLinesBySnapshot(existing.id);
|
|
for (const line of lines) {
|
|
const acc = accountById.get(line.account_id);
|
|
if (acc?.kind === "detailed") {
|
|
// Hydrate the basket from this line's persisted holdings.
|
|
const rows = await listHoldingsBySnapshotLine(line.id);
|
|
holdings[line.account_id] = holdingsFromServiceHoldings(rows, {
|
|
keepPrice: true,
|
|
});
|
|
} else {
|
|
values[line.account_id] = String(line.value);
|
|
}
|
|
}
|
|
// Detailed accounts with NO line yet at this snapshot still get an
|
|
// (empty) basket so the editor renders the detailed variant for them.
|
|
for (const acc of accounts) {
|
|
if (acc.kind === "detailed" && holdings[acc.id] === undefined) {
|
|
holdings[acc.id] = [];
|
|
}
|
|
}
|
|
} else {
|
|
// 'new' mode: prefill detailed baskets from each account's latest
|
|
// snapshot holdings (qty-0 excluded server-side), price dropped.
|
|
for (const acc of accounts) {
|
|
if (acc.kind === "detailed") {
|
|
const rows = await getHoldingsForLatestSnapshot(acc.id);
|
|
holdings[acc.id] = holdingsFromServiceHoldings(rows, {
|
|
keepPrice: false,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
holdings,
|
|
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 addHolding = useCallback(
|
|
(accountId: number, assetType: BalanceAssetType = "stock") => {
|
|
dispatch({
|
|
type: "ADD_HOLDING",
|
|
payload: { accountId, holding: makeEmptyHolding(assetType) },
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const removeHolding = useCallback((accountId: number, rowId: string) => {
|
|
dispatch({ type: "REMOVE_HOLDING", payload: { accountId, rowId } });
|
|
}, []);
|
|
|
|
const setHoldingField = useCallback(
|
|
(
|
|
accountId: number,
|
|
rowId: string,
|
|
field: keyof Omit<HoldingDraft, "rowId">,
|
|
value: string
|
|
) => {
|
|
dispatch({
|
|
type: "SET_HOLDING_FIELD",
|
|
payload: { accountId, rowId, field, value },
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
const reset = useCallback(() => {
|
|
dispatch({ type: "RESET" });
|
|
}, []);
|
|
|
|
/**
|
|
* Build the prefill map from the previous snapshot (simple accounts only —
|
|
* detailed accounts are prefilled from their latest holdings at LOAD time in
|
|
* 'new' mode, which is more accurate than copying the previous *line*). Per
|
|
* spec-decisions row "Bouton Pré-remplir": simple → copy value.
|
|
*/
|
|
const prefillFromPrevious = useCallback(() => {
|
|
const lines = state.previousLines;
|
|
if (!lines || lines.length === 0) return;
|
|
const accountById = new Map<number, BalanceAccountWithCategory>();
|
|
for (const acc of state.accounts) accountById.set(acc.id, acc);
|
|
const nextSimple: Record<number, string> = {};
|
|
for (const line of lines) {
|
|
const acc = accountById.get(line.account_id);
|
|
if (!acc) continue; // archived account — skip
|
|
if (acc.kind === "simple") {
|
|
nextSimple[line.account_id] = String(line.value);
|
|
}
|
|
// Detailed accounts: intentionally NOT prefilled from the previous line
|
|
// here — their basket was already hydrated from the latest holdings.
|
|
}
|
|
dispatch({
|
|
type: "PREFILL",
|
|
payload: { values: nextSimple, holdings: {} },
|
|
});
|
|
}, [state.previousLines, state.accounts]);
|
|
|
|
/**
|
|
* Persist the editor state to the database (#176 — atomic; #213 — detailed).
|
|
*
|
|
* Order of operations:
|
|
* 1. Build & validate `simpleLines` (scalar) and `detailedLines` (holdings)
|
|
* from editor state. Any input parsing error throws BEFORE any DB
|
|
* mutation, so an invalid form never produces an orphan snapshot row.
|
|
* 2. Call `saveSnapshotAtomic` which wraps the snapshot INSERT (new mode),
|
|
* the line rewrite AND the holdings rewrite in a single BEGIN/COMMIT/
|
|
* ROLLBACK transaction.
|
|
*
|
|
* Modes:
|
|
* - 'new' mode: atomic helper inserts the snapshot row + its lines/holdings.
|
|
* - 'edit' mode: only the lines/holdings get rewritten on the existing row.
|
|
*/
|
|
const save = useCallback(async (): Promise<{ snapshotId: number }> => {
|
|
dispatch({ type: "SET_SAVING", payload: true });
|
|
dispatch({ type: "SET_ERROR", payload: { message: null, code: null } });
|
|
try {
|
|
// Set of detailed account ids — dispatched on each account's OWN kind.
|
|
const detailedAccountIds = new Set<number>();
|
|
for (const acc of state.accounts) {
|
|
if (acc.kind === "detailed") detailedAccountIds.add(acc.id);
|
|
}
|
|
|
|
// 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 = buildSimpleLines(state.values, detailedAccountIds);
|
|
const detailedLines = buildDetailedLines(
|
|
state.holdings,
|
|
detailedAccountIds
|
|
);
|
|
|
|
// Step 2 — atomic write. BEGIN / INSERT snapshot (if 'new') /
|
|
// INSERT lines + holdings / COMMIT, with ROLLBACK on any failure.
|
|
const existingSnapshotId =
|
|
state.mode === "edit" && state.snapshot ? state.snapshot.id : null;
|
|
// Edit-mode date move (#200): when the user changed the date of an
|
|
// existing snapshot, forward the new date so the atomic save moves the
|
|
// row (preserving its lines) in the same transaction. A collision
|
|
// surfaces as `snapshot_date_exists` and rolls back.
|
|
const moveToDate =
|
|
state.mode === "edit" &&
|
|
state.snapshot &&
|
|
state.snapshotDate !== state.snapshot.snapshot_date
|
|
? state.snapshotDate
|
|
: null;
|
|
const { snapshotId } = await saveSnapshotAtomic({
|
|
existingSnapshotId,
|
|
snapshot_date: state.snapshotDate,
|
|
lines: [...simpleLines, ...detailedLines],
|
|
moveToDate,
|
|
});
|
|
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.holdings,
|
|
state.accounts,
|
|
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,
|
|
addHolding,
|
|
removeHolding,
|
|
setHoldingField,
|
|
reset,
|
|
prefillFromPrevious,
|
|
save,
|
|
remove,
|
|
/** Manual reload (e.g. after navigation between dates). */
|
|
reload: () => loadForDate(state.snapshotDate),
|
|
};
|
|
}
|