// 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 + priced lines (computed live as the // user types). Priced contribution = quantity × unit_price. 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 entry of Object.values(state.pricedValues)) { if (!entry) continue; const qty = Number(String(entry.quantity ?? "").trim().replace(",", ".")); const price = Number( String(entry.unit_price ?? "").trim().replace(",", ".") ); if (Number.isFinite(qty) && Number.isFinite(price)) { total += qty * price; hasAny = true; } } return hasAny ? total : null; }, [state.values, state.pricedValues]); const handleSave = async () => { try { await editor.save(); // After a successful create, the URL should become `?date=...` so // refreshing keeps the user in edit mode. if (!isEditMode) { setSearchParams( { date: state.snapshotDate }, { replace: true } ); } } catch { // The hook surfaced the error via state.errorCode/state.error. } }; const handleDelete = async () => { try { await editor.remove(); navigate("/balance/accounts"); } catch { // surfaced via state.error } }; return (

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

{state.error && (
{state.errorCode ? t(`balance.errors.${state.errorCode}`, { defaultValue: state.error, }) : state.error}
)}
{ const next = e.target.value; editor.setDate(next); // Drive the route param so reloads stay coherent and an // existing snapshot at the chosen date flips us into 'edit'. if (next) { setSearchParams({ date: next }, { replace: true }); } else { setSearchParams({}, { replace: true }); } // 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)] disabled:opacity-60" /> {isEditMode && (

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

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

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

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

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

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

onConfirmTextChange(e.target.value)} placeholder={snapshotDate} autoComplete="off" className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--background)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--negative)]" />
); }