Simpl-Resultat/src/pages/SnapshotEditPage.tsx
le king fu 0d50a92b0e
All checks were successful
PR Check / rust (pull_request) Successful in 22m43s
PR Check / frontend (pull_request) Successful in 2m26s
fix(ui): close native date picker after selection on WebKitGTK (#177)
WebKitGTK (Linux Tauri WebView) does not auto-dismiss the native
<input type="date"> popup after a value commit — the user has to
press Esc. Force-blur on change is a no-op on Edge Chromium WebView2
(Windows) and WKWebView (macOS), where the popup already closes.

Scope narrowed to /balance/snapshot per issue body. Six other date
inputs across the app share the same WebKitGTK bug; tracked in a
follow-up issue rather than bundled here.

Diagnostic: Ubuntu 24.04 + libwebkit2gtk-4.1-0 2.50.4-0ubuntu0.24.04.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:55:57 -04:00

363 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 });
}
// 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 && (
<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>
);
}