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)]" + /> +
+ + +
+
+
+ ); +}