Simpl-Resultat/src/pages/DashboardPage.tsx
le king fu 75c1708bbe feat(onboarding): show the getting-started banner on top of every page
The getting-started path now follows the user: AppShell mounts
OnboardingBanner at the top of <main>, in the page flow, and the
provider's mode keeps it off a hidden path, the dashboard of a profile
without transactions, a page that carries a step card and the first
load. The banner shows "Getting started n/N" once the Budget
entitlement is known, the next step linked with its navigation state,
"Show the list", which unfolds the detailed list in place, and "Hide";
at the end, the closing message, "Open the guide" and "Close". Each
route mounts its own banner slot, so a page always starts folded.

The dashboard's compact bar and the compact mode of
OnboardingChecklist are gone (D7). The detailed list reads each state
out to screen readers, shows a skipped step with its badge and
"Resume", and gives every link to a step the same navigation state
(stepNavState), with the guided entry for the manual category.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:02:39 -04:00

244 lines
8.9 KiB
TypeScript

// DashboardPage — home page at `/`.
//
// Converged onto the `/reports/cartes` model (Issue #279, epic #260): the KPI
// row, top movers and budget adherence are the exact Cartes presentational
// components, sourced from the same `getCartesSnapshot` reference-month
// snapshot. Two Dashboard-specific widgets remain: the top-expense-categories
// ranked bar chart (replacing the former expense-only pie) and the net-worth
// tile (from the Bilan — a different metric from every transactional widget
// here, see NetWorthTile's own header comment).
//
// Onboarding (#352): the page branches on the getting-started checklist
// `status`, never on a bare counter. While it loads, only the title and the
// categories banner are rendered (no flash of 0 $ widgets nor of the empty state). A
// profile without any transaction gets `DashboardEmptyState` instead of the
// widgets, outside the `isLoading` veil so its link stays clickable. With
// transactions, the page shows no list of its own: the getting-started banner
// that `AppShell` puts on top of every page replaced the compact bar (#397,
// D7). A first load that fails falls back to the v0.15.0 page without the
// list; the provider logs it.
//
// Since #396 the checklist comes from `OnboardingProvider` (AppShell), shared
// by every page and re-read live, instead of a load of its own at mount.
import { useState, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useDashboard } from "../hooks/useDashboard";
import { useOnboarding } from "../contexts/OnboardingContext";
import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoriesV1DiscoveryBanner from "../components/dashboard/CategoriesV1DiscoveryBanner";
import DashboardEmptyState from "../components/dashboard/DashboardEmptyState";
import NetWorthTile from "../components/dashboard/NetWorthTile";
import FilterPanel from "../components/reports/FilterPanel";
import KpiCard from "../components/reports/cards/KpiCard";
import IncomeExpenseOverlayChart from "../components/reports/cards/IncomeExpenseOverlayChart";
import TopMoversList from "../components/reports/cards/TopMoversList";
import BudgetAdherenceCard from "../components/reports/cards/BudgetAdherenceCard";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem } from "../shared/types";
import { computeDateRange, buildMonthOptions } from "../utils/dateRange";
export default function DashboardPage() {
const { t, i18n } = useTranslation();
const {
state,
setPeriod,
setCustomDates,
setReferencePeriod,
setAccountIds,
} = useDashboard();
const { categoryBreakdown, categoryOverTime, cartesSnapshot, period, isLoading } = state;
const onboarding = useOnboarding();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
setDetailModal(item);
}, []);
const monthOptions = useMemo(() => buildMonthOptions(i18n.language), [i18n.language]);
const { dateFrom, dateTo } = computeDateRange(period, state.customDateFrom, state.customDateTo);
const header = (
<>
<CategoriesV1DiscoveryBanner />
<div className="relative flex items-center gap-3 mb-6">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<PageHelp helpKey="dashboard" />
</div>
</>
);
if (onboarding.status === "loading") {
return <div>{header}</div>;
}
const { checklist } = onboarding;
const checklistReady = onboarding.status === "ready" && checklist !== null;
if (checklistReady && onboarding.counters?.transactions === 0) {
return (
<div>
{header}
<DashboardEmptyState
checklist={checklist}
budgetReady={onboarding.budgetReady}
hidden={onboarding.hidden}
onHide={onboarding.hide}
onResume={onboarding.resume}
/>
</div>
);
}
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
{header}
{state.error && (
<div className="bg-[var(--negative)]/10 text-[var(--negative)] rounded-xl p-4 mb-6">
{state.error}
</div>
)}
<FilterPanel
temporalControl={
<PeriodSelector
value={period}
onChange={setPeriod}
customDateFrom={state.customDateFrom}
customDateTo={state.customDateTo}
onCustomDateChange={setCustomDates}
/>
}
accountIds={state.accountIds}
onAccountIdsChange={setAccountIds}
accounts={state.accounts}
/>
<div className="flex items-center gap-2 mb-3 flex-wrap">
<h2 className="text-lg font-semibold">{t("dashboard.kpiSectionTitle")}</h2>
<select
value={`${state.referenceYear}-${state.referenceMonth}`}
onChange={(e) => {
const [y, m] = e.target.value.split("-").map(Number);
setReferencePeriod(y, m);
}}
className="text-sm font-medium bg-[var(--card)] border border-[var(--border)] rounded-lg px-2 py-0.5 cursor-pointer hover:bg-[var(--muted)] transition-colors"
>
{monthOptions.map((opt) => (
<option key={opt.key} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex flex-wrap gap-3 mb-6">
<NetWorthTile data={state.netWorth} />
{cartesSnapshot && (
<>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="income"
title={t("reports.cartes.income")}
kpi={cartesSnapshot.kpis.income}
format="currency"
deltaIsBadWhenUp={false}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="expenses"
title={t("reports.cartes.expenses")}
kpi={cartesSnapshot.kpis.expenses}
format="currency"
deltaIsBadWhenUp={true}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="net"
title={t("reports.cartes.net")}
kpi={cartesSnapshot.kpis.net}
format="currency"
deltaIsBadWhenUp={false}
/>
</div>
<div className="flex-1 min-w-[180px]">
<KpiCard
id="savingsRate"
title={t("reports.cartes.savingsRate")}
kpi={cartesSnapshot.kpis.savingsRate}
format="percent"
deltaIsBadWhenUp={false}
/>
</div>
</>
)}
</div>
{cartesSnapshot && (
<>
<div className="mb-6">
<IncomeExpenseOverlayChart flow={cartesSnapshot.flow12Months} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 mb-6">
<TopMoversList movers={cartesSnapshot.topMoversUp} direction="up" />
<TopMoversList movers={cartesSnapshot.topMoversDown} direction="down" />
<BudgetAdherenceCard adherence={cartesSnapshot.budgetAdherence} />
</div>
</>
)}
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesByCategory")}</h2>
<CategoryBarChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div>
<div className="mb-6">
<h2 className="text-lg font-semibold mb-3">{t("dashboard.expensesOverTime")}</h2>
<CategoryOverTimeChart
data={categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
</div>
{detailModal && (
<TransactionDetailModal
categoryId={detailModal.category_id}
categoryName={detailModal.category_name}
categoryColor={detailModal.category_color}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setDetailModal(null)}
/>
)}
</div>
);
}