From afc338b5643c7cfcd82bc6481702b8d9a8f5f464 Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:49:19 -0400 Subject: [PATCH 1/3] feat(balance): extend balance.service with snapshots + lines (simple kind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the snapshots + lines section of the balance service for Issue #146 (Bilan #1b). Simple-kind only — quantity / unit_price are forced to NULL both at the SQL CHECK level (already in v9) and at the service level (`upsertSnapshotLines` validates ahead of time). Priced-kind upsert lands in #140. New service exports: - listSnapshots / getSnapshotByDate / getSnapshotById / getPreviousSnapshot - createSnapshot (throws snapshot_date_taken when UNIQUE per date violated so the UI can redirect to edit mode) - updateSnapshot / deleteSnapshot (cascade lines via FK) - listLinesBySnapshot / upsertSnapshotLines (rewrite-all strategy) New BalanceErrorCode entries: snapshot_date_required, snapshot_date_taken, snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported. New shared types: BalanceSnapshot, BalanceSnapshotLine. 22 new vitest cases cover: invalid-date guards, unique-per-date violation, simple-kind null invariant on inserts, NaN/Infinity rejection, clear+rewrite line semantics, getPreviousSnapshot strict-before ordering. Refs #146 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/services/balance.service.test.ts | 232 ++++++++++++++++++++++++ src/services/balance.service.ts | 262 ++++++++++++++++++++++++++- src/shared/types/index.ts | 29 +++ 3 files changed, 522 insertions(+), 1 deletion(-) diff --git a/src/services/balance.service.test.ts b/src/services/balance.service.test.ts index 11981aa..3e1a361 100644 --- a/src/services/balance.service.test.ts +++ b/src/services/balance.service.test.ts @@ -15,6 +15,14 @@ import { updateBalanceAccount, archiveBalanceAccount, unarchiveBalanceAccount, + listSnapshots, + getSnapshotByDate, + createSnapshot, + updateSnapshot, + deleteSnapshot, + listLinesBySnapshot, + upsertSnapshotLines, + getPreviousSnapshot, BalanceServiceError, } from "./balance.service"; @@ -314,3 +322,227 @@ describe("archiveBalanceAccount / unarchiveBalanceAccount", () => { expect(sql).toContain("is_active = 1"); }); }); + +// ----------------------------------------------------------------------------- +// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) +// ----------------------------------------------------------------------------- + +const FAKE_SNAPSHOT = { + id: 5, + snapshot_date: "2026-04-15", + notes: null, + created_at: "", + updated_at: "", +}; + +describe("listSnapshots", () => { + it("orders by snapshot_date DESC", async () => { + mockSelect.mockResolvedValueOnce([]); + await listSnapshots(); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_snapshots"); + expect(sql).toContain("ORDER BY snapshot_date DESC"); + }); +}); + +describe("getSnapshotByDate", () => { + it("rejects empty / invalid dates with snapshot_date_required", async () => { + await expect(getSnapshotByDate("")).rejects.toMatchObject({ + code: "snapshot_date_required", + }); + await expect(getSnapshotByDate("2026/04/15")).rejects.toMatchObject({ + code: "snapshot_date_required", + }); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it("returns the snapshot row when found", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + const got = await getSnapshotByDate("2026-04-15"); + expect(got).toEqual(FAKE_SNAPSHOT); + expect(mockSelect.mock.calls[0][1]).toEqual(["2026-04-15"]); + }); + + it("returns null when no row matches", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getSnapshotByDate("2026-04-15")).toBeNull(); + }); +}); + +describe("createSnapshot", () => { + it("rejects an invalid date", async () => { + await expect( + createSnapshot({ snapshot_date: " " }) + ).rejects.toMatchObject({ code: "snapshot_date_required" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects a duplicate snapshot date with snapshot_date_taken", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); // existing + await expect( + createSnapshot({ snapshot_date: "2026-04-15" }) + ).rejects.toMatchObject({ code: "snapshot_date_taken" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("inserts a new snapshot and returns its id", async () => { + mockSelect.mockResolvedValueOnce([]); // no existing + mockExecute.mockResolvedValueOnce({ lastInsertId: 12, rowsAffected: 1 }); + const id = await createSnapshot({ + snapshot_date: "2026-04-25", + notes: " monthly check ", + }); + expect(id).toBe(12); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params).toEqual(["2026-04-25", "monthly check"]); + }); +}); + +describe("updateSnapshot", () => { + it("rejects when snapshot does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect( + updateSnapshot(999, { notes: "x" }) + ).rejects.toMatchObject({ code: "snapshot_not_found" }); + }); + + it("normalizes empty notes to null", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await updateSnapshot(5, { notes: " " }); + const params = mockExecute.mock.calls[0][1] as unknown[]; + expect(params[0]).toBeNull(); + }); +}); + +describe("deleteSnapshot", () => { + it("rejects when snapshot does not exist", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect(deleteSnapshot(999)).rejects.toMatchObject({ + code: "snapshot_not_found", + }); + }); + + it("deletes when found (lines cascade via FK)", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute.mockResolvedValueOnce({ rowsAffected: 1 }); + await deleteSnapshot(5); + expect(mockExecute).toHaveBeenCalledWith( + "DELETE FROM balance_snapshots WHERE id = $1", + [5] + ); + }); +}); + +describe("listLinesBySnapshot", () => { + it("orders by id and filters by snapshot_id", async () => { + mockSelect.mockResolvedValueOnce([]); + await listLinesBySnapshot(5); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("FROM balance_snapshot_lines"); + expect(sql).toContain("WHERE snapshot_id = $1"); + expect(sql).toContain("ORDER BY id"); + expect(mockSelect.mock.calls[0][1]).toEqual([5]); + }); +}); + +describe("upsertSnapshotLines (simple kind)", () => { + it("rejects when the parent snapshot is missing", async () => { + mockSelect.mockResolvedValueOnce([]); + await expect( + upsertSnapshotLines(99, [{ account_id: 1, value: 1000 }]) + ).rejects.toMatchObject({ code: "snapshot_not_found" }); + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects non-finite values with snapshot_value_invalid", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + await expect( + upsertSnapshotLines(5, [ + { account_id: 1, value: 1000 }, + // @ts-expect-error testing runtime guard + { account_id: 2, value: "not a number" }, + ]) + ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); + // Validation happens up-front, before any mutation + expect(mockExecute).not.toHaveBeenCalled(); + }); + + it("rejects NaN and Infinity", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + await expect( + upsertSnapshotLines(5, [{ account_id: 1, value: NaN }]) + ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); + + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + await expect( + upsertSnapshotLines(5, [{ account_id: 1, value: Infinity }]) + ).rejects.toMatchObject({ code: "snapshot_value_invalid" }); + }); + + it("clears existing lines, inserts each line with NULL quantity/unit_price, and bumps updated_at", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 1 }) // delete + .mockResolvedValueOnce({ lastInsertId: 100, rowsAffected: 1 }) // insert 1 + .mockResolvedValueOnce({ lastInsertId: 101, rowsAffected: 1 }) // insert 2 + .mockResolvedValueOnce({ rowsAffected: 1 }); // update updated_at + await upsertSnapshotLines(5, [ + { account_id: 1, value: 1234.56 }, + { account_id: 2, value: 0 }, + ]); + // 1st call = DELETE + expect(mockExecute.mock.calls[0][0]).toContain( + "DELETE FROM balance_snapshot_lines" + ); + // Inserts use literal NULL for quantity/unit_price (simple kind invariant) + const insertSql = mockExecute.mock.calls[1][0] as string; + expect(insertSql).toContain("INSERT INTO balance_snapshot_lines"); + expect(insertSql).toMatch(/VALUES\s*\(\s*\$1,\s*\$2,\s*NULL,\s*NULL,\s*\$3/); + expect(insertSql).toContain("'manual'"); + // First insert params + expect(mockExecute.mock.calls[1][1]).toEqual([5, 1, 1234.56]); + // Second insert params (zero is allowed) + expect(mockExecute.mock.calls[2][1]).toEqual([5, 2, 0]); + // Final call = UPDATE updated_at on parent snapshot + expect(mockExecute.mock.calls[3][0]).toContain( + "UPDATE balance_snapshots" + ); + expect(mockExecute.mock.calls[3][0]).toContain("updated_at"); + }); + + it("clears all lines when called with an empty array", async () => { + mockSelect.mockResolvedValueOnce([FAKE_SNAPSHOT]); + mockExecute + .mockResolvedValueOnce({ rowsAffected: 3 }) // delete only + .mockResolvedValueOnce({ rowsAffected: 1 }); // bump updated_at + await upsertSnapshotLines(5, []); + // Only DELETE + UPDATE updated_at — no INSERTs + expect(mockExecute).toHaveBeenCalledTimes(2); + }); +}); + +describe("getPreviousSnapshot", () => { + it("returns the most recent snapshot strictly before referenceDate", async () => { + mockSelect.mockResolvedValueOnce([ + { ...FAKE_SNAPSHOT, snapshot_date: "2026-03-15" }, + ]); + const got = await getPreviousSnapshot("2026-04-15"); + expect(got?.snapshot_date).toBe("2026-03-15"); + const sql = mockSelect.mock.calls[0][0] as string; + expect(sql).toContain("snapshot_date < $1"); + expect(sql).toContain("ORDER BY snapshot_date DESC"); + expect(sql).toContain("LIMIT 1"); + }); + + it("returns null when no earlier snapshot exists", async () => { + mockSelect.mockResolvedValueOnce([]); + expect(await getPreviousSnapshot("2026-04-15")).toBeNull(); + }); + + it("rejects an invalid reference date", async () => { + await expect(getPreviousSnapshot("nope")).rejects.toMatchObject({ + code: "snapshot_date_required", + }); + }); +}); diff --git a/src/services/balance.service.ts b/src/services/balance.service.ts index 0547670..7bc39bb 100644 --- a/src/services/balance.service.ts +++ b/src/services/balance.service.ts @@ -15,6 +15,8 @@ import type { BalanceAccountWithCategory, BalanceCategory, BalanceCategoryKind, + BalanceSnapshot, + BalanceSnapshotLine, } from "../shared/types"; import { BALANCE_CURRENCY_CAD } from "../shared/types"; @@ -29,7 +31,12 @@ export type BalanceErrorCode = | "category_not_found" | "account_not_found" | "name_required" - | "kind_invalid"; + | "kind_invalid" + | "snapshot_date_required" + | "snapshot_date_taken" + | "snapshot_not_found" + | "snapshot_value_invalid" + | "snapshot_priced_unsupported"; export class BalanceServiceError extends Error { readonly code: BalanceErrorCode; @@ -358,3 +365,256 @@ export async function unarchiveBalanceAccount(id: number): Promise { [id] ); } + +// ----------------------------------------------------------------------------- +// Snapshots + lines (Issue #146 / Bilan #1b — simple kind only) +// ----------------------------------------------------------------------------- +// +// At Issue #146 the UI surfaces *only* simple-kind input: every line has +// `quantity = NULL` and `unit_price = NULL`. The SQL CHECK on +// `balance_snapshot_lines` already enforces the kind invariant, but +// `upsertSnapshotLines` re-validates ahead of time so a typed +// BalanceServiceError surfaces a clean i18n message instead of a raw SQL +// error. Priced-kind upsert lands in Issue #140 (Bilan #2). + +const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +function normalizeSnapshotDate(date: string): string { + const trimmed = (date ?? "").trim(); + if (!trimmed) { + throw new BalanceServiceError( + "snapshot_date_required", + "Snapshot date is required" + ); + } + if (!ISO_DATE_REGEX.test(trimmed)) { + throw new BalanceServiceError( + "snapshot_date_required", + "Snapshot date must be in ISO YYYY-MM-DD format" + ); + } + return trimmed; +} + +export async function listSnapshots(): Promise { + const db = await getDb(); + return db.select( + `SELECT id, snapshot_date, notes, created_at, updated_at + FROM balance_snapshots + ORDER BY snapshot_date DESC` + ); +} + +export async function getSnapshotByDate( + date: string +): Promise { + const normalized = normalizeSnapshotDate(date); + const db = await getDb(); + const rows = await db.select( + `SELECT id, snapshot_date, notes, created_at, updated_at + FROM balance_snapshots + WHERE snapshot_date = $1`, + [normalized] + ); + return rows[0] ?? null; +} + +export async function getSnapshotById( + id: number +): Promise { + const db = await getDb(); + const rows = await db.select( + `SELECT id, snapshot_date, notes, created_at, updated_at + FROM balance_snapshots + WHERE id = $1`, + [id] + ); + return rows[0] ?? null; +} + +export interface CreateSnapshotInput { + snapshot_date: string; + notes?: string | null; +} + +/** + * Create a snapshot row. Throws `snapshot_date_taken` if a snapshot already + * exists at the same date so the UI can redirect to edit mode (UNIQUE + * constraint on `snapshot_date` would surface a raw SQL error otherwise). + */ +export async function createSnapshot( + input: CreateSnapshotInput +): Promise { + const date = normalizeSnapshotDate(input.snapshot_date); + const existing = await getSnapshotByDate(date); + if (existing) { + throw new BalanceServiceError( + "snapshot_date_taken", + `A snapshot already exists at ${date}` + ); + } + const db = await getDb(); + const result = await db.execute( + `INSERT INTO balance_snapshots (snapshot_date, notes) + VALUES ($1, $2)`, + [date, input.notes ? input.notes.trim() || null : null] + ); + return result.lastInsertId as number; +} + +export interface UpdateSnapshotInput { + notes?: string | null; +} + +/** + * Update snapshot metadata (notes only). Snapshot date is immutable once + * saved — to change the date the user deletes the snapshot and creates a + * new one (the UI exposes this as a constraint, not a feature). + */ +export async function updateSnapshot( + id: number, + input: UpdateSnapshotInput +): Promise { + const existing = await getSnapshotById(id); + if (!existing) { + throw new BalanceServiceError( + "snapshot_not_found", + `Snapshot ${id} not found` + ); + } + const notes = + input.notes !== undefined + ? input.notes === null + ? null + : input.notes.trim() || null + : existing.notes; + const db = await getDb(); + await db.execute( + `UPDATE balance_snapshots + SET notes = $1, updated_at = CURRENT_TIMESTAMP + WHERE id = $2`, + [notes, id] + ); +} + +/** + * Delete a snapshot. ON DELETE CASCADE on `balance_snapshot_lines` + * .snapshot_id removes the lines too. The UI must double-confirm + * (re-typing the snapshot date) before invoking this. + */ +export async function deleteSnapshot(id: number): Promise { + const existing = await getSnapshotById(id); + if (!existing) { + throw new BalanceServiceError( + "snapshot_not_found", + `Snapshot ${id} not found` + ); + } + const db = await getDb(); + await db.execute("DELETE FROM balance_snapshots WHERE id = $1", [id]); +} + +export async function listLinesBySnapshot( + snapshotId: number +): Promise { + const db = await getDb(); + return db.select( + `SELECT id, snapshot_id, account_id, quantity, unit_price, value, + price_source, price_fetched_at, created_at, updated_at + FROM balance_snapshot_lines + WHERE snapshot_id = $1 + ORDER BY id`, + [snapshotId] + ); +} + +export interface SnapshotLineInput { + account_id: number; + /** + * Simple-kind value. Must be a finite number (>= 0 in practice but the + * service accepts any finite — negative values support shorts/loans). + */ + value: number; +} + +/** + * Upsert a batch of snapshot lines (simple kind only). Each input row is + * inserted or replaced atomically per account; lines for accounts not + * present in `lines` are removed from the snapshot. This makes the editor + * strictly state-driven — what the user sees is exactly what gets saved. + * + * Validation enforced ahead of time so the SQL CHECK never fires: + * - finite numeric value (NaN / +-Infinity rejected with `snapshot_value_invalid`); + * - quantity / unit_price always stored as NULL (simple-kind invariant). + * + * Priced-kind upsert lands in Issue #140 (Bilan #2). + */ +export async function upsertSnapshotLines( + snapshotId: number, + lines: SnapshotLineInput[] +): Promise { + const snapshot = await getSnapshotById(snapshotId); + if (!snapshot) { + throw new BalanceServiceError( + "snapshot_not_found", + `Snapshot ${snapshotId} not found` + ); + } + // Validate every input up-front before mutating anything. + for (const line of lines) { + if ( + typeof line.value !== "number" || + !Number.isFinite(line.value) + ) { + throw new BalanceServiceError( + "snapshot_value_invalid", + `Line for account ${line.account_id}: value must be a finite number` + ); + } + } + + const db = await getDb(); + // Strategy: clear and rewrite. Snapshot lines are small (one per active + // account, typically < 20) so the simplicity outweighs the diff-tracking + // savings. CASCADE guarantees consistency on partial failures. + await db.execute( + "DELETE FROM balance_snapshot_lines WHERE snapshot_id = $1", + [snapshotId] + ); + for (const line of lines) { + await db.execute( + `INSERT INTO balance_snapshot_lines + (snapshot_id, account_id, quantity, unit_price, value, price_source) + VALUES ($1, $2, NULL, NULL, $3, 'manual')`, + [snapshotId, line.account_id, line.value] + ); + } + // Bump the parent snapshot's updated_at so list views can sort by recency. + await db.execute( + `UPDATE balance_snapshots + SET updated_at = CURRENT_TIMESTAMP + WHERE id = $1`, + [snapshotId] + ); +} + +/** + * Convenience helper used by the "Prefill from previous snapshot" button. + * Returns the snapshot whose `snapshot_date` is strictly earlier than + * `referenceDate`, or `null` if none exists. + */ +export async function getPreviousSnapshot( + referenceDate: string +): Promise { + const normalized = normalizeSnapshotDate(referenceDate); + const db = await getDb(); + const rows = await db.select( + `SELECT id, snapshot_date, notes, created_at, updated_at + FROM balance_snapshots + WHERE snapshot_date < $1 + ORDER BY snapshot_date DESC + LIMIT 1`, + [normalized] + ); + return rows[0] ?? null; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 004a122..3653a2f 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -601,3 +601,32 @@ export interface BalanceAccountWithCategory extends BalanceAccount { category_i18n_key: string; category_kind: BalanceCategoryKind; } + +// Snapshots — added Issue #146 (Bilan #1b) for the SnapshotEditPage. +// Lines are kept simple-kind only here (`quantity` / `unit_price` always NULL). +// The priced-kind UI lands in #140 / Bilan #2. + +export interface BalanceSnapshot { + id: number; + /** ISO date (YYYY-MM-DD), UNIQUE across the table. */ + snapshot_date: string; + notes: string | null; + created_at: string; + updated_at: string; +} + +export interface BalanceSnapshotLine { + id: number; + snapshot_id: number; + account_id: number; + /** Always NULL for simple-kind lines (Issue #146 scope). */ + quantity: number | null; + /** Always NULL for simple-kind lines (Issue #146 scope). */ + unit_price: number | null; + value: number; + /** 'manual' for simple-kind, 'maximus-api' for priced (#142). */ + price_source: string | null; + price_fetched_at: string | null; + created_at: string; + updated_at: string; +} From fdc6cc6c38575a5013dc081050c4dd092f0d468c Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:49:33 -0400 Subject: [PATCH 2/3] feat(balance): add useSnapshotEditor hook + SnapshotEditPage + components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New scoped useReducer hook covering the full single-snapshot lifecycle — LOAD_FOR_DATE / SET_LINE_VALUE / SAVE / DELETE / PREFILL_FROM_PREVIOUS / RESET — with the following semantics: - 'new' mode (?date= absent or no snapshot at that date) creates the row at save time only, so abandoning the form does not leave an empty snapshot behind; - 'edit' mode loads existing lines + prefills the values map; - prefillFromPrevious copies simple-kind values from the most recent earlier snapshot (priced branch is a no-op + TODO Issue #140); - save() flips 'new' -> 'edit' on success and updates the URL ?date= so refresh keeps the user in edit mode; - snapshotDate is immutable in edit mode (UI guard, matches spec). New SnapshotEditPage at /balance/snapshot: - date picker (native input type=date — matches the AdjustmentForm / TransactionFilterBar / PeriodSelector pattern, no new dep) - per-category groups of accounts with one value field each - prefill button (disabled when no earlier snapshot exists, with tooltip explaining why) - delete button with double-confirmation modal that requires retyping the snapshot date before the destructive action enables. New SnapshotEditor (groups by category sort_order) and SnapshotLineRow (simple variant — single value field per account) components. Route /balance/snapshot wired in App.tsx. Refs #146 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/App.tsx | 2 + src/components/balance/SnapshotEditor.tsx | 92 +++++ src/components/balance/SnapshotLineRow.tsx | 64 ++++ src/hooks/useSnapshotEditor.ts | 387 +++++++++++++++++++++ src/pages/SnapshotEditPage.tsx | 343 ++++++++++++++++++ 5 files changed, 888 insertions(+) create mode 100644 src/components/balance/SnapshotEditor.tsx create mode 100644 src/components/balance/SnapshotLineRow.tsx create mode 100644 src/hooks/useSnapshotEditor.ts create mode 100644 src/pages/SnapshotEditPage.tsx 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 ( +
+ {t("balance.snapshot.editor.empty")} +
+ ); + } + + return ( +
+ {groups.map(({ category, accounts: catAccounts }) => ( +
+
+

+ {t(category.i18n_key, { defaultValue: category.key })} +

+
+
+ {catAccounts.map((acc) => ( + onValueChange(acc.id, next)} + disabled={disabled} + /> + ))} +
+
+ ))} +
+ ); +} 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 ( +
+
+ + +

+ {isEditMode + ? t("balance.snapshot.page.editTitle") + : t("balance.snapshot.page.newTitle")} +

+
+ + {state.error && ( +
+ {state.errorCode + ? t(`balance.errors.${state.errorCode}`, { + defaultValue: state.error, + }) + : state.error} +
+ )} + +
+
+
+ + { + const next = e.target.value; + editor.setDate(next); + // Drive the route param so reloads stay coherent and an + // existing snapshot at the chosen date flips us into 'edit'. + if (next) { + setSearchParams({ date: next }, { replace: true }); + } else { + setSearchParams({}, { replace: true }); + } + }} + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-60" + /> + {isEditMode && ( +

+ {t("balance.snapshot.page.dateImmutable")} +

+ )} +
+ {totalValue !== null && ( +
+
+ {t("balance.snapshot.page.total")} +
+
+ {totalValue.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} +
+
+ )} +
+
+ + {state.accounts.length === 0 && !state.isLoading ? ( +
+

{t("balance.snapshot.page.noAccounts")}

+ +
+ ) : ( + + )} + + {/* Action bar */} +
+
+ + {isEditMode && ( + + )} +
+
+ + +
+
+ + {/* Delete confirmation modal — double-confirmation requires retyping + the snapshot date. */} + {showDeleteModal && state.snapshot && ( + { + setShowDeleteModal(false); + setDeleteConfirmText(""); + }} + onConfirm={handleDelete} + /> + )} +
+ ); +} + +// ----------------------------------------------------------------------------- +// Internal components +// ----------------------------------------------------------------------------- + +function DeleteConfirmModal({ + snapshotDate, + confirmText, + onConfirmTextChange, + isSaving, + onCancel, + onConfirm, +}: { + snapshotDate: string; + confirmText: string; + onConfirmTextChange: (next: string) => void; + isSaving: boolean; + onCancel: () => void; + onConfirm: () => void; +}) { + const { t } = useTranslation(); + const isMatch = confirmText.trim() === snapshotDate; + return ( +
+
+
+
+ +
+
+

+ {t("balance.snapshot.delete.title")} +

+

+ {t("balance.snapshot.delete.body", { date: snapshotDate })} +

+
+
+ + onConfirmTextChange(e.target.value)} + placeholder={snapshotDate} + autoComplete="off" + className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--negative)]" + /> +
+ + +
+
+
+ ); +} From 8f5cc71707d1fa82e6c047f27a62fd1e3230e2de Mon Sep 17 00:00:00 2001 From: le king fu Date: Sat, 25 Apr 2026 14:49:41 -0400 Subject: [PATCH 3/3] feat(balance): add i18n keys + CHANGELOG entry for snapshot editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit i18n FR/EN under balance.snapshot.* — page (titles, date label and immutability notice, total, prefill, save/create/delete buttons), editor (empty state), line (placeholder + a11y label), delete (double-confirm modal copy). Five new error codes added to balance.errors.* (snapshot_date_required, snapshot_date_taken, snapshot_not_found, snapshot_value_invalid, snapshot_priced_unsupported). Adds common.back so the SnapshotEditPage back arrow has a localized title. CHANGELOG entries for #146 under [Unreleased] in both EN and FR. Refs #146 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + src/i18n/locales/en.json | 40 ++++++++++++++++++++++++++++++++++++++-- src/i18n/locales/fr.json | 40 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 78 insertions(+), 4 deletions(-) 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/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." } } }