Merge pull request 'feat(balance): detail-account wizard (pivot date) (#215)' (#225) from issue-215-detail-wizard into main

This commit is contained in:
maximus 2026-06-10 01:07:58 +00:00
commit c8a6f74a1d
6 changed files with 266 additions and 14 deletions

View file

@ -28,6 +28,7 @@ import {
AlertTriangle, AlertTriangle,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
ListTree,
} from "lucide-react"; } from "lucide-react";
import type { import type {
AccountLatestSnapshot, AccountLatestSnapshot,
@ -86,6 +87,12 @@ 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
@ -116,6 +123,7 @@ export default function BalanceAccountsTable({
periodAnchor, periodAnchor,
onArchiveAccount, onArchiveAccount,
onLinkTransfers, onLinkTransfers,
onDetailAccount,
sinceCreationDate, sinceCreationDate,
latentGainByAccount = {}, latentGainByAccount = {},
latentGainRollup, latentGainRollup,
@ -555,14 +563,19 @@ 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);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
> >
{t("balance.overview.detailAction")} <ListTree size={14} />
{t("balance.detailWizard.action")}
</button> </button>
)}
{onLinkTransfers && ( {onLinkTransfers && (
<button <button
type="button" type="button"

View file

@ -0,0 +1,35 @@
// 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");
});
});

View file

@ -0,0 +1,163 @@
// 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
);
}

View file

@ -1550,9 +1550,7 @@
"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",
@ -1861,6 +1859,20 @@
"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": {

View file

@ -1550,9 +1550,7 @@
"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",
@ -1861,6 +1859,20 @@
"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": {

View file

@ -34,6 +34,7 @@ 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";
@ -52,6 +53,10 @@ 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[]>
@ -324,6 +329,7 @@ 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>
@ -348,6 +354,17 @@ export default function BalancePage() {
}} }}
/> />
)} )}
{detailTarget && (
<DetailAccountWizard
accountId={detailTarget.account_id}
accountName={detailTarget.account_name}
onClose={() => setDetailTarget(null)}
onDetailed={() => {
void reload();
}}
/>
)}
</div> </div>
); );
} }