Merge pull request 'feat(balance): detail-account wizard (pivot date) (#215)' (#225) from issue-215-detail-wizard into main
This commit is contained in:
commit
c8a6f74a1d
6 changed files with 266 additions and 14 deletions
|
|
@ -28,6 +28,7 @@ import {
|
|||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ListTree,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
AccountLatestSnapshot,
|
||||
|
|
@ -86,6 +87,12 @@ interface BalanceAccountsTableProps {
|
|||
periodAnchor: AccountPeriodAnchor[];
|
||||
onArchiveAccount?: (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
|
||||
* "depuis création" horizon. Falls back to "1A" range if not provided
|
||||
|
|
@ -116,6 +123,7 @@ export default function BalanceAccountsTable({
|
|||
periodAnchor,
|
||||
onArchiveAccount,
|
||||
onLinkTransfers,
|
||||
onDetailAccount,
|
||||
sinceCreationDate,
|
||||
latentGainByAccount = {},
|
||||
latentGainRollup,
|
||||
|
|
@ -555,14 +563,19 @@ export default function BalanceAccountsTable({
|
|||
</button>
|
||||
{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">
|
||||
{onDetailAccount && acc.kind === "simple" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
||||
title={t("balance.overview.detailComingSoon")}
|
||||
onClick={() => {
|
||||
setOpenMenuFor(null);
|
||||
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>
|
||||
)}
|
||||
{onLinkTransfers && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
35
src/components/balance/DetailAccountWizard.test.ts
Normal file
35
src/components/balance/DetailAccountWizard.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
163
src/components/balance/DetailAccountWizard.tsx
Normal file
163
src/components/balance/DetailAccountWizard.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -1550,9 +1550,7 @@
|
|||
"latestValue": "Latest value",
|
||||
"periodDelta": "Δ% over period",
|
||||
"noAccounts": "No active accounts. Create a balance account to get started.",
|
||||
"accountsTitle": "Accounts",
|
||||
"detailAction": "Details",
|
||||
"detailComingSoon": "Available in a future release."
|
||||
"accountsTitle": "Accounts"
|
||||
},
|
||||
"period": {
|
||||
"legend": "Analysis period",
|
||||
|
|
@ -1861,6 +1859,20 @@
|
|||
"vehicle": {
|
||||
"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": {
|
||||
"linkAction": "Link transfers",
|
||||
"direction": {
|
||||
|
|
|
|||
|
|
@ -1550,9 +1550,7 @@
|
|||
"latestValue": "Dernière valeur",
|
||||
"periodDelta": "Δ% sur la période",
|
||||
"noAccounts": "Aucun compte actif. Commencez par créer un compte de bilan.",
|
||||
"accountsTitle": "Comptes",
|
||||
"detailAction": "Détail",
|
||||
"detailComingSoon": "Disponible dans une prochaine version."
|
||||
"accountsTitle": "Comptes"
|
||||
},
|
||||
"period": {
|
||||
"legend": "Période d'analyse",
|
||||
|
|
@ -1861,6 +1859,20 @@
|
|||
"vehicle": {
|
||||
"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": {
|
||||
"linkAction": "Lier transferts",
|
||||
"direction": {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import BalanceOnboardingCard from "../components/balance/BalanceOnboardingCard";
|
|||
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||
import LinkTransfersModal from "../components/balance/LinkTransfersModal";
|
||||
import DetailAccountWizard from "../components/balance/DetailAccountWizard";
|
||||
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||
|
|
@ -52,6 +53,10 @@ export default function BalancePage() {
|
|||
const [linkTarget, setLinkTarget] = useState<AccountLatestSnapshot | 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 [transfersByAccount, setTransfersByAccount] = useState<
|
||||
Map<number, BalanceAccountTransferWithTransaction[]>
|
||||
|
|
@ -324,6 +329,7 @@ export default function BalancePage() {
|
|||
vehicleLabels={vehicleLabels}
|
||||
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||
onLinkTransfers={(acc) => setLinkTarget(acc)}
|
||||
onDetailAccount={(acc) => setDetailTarget(acc)}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue