Simpl-Resultat/src/pages/SnapshotEditPage.tsx
le king fu 043e9bf622
All checks were successful
PR Check / rust (push) Successful in 30m45s
PR Check / frontend (push) Successful in 3m12s
PR Check / rust (pull_request) Successful in 28m50s
PR Check / frontend (pull_request) Successful in 3m15s
feat(prices): PriceFetchControl component + consent modal + best-effort UX
- New component renders button + consent modal + spinner + attribution
- Best-effort warning shown once per session for stock categories
- Hidden if not premium or category kind != 'priced'
- Consent persisted per-profile in user_preferences.price_fetching_consent
- Manual unit_price input remains active in all paths
- 17 vitest tests (no RTL/jsdom — logged MEDIUM in decisions-log.md)
- Wired into SnapshotLineRow/SnapshotEditor/SnapshotEditPage
- asset_type hardcoded to 'stock' pending category schema extension (MEDIUM)

Closes #158
2026-04-27 08:36:23 -04:00

359 lines
13 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 + 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 (
<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}
pricedValues={state.pricedValues}
onValueChange={editor.setLineValue}
onQuantityChange={editor.setLineQuantity}
onUnitPriceChange={editor.setLineUnitPrice}
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>
);
}