feat(balance): /balance page + evolution chart + sidebar (#141) #150
5 changed files with 659 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ import ReportsCategoryPage from "./pages/ReportsCategoryPage";
|
||||||
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
import ReportsCartesPage from "./pages/ReportsCartesPage";
|
||||||
import SettingsPage from "./pages/SettingsPage";
|
import SettingsPage from "./pages/SettingsPage";
|
||||||
import AccountsPage from "./pages/AccountsPage";
|
import AccountsPage from "./pages/AccountsPage";
|
||||||
|
import BalancePage from "./pages/BalancePage";
|
||||||
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
import SnapshotEditPage from "./pages/SnapshotEditPage";
|
||||||
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
import CategoriesStandardGuidePage from "./pages/CategoriesStandardGuidePage";
|
||||||
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
import CategoriesMigrationPage from "./pages/CategoriesMigrationPage";
|
||||||
|
|
@ -116,6 +117,7 @@ export default function App() {
|
||||||
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
<Route path="/reports/category" element={<ReportsCategoryPage />} />
|
||||||
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
<Route path="/reports/cartes" element={<ReportsCartesPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
<Route path="/balance" element={<BalancePage />} />
|
||||||
<Route path="/balance/accounts" element={<AccountsPage />} />
|
<Route path="/balance/accounts" element={<AccountsPage />} />
|
||||||
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
<Route path="/balance/snapshot" element={<SnapshotEditPage />} />
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
170
src/components/balance/BalanceAccountsTable.tsx
Normal file
170
src/components/balance/BalanceAccountsTable.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// BalanceAccountsTable — one-row-per-active-account table on /balance.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Columns:
|
||||||
|
// - Account name + category label
|
||||||
|
// - Latest snapshot value (or "—" when no snapshot exists yet)
|
||||||
|
// - Δ% over the active period (latest value vs the period-anchor value;
|
||||||
|
// null when no anchor exists, rendered as "—").
|
||||||
|
// - Actions menu (Detail no-op for now, Archive via service).
|
||||||
|
//
|
||||||
|
// Future return-metric columns (3M / 1A / since-creation / unadjusted)
|
||||||
|
// land in Issue #142. They have a TODO marker below.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Archive, MoreVertical } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
AccountLatestSnapshot,
|
||||||
|
AccountPeriodAnchor,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
|
||||||
|
const cadFormatter = (locale: string) =>
|
||||||
|
new Intl.NumberFormat(locale, {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
interface BalanceAccountsTableProps {
|
||||||
|
accounts: AccountLatestSnapshot[];
|
||||||
|
periodAnchor: AccountPeriodAnchor[];
|
||||||
|
onArchiveAccount?: (account: AccountLatestSnapshot) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceAccountsTable({
|
||||||
|
accounts,
|
||||||
|
periodAnchor,
|
||||||
|
onArchiveAccount,
|
||||||
|
}: BalanceAccountsTableProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const fmt = cadFormatter(i18n.language === "fr" ? "fr-CA" : "en-CA");
|
||||||
|
|
||||||
|
/** account_id → period anchor (start-of-period value). */
|
||||||
|
const anchorMap = useMemo(() => {
|
||||||
|
const m = new Map<number, AccountPeriodAnchor>();
|
||||||
|
for (const a of periodAnchor) m.set(a.account_id, a);
|
||||||
|
return m;
|
||||||
|
}, [periodAnchor]);
|
||||||
|
|
||||||
|
const [openMenuFor, setOpenMenuFor] = useState<number | null>(null);
|
||||||
|
|
||||||
|
if (accounts.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-8 text-center text-[var(--muted-foreground)] italic">
|
||||||
|
{t("balance.overview.noAccounts")}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-[var(--muted)]/30">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
|
{t("balance.account.fields.name")}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">
|
||||||
|
{t("balance.account.fields.category")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
|
{t("balance.overview.latestValue")}
|
||||||
|
</th>
|
||||||
|
<th className="text-right px-4 py-3 font-medium">
|
||||||
|
{t("balance.overview.periodDelta")}
|
||||||
|
</th>
|
||||||
|
{/* TODO Issue #142: 3M / 1A / depuis-création / non-ajusté columns */}
|
||||||
|
<th className="text-right px-4 py-3 font-medium w-12">
|
||||||
|
{t("balance.account.fields.actions")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{accounts.map((acc) => {
|
||||||
|
const anchor = anchorMap.get(acc.account_id);
|
||||||
|
const deltaPct =
|
||||||
|
acc.latest_value !== null && anchor && anchor.anchor_value !== 0
|
||||||
|
? ((acc.latest_value - anchor.anchor_value) /
|
||||||
|
Math.abs(anchor.anchor_value)) *
|
||||||
|
100
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={acc.account_id}
|
||||||
|
className="border-t border-[var(--border)] hover:bg-[var(--muted)]/10"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
{acc.account_name}
|
||||||
|
{acc.symbol ? (
|
||||||
|
<span className="ml-2 text-xs text-[var(--muted-foreground)]">
|
||||||
|
({acc.symbol})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[var(--muted-foreground)]">
|
||||||
|
{t(acc.category_i18n_key, { defaultValue: acc.category_key })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{acc.latest_value !== null ? fmt.format(acc.latest_value) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right tabular-nums">
|
||||||
|
{deltaPct !== null ? (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
deltaPct >= 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--negative)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{deltaPct >= 0 ? "+" : ""}
|
||||||
|
{deltaPct.toFixed(2)}%
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenMenuFor(
|
||||||
|
openMenuFor === acc.account_id ? null : acc.account_id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="p-1 rounded hover:bg-[var(--muted)]/40"
|
||||||
|
aria-label={t("balance.account.fields.actions")}
|
||||||
|
>
|
||||||
|
<MoreVertical size={16} />
|
||||||
|
</button>
|
||||||
|
{openMenuFor === acc.account_id && (
|
||||||
|
<div className="absolute right-2 top-full z-10 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-md py-1 min-w-[160px] text-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="block w-full px-3 py-2 text-sm text-[var(--muted-foreground)] cursor-not-allowed"
|
||||||
|
title={t("balance.overview.detailComingSoon")}
|
||||||
|
>
|
||||||
|
{t("balance.overview.detailAction")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuFor(null);
|
||||||
|
onArchiveAccount?.(acc);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-[var(--muted)]/40"
|
||||||
|
>
|
||||||
|
<Archive size={14} />
|
||||||
|
{t("balance.account.actions.archive")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
218
src/components/balance/BalanceEvolutionChart.tsx
Normal file
218
src/components/balance/BalanceEvolutionChart.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
// BalanceEvolutionChart — line / stacked-area chart of net worth over time.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Reuses the established Recharts patterns from the
|
||||||
|
// reports/* charts (see decisions-log #141 — native SVG was reconsidered;
|
||||||
|
// Recharts is the single chart pattern in this codebase). Two modes:
|
||||||
|
// - 'line' : a single LineChart of `SUM(value)` per snapshot date.
|
||||||
|
// - 'stacked' : an AreaChart with one Area per category (stackId='all').
|
||||||
|
//
|
||||||
|
// Tooltip shows per-category breakdown in stacked mode and just the total in
|
||||||
|
// line mode.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Legend,
|
||||||
|
} from "recharts";
|
||||||
|
import type {
|
||||||
|
SnapshotTotalPoint,
|
||||||
|
SnapshotCategoryBreakdownPoint,
|
||||||
|
} from "../../services/balance.service";
|
||||||
|
import type { BalanceChartMode } from "../../hooks/useBalanceOverview";
|
||||||
|
|
||||||
|
// Stable palette for the stacked-by-category areas. Indexed deterministically
|
||||||
|
// by category sort order so the colour assignment stays consistent across
|
||||||
|
// renders and period changes. Reused from the reports CategoryBarChart palette.
|
||||||
|
const CATEGORY_PALETTE = [
|
||||||
|
"#3b82f6", // blue
|
||||||
|
"#10b981", // emerald
|
||||||
|
"#f59e0b", // amber
|
||||||
|
"#8b5cf6", // violet
|
||||||
|
"#ef4444", // red
|
||||||
|
"#06b6d4", // cyan
|
||||||
|
"#ec4899", // pink
|
||||||
|
"#84cc16", // lime
|
||||||
|
"#f97316", // orange
|
||||||
|
"#6366f1", // indigo
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface BalanceEvolutionChartProps {
|
||||||
|
mode: BalanceChartMode;
|
||||||
|
totals: SnapshotTotalPoint[];
|
||||||
|
byCategory: SnapshotCategoryBreakdownPoint[];
|
||||||
|
/** Map category_key → translated label so the legend reads naturally. */
|
||||||
|
categoryLabels?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceEvolutionChart({
|
||||||
|
mode,
|
||||||
|
totals,
|
||||||
|
byCategory,
|
||||||
|
categoryLabels = {},
|
||||||
|
}: BalanceEvolutionChartProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const cadFormatter = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.NumberFormat(i18n.language === "fr" ? "fr-CA" : "en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}),
|
||||||
|
[i18n.language]
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(dateLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Line-mode dataset ---
|
||||||
|
const lineData = useMemo(
|
||||||
|
() =>
|
||||||
|
totals.map((p) => ({
|
||||||
|
snapshot_date: p.snapshot_date,
|
||||||
|
total: p.total,
|
||||||
|
})),
|
||||||
|
[totals]
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Stacked-area dataset ---
|
||||||
|
// We transpose the per-snapshot bucket into one row per snapshot_date with
|
||||||
|
// one column per category_key. Categories absent at a snapshot date are
|
||||||
|
// emitted as 0 so Recharts renders a continuous stack.
|
||||||
|
const { stackedData, categoryKeys } = useMemo(() => {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const point of byCategory) {
|
||||||
|
for (const k of Object.keys(point.byCategory)) keys.add(k);
|
||||||
|
}
|
||||||
|
const orderedKeys = Array.from(keys).sort();
|
||||||
|
const data = byCategory.map((point) => {
|
||||||
|
const row: Record<string, string | number> = {
|
||||||
|
snapshot_date: point.snapshot_date,
|
||||||
|
};
|
||||||
|
for (const k of orderedKeys) {
|
||||||
|
row[k] = point.byCategory[k] ?? 0;
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return { stackedData: data, categoryKeys: orderedKeys };
|
||||||
|
}, [byCategory]);
|
||||||
|
|
||||||
|
const isEmpty =
|
||||||
|
mode === "line" ? lineData.length === 0 : stackedData.length === 0;
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6">
|
||||||
|
<p className="text-center text-[var(--muted-foreground)] italic py-12">
|
||||||
|
{t("balance.chart.empty")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipContentStyle = {
|
||||||
|
backgroundColor: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={360}>
|
||||||
|
{mode === "line" ? (
|
||||||
|
<LineChart
|
||||||
|
data={lineData}
|
||||||
|
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="snapshot_date"
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(s: string) => formatDate(s)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v: number) => cadFormatter.format(v)}
|
||||||
|
width={88}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) =>
|
||||||
|
cadFormatter.format(value ?? 0)
|
||||||
|
}
|
||||||
|
labelFormatter={(label) => formatDate(String(label))}
|
||||||
|
contentStyle={tooltipContentStyle}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
name={t("balance.chart.totalSeriesLabel")}
|
||||||
|
stroke="var(--primary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 3 }}
|
||||||
|
activeDot={{ r: 5 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
) : (
|
||||||
|
<AreaChart
|
||||||
|
data={stackedData}
|
||||||
|
margin={{ top: 10, right: 16, bottom: 10, left: 10 }}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="snapshot_date"
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(s: string) => formatDate(s)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fontSize={11}
|
||||||
|
tickFormatter={(v: number) => cadFormatter.format(v)}
|
||||||
|
width={88}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined, name) => [
|
||||||
|
cadFormatter.format(value ?? 0),
|
||||||
|
categoryLabels[String(name)] ?? String(name),
|
||||||
|
]}
|
||||||
|
labelFormatter={(label) => formatDate(String(label))}
|
||||||
|
contentStyle={tooltipContentStyle}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => categoryLabels[String(value)] ?? String(value)}
|
||||||
|
/>
|
||||||
|
{categoryKeys.map((key, idx) => (
|
||||||
|
<Area
|
||||||
|
key={key}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={key}
|
||||||
|
stackId="all"
|
||||||
|
stroke={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
|
||||||
|
fill={CATEGORY_PALETTE[idx % CATEGORY_PALETTE.length]}
|
||||||
|
fillOpacity={0.5}
|
||||||
|
name={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AreaChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/balance/BalanceOverviewCard.tsx
Normal file
128
src/components/balance/BalanceOverviewCard.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
// BalanceOverviewCard — top summary tile of /balance.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Displays:
|
||||||
|
// - The latest aggregate snapshot total (sum across all accounts on the
|
||||||
|
// most recent snapshot date).
|
||||||
|
// - Δ% versus the previous chronological snapshot (null when only one
|
||||||
|
// snapshot exists; rendered as "—").
|
||||||
|
// - A staleness warning when the latest snapshot is older than 60 days.
|
||||||
|
// - "+ Nouveau snapshot" CTA → `/balance/snapshot`.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Plus, TrendingUp, TrendingDown, AlertTriangle } from "lucide-react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import type { SnapshotTotalPoint } from "../../services/balance.service";
|
||||||
|
|
||||||
|
const STALENESS_DAYS = 60;
|
||||||
|
const cadFormatter = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-CA", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "CAD",
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
interface BalanceOverviewCardProps {
|
||||||
|
/** The full evolution series for the active period (latest at the end). */
|
||||||
|
totals: SnapshotTotalPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BalanceOverviewCard({ totals }: BalanceOverviewCardProps) {
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
if (totals.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const last = totals[totals.length - 1];
|
||||||
|
const prev = totals.length >= 2 ? totals[totals.length - 2] : null;
|
||||||
|
const deltaPct =
|
||||||
|
prev && prev.total !== 0
|
||||||
|
? ((last.total - prev.total) / Math.abs(prev.total)) * 100
|
||||||
|
: null;
|
||||||
|
const ageMs = Date.now() - new Date(last.snapshot_date).getTime();
|
||||||
|
const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
|
||||||
|
return {
|
||||||
|
latest: last,
|
||||||
|
deltaPct,
|
||||||
|
isStale: ageDays > STALENESS_DAYS,
|
||||||
|
ageDays,
|
||||||
|
};
|
||||||
|
}, [totals]);
|
||||||
|
|
||||||
|
const dateLocale = i18n.language === "fr" ? "fr-CA" : "en-CA";
|
||||||
|
const formatDate = (iso: string) =>
|
||||||
|
new Date(iso).toLocaleDateString(dateLocale, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] p-6">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)]">
|
||||||
|
{t("balance.overview.latestTotal")}
|
||||||
|
</p>
|
||||||
|
{summary ? (
|
||||||
|
<>
|
||||||
|
<p className="text-3xl font-bold mt-1">
|
||||||
|
{cadFormatter(summary.latest.total)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--muted-foreground)] mt-1">
|
||||||
|
{t("balance.overview.asOf", {
|
||||||
|
date: formatDate(summary.latest.snapshot_date),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--muted-foreground)] mt-2">
|
||||||
|
{t("balance.overview.noSnapshots")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-stretch sm:items-end gap-2">
|
||||||
|
{summary && summary.deltaPct !== null && (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1 text-sm font-medium ${
|
||||||
|
summary.deltaPct >= 0
|
||||||
|
? "text-[var(--positive)]"
|
||||||
|
: "text-[var(--negative)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{summary.deltaPct >= 0 ? (
|
||||||
|
<TrendingUp size={16} />
|
||||||
|
) : (
|
||||||
|
<TrendingDown size={16} />
|
||||||
|
)}
|
||||||
|
{summary.deltaPct >= 0 ? "+" : ""}
|
||||||
|
{summary.deltaPct.toFixed(2)}%
|
||||||
|
<span className="text-[var(--muted-foreground)] font-normal text-xs ml-1">
|
||||||
|
{t("balance.overview.vsPrevious")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to="/balance/snapshot"
|
||||||
|
className="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
{t("balance.overview.newSnapshot")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary?.isStale && (
|
||||||
|
<div className="mt-4 flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 text-amber-700 dark:text-amber-400 border border-amber-500/30 text-sm">
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0" />
|
||||||
|
<span>
|
||||||
|
{t("balance.overview.staleWarning", { days: summary.ageDays })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/pages/BalancePage.tsx
Normal file
141
src/pages/BalancePage.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
// BalancePage — overview of net worth at `/balance`.
|
||||||
|
//
|
||||||
|
// Issue #141 (Bilan #3). Composes:
|
||||||
|
// - BalanceOverviewCard (latest total + Δ% + staleness warning + new-snapshot CTA)
|
||||||
|
// - Period selector (3M / 6M / 1A / 3A / Tout)
|
||||||
|
// - Chart-mode toggle (Line / Stacked-by-category)
|
||||||
|
// - BalanceEvolutionChart
|
||||||
|
// - BalanceAccountsTable (one row per active account with latest value + Δ%)
|
||||||
|
//
|
||||||
|
// All data flows through `useBalanceOverview` (scoped useReducer). Returns
|
||||||
|
// (Modified Dietz) are deferred to Issue #142 — the accounts table reserves
|
||||||
|
// columns with a TODO comment.
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Wallet } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useBalanceOverview,
|
||||||
|
type BalancePeriod,
|
||||||
|
type BalanceChartMode,
|
||||||
|
} from "../hooks/useBalanceOverview";
|
||||||
|
import { archiveBalanceAccount } from "../services/balance.service";
|
||||||
|
import BalanceOverviewCard from "../components/balance/BalanceOverviewCard";
|
||||||
|
import BalanceEvolutionChart from "../components/balance/BalanceEvolutionChart";
|
||||||
|
import BalanceAccountsTable from "../components/balance/BalanceAccountsTable";
|
||||||
|
|
||||||
|
const PERIOD_OPTIONS: BalancePeriod[] = ["3M", "6M", "1A", "3A", "all"];
|
||||||
|
|
||||||
|
export default function BalancePage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { state, setPeriod, setChartMode, reload } = useBalanceOverview();
|
||||||
|
|
||||||
|
// Build a category_key → translated label map from the accounts payload —
|
||||||
|
// the byCategory series is keyed by `key`, not by id, and the same
|
||||||
|
// taxonomy is already loaded with `accountsLatest` joins.
|
||||||
|
const categoryLabels = useMemo(() => {
|
||||||
|
const m: Record<string, string> = {};
|
||||||
|
for (const a of state.accountsLatest) {
|
||||||
|
if (!m[a.category_key]) {
|
||||||
|
m[a.category_key] = t(a.category_i18n_key, {
|
||||||
|
defaultValue: a.category_key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [state.accountsLatest, t]);
|
||||||
|
|
||||||
|
const handleArchiveAccount = async (accountId: number) => {
|
||||||
|
try {
|
||||||
|
await archiveBalanceAccount(accountId);
|
||||||
|
await reload();
|
||||||
|
} catch {
|
||||||
|
// Reload swallows; the row simply stays. UX feedback can be added later.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={state.isLoading ? "opacity-60 pointer-events-none" : ""}>
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<Wallet size={24} className="text-[var(--primary)]" />
|
||||||
|
<h1 className="text-2xl font-bold">{t("balance.overview.title")}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm border border-[var(--negative)]/20">
|
||||||
|
{state.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<BalanceOverviewCard totals={state.evolutionTotals} />
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
|
{/* Period selector */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("balance.period.legend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{PERIOD_OPTIONS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPeriod(p)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.period === p
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.period === p}
|
||||||
|
>
|
||||||
|
{t(`balance.period.${p}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chart mode toggle */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("balance.chart.modeLegend")}
|
||||||
|
className="inline-flex rounded-lg border border-[var(--border)] overflow-hidden"
|
||||||
|
>
|
||||||
|
{(["line", "stacked"] as BalanceChartMode[]).map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setChartMode(mode)}
|
||||||
|
className={`px-3 py-1.5 text-sm font-medium ${
|
||||||
|
state.chartMode === mode
|
||||||
|
? "bg-[var(--primary)] text-white"
|
||||||
|
: "bg-[var(--card)] text-[var(--foreground)] hover:bg-[var(--muted)]/40"
|
||||||
|
}`}
|
||||||
|
aria-pressed={state.chartMode === mode}
|
||||||
|
>
|
||||||
|
{t(`balance.chart.mode.${mode}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BalanceEvolutionChart
|
||||||
|
mode={state.chartMode}
|
||||||
|
totals={state.evolutionTotals}
|
||||||
|
byCategory={state.evolutionByCategory}
|
||||||
|
categoryLabels={categoryLabels}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold mb-3">
|
||||||
|
{t("balance.overview.accountsTitle")}
|
||||||
|
</h2>
|
||||||
|
<BalanceAccountsTable
|
||||||
|
accounts={state.accountsLatest}
|
||||||
|
periodAnchor={state.accountsPeriodAnchor}
|
||||||
|
onArchiveAccount={(acc) => handleArchiveAccount(acc.account_id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue