Simpl-Resultat/src/components/balance/BalanceAccountsTable.tsx
le king fu ffefa90fd0 feat(balance): add BalancePage with chart + accounts table
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>
2026-04-25 16:07:04 -04:00

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>
);
}