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>
363 lines
13 KiB
TypeScript
363 lines
13 KiB
TypeScript
// 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>
|
||
);
|
||
}
|