Compare commits
2 commits
1a4cab2e9b
...
c8a6f74a1d
| Author | SHA1 | Date | |
|---|---|---|---|
| c8a6f74a1d | |||
|
|
9608fd3618 |
6 changed files with 266 additions and 14 deletions
|
|
@ -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">
|
||||||
<button
|
{onDetailAccount && acc.kind === "simple" && (
|
||||||
type="button"
|
<button
|
||||||
disabled
|
type="button"
|
||||||
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
onClick={() => {
|
||||||
title={t("balance.overview.detailComingSoon")}
|
setOpenMenuFor(null);
|
||||||
>
|
onDetailAccount(acc);
|
||||||
{t("balance.overview.detailAction")}
|
}}
|
||||||
</button>
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
||||||
|
>
|
||||||
|
<ListTree size={14} />
|
||||||
|
{t("balance.detailWizard.action")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{onLinkTransfers && (
|
{onLinkTransfers && (
|
||||||
<button
|
<button
|
||||||
type="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",
|
"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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue