Compare commits
No commits in common. "c8a6f74a1d3a66ed671ccff5dc3eb4cd92141487" and "1a4cab2e9b82cca6c5143adf2aa497df567ab707" have entirely different histories.
c8a6f74a1d
...
1a4cab2e9b
6 changed files with 14 additions and 266 deletions
|
|
@ -28,7 +28,6 @@ import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ListTree,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
AccountLatestSnapshot,
|
AccountLatestSnapshot,
|
||||||
|
|
@ -87,12 +86,6 @@ interface BalanceAccountsTableProps {
|
||||||
periodAnchor: AccountPeriodAnchor[];
|
periodAnchor: AccountPeriodAnchor[];
|
||||||
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
||||||
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
|
onLinkTransfers?: (account: AccountLatestSnapshot) => void;
|
||||||
/**
|
|
||||||
* Open the "détailler en titres" wizard for a *simple* account (Issue #215).
|
|
||||||
* The action is only offered on `kind === 'simple'` rows; once detailed, the
|
|
||||||
* flip is one-way (service backstop #212) so no inverse action is exposed.
|
|
||||||
*/
|
|
||||||
onDetailAccount?: (account: AccountLatestSnapshot) => void;
|
|
||||||
/**
|
/**
|
||||||
* Earliest snapshot date across the whole profile, used to anchor the
|
* Earliest snapshot date across the whole profile, used to anchor the
|
||||||
* "depuis création" horizon. Falls back to "1A" range if not provided
|
* "depuis création" horizon. Falls back to "1A" range if not provided
|
||||||
|
|
@ -123,7 +116,6 @@ export default function BalanceAccountsTable({
|
||||||
periodAnchor,
|
periodAnchor,
|
||||||
onArchiveAccount,
|
onArchiveAccount,
|
||||||
onLinkTransfers,
|
onLinkTransfers,
|
||||||
onDetailAccount,
|
|
||||||
sinceCreationDate,
|
sinceCreationDate,
|
||||||
latentGainByAccount = {},
|
latentGainByAccount = {},
|
||||||
latentGainRollup,
|
latentGainRollup,
|
||||||
|
|
@ -563,19 +555,14 @@ export default function BalanceAccountsTable({
|
||||||
</button>
|
</button>
|
||||||
{openMenuFor === acc.account_id && (
|
{openMenuFor === acc.account_id && (
|
||||||
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
|
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[180px] text-left">
|
||||||
{onDetailAccount && acc.kind === "simple" && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
disabled
|
||||||
onClick={() => {
|
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
setOpenMenuFor(null);
|
title={t("balance.overview.detailComingSoon")}
|
||||||
onDetailAccount(acc);
|
>
|
||||||
}}
|
{t("balance.overview.detailAction")}
|
||||||
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
</button>
|
||||||
>
|
|
||||||
<ListTree size={14} />
|
|
||||||
{t("balance.detailWizard.action")}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{onLinkTransfers && (
|
{onLinkTransfers && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
// DetailAccountWizard — unit tests (Issue #215).
|
|
||||||
//
|
|
||||||
// NOTE: this project has no jsdom / @testing-library harness (see
|
|
||||||
// SecurityPicker.test.ts, StarterAccountsModal.test.tsx). We exercise the pure
|
|
||||||
// toggle-payload builder — the load-bearing decision of the wizard — directly.
|
|
||||||
// DOM rendering / the confirm click are not exercised here; the wizard is thin
|
|
||||||
// orchestration over `buildDetailToggleInput` + `updateBalanceAccount`.
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { buildDetailToggleInput } from "./DetailAccountWizard";
|
|
||||||
|
|
||||||
describe("buildDetailToggleInput", () => {
|
|
||||||
it("flips kind to detailed and pins the pivot to today's local civil day", () => {
|
|
||||||
// 2026-06-06 in the local civil day (midday avoids any DST edge confusion).
|
|
||||||
const today = new Date(2026, 5, 6, 12, 30, 0);
|
|
||||||
const input = buildDetailToggleInput(today);
|
|
||||||
expect(input).toEqual({ kind: "detailed", detailed_since: "2026-06-06" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("zero-pads single-digit month and day to a YYYY-MM-DD snapshot shape", () => {
|
|
||||||
// January 2nd → "2026-01-02", matching normalizeSnapshotDate's ISO regex.
|
|
||||||
const today = new Date(2026, 0, 2, 9, 0, 0);
|
|
||||||
const input = buildDetailToggleInput(today);
|
|
||||||
expect(input.detailed_since).toBe("2026-01-02");
|
|
||||||
expect(input.kind).toBe("detailed");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never emits a detailed → simple downgrade or an envelope mutation", () => {
|
|
||||||
const input = buildDetailToggleInput(new Date(2026, 11, 31, 23, 0, 0));
|
|
||||||
// Only the two pivot fields are set; vehicle_type/name/etc. stay untouched
|
|
||||||
// so updateBalanceAccount's read-and-rewrite preserves them.
|
|
||||||
expect(Object.keys(input).sort()).toEqual(["detailed_since", "kind"]);
|
|
||||||
expect(input.kind).not.toBe("simple");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
// DetailAccountWizard — light confirmation modal that flips a *simple* balance
|
|
||||||
// account to *detailed* entry mode. Issue #215 (Bilan détail par titres #5).
|
|
||||||
//
|
|
||||||
// Decision 2026-06-04 (plan-overnight): TOGGLE-ONLY. The wizard does NOT capture
|
|
||||||
// any titles. It sets `kind='detailed'` and `detailed_since = today` (the
|
|
||||||
// authoritative pivot date) via `updateBalanceAccount`. Per-security holdings
|
|
||||||
// are entered at the NEXT normal snapshot — `validateDetailedSnapshot` tolerates
|
|
||||||
// pre-pivot aggregated lines and requires holdings only at/after the pivot.
|
|
||||||
//
|
|
||||||
// Irreversibility: once holdings exist at/after the pivot, the service backstop
|
|
||||||
// (#212) rejects a `detailed → simple` downgrade with
|
|
||||||
// `account_kind_detailed_has_holdings`. The UI exposes no "simplify" action, so
|
|
||||||
// this confirmation makes the one-way nature explicit before committing.
|
|
||||||
//
|
|
||||||
// Mirrors the LinkTransfersModal idiom (createPortal overlay, stopPropagation
|
|
||||||
// inner card, i18n-only copy, WebKitGTK-safe close button).
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { X, Loader2, AlertTriangle } from "lucide-react";
|
|
||||||
import {
|
|
||||||
updateBalanceAccount,
|
|
||||||
BalanceServiceError,
|
|
||||||
} from "../../services/balance.service";
|
|
||||||
import type { UpdateBalanceAccountInput } from "../../services/balance.service";
|
|
||||||
|
|
||||||
/** Local civil-day ISO (YYYY-MM-DD) — matches stored snapshot date format. */
|
|
||||||
function localISO(d: Date): string {
|
|
||||||
const yy = d.getFullYear();
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
return `${yy}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure builder for the toggle payload — exported for unit testing. Produces the
|
|
||||||
* exact `updateBalanceAccount` input the wizard commits: flip to detailed and
|
|
||||||
* pin the pivot to `today`'s local civil day. `normalizeSnapshotDate` on the
|
|
||||||
* service side accepts this YYYY-MM-DD shape verbatim.
|
|
||||||
*/
|
|
||||||
export function buildDetailToggleInput(today: Date): UpdateBalanceAccountInput {
|
|
||||||
return { kind: "detailed", detailed_since: localISO(today) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailAccountWizardProps {
|
|
||||||
accountId: number;
|
|
||||||
accountName: string;
|
|
||||||
onClose: () => void;
|
|
||||||
/** Fired after the account was successfully flipped to detailed. */
|
|
||||||
onDetailed?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DetailAccountWizard({
|
|
||||||
accountId,
|
|
||||||
accountName,
|
|
||||||
onClose,
|
|
||||||
onDetailed,
|
|
||||||
}: DetailAccountWizardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Pivot resolved once at render: the local civil day at confirmation time.
|
|
||||||
const pivot = localISO(new Date());
|
|
||||||
|
|
||||||
async function handleConfirm() {
|
|
||||||
setSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await updateBalanceAccount(accountId, buildDetailToggleInput(new Date()));
|
|
||||||
onDetailed?.();
|
|
||||||
onClose();
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof BalanceServiceError) {
|
|
||||||
setError(
|
|
||||||
t(`balance.detailWizard.errors.${e.code}`, {
|
|
||||||
defaultValue: e.message,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
|
||||||
}
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-xl w-full max-w-lg flex flex-col"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{t("balance.detailWizard.title", { account: accountName })}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
|
||||||
aria-label={t("common.close")}
|
|
||||||
>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-4 space-y-3 text-sm">
|
|
||||||
<p>{t("balance.detailWizard.intro")}</p>
|
|
||||||
|
|
||||||
<ul className="list-disc pl-5 space-y-1.5 text-[var(--muted-foreground)]">
|
|
||||||
<li>{t("balance.detailWizard.pointFrozen")}</li>
|
|
||||||
<li>{t("balance.detailWizard.pointNextSnapshot")}</li>
|
|
||||||
<li>{t("balance.detailWizard.pointPivot", { date: pivot })}</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[var(--foreground)]">
|
|
||||||
<AlertTriangle
|
|
||||||
size={16}
|
|
||||||
className="mt-0.5 shrink-0 text-amber-500"
|
|
||||||
aria-hidden
|
|
||||||
/>
|
|
||||||
<span>{t("balance.detailWizard.irreversible")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-xs text-[var(--negative)]">{error}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-[var(--border)] flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
disabled={submitting}
|
|
||||||
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] hover:bg-[var(--muted)]/30 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={submitting}
|
|
||||||
className="px-3 py-1.5 text-sm rounded bg-[var(--primary)] text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<Loader2 className="animate-spin" size={14} />
|
|
||||||
{t("balance.detailWizard.confirming")}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
t("balance.detailWizard.confirm")
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1550,7 +1550,9 @@
|
||||||
"latestValue": "Latest value",
|
"latestValue": "Latest value",
|
||||||
"periodDelta": "Δ% over period",
|
"periodDelta": "Δ% over period",
|
||||||
"noAccounts": "No active accounts. Create a balance account to get started.",
|
"noAccounts": "No active accounts. Create a balance account to get started.",
|
||||||
"accountsTitle": "Accounts"
|
"accountsTitle": "Accounts",
|
||||||
|
"detailAction": "Details",
|
||||||
|
"detailComingSoon": "Available in a future release."
|
||||||
},
|
},
|
||||||
"period": {
|
"period": {
|
||||||
"legend": "Analysis period",
|
"legend": "Analysis period",
|
||||||
|
|
@ -1859,20 +1861,6 @@
|
||||||
"vehicle": {
|
"vehicle": {
|
||||||
"none": "No envelope"
|
"none": "No envelope"
|
||||||
},
|
},
|
||||||
"detailWizard": {
|
|
||||||
"action": "Detail into securities",
|
|
||||||
"title": "Detail “{{account}}” into securities",
|
|
||||||
"intro": "This account will switch to per-security detailed entry. Securities are entered at the next regular snapshot — this wizard captures none right now.",
|
|
||||||
"pointFrozen": "Past aggregated history stays frozen read-only: older snapshots keep their single total value.",
|
|
||||||
"pointNextSnapshot": "From today on, every new snapshot will require the per-security breakdown (with cost basis).",
|
|
||||||
"pointPivot": "Pivot date: {{date}}.",
|
|
||||||
"irreversible": "One-way action: once securities are entered, this account can no longer return to aggregated entry.",
|
|
||||||
"confirm": "Detail",
|
|
||||||
"confirming": "Switching…",
|
|
||||||
"errors": {
|
|
||||||
"account_kind_detailed_has_holdings": "This account already has securities entered and can no longer return to aggregated entry."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"transfers": {
|
"transfers": {
|
||||||
"linkAction": "Link transfers",
|
"linkAction": "Link transfers",
|
||||||
"direction": {
|
"direction": {
|
||||||
|
|
|
||||||
|
|
@ -1550,7 +1550,9 @@
|
||||||
"latestValue": "Dernière valeur",
|
"latestValue": "Dernière valeur",
|
||||||
"periodDelta": "Δ% sur la période",
|
"periodDelta": "Δ% sur la période",
|
||||||
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
||||||
"accountsTitle": "Comptes"
|
"accountsTitle": "Comptes",
|
||||||
|
"detailAction": "Détail",
|
||||||
|
"detailComingSoon": "Disponible dans une prochaine version."
|
||||||
},
|
},
|
||||||
"period": {
|
"period": {
|
||||||
"legend": "Période d'analyse",
|
"legend": "Période d'analyse",
|
||||||
|
|
@ -1859,20 +1861,6 @@
|
||||||
"vehicle": {
|
"vehicle": {
|
||||||
"none": "Sans enveloppe"
|
"none": "Sans enveloppe"
|
||||||
},
|
},
|
||||||
"detailWizard": {
|
|
||||||
"action": "Détailler en titres",
|
|
||||||
"title": "Détailler « {{account}} » en titres",
|
|
||||||
"intro": "Ce compte passera en saisie détaillée par titre. Les titres se saisissent au prochain instantané (snapshot) normal — cet assistant ne capture aucun titre maintenant.",
|
|
||||||
"pointFrozen": "L'historique agrégé passé reste figé en lecture seule : les anciens instantanés conservent leur valeur globale.",
|
|
||||||
"pointNextSnapshot": "À partir d'aujourd'hui, chaque nouvel instantané exigera le détail par titre (avec coût d'acquisition).",
|
|
||||||
"pointPivot": "Date de bascule (pivot) : {{date}}.",
|
|
||||||
"irreversible": "Action à sens unique : une fois des titres saisis, ce compte ne pourra plus revenir en saisie agrégée.",
|
|
||||||
"confirm": "Détailler",
|
|
||||||
"confirming": "Bascule…",
|
|
||||||
"errors": {
|
|
||||||
"account_kind_detailed_has_holdings": "Ce compte a déjà des titres saisis et ne peut plus revenir en saisie agrégée."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"transfers": {
|
"transfers": {
|
||||||
"linkAction": "Lier transferts",
|
"linkAction": "Lier transferts",
|
||||||
"direction": {
|
"direction": {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
|
||||||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||||
import DetailAccountWizard from "../components/balance/DetailAccountWizard";
|
|
||||||
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
||||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||||
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||||
|
|
@ -53,10 +52,6 @@ export default function BalancePage() {
|
||||||
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | null>(
|
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
// Issue #215 — "détailler en titres" wizard target (a simple account being
|
|
||||||
// flipped to detailed entry mode).
|
|
||||||
const [detailTarget, setDetailTarget] =
|
|
||||||
useState<AccountLatestSnapshot | null>(null);
|
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [transfersByAccount, setTransfersByAccount] = useState<
|
const [transfersByAccount, setTransfersByAccount] = useState<
|
||||||
Map<number, BalanceAccountTransferWithTransaction[]>
|
Map<number, BalanceAccountTransferWithTransaction[]>
|
||||||
|
|
@ -329,7 +324,6 @@ export default function BalancePage() {
|
||||||
vehicleLabels={vehicleLabels}
|
vehicleLabels={vehicleLabels}
|
||||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||||
onDetailAccount={(acc) => setDetailTarget(acc)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -354,17 +348,6 @@ export default function BalancePage() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{detailTarget && (
|
|
||||||
<DetailAccountWizard
|
|
||||||
accountId={detailTarget.account_id}
|
|
||||||
accountName={detailTarget.account_name}
|
|
||||||
onClose={() => setDetailTarget(null)}
|
|
||||||
onDetailed={() => {
|
|
||||||
void reload();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue