Three new components composed under a new BalancePage at /balance: - BalanceOverviewCard — latest aggregate net worth, Δ% vs the previous chronological snapshot (rendered as "—" when only one snapshot exists), 60-day staleness warning, and a "+ Nouveau snapshot" CTA pointing at /balance/snapshot. - BalanceEvolutionChart — Recharts-based line / stacked-area toggle. Line mode plots SUM(value) per snapshot_date with a single primary-coloured stroke. Stacked mode transposes the byCategory series into one Area per category_key with a fixed 10-color palette indexed deterministically. Tooltip formats CAD via Intl.NumberFormat. - BalanceAccountsTable — one row per active account with name, category label, latest value, and Δ% over the active period (latest_value vs the period anchor). Returns columns (3M / 1Y / since-creation / unadjusted) reserved for #142 with a TODO marker. Action menu includes a disabled "Detail" placeholder + functional "Archive" wired through reload(). BalancePage composes the three with an inline period selector (3M / 6M / 1A / 3A / Tout) and chart-mode toggle, both styled as segmented controls. State flows through useBalanceOverview. Route /balance registered before /balance/accounts in App.tsx. Refs: #141 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
170 lines
6.4 KiB
TypeScript
170 lines
6.4 KiB
TypeScript
// 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>
|
|
);
|
|
}
|