Merge pull request 'feat(balance): SnapshotEditPage + simple-kind editor (#146)' (#148) from issue-146-bilan-1b into main
This commit is contained in:
commit
a344eab2bb
12 changed files with 1488 additions and 5 deletions
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
||||
<Route
|
||||
path="/settings/categories/standard"
|
||||
element={<CategoriesStandardGuidePage />}
|
||||
|
|
|
|||
92
src/components/balance/SnapshotEditor.tsx
Normal file
92
src/components/balance/SnapshotEditor.tsx
Normal file
|
|
@ -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<number, string>;
|
||||
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<number, BalanceAccountWithCategory[]>();
|
||||
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 (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
|
||||
{t("balance.snapshot.editor.empty")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map(({ category, accounts: catAccounts }) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"
|
||||
>
|
||||
<div className="px-4 py-2 bg-[var(--muted)] border-b border-[var(--border)]">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(category.i18n_key, { defaultValue: category.key })}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="px-4">
|
||||
{catAccounts.map((acc) => (
|
||||
<SnapshotLineRow
|
||||
key={acc.id}
|
||||
account={acc}
|
||||
value={values[acc.id] ?? ""}
|
||||
onChange={(next) => onValueChange(acc.id, next)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/components/balance/SnapshotLineRow.tsx
Normal file
64
src/components/balance/SnapshotLineRow.tsx
Normal file
|
|
@ -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<HTMLInputElement>) => {
|
||||
onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 py-2 border-b border-[var(--border)] last:border-b-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{account.name}</div>
|
||||
{account.symbol && (
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{account.symbol}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
placeholder={t("balance.snapshot.line.valuePlaceholder")}
|
||||
className="w-32 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)] disabled:opacity-50"
|
||||
aria-label={t("balance.snapshot.line.valueLabel", {
|
||||
account: account.name,
|
||||
})}
|
||||
/>
|
||||
<span className="text-xs text-[var(--muted-foreground)] w-10">
|
||||
{account.currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
387
src/hooks/useSnapshotEditor.ts
Normal file
387
src/hooks/useSnapshotEditor.ts
Normal file
|
|
@ -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<number, string>;
|
||||
/** Snapshot whose values would prefill if the user clicks "Prefill". */
|
||||
previousSnapshot: BalanceSnapshot | null;
|
||||
/** Lines from `previousSnapshot` (loaded lazily when needed). */
|
||||
previousLines: BalanceSnapshotLine[] | null;
|
||||
isLoading: boolean;
|
||||
isSaving: boolean;
|
||||
isDirty: boolean;
|
||||
error: string | null;
|
||||
errorCode: string | null;
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: "SET_LOADING"; payload: boolean }
|
||||
| { type: "SET_SAVING"; payload: boolean }
|
||||
| { type: "SET_ERROR"; payload: { message: string | null; code: string | null } }
|
||||
| {
|
||||
type: "LOADED";
|
||||
payload: {
|
||||
mode: SnapshotEditorMode;
|
||||
snapshotDate: string;
|
||||
snapshot: BalanceSnapshot | null;
|
||||
accounts: BalanceAccountWithCategory[];
|
||||
categories: BalanceCategory[];
|
||||
values: Record<number, string>;
|
||||
previousSnapshot: BalanceSnapshot | null;
|
||||
previousLines: BalanceSnapshotLine[] | null;
|
||||
};
|
||||
}
|
||||
| { type: "SET_DATE"; payload: string }
|
||||
| { type: "SET_VALUE"; payload: { accountId: number; value: string } }
|
||||
| { type: "PREFILL"; payload: Record<number, string> }
|
||||
| { 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<number, string> = {};
|
||||
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<number, BalanceCategory["kind"]>();
|
||||
for (const acc of state.accounts) {
|
||||
accountKindById.set(acc.id, acc.category_kind);
|
||||
}
|
||||
const next: Record<number, string> = {};
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
343
src/pages/SnapshotEditPage.tsx
Normal file
343
src/pages/SnapshotEditPage.tsx
Normal file
|
|
@ -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 (
|
||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/balance/accounts")}
|
||||
className="p-1.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)]"
|
||||
title={t("common.back")}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<Wallet size={24} className="text-[var(--primary)]" />
|
||||
<h1 className="text-2xl font-bold">
|
||||
{isEditMode
|
||||
? t("balance.snapshot.page.editTitle")
|
||||
: t("balance.snapshot.page.newTitle")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||
{state.errorCode
|
||||
? t(`balance.errors.${state.errorCode}`, {
|
||||
defaultValue: state.error,
|
||||
})
|
||||
: state.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-4 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
|
||||
<div className="flex-1">
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="snapshot-date"
|
||||
>
|
||||
{t("balance.snapshot.page.dateLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="snapshot-date"
|
||||
type="date"
|
||||
value={state.snapshotDate}
|
||||
disabled={isEditMode}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.snapshot.page.dateImmutable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{totalValue !== null && (
|
||||
<div className="text-right">
|
||||
<div className="text-xs text-[var(--muted-foreground)]">
|
||||
{t("balance.snapshot.page.total")}
|
||||
</div>
|
||||
<div className="text-2xl font-semibold tabular-nums">
|
||||
{totalValue.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.accounts.length === 0 && !state.isLoading ? (
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-8 text-center text-[var(--muted-foreground)]">
|
||||
<p className="mb-3">{t("balance.snapshot.page.noAccounts")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/balance/accounts")}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
||||
>
|
||||
{t("balance.snapshot.page.goToAccounts")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<SnapshotEditor
|
||||
accounts={state.accounts}
|
||||
categories={state.categories}
|
||||
values={state.values}
|
||||
onValueChange={editor.setLineValue}
|
||||
disabled={state.isSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action bar */}
|
||||
<div className="mt-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={editor.prefillFromPrevious}
|
||||
disabled={!canPrefill || state.isSaving}
|
||||
title={
|
||||
canPrefill
|
||||
? t("balance.snapshot.page.prefillTooltip", {
|
||||
date: state.previousSnapshot?.snapshot_date,
|
||||
})
|
||||
: t("balance.snapshot.page.prefillNoPrevious")
|
||||
}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{t("balance.snapshot.page.prefill")}
|
||||
</button>
|
||||
{isEditMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
disabled={state.isSaving}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--negative)]/40 text-sm text-[var(--negative)] hover:bg-[var(--negative)]/10 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("balance.snapshot.page.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/balance/accounts")}
|
||||
disabled={state.isSaving}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
state.isSaving ||
|
||||
state.isLoading ||
|
||||
state.accounts.length === 0 ||
|
||||
!state.snapshotDate
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} />
|
||||
{isEditMode
|
||||
? t("balance.snapshot.page.save")
|
||||
: t("balance.snapshot.page.create")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation modal — double-confirmation requires retyping
|
||||
the snapshot date. */}
|
||||
{showDeleteModal && state.snapshot && (
|
||||
<DeleteConfirmModal
|
||||
snapshotDate={state.snapshot.snapshot_date}
|
||||
confirmText={deleteConfirmText}
|
||||
onConfirmTextChange={setDeleteConfirmText}
|
||||
isSaving={state.isSaving}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmText("");
|
||||
}}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-xl max-w-md w-full p-6">
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 rounded-full bg-[var(--negative)]/10 text-[var(--negative)]">
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("balance.snapshot.delete.title")}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
|
||||
{t("balance.snapshot.delete.body", { date: snapshotDate })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
className="block text-sm font-medium mb-1"
|
||||
htmlFor="delete-confirm-input"
|
||||
>
|
||||
{t("balance.snapshot.delete.confirmLabel", { date: snapshotDate })}
|
||||
</label>
|
||||
<input
|
||||
id="delete-confirm-input"
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => 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)]"
|
||||
/>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSaving}
|
||||
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)] disabled:opacity-50"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={isSaving || !isMatch}
|
||||
className="px-4 py-2 rounded-lg bg-[var(--negative)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t("balance.snapshot.delete.confirm")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
[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<BalanceSnapshot[]> {
|
||||
const db = await getDb();
|
||||
return db.select<BalanceSnapshot[]>(
|
||||
`SELECT id, snapshot_date, notes, created_at, updated_at
|
||||
FROM balance_snapshots
|
||||
ORDER BY snapshot_date DESC`
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSnapshotByDate(
|
||||
date: string
|
||||
): Promise<BalanceSnapshot | null> {
|
||||
const normalized = normalizeSnapshotDate(date);
|
||||
const db = await getDb();
|
||||
const rows = await db.select<BalanceSnapshot[]>(
|
||||
`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<BalanceSnapshot | null> {
|
||||
const db = await getDb();
|
||||
const rows = await db.select<BalanceSnapshot[]>(
|
||||
`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<number> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<BalanceSnapshotLine[]> {
|
||||
const db = await getDb();
|
||||
return db.select<BalanceSnapshotLine[]>(
|
||||
`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<void> {
|
||||
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<BalanceSnapshot | null> {
|
||||
const normalized = normalizeSnapshotDate(referenceDate);
|
||||
const db = await getDb();
|
||||
const rows = await db.select<BalanceSnapshot[]>(
|
||||
`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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue