Simpl-Resultat/src/pages/SnapshotEditPage.tsx
le king fu 4846120b0f feat(balance): multi-security snapshot entry UI + SecurityPicker (#214)
Turn the detailed-account snapshot variant into the real per-title entry
surface (building on the minimal sub-rows from #213):

- New SecurityPicker (src/components/balance/SecurityPicker.tsx): an
  autocomplete combobox over the existing balance_securities catalogue
  (loaded via listSecurities()) with inline creation. Accepts any
  normalized symbol (UPPER/TRIM) with NO live ticker validation — the
  price fetch is best-effort and separate. On pick/create it emits a
  SecurityPick {symbol, asset_type, name, isNew}; a stock/crypto toggle
  lets the user set the asset class when creating a new symbol (default
  'stock'). Built on the CategoryCombobox UI idiom (ARIA listbox,
  keyboard nav, click-outside). Pure helpers filterSecurities /
  decideCreateOption are exported and unit-tested (no jsdom harness).

- SnapshotLineRow detailed sub-rows: labeled columns
  [title (SecurityPicker), quantity, price (+ existing PriceFetchControl),
  value (qty x price, read-only), book_cost, live unrealized gain].
  Account value = displayed SUM of positions. Simple accounts unchanged.

- useSnapshotEditor: new SET_HOLDING_SECURITY action + setHoldingSecurity
  callback (atomically sets symbol + asset_type + name and drops the
  stale fetched-price attribution since the symbol changed). The
  securities catalogue is loaded in loadForDate and exposed as
  state.securities, so it refreshes after a save that creates a security.

- i18n: extended balance.snapshot.detailed.* (col.*, picker.*, book cost,
  unrealized gain) in FR + EN — no hardcoded UI text.

- CHANGELOG (EN + FR) under [Unreleased]: first user-visible surface of
  the per-title detail chain (#210-#213 were schema/service/reducer).

Build (tsc + vite) green; npm test green (613 tests, +10 SecurityPicker).

Generated autonomously by /autopilot run of 2026-06-06

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:39:42 -04:00

371 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 across simple + detailed accounts (computed live as the
// user types). A detailed account contributes the sum of its holdings'
// quantity × unit_price (#213).
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;
}
}
for (const basket of Object.values(state.holdings)) {
if (!basket) continue;
for (const h of basket) {
const qty = Number(String(h.quantity ?? "").trim().replace(",", "."));
const price = Number(
String(h.unit_price ?? "").trim().replace(",", ".")
);
if (Number.isFinite(qty) && Number.isFinite(price)) {
total += qty * price;
hasAny = true;
}
}
}
return hasAny ? total : null;
}, [state.values, state.holdings]);
const handleSave = async () => {
try {
await editor.save();
// Keep the URL in sync with the saved date so a reload reopens the
// right snapshot in edit mode. Covers both a fresh create (new → edit)
// and an edit-mode date move (#200), where `?date=` still points at the
// pre-move date until we update it here.
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}
onChange={(e) => {
const next = e.target.value;
editor.setDate(next);
// In 'new' mode, drive the route param so reloads stay
// coherent and an existing snapshot at the chosen date flips
// us into 'edit'. In 'edit' mode we deliberately DON'T sync
// the URL: changing the date is a pending "move" applied on
// save (#200), so reloading from the route would discard the
// edit and reopen the original snapshot.
if (!isEditMode) {
if (next) {
setSearchParams({ date: next }, { replace: true });
} else {
setSearchParams({}, { replace: true });
}
}
// WebKitGTK (Linux Tauri WebView) does not always dismiss the
// native date popup after a value commit — user has to hit
// Esc. Force-blur is a no-op on WebView2/WKWebView. See #177.
e.currentTarget.blur();
}}
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)]"
/>
{isEditMode && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{t("balance.snapshot.page.dateMovableHint")}
</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}
holdings={state.holdings}
securities={state.securities}
onValueChange={editor.setLineValue}
onAddHolding={editor.addHolding}
onRemoveHolding={editor.removeHolding}
onHoldingFieldChange={editor.setHoldingField}
onHoldingSecurityPick={editor.setHoldingSecurity}
disabled={state.isSaving}
snapshotDate={state.snapshotDate}
/>
)}
{/* 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>
);
}