DashboardPage now reuses the Cartes report's presentational widgets (KpiCard with MoM/YoY deltas, top movers, budget adherence) sourced from getCartesSnapshot against a Dashboard-owned reference month (defaults to the last complete month). The expense-only pie chart is replaced by a ranked bar chart of top expense categories (resurrecting the previously unused CategoryBarChart), and the account (import-source) filter introduced on Trends/Compare/Budget now also applies to the Dashboard's own transactional widgets via a Dashboard-local accountIds state (kept separate from useReportsPeriod, since the Dashboard owns its own two temporal axes). A new net-worth tile surfaces the Balance sheet's latest total (getSnapshotTotalsByDate) — a distinct metric from every transactional card here, so it stays hidden (never a misleading "$0") until at least one balance account has a recorded snapshot, reusing deriveLandingState rather than re-inferring emptiness from nulls. It is not scoped by the account filter (balance_accounts is a disjoint concept from import_sources) and is fetched independently on mount. The category-over-time trend chart now passes typeFilter "expense", fixing a latent mismatch where a revenue category could silently show up in a chart titled "expenses over time". Resolves #279
200 lines
7.3 KiB
TypeScript
200 lines
7.3 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).
|
|
import { useState, useCallback, useMemo } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useDashboard } from "../hooks/useDashboard";
|
|
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 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 [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);
|
|
|
|
return (
|
|
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
|
<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>
|
|
|
|
{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>
|
|
);
|
|
}
|