+ );
+}
diff --git a/src/components/balance/SnapshotLineRow.tsx b/src/components/balance/SnapshotLineRow.tsx
new file mode 100644
index 0000000..2418f1a
--- /dev/null
+++ b/src/components/balance/SnapshotLineRow.tsx
@@ -0,0 +1,64 @@
+// SnapshotLineRow — single account line inside the snapshot editor.
+//
+// Issue #146 / Bilan #1b ships the *simple* variant only: a single value
+// input keyed by `account_id`. The priced variant (quantity / unit_price /
+// computed value + price-fetch button) lands in Issue #140 / Bilan #2.
+//
+// We intentionally keep this component dumb: it receives a string value
+// from the parent (the editor stores raw strings to preserve partial input
+// the user is typing) and emits the new string on every change. Numeric
+// validation happens at save time in `useSnapshotEditor.save`.
+
+import { ChangeEvent } from "react";
+import { useTranslation } from "react-i18next";
+import type { BalanceAccountWithCategory } from "../../shared/types";
+
+interface Props {
+ account: BalanceAccountWithCategory;
+ value: string;
+ onChange: (next: string) => void;
+ disabled?: boolean;
+}
+
+export default function SnapshotLineRow({
+ account,
+ value,
+ onChange,
+ disabled,
+}: Props) {
+ const { t } = useTranslation();
+
+ const handleChange = (e: ChangeEvent) => {
+ onChange(e.target.value);
+ };
+
+ return (
+
+
+
{account.name}
+ {account.symbol && (
+
+ {account.symbol}
+
+ )}
+
+
+
+
+ {account.currency}
+
+
+
+ );
+}
diff --git a/src/hooks/useSnapshotEditor.ts b/src/hooks/useSnapshotEditor.ts
new file mode 100644
index 0000000..2d835ef
--- /dev/null
+++ b/src/hooks/useSnapshotEditor.ts
@@ -0,0 +1,387 @@
+// 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),
+ };
+}
diff --git a/src/pages/SnapshotEditPage.tsx b/src/pages/SnapshotEditPage.tsx
new file mode 100644
index 0000000..4a8062b
--- /dev/null
+++ b/src/pages/SnapshotEditPage.tsx
@@ -0,0 +1,343 @@
+// SnapshotEditPage — create or edit a balance snapshot at a given date.
+//
+// Issue #146 / Bilan #1b ships the route `/balance/snapshot` with two modes
+// driven by the `?date=` query parameter:
+// - `?date=` absent → 'new' mode (date picker editable, defaults to today)
+// - `?date=YYYY-MM-DD` → 'edit' mode if a snapshot exists at that date,
+// otherwise 'new' mode pre-selected at that date (which mirrors the
+// "redirect to edit" flow when the user comes from the future
+// /balance overview's "Edit" link).
+//
+// The page itself only orchestrates: all DB work flows through
+// `useSnapshotEditor`, the editor view through `SnapshotEditor`. Per spec
+// (decisions row "Bouton Pré-remplir"), priced-kind prefill is a no-op
+// here (the priced editor lands in #140).
+
+import { useEffect, useMemo, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import {
+ ArrowLeft,
+ Trash2,
+ Save,
+ Wallet,
+ RotateCcw,
+ AlertTriangle,
+} from "lucide-react";
+import { useSnapshotEditor } from "../hooks/useSnapshotEditor";
+import SnapshotEditor from "../components/balance/SnapshotEditor";
+
+export default function SnapshotEditPage() {
+ const { t } = useTranslation();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const dateParam = searchParams.get("date");
+ const editor = useSnapshotEditor({ dateParam });
+ const { state } = editor;
+
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [deleteConfirmText, setDeleteConfirmText] = useState("");
+
+ // Reset the delete modal whenever the underlying snapshot changes (e.g.
+ // after switching ?date=).
+ useEffect(() => {
+ setShowDeleteModal(false);
+ setDeleteConfirmText("");
+ }, [state.snapshot?.id]);
+
+ const isEditMode = state.mode === "edit";
+ const canPrefill = !!state.previousSnapshot;
+
+ // Aggregate value (simple kind only — sums all visible numeric inputs).
+ const totalValue = useMemo(() => {
+ let total = 0;
+ let hasAny = false;
+ for (const raw of Object.values(state.values)) {
+ if (!raw) continue;
+ const trimmed = String(raw).trim().replace(",", ".");
+ const n = Number(trimmed);
+ if (Number.isFinite(n)) {
+ total += n;
+ hasAny = true;
+ }
+ }
+ return hasAny ? total : null;
+ }, [state.values]);
+
+ const handleSave = async () => {
+ try {
+ await editor.save();
+ // After a successful create, the URL should become `?date=...` so
+ // refreshing keeps the user in edit mode.
+ if (!isEditMode) {
+ setSearchParams(
+ { date: state.snapshotDate },
+ { replace: true }
+ );
+ }
+ } catch {
+ // The hook surfaced the error via state.errorCode/state.error.
+ }
+ };
+
+ const handleDelete = async () => {
+ try {
+ await editor.remove();
+ navigate("/balance/accounts");
+ } catch {
+ // surfaced via state.error
+ }
+ };
+
+ return (
+