feat(balance): tile-based landing hub + reachable account management
Decouple the /balance empty-state guard into three explicit states via a new pure helper `deriveLandingState` (empty / accounts-no-snapshot / populated). Having accounts but no snapshot no longer strands the user on a snapshot-only onboarding card: each state now renders navigation tiles (HubReportNavCard, modeled on the Reports hub), and "Manage accounts" (/balance/accounts) is reachable in every state, including the populated dashboard. - Remove BalanceOnboardingCard (component + test) and the dead balance.onboarding.* i18n keys; add balanceLanding.ts + test. - Add balance.hub.* and balance.landing.* keys in FR and EN. - CHANGELOG (EN + FR) under [Unreleased]. Resolves #244
This commit is contained in:
parent
110ad57e1b
commit
82550d6d38
9 changed files with 194 additions and 286 deletions
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Non publié]
|
## [Non publié]
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- Bilan : la page d'accueil du bilan est désormais un hub de tuiles. Avoir des comptes mais aucun snapshot ne vous bloque plus sur une invite « créer un snapshot » — la page affiche maintenant des tuiles de navigation, et **Gérer les comptes** est accessible en tout temps, y compris depuis le tableau de bord peuplé. La page de gestion des comptes (créer/modifier/archiver les comptes et les types d'actif) n'était auparavant accessible qu'en tapant son URL (#244).
|
||||||
|
|
||||||
### Sécurité
|
### Sécurité
|
||||||
|
|
||||||
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).
|
- Mise à jour de dépendances pour corriger quatre advisories signalés par `npm audit` : `react-router-dom` 7.13 → 7.18.1 (ce qui tire `react-router` au-delà des advisories RCE turbo-stream, DoS `__manifest`/single-fetch et XSS de redirection RSC), plus l'outillage de développement `vite` 6.4.2 → 6.4.3 et `vitest` 4.0.18 → 4.1.9. Aucun changement de comportement. L'app est livrée comme un client de bureau local : les advisories runtime (qui nécessitent un serveur SSR/RSC react-router) ne s'appliquaient pas au produit livré ; ce sont des mises à jour de durcissement (#235, #236, #237, #238).
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Balance: the balance-sheet landing page is now a tile-based hub. Having accounts but no snapshot yet no longer strands you on a "create a snapshot" prompt — the page now shows navigation tiles, and **Manage accounts** is reachable at all times, including from the populated dashboard. The account-management page (create/edit/archive accounts and asset types) was previously only reachable by typing its URL (#244).
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).
|
- Updated dependencies to clear four advisories reported by `npm audit`: `react-router-dom` 7.13 → 7.18.1 (which pulls `react-router` past the turbo-stream RCE, `__manifest`/single-fetch DoS, and RSC-redirect XSS advisories), plus the dev toolchain `vite` 6.4.2 → 6.4.3 and `vitest` 4.0.18 → 4.1.9. No behaviour change. The app ships as a local desktop client, so the runtime advisories (which require a react-router SSR/RSC server) did not apply to the shipped product; these are hardening bumps (#235, #236, #237, #238).
|
||||||
|
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
// BalanceOnboardingCard — unit tests (issue #178)
|
|
||||||
//
|
|
||||||
// NOTE: This project does not have @testing-library/react or jsdom configured
|
|
||||||
// (logged as MEDIUM in autopilot decisions-log). Tests cover the pure
|
|
||||||
// `deriveOnboardingSteps` helper that drives the visual state of each step.
|
|
||||||
// All React rendering is bypassed.
|
|
||||||
|
|
||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { deriveOnboardingSteps } from "./BalanceOnboardingCard";
|
|
||||||
|
|
||||||
describe("BalanceOnboardingCard — deriveOnboardingSteps", () => {
|
|
||||||
it("0 accounts, 0 snapshots → step1 active, step2 disabled", () => {
|
|
||||||
const r = deriveOnboardingSteps(0, 0);
|
|
||||||
expect(r.step1).toBe("active");
|
|
||||||
expect(r.step2).toBe("disabled");
|
|
||||||
});
|
|
||||||
|
|
||||||
it(">=1 account, 0 snapshots → step1 done, step2 active", () => {
|
|
||||||
const r = deriveOnboardingSteps(1, 0);
|
|
||||||
expect(r.step1).toBe("done");
|
|
||||||
expect(r.step2).toBe("active");
|
|
||||||
|
|
||||||
const r2 = deriveOnboardingSteps(5, 0);
|
|
||||||
expect(r2.step1).toBe("done");
|
|
||||||
expect(r2.step2).toBe("active");
|
|
||||||
});
|
|
||||||
|
|
||||||
it(">=1 account, >=1 snapshot → both done (defensive — card normally hidden)", () => {
|
|
||||||
const r = deriveOnboardingSteps(2, 3);
|
|
||||||
expect(r.step1).toBe("done");
|
|
||||||
expect(r.step2).toBe("done");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("guard: 0 accounts but >=1 snapshot (anomaly) → step1 active, step2 done", () => {
|
|
||||||
// This combination should not happen in practice (a snapshot requires at
|
|
||||||
// least one account), but the helper handles it conservatively.
|
|
||||||
const r = deriveOnboardingSteps(0, 1);
|
|
||||||
expect(r.step1).toBe("active");
|
|
||||||
expect(r.step2).toBe("done");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
// BalanceOnboardingCard — empty-state onboarding for /balance.
|
|
||||||
//
|
|
||||||
// Issue #178. Replaces the BalanceOverviewCard when the user has no accounts
|
|
||||||
// or no snapshots yet. Two vertical steps:
|
|
||||||
// 1. Create an account → /balance/accounts
|
|
||||||
// 2. Enter a snapshot → /balance/snapshot
|
|
||||||
//
|
|
||||||
// Each step has 3 states:
|
|
||||||
// - "active": primary CTA, currently the next thing to do
|
|
||||||
// - "done": marked with a checkmark, no CTA
|
|
||||||
// - "disabled": grayed out (e.g. step 2 when 0 accounts), CTA disabled
|
|
||||||
//
|
|
||||||
// The whole card is replaced by BalanceOverviewCard once at least one
|
|
||||||
// snapshot exists, so step 2 in practice is rendered as "active" or
|
|
||||||
// "disabled"; the "done" branch is supported for completeness/tests.
|
|
||||||
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Wallet, FileText, Check, ArrowRight } from "lucide-react";
|
|
||||||
|
|
||||||
interface BalanceOnboardingCardProps {
|
|
||||||
/** Number of active (non-archived) accounts. */
|
|
||||||
accountsCount: number;
|
|
||||||
/** Number of snapshots saved (any date). */
|
|
||||||
snapshotsCount: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type StepState = "active" | "done" | "disabled";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure helper exposed for unit tests — derives the state of each onboarding
|
|
||||||
* step from the (accountsCount, snapshotsCount) pair.
|
|
||||||
*
|
|
||||||
* - Step 1 is "done" once at least one account exists, "active" otherwise.
|
|
||||||
* - Step 2 is "done" once any snapshot exists, "active" once at least one
|
|
||||||
* account exists, "disabled" otherwise. In practice the parent guard on
|
|
||||||
* /balance only renders this card when snapshotsCount === 0, so the
|
|
||||||
* "done" branch for step 2 is mostly defensive.
|
|
||||||
*/
|
|
||||||
export function deriveOnboardingSteps(
|
|
||||||
accountsCount: number,
|
|
||||||
snapshotsCount: number
|
|
||||||
): { step1: StepState; step2: StepState } {
|
|
||||||
const step1: StepState = accountsCount >= 1 ? "done" : "active";
|
|
||||||
const step2: StepState =
|
|
||||||
snapshotsCount >= 1
|
|
||||||
? "done"
|
|
||||||
: accountsCount >= 1
|
|
||||||
? "active"
|
|
||||||
: "disabled";
|
|
||||||
return { step1, step2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BalanceOnboardingCard({
|
|
||||||
accountsCount,
|
|
||||||
snapshotsCount,
|
|
||||||
}: BalanceOnboardingCardProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const { step1: step1State, step2: step2State } = deriveOnboardingSteps(
|
|
||||||
accountsCount,
|
|
||||||
snapshotsCount
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-1">
|
|
||||||
{t("balance.onboarding.title")}
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)] mb-5">
|
|
||||||
{t("balance.onboarding.subtitle")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<ol className="space-y-3">
|
|
||||||
<Step
|
|
||||||
number={1}
|
|
||||||
state={step1State}
|
|
||||||
icon={<Wallet size={18} />}
|
|
||||||
title={t("balance.onboarding.step1.title")}
|
|
||||||
description={t("balance.onboarding.step1.description")}
|
|
||||||
ctaLabel={t("balance.onboarding.step1.cta")}
|
|
||||||
ctaHref="/balance/accounts"
|
|
||||||
/>
|
|
||||||
<Step
|
|
||||||
number={2}
|
|
||||||
state={step2State}
|
|
||||||
icon={<FileText size={18} />}
|
|
||||||
title={t("balance.onboarding.step2.title")}
|
|
||||||
description={t("balance.onboarding.step2.description")}
|
|
||||||
ctaLabel={t("balance.onboarding.step2.cta")}
|
|
||||||
ctaHref="/balance/snapshot"
|
|
||||||
disabledHint={t("balance.onboarding.step2.disabledHint")}
|
|
||||||
/>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
// Internal — single step row
|
|
||||||
// -----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface StepProps {
|
|
||||||
number: number;
|
|
||||||
state: StepState;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
ctaLabel: string;
|
|
||||||
ctaHref: string;
|
|
||||||
disabledHint?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Step({
|
|
||||||
number,
|
|
||||||
state,
|
|
||||||
icon,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
ctaLabel,
|
|
||||||
ctaHref,
|
|
||||||
disabledHint,
|
|
||||||
}: StepProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const isDone = state === "done";
|
|
||||||
const isActive = state === "active";
|
|
||||||
const isDisabled = state === "disabled";
|
|
||||||
|
|
||||||
// Number bubble: green check when done, primary bg when active, muted when disabled.
|
|
||||||
const bubbleClass = isDone
|
|
||||||
? "bg-[var(--positive)] text-white"
|
|
||||||
: isActive
|
|
||||||
? "bg-[var(--primary)] text-white"
|
|
||||||
: "bg-[var(--muted)] text-[var(--muted-foreground)]";
|
|
||||||
|
|
||||||
const titleClass = isDisabled
|
|
||||||
? "text-[var(--muted-foreground)]"
|
|
||||||
: "text-[var(--foreground)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
data-testid={`balance-onboarding-step-${number}`}
|
|
||||||
data-state={state}
|
|
||||||
className={`flex items-start gap-4 p-4 rounded-lg border ${
|
|
||||||
isDisabled
|
|
||||||
? "border-[var(--border)] opacity-60"
|
|
||||||
: "border-[var(--border)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ${bubbleClass}`}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
{isDone ? <Check size={16} /> : number}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-[var(--muted-foreground)]" aria-hidden="true">
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
<h3 className={`text-sm font-semibold ${titleClass}`}>{title}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">{description}</p>
|
|
||||||
{isDisabled && disabledHint && (
|
|
||||||
<p className="text-xs text-[var(--muted-foreground)] italic mt-1">
|
|
||||||
{disabledHint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="shrink-0 self-center">
|
|
||||||
{isDone ? (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 text-xs text-[var(--positive)] font-medium"
|
|
||||||
data-testid={`balance-onboarding-step-${number}-done-badge`}
|
|
||||||
>
|
|
||||||
<Check size={14} />
|
|
||||||
{t("balance.onboarding.doneBadge")}
|
|
||||||
</span>
|
|
||||||
) : isActive ? (
|
|
||||||
<Link
|
|
||||||
to={ctaHref}
|
|
||||||
data-testid={`balance-onboarding-step-${number}-cta`}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
||||||
>
|
|
||||||
{ctaLabel}
|
|
||||||
<ArrowRight size={14} />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
data-testid={`balance-onboarding-step-${number}-cta`}
|
|
||||||
aria-disabled="true"
|
|
||||||
title={disabledHint}
|
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-[var(--border)] text-[var(--muted-foreground)] text-sm font-medium cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{ctaLabel}
|
|
||||||
<ArrowRight size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
30
src/components/balance/balanceLanding.test.ts
Normal file
30
src/components/balance/balanceLanding.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// balanceLanding — unit tests (issue #244)
|
||||||
|
//
|
||||||
|
// Covers the pure `deriveLandingState` helper that replaces the old boolean
|
||||||
|
// empty-state guard. No React rendering (this project has no jsdom configured);
|
||||||
|
// the decoupled-guard logic is exercised directly as a pure helper.
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { deriveLandingState } from "./balanceLanding";
|
||||||
|
|
||||||
|
describe("balanceLanding — deriveLandingState", () => {
|
||||||
|
it("0 accounts → empty (regardless of snapshot flag)", () => {
|
||||||
|
expect(deriveLandingState(0, false)).toBe("empty");
|
||||||
|
// Anomalous (a snapshot implies an account) but handled conservatively.
|
||||||
|
expect(deriveLandingState(0, true)).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account but no snapshot → accounts-no-snapshot (no longer 'empty')", () => {
|
||||||
|
expect(deriveLandingState(1, false)).toBe("accounts-no-snapshot");
|
||||||
|
expect(deriveLandingState(5, false)).toBe("accounts-no-snapshot");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(">=1 account with a snapshot → populated", () => {
|
||||||
|
expect(deriveLandingState(1, true)).toBe("populated");
|
||||||
|
expect(deriveLandingState(12, true)).toBe("populated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("guards against negative counts → empty", () => {
|
||||||
|
expect(deriveLandingState(-1, true)).toBe("empty");
|
||||||
|
});
|
||||||
|
});
|
||||||
37
src/components/balance/balanceLanding.ts
Normal file
37
src/components/balance/balanceLanding.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// balanceLanding.ts — pure landing-state helper for the /balance page.
|
||||||
|
//
|
||||||
|
// Issue #244. Decouples the old boolean empty-state guard
|
||||||
|
// (`accountsCount === 0 || !hasAnySnapshot`) into three explicit states so the
|
||||||
|
// UI can stop conflating "no account yet" with "accounts but no snapshot yet":
|
||||||
|
//
|
||||||
|
// - "empty": no active account at all → offer account creation
|
||||||
|
// (StarterAccountsModal + a "Manage accounts" tile).
|
||||||
|
// - "accounts-no-snapshot": at least one account but no snapshot → offer BOTH
|
||||||
|
// "Manage accounts" and "New snapshot" tiles, without
|
||||||
|
// forcing the user toward the snapshot flow.
|
||||||
|
// - "populated": at least one account with at least one snapshot →
|
||||||
|
// render the full dashboard (overview + chart +
|
||||||
|
// accounts table) plus the navigation tiles.
|
||||||
|
//
|
||||||
|
// Extracted as a pure, DOM-free helper because this project has no
|
||||||
|
// @testing-library/react or jsdom configured — UI branching is unit-tested via
|
||||||
|
// pure helpers (same convention as `deriveOnboardingSteps` / `computeBalanceDateRange`).
|
||||||
|
|
||||||
|
export type BalanceLandingState = "empty" | "accounts-no-snapshot" | "populated";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the landing state from the two independent signals the balance page
|
||||||
|
* already computes: the number of active accounts and whether ANY snapshot
|
||||||
|
* exists (probed across accounts, independent of the active period filter).
|
||||||
|
*
|
||||||
|
* @param accountsCount number of active (non-archived) accounts
|
||||||
|
* @param hasAnySnapshot true when at least one account carries a snapshot date
|
||||||
|
*/
|
||||||
|
export function deriveLandingState(
|
||||||
|
accountsCount: number,
|
||||||
|
hasAnySnapshot: boolean
|
||||||
|
): BalanceLandingState {
|
||||||
|
if (accountsCount <= 0) return "empty";
|
||||||
|
if (!hasAnySnapshot) return "accounts-no-snapshot";
|
||||||
|
return "populated";
|
||||||
|
}
|
||||||
|
|
@ -1579,20 +1579,26 @@
|
||||||
"byVehicle": "By envelope"
|
"byVehicle": "By envelope"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"hub": {
|
||||||
"title": "Get started with your balance sheet",
|
"getStarted": "Get started",
|
||||||
"subtitle": "Two steps to start tracking your net worth.",
|
"manage": "Manage",
|
||||||
"doneBadge": "Done",
|
"accounts": {
|
||||||
"step1": {
|
"title": "Manage accounts",
|
||||||
"title": "Create an account",
|
"description": "Create, edit, or archive your accounts and asset types."
|
||||||
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
|
|
||||||
"cta": "Create an account"
|
|
||||||
},
|
},
|
||||||
"step2": {
|
"snapshot": {
|
||||||
"title": "Enter a snapshot",
|
"title": "New snapshot",
|
||||||
"description": "A snapshot is the picture, at a given date, of the balance in each account. Enter one a month to track changes over time.",
|
"description": "Record the balance of each account at a given date."
|
||||||
"cta": "Enter a snapshot",
|
}
|
||||||
"disabledHint": "Create an account first to unlock this step."
|
},
|
||||||
|
"landing": {
|
||||||
|
"empty": {
|
||||||
|
"title": "Track your net worth",
|
||||||
|
"subtitle": "Start by creating an account: chequing, TFSA, RRSP, stocks, crypto, and so on."
|
||||||
|
},
|
||||||
|
"noSnapshot": {
|
||||||
|
"title": "Take your first snapshot",
|
||||||
|
"subtitle": "Your accounts are ready. Enter a snapshot to start tracking your net worth over time — or keep managing your accounts."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"starters": {
|
"starters": {
|
||||||
|
|
|
||||||
|
|
@ -1579,20 +1579,26 @@
|
||||||
"byVehicle": "Par enveloppe"
|
"byVehicle": "Par enveloppe"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"hub": {
|
||||||
"title": "Premiers pas avec le bilan",
|
"getStarted": "Commencer",
|
||||||
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.",
|
"manage": "Gérer",
|
||||||
"doneBadge": "Fait",
|
"accounts": {
|
||||||
"step1": {
|
"title": "Gérer les comptes",
|
||||||
"title": "Créer un compte",
|
"description": "Créez, modifiez ou archivez vos comptes et vos types d'actif."
|
||||||
"description": "Un compte représente l'endroit où vous tenez votre argent : compte chèque, CELI, REER, actions, crypto, etc.",
|
|
||||||
"cta": "Créer un compte"
|
|
||||||
},
|
},
|
||||||
"step2": {
|
"snapshot": {
|
||||||
"title": "Saisir un snapshot",
|
"title": "Nouveau snapshot",
|
||||||
"description": "Un snapshot est la photo, à une date donnée, du solde de chaque compte. Saisissez-en un par mois pour suivre l'évolution.",
|
"description": "Enregistrez le solde de chaque compte à une date donnée."
|
||||||
"cta": "Saisir un snapshot",
|
}
|
||||||
"disabledHint": "Créez d'abord un compte pour activer cette étape."
|
},
|
||||||
|
"landing": {
|
||||||
|
"empty": {
|
||||||
|
"title": "Suivez votre valeur nette",
|
||||||
|
"subtitle": "Commencez par créer un compte : compte chèque, CELI, REER, actions, crypto, etc."
|
||||||
|
},
|
||||||
|
"noSnapshot": {
|
||||||
|
"title": "Saisissez votre premier snapshot",
|
||||||
|
"subtitle": "Vos comptes sont prêts. Saisissez un snapshot pour suivre l'évolution de votre valeur nette dans le temps — ou continuez à gérer vos comptes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"starters": {
|
"starters": {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet } from "lucide-react";
|
import { Wallet, FilePlus } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useBalanceOverview,
|
useBalanceOverview,
|
||||||
type BalancePeriod,
|
type BalancePeriod,
|
||||||
|
|
@ -30,12 +30,15 @@ import {
|
||||||
import { getAllCategories } from "../services/transactionService";
|
import { getAllCategories } from "../services/transactionService";
|
||||||
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
||||||
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
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 DetailAccountWizard from "../components/balance/DetailAccountWizard";
|
||||||
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
import StarterAccountsModal from "../components/balance/StarterAccountsModal";
|
||||||
|
import HubReportNavCard, {
|
||||||
|
type HubReportNavCardProps,
|
||||||
|
} from "../components/reports/HubReportNavCard";
|
||||||
|
import { deriveLandingState } from "../components/balance/balanceLanding";
|
||||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||||
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||||
|
|
||||||
|
|
@ -195,25 +198,88 @@ export default function BalancePage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Issue #178 — empty-state guard. We probe accountsLatest for ANY
|
{/* Issue #244 — landing state. We probe accountsLatest for ANY snapshot
|
||||||
snapshot date so the guard is independent of the active period
|
date (independent of the active period filter, state.period) and
|
||||||
filter (state.period). When empty, we render only the onboarding
|
derive one of three states via `deriveLandingState`. The old boolean
|
||||||
card — period selector, chart and accounts table would all show
|
guard conflated "no account" with "accounts but no snapshot" and
|
||||||
empty states stacked under it (S2 from #187). */}
|
stranded the latter on a snapshot-only onboarding card, leaving the
|
||||||
|
account-management page (/balance/accounts) unreachable. We now render
|
||||||
|
navigation tiles (HubReportNavCard, modeled on the Reports hub) so
|
||||||
|
account management is reachable in every state, and only the
|
||||||
|
"populated" state (>=1 account WITH a snapshot) renders the full
|
||||||
|
dashboard — otherwise period selector, chart and accounts table would
|
||||||
|
all stack empty states (S2 from #187). */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const accountsCount = state.accountsLatest.length;
|
const accountsCount = state.accountsLatest.length;
|
||||||
const hasAnySnapshot = state.accountsLatest.some(
|
const hasAnySnapshot = state.accountsLatest.some(
|
||||||
(a) => a.latest_snapshot_date != null
|
(a) => a.latest_snapshot_date != null
|
||||||
);
|
);
|
||||||
const isEmpty = accountsCount === 0 || !hasAnySnapshot;
|
const landingState = deriveLandingState(accountsCount, hasAnySnapshot);
|
||||||
|
|
||||||
if (isEmpty) {
|
// Navigation tiles reused across states. "Manage accounts" is present
|
||||||
|
// everywhere so /balance/accounts is reachable at all times (#244); the
|
||||||
|
// snapshot tile is withheld in the empty state (nothing to snapshot).
|
||||||
|
const accountsTile: HubReportNavCardProps = {
|
||||||
|
to: "/balance/accounts",
|
||||||
|
icon: <Wallet size={24} />,
|
||||||
|
title: t("balance.hub.accounts.title"),
|
||||||
|
description: t("balance.hub.accounts.description"),
|
||||||
|
};
|
||||||
|
const snapshotTile: HubReportNavCardProps = {
|
||||||
|
to: "/balance/snapshot",
|
||||||
|
icon: <FilePlus size={24} />,
|
||||||
|
title: t("balance.hub.snapshot.title"),
|
||||||
|
description: t("balance.hub.snapshot.description"),
|
||||||
|
};
|
||||||
|
const renderHub = (
|
||||||
|
tiles: HubReportNavCardProps[],
|
||||||
|
headingKey: string
|
||||||
|
) => (
|
||||||
|
<section>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-[var(--muted-foreground)] mb-3">
|
||||||
|
{t(headingKey)}
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{tiles.map((card) => (
|
||||||
|
<HubReportNavCard key={card.to} {...card} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (landingState === "empty") {
|
||||||
|
// No account yet. StarterAccountsModal auto-opens (one-shot, #179);
|
||||||
|
// once dismissed the "Manage accounts" tile keeps account creation
|
||||||
|
// reachable. The snapshot tile is withheld — nothing to snapshot yet.
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<BalanceOnboardingCard
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
accountsCount={accountsCount}
|
<h2 className="text-lg font-semibold mb-1">
|
||||||
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
{t("balance.landing.empty.title")}
|
||||||
/>
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.landing.empty.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{renderHub([accountsTile], "balance.hub.getStarted")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (landingState === "accounts-no-snapshot") {
|
||||||
|
// Accounts exist but no snapshot. Offer BOTH paths (manage accounts
|
||||||
|
// OR enter a snapshot) without forcing the snapshot flow (#244).
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">
|
||||||
|
{t("balance.landing.noSnapshot.title")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.landing.noSnapshot.subtitle")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{renderHub([accountsTile, snapshotTile], "balance.hub.getStarted")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -332,6 +398,8 @@ export default function BalancePage() {
|
||||||
onDetailAccount={(acc) => setDetailTarget(acc)}
|
onDetailAccount={(acc) => setDetailTarget(acc)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderHub([accountsTile, snapshotTile], "balance.hub.manage")}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue