diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index 7415247..d59f703 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -3,6 +3,7 @@
## [Non publié]
### Ajouté
+- **Bilan — éditeur de snapshot (type simple)** (route `/balance/snapshot`) : deuxième tranche de la feature *Bilan*. La nouvelle page permet de créer ou modifier un snapshot daté de votre patrimoine : choisissez une date (par défaut aujourd'hui), saisissez la valeur de chaque compte actif groupé par catégorie, puis enregistrez. Le mode est piloté par le paramètre `?date=` de l'URL — si un snapshot existe déjà à cette date, la page bascule automatiquement en mode édition (la contrainte UNIQUE sur `balance_snapshots.snapshot_date` garantit un snapshot par jour). La date d'un snapshot existant est immuable : pour la changer, supprimez puis recréez. Un bouton *Pré-remplir depuis le précédent* copie les valeurs du snapshot antérieur le plus récent (comptes simples uniquement — les comptes cotés seront pris en charge quand l'éditeur coté arrivera). Un bouton *Supprimer* affiche une modal de double confirmation qui exige de retaper la date du snapshot avant d'activer l'action destructive. Seules les valeurs de type simple sont acceptées à ce stade (`quantity` et `unit_price` sont laissés `NULL`) ; l'éditeur coté (quantité × prix unitaire + récupération de prix) arrivera dans une prochaine version. Nouveau hook `useSnapshotEditor` (`useReducer` couvrant tout le cycle de vie) et deux nouveaux composants `SnapshotEditor` + `SnapshotLineRow`. i18n FR/EN sous `balance.snapshot.*` (#146)
- **Bilan — fondations du schéma et page Comptes** (route `/balance/accounts`) : première tranche de la nouvelle feature *Bilan*. La migration SQL v9 introduit 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) avec 7 index et seede 7 catégories standard — Encaisse, CELI, REER, Fonds commun, Autre (type simple) + Action et Cryptomonnaie (type coté). La colonne `currency` est verrouillée à `CAD` via une contrainte CHECK au MVP — le support multi-devises arrivera plus tard. La nouvelle page expose deux onglets : *Comptes* (CRUD complet sur les comptes de l'utilisateur, archivage soft plutôt que suppression dure pour préserver les snapshots historiques) et *Catégories* (renommer une catégorie, créer des catégories de type simple, supprimer celles créées par l'utilisateur — les catégories standard sont protégées). Couverture i18n FR/EN complète sous `balance.*`. Snapshots, transferts, rendements et price-fetching premium arriveront dans les prochaines issues ; pour l'instant la route est accessible directement par URL (pas encore d'entrée sidebar) (#138)
### Corrigé
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 66f4e04..0274106 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
## [Unreleased]
### Added
+- **Balance sheet — snapshot editor (simple kind)** (route `/balance/snapshot`): second slice of the *Bilan* feature. The new page lets you create or edit a dated snapshot of your balance: pick a date (defaulting to today), enter the value of each active account grouped by category, and save. The mode is driven by the `?date=` query parameter — when a snapshot already exists at that date the page automatically flips into edit mode (the underlying `balance_snapshots.snapshot_date` UNIQUE constraint guarantees one snapshot per day). The date of an existing snapshot is immutable: to change it, delete the snapshot and create a new one. A *Prefill from previous snapshot* button copies values from the most recent earlier snapshot (simple-kind accounts only — priced accounts will be handled when the priced editor lands in a later release). A *Delete* button surfaces a double-confirmation modal that requires retyping the snapshot date before the destructive action is enabled. Only simple-kind values are accepted at this stage (`quantity` and `unit_price` are kept `NULL`); the priced editor (quantity × unit price + price fetch) ships in a later release. New `useSnapshotEditor` hook (scoped `useReducer` covering the full lifecycle) and two new components `SnapshotEditor` + `SnapshotLineRow`. FR/EN i18n under `balance.snapshot.*` (#146)
- **Balance sheet — schema foundation and accounts page** (route `/balance/accounts`): first slice of the upcoming *Bilan* feature. New SQL migration v9 introduces 5 tables (`balance_categories`, `balance_accounts`, `balance_snapshots`, `balance_snapshot_lines`, `balance_account_transfers`) with 7 indexes and seeds 7 standard categories — Cash, TFSA, RRSP, Mutual Fund, Other (simple kind) plus Stock and Crypto (priced kind). The `currency` column is hardcoded to `CAD` via a CHECK constraint at the MVP — multi-currency support will come in a later release. The new accounts page exposes two tabs: *Accounts* (full CRUD over the user's holdings, soft-archive instead of hard delete to preserve historic snapshots) and *Categories* (rename any category, create simple-kind ones, delete user-created ones — seeded categories are protected). The full FR/EN i18n coverage uses keys under `balance.*`. Snapshots, transfers, returns and the price-fetching premium remain to ship in upcoming issues; for now the route is reachable directly via URL (no sidebar entry yet) (#138)
### Fixed
diff --git a/src/App.tsx b/src/App.tsx
index 10a099a..db4b1f8 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage";
import ReportsCartesPage from "./pages/ReportsCartesPage";
import SettingsPage from "./pages/SettingsPage";
import AccountsPage from "./pages/AccountsPage";
+import SnapshotEditPage from "./pages/SnapshotEditPage";
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
import DocsPage from "./pages/DocsPage";
@@ -116,6 +117,7 @@ export default function App() {
} />
} />
} />
+ } />
}
diff --git a/src/components/balance/SnapshotEditor.tsx b/src/components/balance/SnapshotEditor.tsx
new file mode 100644
index 0000000..ca269cc
--- /dev/null
+++ b/src/components/balance/SnapshotEditor.tsx
@@ -0,0 +1,92 @@
+// SnapshotEditor — groups the active accounts by balance category and
+// renders one `SnapshotLineRow` per account.
+//
+// Issue #146 / Bilan #1b: simple-kind editor only. The priced variant
+// (quantity x unit_price + price fetch button) is rendered in #140.
+// Until then, accounts whose category is `priced` still appear here so
+// the user can enter a manual aggregate value — the storage layer accepts
+// a simple-kind line for any account regardless of its category kind.
+
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import type {
+ BalanceAccountWithCategory,
+ BalanceCategory,
+} from "../../shared/types";
+import SnapshotLineRow from "./SnapshotLineRow";
+
+interface Props {
+ accounts: BalanceAccountWithCategory[];
+ categories: BalanceCategory[];
+ values: Record;
+ onValueChange: (accountId: number, next: string) => void;
+ disabled?: boolean;
+}
+
+export default function SnapshotEditor({
+ accounts,
+ categories,
+ values,
+ onValueChange,
+ disabled,
+}: Props) {
+ const { t } = useTranslation();
+
+ // Group accounts by their category, preserving the categories' sort_order
+ // first then the account name within each group.
+ const groups = useMemo(() => {
+ const byCategory = new Map();
+ for (const acc of accounts) {
+ const list = byCategory.get(acc.balance_category_id) ?? [];
+ list.push(acc);
+ byCategory.set(acc.balance_category_id, list);
+ }
+ const sortedCategories = [...categories].sort(
+ (a, b) => a.sort_order - b.sort_order || a.key.localeCompare(b.key)
+ );
+ return sortedCategories
+ .map((cat) => ({
+ category: cat,
+ accounts: (byCategory.get(cat.id) ?? []).sort((a, b) =>
+ a.name.localeCompare(b.name)
+ ),
+ }))
+ .filter((group) => group.accounts.length > 0);
+ }, [accounts, categories]);
+
+ if (accounts.length === 0) {
+ return (
+
+ );
+}
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/i18n/locales/en.json b/src/i18n/locales/en.json
index 7a1f03a..2f52ba9 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -981,7 +981,8 @@
"darkMode": "Dark mode",
"lightMode": "Light mode",
"close": "Close",
- "underConstruction": "Under construction"
+ "underConstruction": "Under construction",
+ "back": "Back"
},
"license": {
"title": "License",
@@ -1535,6 +1536,36 @@
"stock": "Stock",
"crypto": "Crypto"
},
+ "snapshot": {
+ "page": {
+ "newTitle": "New snapshot",
+ "editTitle": "Edit snapshot",
+ "dateLabel": "Snapshot date",
+ "dateImmutable": "An existing snapshot date cannot be changed. To change the date, delete this snapshot and create a new one.",
+ "total": "Entered total",
+ "noAccounts": "You need to create at least one balance account first.",
+ "goToAccounts": "Go to accounts",
+ "prefill": "Prefill from previous",
+ "prefillTooltip": "Copy values from the snapshot dated {{date}}",
+ "prefillNoPrevious": "No earlier snapshot available.",
+ "save": "Save",
+ "create": "Create snapshot",
+ "delete": "Delete this snapshot"
+ },
+ "editor": {
+ "empty": "No active accounts. Create an account before entering a snapshot."
+ },
+ "line": {
+ "valuePlaceholder": "0.00",
+ "valueLabel": "Value for {{account}}"
+ },
+ "delete": {
+ "title": "Delete this snapshot?",
+ "body": "This permanently deletes the snapshot dated {{date}} and all its lines. To confirm, retype the date below.",
+ "confirmLabel": "Retype the date {{date}} to confirm",
+ "confirm": "Delete permanently"
+ }
+ },
"errors": {
"currency_unsupported": "Only CAD is supported at the MVP.",
"category_seed_protected": "Standard categories cannot be deleted.",
@@ -1542,7 +1573,12 @@
"category_not_found": "Category not found.",
"account_not_found": "Account not found.",
"name_required": "Name is required.",
- "kind_invalid": "Invalid category kind."
+ "kind_invalid": "Invalid category kind.",
+ "snapshot_date_required": "A date in YYYY-MM-DD format is required.",
+ "snapshot_date_taken": "A snapshot already exists at that date — edit it instead of creating a new one.",
+ "snapshot_not_found": "Snapshot not found.",
+ "snapshot_value_invalid": "An entered value is not a valid number.",
+ "snapshot_priced_unsupported": "Priced accounts (stocks/crypto) will be supported in a future release."
}
}
}
diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json
index d5b5f8a..2a777ff 100644
--- a/src/i18n/locales/fr.json
+++ b/src/i18n/locales/fr.json
@@ -981,7 +981,8 @@
"darkMode": "Mode sombre",
"lightMode": "Mode clair",
"close": "Fermer",
- "underConstruction": "En construction"
+ "underConstruction": "En construction",
+ "back": "Retour"
},
"license": {
"title": "Licence",
@@ -1535,6 +1536,36 @@
"stock": "Action",
"crypto": "Cryptomonnaie"
},
+ "snapshot": {
+ "page": {
+ "newTitle": "Nouveau snapshot",
+ "editTitle": "Modifier le snapshot",
+ "dateLabel": "Date du snapshot",
+ "dateImmutable": "La date d'un snapshot existant ne peut pas être modifiée. Pour changer la date, supprimez ce snapshot et créez-en un nouveau.",
+ "total": "Total saisi",
+ "noAccounts": "Vous devez d'abord créer au moins un compte de bilan.",
+ "goToAccounts": "Aller aux comptes",
+ "prefill": "Pré-remplir depuis le précédent",
+ "prefillTooltip": "Copier les valeurs du snapshot du {{date}}",
+ "prefillNoPrevious": "Aucun snapshot antérieur disponible.",
+ "save": "Enregistrer",
+ "create": "Créer le snapshot",
+ "delete": "Supprimer ce snapshot"
+ },
+ "editor": {
+ "empty": "Aucun compte actif. Créez un compte avant de saisir un snapshot."
+ },
+ "line": {
+ "valuePlaceholder": "0,00",
+ "valueLabel": "Valeur pour {{account}}"
+ },
+ "delete": {
+ "title": "Supprimer ce snapshot ?",
+ "body": "Cette action supprime définitivement le snapshot du {{date}} et toutes ses lignes. Pour confirmer, retapez la date ci-dessous.",
+ "confirmLabel": "Retapez la date {{date}} pour confirmer",
+ "confirm": "Supprimer définitivement"
+ }
+ },
"errors": {
"currency_unsupported": "Seul le CAD est supporté au MVP.",
"category_seed_protected": "Les catégories standard ne peuvent pas être supprimées.",
@@ -1542,7 +1573,12 @@
"category_not_found": "Catégorie introuvable.",
"account_not_found": "Compte introuvable.",
"name_required": "Le nom est obligatoire.",
- "kind_invalid": "Type de catégorie invalide."
+ "kind_invalid": "Type de catégorie invalide.",
+ "snapshot_date_required": "Une date au format AAAA-MM-JJ est obligatoire.",
+ "snapshot_date_taken": "Un snapshot existe déjà à cette date — modifiez-le au lieu d'en créer un nouveau.",
+ "snapshot_not_found": "Snapshot introuvable.",
+ "snapshot_value_invalid": "Une valeur saisie n'est pas un nombre valide.",
+ "snapshot_priced_unsupported": "Les comptes cotés (actions/crypto) seront supportés dans une prochaine version."
}
}
}
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 (
+