Files
finance-app/src/app/insights/page.tsx
T
siddharthd a4ab543a6c
ci / lint-test (push) Failing after 44s
fix(analytics): make the displayed numbers mean what they say
Six metric-integrity defects from the UI/IA review, plus two found while
verifying the review's own claims against the code.

The reconciled-row exclusion existed only in queries.ts. Every analytics
route counted the superseded manual rows as spend — 48 rows, $4,474.79 of
double count, invisible precisely because the transaction list looked
right. It is now one fragment both sides import.

The spend-pace chart computed its own totals in the browser: gross
amounts, debits only, no personal share, no refunds, fees, interest or
itemised loan repayments. On live data it ended July at $4,747.31 under a
headline reading $3,597.10 — and its own baseline line was drawn from the
split-adjusted monthly totals, so the two series in one chart disagreed
with each other. Both now come from /api/analytics/daily, built from the
same fragments as the headline.

Fees aggregated every statement ever imported with no date filter, under
a heading with no period, so a lifetime figure read as a current one and
grew forever. Now bounded, labelled, and selectable.

Comparisons no longer measure a month in progress against complete ones:
the in-progress month is out of every baseline, and a selected current
month is compared through the same day.

Two the review did not catch:

- Every analytics window was a day early. toISOString() on a
  local-midnight Date converts backwards through UTC. Surfaced only once
  fees started reporting the range it had used.
- /monthly rounded per category, /daily per category-day, so the pace
  chart ended a few cents off the headline above it.

Shared currency needed amending rather than applying. Reading s.currency
would have labelled every order row AUD, since an order receipt has no
statement and carries its own currency — the opposite convention from a
foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's
COALESCE order keeps the two apart. Balances also now count rows whose AUD
value is genuinely unknown instead of netting a foreign figure against AUD
ones. Latent today: no foreign transaction is currently split.

Tag-filtered balance cards no longer claim "owes you". With a filter on,
payments are deliberately not subtracted, so the figure is a split total
and settling against it would record a payment for a debt that never was.

Split-coverage warnings deliberately omitted (user decision).
2026-07-27 17:10:27 +10:00

555 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useMemo, useState } from "react";
import {
ComposedChart, Bar, Line, XAxis, YAxis, Tooltip, ResponsiveContainer,
} from "recharts";
import { useMonthlyAnalytics, useSubscriptions, useFees, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { CATEGORIES, REGULAR_CATEGORIES, formatCategory } from "@/lib/categories";
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
/**
* The API returns an exclusive upper bound (first day of the month after the
* window). Showing that date verbatim would claim a month the totals exclude,
* so `exclusive` steps back a day for display.
*/
function formatPeriodBound(iso: string | null, exclusive = false): string {
if (!iso) return "—";
const d = new Date(`${iso}T00:00:00`);
if (exclusive) d.setDate(d.getDate() - 1);
return d.toLocaleDateString("default", { month: "short", year: "numeric" });
}
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
}
function fmtTx(amount: number, type: string) {
const formatted = new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(amount);
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
function fmtExact(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", minimumFractionDigits: 2 }).format(n);
}
function fmtDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", { month: "short", year: "numeric" });
}
function trend(values: number[]): { pct: number; dir: "up" | "down" | "flat" } {
if (values.length < 2) return { pct: 0, dir: "flat" };
const recent = values.slice(-3).reduce((a, b) => a + b, 0) / 3;
const prior = values.slice(0, 3).reduce((a, b) => a + b, 0) / 3;
if (prior === 0) return { pct: 0, dir: "flat" };
const pct = Math.round(((recent - prior) / prior) * 100);
return { pct: Math.abs(pct), dir: pct > 2 ? "up" : pct < -2 ? "down" : "flat" };
}
const FREQ_LABEL: Record<string, string> = {
weekly: "Weekly",
fortnightly: "Fortnightly",
monthly: "Monthly",
quarterly: "Quarterly",
annual: "Annual",
};
// ─── Section wrapper ────────────────────────────────────────────────
function Section({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="mb-8">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-3">
<h3 className="text-base font-semibold text-zinc-200">{title}</h3>
{aside}
</div>
{children}
</div>
);
}
// ─── Custom tooltip ──────────────────────────────────────────────────
function RegularTooltip({ active, payload, label }: { active?: boolean; payload?: { dataKey: string; value: number }[]; label?: string }) {
if (!active || !payload?.length) return null;
const regular = payload.find((p) => p.dataKey === "regular")?.value ?? 0;
const occasional = payload.find((p) => p.dataKey === "occasional")?.value ?? 0;
return (
<div style={TOOLTIP_STYLE} className="px-3 py-2 text-xs space-y-1">
<div className="font-medium text-zinc-300 mb-1">{label}</div>
<div className="flex justify-between gap-4"><span className="text-indigo-300">Regular</span><span className="font-mono">{fmt(regular)}</span></div>
<div className="flex justify-between gap-4"><span className="text-zinc-400">Occasional</span><span className="font-mono">{fmt(occasional)}</span></div>
<div className="flex justify-between gap-4 border-t border-zinc-700 pt-1"><span className="text-zinc-500">Total</span><span className="font-mono">{fmt(regular + occasional)}</span></div>
</div>
);
}
// ─── Drill-down row ──────────────────────────────────────────────────
function DrillDownRow({
category,
from,
to,
}: {
category: string;
from: string;
to: string;
}) {
const { data, isLoading } = useTransactions({ categories: category ? [category] : [], from, to, limit: 200 });
const updateTx = useUpdateTransaction();
if (isLoading) {
return (
<tr>
<td colSpan={2} className="px-4 py-3 text-xs text-zinc-500">Loading...</td>
</tr>
);
}
const txns = data?.data ?? [];
return (
<tr>
<td colSpan={2} className="px-0 py-0">
<div className="bg-zinc-950 border-t border-zinc-800/50">
{txns.length === 0 ? (
<p className="px-6 py-3 text-xs text-zinc-600">No transactions found.</p>
) : (
<table className="w-full text-xs">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-6 py-2 text-zinc-600 font-medium">Date</th>
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Merchant</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">Total</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">My share</th>
<th className="text-left px-4 py-2 text-zinc-600 font-medium">Category</th>
<th className="text-right px-4 py-2 text-zinc-600 font-medium">% mine</th>
</tr>
</thead>
<tbody>
{txns.map((t) => {
// Share is resolved server-side (see getTransactions) so this
// matches the category total being drilled into. The old
// `my_share_percent ?? 100` missed transaction_splits rows
// entirely and assumed 100% when a transaction was allocated
// wholly to someone else.
const sharePct = Number(t.my_share_pct ?? 100);
const grossAmt = Number(t.amount_aud ?? t.amount);
const effectiveAmt = Number(t.my_amount ?? grossAmt);
const isDebit = SPEND_TYPES.has(t.transaction_type);
return (
<tr key={t.id} className="border-b border-zinc-800/30 hover:bg-zinc-900/30">
<td className="px-6 py-2 text-zinc-500 whitespace-nowrap">
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
</td>
<td className="px-4 py-2 text-zinc-300">{t.merchant_name || t.description}</td>
<td className="px-4 py-2 text-right tabular-nums text-zinc-500">
{fmtExact(grossAmt)}
</td>
<td className={`px-4 py-2 text-right tabular-nums ${isDebit ? "text-zinc-200" : "text-green-400"}`}>
{fmtTx(effectiveAmt, t.transaction_type)}
{sharePct < 100 && (
<span className="text-zinc-600 ml-1">({sharePct}%)</span>
)}
</td>
<td className="px-4 py-2">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
defaultValue={t.effective_category ?? "other"}
onChange={(e) => updateTx.mutate({ id: t.id, category: e.target.value })}
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
</td>
<td className="px-4 py-2 text-right">
<select
className="bg-zinc-800 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500"
defaultValue={t.my_share_percent ?? 100}
onChange={(e) => {
const val = Number(e.target.value);
updateTx.mutate({ id: t.id, my_share_percent: val === 100 ? null : val });
}}
>
<option value={100}>100%</option>
<option value={75}>75%</option>
<option value={67}>67%</option>
<option value={50}>50%</option>
<option value={33}>33%</option>
<option value={25}>25%</option>
</select>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
</td>
</tr>
);
}
// ─── Monthly Spend Breakdown ─────────────────────────────────────────
function MonthlyBreakdown({ analytics }: { analytics: NonNullable<ReturnType<typeof useMonthlyAnalytics>["data"]> }) {
// analytics.months is newest-first; show last 6
const months = useMemo(() => analytics.months.slice(0, 6), [analytics.months]);
const [selectedMonth, setSelectedMonth] = useState<string>(months[0] ?? "");
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
// Reset expanded when month changes
const handleSelectMonth = (m: string) => {
setSelectedMonth(m);
setExpandedCategory(null);
};
const from = selectedMonth + "-01";
const lastDay = new Date(parseInt(selectedMonth.slice(0, 4)), parseInt(selectedMonth.slice(5, 7)), 0).getDate();
const to = selectedMonth + "-" + String(lastDay).padStart(2, "0");
const categoryData = useMemo(() => {
return analytics.rows
.map((row) => ({ category: row.category, amount: Number(row.spent[selectedMonth] ?? 0) }))
.filter((r) => r.amount > 0)
.sort((a, b) => b.amount - a.amount);
}, [analytics.rows, selectedMonth]);
const regularRows = categoryData.filter((r) => (REGULAR_CATEGORIES as Set<string>).has(r.category));
const occasionalRows = categoryData.filter((r) => !(REGULAR_CATEGORIES as Set<string>).has(r.category));
const regularTotal = regularRows.reduce((s, r) => s + r.amount, 0);
const occasionalTotal = occasionalRows.reduce((s, r) => s + r.amount, 0);
function monthLabel(m: string) {
const [year, month] = m.split("-");
return new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-AU", { month: "short", year: "2-digit" });
}
function renderRows(rows: typeof categoryData, dotClass: string) {
return rows.map((row) => (
<>
<tr
key={row.category}
className="border-b border-zinc-800/50 hover:bg-zinc-800/20 cursor-pointer transition-colors"
onClick={() => setExpandedCategory(expandedCategory === row.category ? null : row.category)}
>
<td className="px-4 py-2.5">
<span className="flex items-center gap-2 text-sm text-zinc-300">
<span className={`w-2 h-2 rounded-full inline-block flex-shrink-0 ${dotClass}`} />
{formatCategory(row.category)}
<span className="text-zinc-600 text-xs">{expandedCategory === row.category ? "▲" : "▼"}</span>
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-sm text-zinc-200">{fmt(row.amount)}</td>
</tr>
{expandedCategory === row.category && (
<DrillDownRow
key={`${row.category}-drill`}
category={row.category}
from={from}
to={to}
/>
)}
</>
));
}
return (
<div>
{/* Month tabs */}
<div className="flex gap-1 mb-3 flex-wrap">
{months.map((m) => (
<button
key={m}
onClick={() => handleSelectMonth(m)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
m === selectedMonth
? "bg-indigo-600 text-white"
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200"
}`}
>
{monthLabel(m)}
</button>
))}
</div>
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Spend</th>
</tr>
</thead>
<tbody>
{regularRows.length > 0 && (
<>
<tr className="bg-zinc-900/30">
<td className="px-4 py-1.5 text-xs text-indigo-400 font-medium tracking-wide uppercase">Regular</td>
<td className="px-4 py-1.5 text-right text-xs text-indigo-400 font-medium">{fmt(regularTotal)}</td>
</tr>
{renderRows(regularRows, "bg-indigo-500")}
</>
)}
{occasionalRows.length > 0 && (
<>
<tr className="bg-zinc-900/30">
<td className="px-4 py-1.5 text-xs text-zinc-500 font-medium tracking-wide uppercase">Occasional</td>
<td className="px-4 py-1.5 text-right text-xs text-zinc-500 font-medium">{fmt(occasionalTotal)}</td>
</tr>
{renderRows(occasionalRows, "bg-zinc-500")}
</>
)}
{categoryData.length === 0 && (
<tr>
<td colSpan={2} className="px-4 py-6 text-center text-xs text-zinc-600">No spend data for this month.</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────
export default function InsightsPage() {
const { data: analytics } = useMonthlyAnalytics(12);
const { data: analytics6 } = useMonthlyAnalytics(6);
const { data: subData } = useSubscriptions();
const [feeMonths, setFeeMonths] = useState(12);
const { data: feesData } = useFees(feeMonths);
// Build regular/occasional chart data
const chartData = useMemo(() => {
if (!analytics) return [];
return [...analytics.months].reverse().map((month) => {
let regular = 0;
let occasional = 0;
for (const row of analytics.rows) {
const spend = Number(row.spent[month] ?? 0);
if ((REGULAR_CATEGORIES as Set<string>).has(row.category)) regular += spend;
else occasional += spend;
}
return {
month: month.slice(5) + "/" + month.slice(2, 4),
regular: Math.round(regular),
occasional: Math.round(occasional),
total: Math.round(regular + occasional),
};
});
}, [analytics]);
const regularValues = chartData.map((d) => d.regular);
const regularTrend = trend(regularValues);
const avgRegular = regularValues.length
? Math.round(regularValues.reduce((a, b) => a + b, 0) / regularValues.length)
: 0;
const latestRegular = regularValues[regularValues.length - 1] ?? 0;
const activeSubscriptions = subData?.subscriptions.filter((s) => s.is_active) ?? [];
const inactiveSubscriptions = subData?.subscriptions.filter((s) => !s.is_active) ?? [];
return (
<div className="max-w-4xl">
<p className="text-[11px] uppercase tracking-[0.18em] text-zinc-500 mb-1">Ledger · patterns</p>
<h2 className="text-2xl font-display mb-6">Insights</h2>
{/* ── 1. Regular vs Occasional ── */}
<Section title="Regular vs occasional spend">
<div className="bg-zinc-900 border border-zinc-800 rounded-xl grid grid-cols-3 divide-x divide-zinc-800 overflow-hidden mb-4">
<div className="px-4 py-3.5">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">This month · regular</div>
<div className="text-xl font-mono tabular-nums text-indigo-300">{fmt(latestRegular)}</div>
</div>
<div className="px-4 py-3.5">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">12-month average</div>
<div className="text-xl font-mono tabular-nums text-zinc-200">{fmt(avgRegular)}</div>
</div>
<div className="px-4 py-3.5">
<div className="text-[10px] uppercase tracking-wider text-zinc-500 mb-1.5">Trend · first 3 vs last 3</div>
<div className={`text-xl font-mono tabular-nums ${regularTrend.dir === "up" ? "text-red-400" : regularTrend.dir === "down" ? "text-emerald-400" : "text-zinc-400"}`}>
{regularTrend.dir === "up" ? "↑" : regularTrend.dir === "down" ? "↓" : "→"} {regularTrend.pct}%
</div>
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
<ResponsiveContainer width="100%" height={220}>
<ComposedChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="month" tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fill: CHART.axis, fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${(v / 1000).toFixed(0)}k`} width={44} />
<Tooltip content={<RegularTooltip />} cursor={{ fill: "rgba(232,224,204,0.04)" }} />
<Bar dataKey="regular" stackId="a" fill={CHART.accent} name="Regular" radius={[0, 0, 0, 0]} />
<Bar dataKey="occasional" stackId="a" fill={CHART.dim} name="Occasional" radius={[3, 3, 0, 0]} />
<Line type="monotone" dataKey="regular" stroke={CHART.accentSoft} strokeWidth={2} dot={false} strokeDasharray="4 2" />
</ComposedChart>
</ResponsiveContainer>
<div className="flex gap-4 mt-2 justify-end">
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.accent }} />Regular (groceries, dining, transport)</span>
<span className="flex items-center gap-1.5 text-xs text-zinc-500"><span className="w-3 h-2 rounded-sm inline-block" style={{ background: CHART.dim }} />Occasional</span>
</div>
</div>
</Section>
{/* ── 2. Monthly Spend Breakdown ── */}
<Section title="Monthly spend breakdown">
{!analytics6 ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : (
<MonthlyBreakdown analytics={analytics6} />
)}
</Section>
{/* ── 3. Recurring Charges ── */}
<Section title="Recurring charges">
{!subData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : subData.subscriptions.length === 0 ? (
<p className="text-zinc-500 text-sm">No recurring patterns detected yet more transaction history needed.</p>
) : (
<>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-zinc-500">{activeSubscriptions.length} active · {inactiveSubscriptions.length} inactive</span>
<span className="text-sm font-medium text-indigo-400">{fmtExact(subData.total_monthly_equiv)}<span className="text-xs text-zinc-500 font-normal ml-1">/ month committed</span></span>
</div>
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Merchant</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Category</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Frequency</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">My $/mo equiv</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Avg charge</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Since</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total paid</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Count</th>
</tr>
</thead>
<tbody>
{[...activeSubscriptions, ...inactiveSubscriptions].map((s) => (
<tr key={s.merchant} className={`border-b border-zinc-800/50 ${s.is_active ? "hover:bg-zinc-800/20" : "opacity-40"} transition-colors`}>
<td className="px-4 py-3 font-medium">{s.merchant}</td>
<td className="px-4 py-3 text-zinc-400 text-xs">{formatCategory(s.category ?? "other")}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-full ${s.is_active ? "bg-indigo-900/40 text-indigo-300" : "bg-zinc-800 text-zinc-500"}`}>
{FREQ_LABEL[s.frequency] ?? s.frequency}
</span>
</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-200">{fmtExact(s.monthly_equiv)}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.avg_amount)}</td>
<td className="px-4 py-3 text-right text-zinc-500 text-xs whitespace-nowrap">{fmtDate(s.first_seen)}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{fmtExact(s.total_paid)}</td>
<td className="px-4 py-3 text-right text-zinc-500">{s.occurrences}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Section>
{/* ── 4. Fees & Interest ── */}
<Section
title="Fees & interest"
aside={
<div className="flex items-center gap-3">
{feesData?.period && (
<span className="text-xs text-zinc-500 tabular-nums">
{feesData.period.all_time
? "All time"
: `${formatPeriodBound(feesData.period.from)} ${formatPeriodBound(feesData.period.to, true)}`}
</span>
)}
<select
value={feeMonths}
onChange={(e) => setFeeMonths(Number(e.target.value))}
className="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
aria-label="Fees period"
>
<option value={3}>Last 3 months</option>
<option value={6}>Last 6 months</option>
<option value={12}>Last 12 months</option>
<option value={24}>Last 24 months</option>
<option value={0}>All time</option>
</select>
</div>
}
>
{!feesData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">
No fees or interest recorded {feesData.period?.all_time ? "on any statement" : "in this period"}.
</p>
) : (
<div className="space-y-4">
{feesData.by_bank.length > 0 && (
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Fees</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Interest</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</th>
</tr>
</thead>
<tbody>
{feesData.by_bank.map((r) => (
<tr key={r.bank_name} className="border-b border-zinc-800/50">
<td className="px-4 py-3 font-medium">{r.bank_name}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.fees > 0 ? fmtExact(r.fees) : <span className="text-zinc-700"></span>}</td>
<td className="px-4 py-3 text-right tabular-nums text-zinc-400">{r.interest > 0 ? fmtExact(r.interest) : <span className="text-zinc-700"></span>}</td>
<td className="px-4 py-3 text-right tabular-nums text-red-400 font-medium">{fmtExact(r.total)}</td>
</tr>
))}
<tr className="bg-zinc-900/50">
<td className="px-4 py-2.5 text-xs text-zinc-500 font-medium">Total</td>
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_fees)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-xs text-zinc-400">{fmtExact(feesData.total_interest)}</td>
<td className="px-4 py-2.5 text-right tabular-nums text-red-400 font-medium">{fmtExact(feesData.total_fees + feesData.total_interest)}</td>
</tr>
</tbody>
</table>
</div>
)}
{feesData.transactions.length > 0 && (
<div>
<p className="text-xs text-zinc-500 mb-2">Individual fee / interest transactions</p>
<div className="border border-zinc-800 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900">
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Date</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Bank</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Description</th>
<th className="text-left px-4 py-2.5 text-xs text-zinc-500 font-medium">Type</th>
<th className="text-right px-4 py-2.5 text-xs text-zinc-500 font-medium">Amount</th>
</tr>
</thead>
<tbody>
{feesData.transactions.map((t) => (
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/20">
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
{new Date(t.transaction_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
</td>
<td className="px-4 py-2.5 text-zinc-400 text-xs">{t.bank_name}</td>
<td className="px-4 py-2.5 text-zinc-300">{t.description}</td>
<td className="px-4 py-2.5">
<span className={`text-xs px-2 py-0.5 rounded-full ${t.transaction_type === "interest" ? "bg-orange-900/40 text-orange-300" : "bg-zinc-800 text-zinc-400"}`}>
{t.transaction_type}
</span>
</td>
<td className="px-4 py-2.5 text-right tabular-nums text-red-400">{fmtExact(t.my_amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</Section>
</div>
);
}