This commit is contained in:
commit
ead62943fe
9 changed files with 194 additions and 286 deletions
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
- Rapports → Comparaison (réel vs réel) : le rapport comparable s'affiche désormais en **hiérarchie de catégories avec sous-totaux**, comme le rapport de budget. Les catégories parentes regroupent leurs enfants dans une ligne de sous-total — sa valeur est le net du groupe, donc un groupe de transferts équilibrés s'annule à ~0 — avec les lignes réparties en sections Dépenses / Revenus / Transferts portant des totaux par section et un total général, plus un choix sous-totaux en haut/en bas. Les montants des catégories feuilles sont inchangés, et l'annulation des transferts du correctif précédent est préservée (#247).
|
||||
|
||||
### 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é
|
||||
|
||||
- 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).
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
- Reports → Compare (real vs real): the comparable report is now shown as a **category hierarchy with subtotals**, like the budget report. Parent categories roll their children up into a subtotal row — its value is the group's net, so a group of balanced transfers subtotals to ~0 — with rows grouped into Expenses / Income / Transfers sections carrying per-section and grand totals, and a subtotals-on-top/bottom toggle. Leaf-category figures are unchanged, and the transfer netting from the previous fix is preserved (#247).
|
||||
|
||||
### 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
|
||||
|
||||
- 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";
|
||||
}
|
||||
|
|
@ -1587,20 +1587,26 @@
|
|||
"byVehicle": "By envelope"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Get started with your balance sheet",
|
||||
"subtitle": "Two steps to start tracking your net worth.",
|
||||
"doneBadge": "Done",
|
||||
"step1": {
|
||||
"title": "Create an account",
|
||||
"description": "An account is where you keep money: chequing, TFSA, RRSP, stocks, crypto, and so on.",
|
||||
"cta": "Create an account"
|
||||
"hub": {
|
||||
"getStarted": "Get started",
|
||||
"manage": "Manage",
|
||||
"accounts": {
|
||||
"title": "Manage accounts",
|
||||
"description": "Create, edit, or archive your accounts and asset types."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Enter a 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.",
|
||||
"cta": "Enter a snapshot",
|
||||
"disabledHint": "Create an account first to unlock this step."
|
||||
"snapshot": {
|
||||
"title": "New snapshot",
|
||||
"description": "Record the balance of each account at a given date."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -1587,20 +1587,26 @@
|
|||
"byVehicle": "Par enveloppe"
|
||||
}
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "Premiers pas avec le bilan",
|
||||
"subtitle": "Deux étapes pour commencer à suivre votre valeur nette.",
|
||||
"doneBadge": "Fait",
|
||||
"step1": {
|
||||
"title": "Créer un compte",
|
||||
"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"
|
||||
"hub": {
|
||||
"getStarted": "Commencer",
|
||||
"manage": "Gérer",
|
||||
"accounts": {
|
||||
"title": "Gérer les comptes",
|
||||
"description": "Créez, modifiez ou archivez vos comptes et vos types d'actif."
|
||||
},
|
||||
"step2": {
|
||||
"title": "Saisir un 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.",
|
||||
"cta": "Saisir un snapshot",
|
||||
"disabledHint": "Créez d'abord un compte pour activer cette étape."
|
||||
"snapshot": {
|
||||
"title": "Nouveau snapshot",
|
||||
"description": "Enregistrez le solde de chaque compte à une date donnée."
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wallet } from "lucide-react";
|
||||
import { Wallet, FilePlus } from "lucide-react";
|
||||
import {
|
||||
useBalanceOverview,
|
||||
type BalancePeriod,
|
||||
|
|
@ -30,12 +30,15 @@ import {
|
|||
import { getAllCategories } from "../services/transactionService";
|
||||
import type { Category, BalanceAccountTransferWithTransaction } from "../shared/types";
|
||||
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||
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 HubReportNavCard, {
|
||||
type HubReportNavCardProps,
|
||||
} from "../components/reports/HubReportNavCard";
|
||||
import { deriveLandingState } from "../components/balance/balanceLanding";
|
||||
import { getPreference, setPreference } from "../services/userPreferenceService";
|
||||
import { renderCategoryLabelFromAccount } from "../utils/renderCategoryLabel";
|
||||
|
||||
|
|
@ -195,25 +198,88 @@ export default function BalancePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue #178 — empty-state guard. We probe accountsLatest for ANY
|
||||
snapshot date so the guard is independent of the active period
|
||||
filter (state.period). When empty, we render only the onboarding
|
||||
card — period selector, chart and accounts table would all show
|
||||
empty states stacked under it (S2 from #187). */}
|
||||
{/* Issue #244 — landing state. We probe accountsLatest for ANY snapshot
|
||||
date (independent of the active period filter, state.period) and
|
||||
derive one of three states via `deriveLandingState`. The old boolean
|
||||
guard conflated "no account" with "accounts but no snapshot" and
|
||||
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 hasAnySnapshot = state.accountsLatest.some(
|
||||
(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 (
|
||||
<div className="space-y-6">
|
||||
<BalanceOnboardingCard
|
||||
accountsCount={accountsCount}
|
||||
snapshotsCount={hasAnySnapshot ? 1 : 0}
|
||||
/>
|
||||
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||
<h2 className="text-lg font-semibold mb-1">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -332,6 +398,8 @@ export default function BalancePage() {
|
|||
onDetailAccount={(acc) => setDetailTarget(acc)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderHub([accountsTile, snapshotTile], "balance.hub.manage")}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
|
|
|||
Loading…
Reference in a new issue