Simpl-Resultat/src/pages/DashboardPage.tsx
le king fu 64a07f359f feat(onboarding): visit steps, eight rows, "Got it" and one-time initialization
Two visit steps join the getting-started path: "Browse categories and
keywords" (5th) and "Browse the reports" (8th). Nothing in the database
counts them: they are done by "Got it" on their card, stored as
onboarding_acknowledged_<key> in user_preferences (no migration).

- computeChecklist: eight rows; the visit rows are locked until the
  profile's visit steps are initialized, so a finished path does not
  flash a "Next step" before they are marked done.
- initializeVisitSteps: once per profile, after the first successful
  load with the Budget entitlement known; acknowledges both steps when
  every other step is done or skipped, then sets the flag. No
  transaction (D16): idempotent writes, the flag last.
- Reducer: ACKNOWLEDGE wins over an older snapshot like SKIP but keeps
  the card latch, so the card says "Done." and offers the next step.
- StepCard: "Got it" before "Skip this step" when onAcknowledge is
  given; OnboardingStepCard passes it for a visit step.
- Banner counter, card rank and end of path wait for
  budgetReady && visitStepsInitialized; the list shows the visit rows
  "pending" until then.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:32:20 -04:00

245 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}
visitStepsInitialized={onboarding.visitStepsInitialized}
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>
);
}