feat(balance): SnapshotEditPage + simple-kind editor (#146) #148
5 changed files with 888 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import AccountsPage from "./pages/AccountsPage";
|
import AccountsPage from "./pages/AccountsPage";
|
||||||
|
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
||||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
||||||
import DocsPage from "./pages/DocsPage";
|
import DocsPage from "./pages/DocsPage";
|
||||||
|
|
@ -116,6 +117,7 @@ export default function App() {
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||||
|
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/settings/categories/standard"
|
path="/settings/categories/standard"
|
||||||
element={<CategoriesStandardGuidePage />}
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue