Simpl-Resultat/src/pages/SnapshotEditPage.tsx
le king fu c8c765e2f0
All checks were successful
PR Check / rust (pull_request) Successful in 22m45s
PR Check / frontend (pull_request) Successful in 2m25s
feat(balance): CSV import of holdings in detailed snapshot
A detailed (by-security) account can now import its positions from a CSV
instead of adding each security one by one. An "Import CSV" button next to
"Add a title" opens a native picker; the delimiter, encoding and columns
(symbol, quantity, optional price + book_cost) are auto-detected via new
csvAutoDetect helpers and adjustable in a small mapping editor. Rows sharing
a symbol are merged (SUM qty + book_cost, first price) and the batch is merged
into the basket by normalized symbol, so no UNIQUE(snapshot_line_id,
security_id) violation can occur at save. A CSV without a price column imports
quantities with an empty unit_price (fetch/type later) — the existing atomic
save path is unchanged (no SQL/Rust change).

- csvAutoDetect: autoDetectHoldingColumns + analyzeHoldingsCsv (+ tests)
- useSnapshotEditor: holdingsFromCsvRows + IMPORT_HOLDINGS action + importHoldings (+ tests)
- HoldingsCsvImportModal: file pick -> parse -> mapping editor + preview
- i18n FR/EN under balance.snapshot.detailed.importCsv.*
- CHANGELOG (Added / Ajouté)

Resolves #245

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 18:04:12 -04:00

372 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}
onImportHoldings={editor.importHoldings}
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>
);
}