730 lines
27 KiB
TypeScript
730 lines
27 KiB
TypeScript
"use client";
|
||
|
||
import { useState, Fragment } from "react";
|
||
import {
|
||
ComposedChart,
|
||
BarChart,
|
||
Bar,
|
||
Line,
|
||
XAxis,
|
||
YAxis,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
Cell,
|
||
} from "recharts";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||
|
||
function currentMonthStr(): string {
|
||
const now = new Date();
|
||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||
}
|
||
|
||
function prevMonth(m: string): string {
|
||
const [year, month] = m.split("-").map(Number);
|
||
const d = new Date(year, month - 2, 1);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||
}
|
||
|
||
function nextMonth(m: string): string {
|
||
const [year, month] = m.split("-").map(Number);
|
||
const d = new Date(year, month, 1);
|
||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||
}
|
||
|
||
function formatMonth(m: string): string {
|
||
const [year, month] = m.split("-");
|
||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||
return date.toLocaleString("default", { month: "long", year: "numeric" });
|
||
}
|
||
|
||
function formatShortMonth(m: string): string {
|
||
const [year, month] = m.split("-");
|
||
const date = new Date(Number(year), Number(month) - 1, 1);
|
||
return date.toLocaleString("default", { month: "short" });
|
||
}
|
||
|
||
function fmt(n: number): string {
|
||
return `$${n.toFixed(0)}`;
|
||
}
|
||
|
||
function fmtExact(n: number): string {
|
||
return `$${n.toFixed(2)}`;
|
||
}
|
||
|
||
function deltaColor(n: number): string {
|
||
if (n > 0) return "text-red-400";
|
||
if (n < 0) return "text-emerald-400";
|
||
return "";
|
||
}
|
||
|
||
const TOOLTIP_STYLE = {
|
||
background: "#18181b",
|
||
border: "1px solid #3f3f46",
|
||
borderRadius: 8,
|
||
fontSize: 12,
|
||
};
|
||
|
||
const CATEGORY_COLORS: Record<string, string> = {
|
||
groceries: "#22c55e",
|
||
dining: "#f97316",
|
||
transport: "#06b6d4",
|
||
fuel: "#eab308",
|
||
shopping: "#ec4899",
|
||
utilities: "#8b5cf6",
|
||
entertainment: "#f43f5e",
|
||
travel: "#0ea5e9",
|
||
health: "#10b981",
|
||
insurance: "#64748b",
|
||
subscriptions: "#a78bfa",
|
||
cash_advance: "#dc2626",
|
||
government: "#78716c",
|
||
education: "#3b82f6",
|
||
rent: "#d97706",
|
||
transfers: "#6b7280",
|
||
income: "#34d399",
|
||
investment: "#818cf8",
|
||
personal_care: "#fb7185",
|
||
pets: "#86efac",
|
||
gifts: "#fcd34d",
|
||
charity: "#a3e635",
|
||
other: "#71717a",
|
||
};
|
||
|
||
interface TooltipPayload {
|
||
name: string;
|
||
value: number;
|
||
fill?: string;
|
||
stroke?: string;
|
||
}
|
||
|
||
function CashflowTooltip({
|
||
active,
|
||
payload,
|
||
label,
|
||
}: {
|
||
active?: boolean;
|
||
payload?: TooltipPayload[];
|
||
label?: string;
|
||
}) {
|
||
if (!active || !payload) return null;
|
||
const items = payload.filter((p) => p.value && Math.abs(p.value) > 0.01);
|
||
if (items.length === 0) return null;
|
||
|
||
return (
|
||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs min-w-36">
|
||
<p className="text-zinc-400 mb-2 font-medium">{label}</p>
|
||
{items.map((item) => {
|
||
const name = item.name;
|
||
let label = name;
|
||
let valueStr = fmtExact(Number(item.value));
|
||
const color = item.fill || item.stroke || "#71717a";
|
||
|
||
if (name === "income") label = "Income";
|
||
else if (name === "savingsRate") {
|
||
label = "Savings Rate";
|
||
valueStr = `${Number(item.value).toFixed(1)}%`;
|
||
} else if (name.startsWith("cat_")) {
|
||
label = formatCategory(name.slice(4));
|
||
}
|
||
|
||
return (
|
||
<div key={name} className="flex items-center gap-2 mb-0.5">
|
||
<span className="w-2 h-2 rounded-sm shrink-0" style={{ background: color }} />
|
||
<span className="text-zinc-400">{label}:</span>
|
||
<span className="text-zinc-100 tabular-nums ml-auto pl-2">{valueStr}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CategoryPanel({
|
||
category,
|
||
selectedMonth,
|
||
}: {
|
||
category: string;
|
||
selectedMonth: string;
|
||
}) {
|
||
const qc = useQueryClient();
|
||
const updateTx = useUpdateTransaction();
|
||
|
||
const from = `${selectedMonth}-01`;
|
||
const [year, month] = selectedMonth.split("-").map(Number);
|
||
const nextDate = new Date(year, month, 1);
|
||
const to = `${nextDate.getFullYear()}-${String(nextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||
|
||
const { data, isLoading } = useTransactions({ category, from, to, limit: 200 });
|
||
const txns = data?.data || [];
|
||
|
||
const handleCategoryChange = (id: number, newCategory: string) => {
|
||
updateTx.mutate(
|
||
{ id, category: newCategory },
|
||
{
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||
},
|
||
}
|
||
);
|
||
};
|
||
|
||
return (
|
||
<tr>
|
||
<td colSpan={4} className="px-0 pb-2 bg-zinc-950/60 border-b border-zinc-800">
|
||
{isLoading ? (
|
||
<p className="text-xs text-zinc-500 px-6 py-2">Loading transactions...</p>
|
||
) : txns.length === 0 ? (
|
||
<p className="text-xs text-zinc-600 px-6 py-2">No transactions found</p>
|
||
) : (
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="text-zinc-600">
|
||
<th className="text-left px-6 py-1 font-normal w-24">Date</th>
|
||
<th className="text-left px-2 py-1 font-normal">Description</th>
|
||
<th className="text-right px-2 py-1 font-normal w-24">Amount</th>
|
||
<th className="text-right px-4 py-1 font-normal w-36">Category</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{txns.map((tx) => (
|
||
<tr key={tx.id} className="border-t border-zinc-800/30 hover:bg-zinc-800/20">
|
||
<td className="px-6 py-1.5 text-zinc-500 tabular-nums">
|
||
{tx.transaction_date.slice(5).replace("-", "/")}
|
||
</td>
|
||
<td className="px-2 py-1.5 text-zinc-300 max-w-xs truncate">
|
||
{tx.effective_merchant || tx.description}
|
||
</td>
|
||
<td className="px-2 py-1.5 text-right tabular-nums text-zinc-300">
|
||
{fmtExact(tx.amount)}
|
||
</td>
|
||
<td className="px-4 py-1.5 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 cursor-pointer"
|
||
defaultValue={tx.effective_category}
|
||
onChange={(e) => handleCategoryChange(tx.id, e.target.value)}
|
||
>
|
||
{CATEGORIES.map((cat) => (
|
||
<option key={cat} value={cat}>
|
||
{formatCategory(cat)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
export default function AnalyticsPage() {
|
||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||
|
||
if (isLoading || !analytics) {
|
||
return (
|
||
<div className="space-y-6">
|
||
<h2 className="text-xl font-semibold">Analytics</h2>
|
||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const months = [...analytics.months].reverse(); // chronological
|
||
|
||
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||
const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||
|
||
const spentDelta = totals.spent - lastTotals.spent;
|
||
|
||
// Category rows for selected month
|
||
const categoryRows = analytics.rows
|
||
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
||
.map((r) => ({
|
||
category: r.category,
|
||
spent: r.spent[selectedMonth] || 0,
|
||
txCount: r.txCount[selectedMonth] || 0,
|
||
}))
|
||
.sort((a, b) => b.spent - a.spent);
|
||
|
||
const largestCategory = categoryRows[0];
|
||
|
||
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||
|
||
// Categories that appear in expense data
|
||
const expenseCategories = analytics.rows.map((r) => r.category);
|
||
|
||
// Cashflow chart data (chronological) — per-category amounts for stacking
|
||
const cashflowData = months.map((m) => {
|
||
const entry: Record<string, unknown> = {
|
||
month: m,
|
||
label: formatShortMonth(m),
|
||
income: analytics.income[m] || 0,
|
||
};
|
||
analytics.rows.forEach((row) => {
|
||
entry[`cat_${row.category}`] = row.spent[m] || 0;
|
||
});
|
||
const income = analytics.income[m] || 0;
|
||
const spent = analytics.totals[m]?.spent || 0;
|
||
const invested = analytics.investments[m] || 0;
|
||
entry.savingsRate =
|
||
income > 0 ? Math.round(((income - spent - invested) / income) * 100) : null;
|
||
return entry;
|
||
});
|
||
|
||
// Horizontal category bar chart for selected month
|
||
const categoryChartData = [...categoryRows].reverse();
|
||
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* Header + month selector */}
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-xl font-semibold">Analytics</h2>
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={() => setSelectedMonth(prevMonth(selectedMonth))}
|
||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
||
>
|
||
‹
|
||
</button>
|
||
<span className="text-sm font-medium min-w-36 text-center">
|
||
{formatMonth(selectedMonth)}
|
||
</span>
|
||
<button
|
||
onClick={() => setSelectedMonth(nextMonth(selectedMonth))}
|
||
className="p-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm leading-none"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Summary cards */}
|
||
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
|
||
{hasIncome && (
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<p className="text-xs text-zinc-500 mb-1">Income</p>
|
||
<p className="text-2xl font-semibold text-emerald-400">{fmtExact(totals.income)}</p>
|
||
<p className="text-xs text-zinc-500 mt-1">received</p>
|
||
</div>
|
||
)}
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
|
||
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
||
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
||
{spentDelta === 0 || lastTotals.spent === 0 ? (
|
||
<span className="text-zinc-500">split-adjusted</span>
|
||
) : (
|
||
`${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`
|
||
)}
|
||
</p>
|
||
</div>
|
||
{(hasInvestments || totals.investments > 0) && (
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<p className="text-xs text-zinc-500 mb-1">Invested</p>
|
||
<p className="text-2xl font-semibold text-indigo-400">{fmtExact(totals.investments)}</p>
|
||
<p className="text-xs text-zinc-500 mt-1">shares / ETFs</p>
|
||
</div>
|
||
)}
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
||
{hasIncome ? (
|
||
<>
|
||
<p
|
||
className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}
|
||
>
|
||
{totals.net >= 0 ? "+" : ""}
|
||
{fmtExact(totals.net)}
|
||
</p>
|
||
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
||
</>
|
||
) : largestCategory ? (
|
||
<>
|
||
<p className="text-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
|
||
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
|
||
</>
|
||
) : (
|
||
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Cashflow chart — stacked expense categories + optional savings rate line */}
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-sm font-medium">
|
||
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}
|
||
</h3>
|
||
{hasIncome && (
|
||
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
||
<span className="flex items-center gap-1">
|
||
<span className="w-3 h-0.5 bg-amber-400 inline-block" />
|
||
Savings Rate
|
||
</span>
|
||
<span className="flex items-center gap-1">
|
||
<span className="w-2.5 h-2.5 rounded-sm bg-emerald-500 inline-block" />
|
||
Income
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={240}>
|
||
<ComposedChart
|
||
data={cashflowData}
|
||
margin={{ top: 4, right: hasIncome ? 48 : 8, bottom: 0, left: 8 }}
|
||
onClick={(data) => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const month = (data as any)?.activePayload?.[0]?.payload?.month as string | undefined;
|
||
if (month) setSelectedMonth(month);
|
||
}}
|
||
style={{ cursor: "pointer" }}
|
||
>
|
||
<XAxis
|
||
dataKey="label"
|
||
tick={{ fill: "#71717a", fontSize: 12 }}
|
||
axisLine={false}
|
||
tickLine={false}
|
||
/>
|
||
<YAxis
|
||
yAxisId="left"
|
||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tickFormatter={(v) => `$${v}`}
|
||
width={56}
|
||
/>
|
||
{hasIncome && (
|
||
<YAxis
|
||
yAxisId="right"
|
||
orientation="right"
|
||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tickFormatter={(v) => `${v}%`}
|
||
domain={[-20, 100]}
|
||
width={36}
|
||
/>
|
||
)}
|
||
<Tooltip content={<CashflowTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||
|
||
{/* Income bar (ungrouped, green) */}
|
||
{hasIncome && (
|
||
<Bar
|
||
yAxisId="left"
|
||
dataKey="income"
|
||
fill="#22c55e"
|
||
radius={[3, 3, 0, 0]}
|
||
maxBarSize={32}
|
||
opacity={0.85}
|
||
/>
|
||
)}
|
||
|
||
{/* Stacked expense category bars */}
|
||
{expenseCategories.map((cat, i) => (
|
||
<Bar
|
||
key={cat}
|
||
yAxisId="left"
|
||
dataKey={`cat_${cat}`}
|
||
stackId="expenses"
|
||
fill={CATEGORY_COLORS[cat] || "#71717a"}
|
||
radius={i === expenseCategories.length - 1 ? [3, 3, 0, 0] : [0, 0, 0, 0]}
|
||
maxBarSize={32}
|
||
>
|
||
{cashflowData.map((entry) => (
|
||
<Cell
|
||
key={entry.month as string}
|
||
fill={
|
||
entry.month === selectedMonth
|
||
? CATEGORY_COLORS[cat] || "#71717a"
|
||
: `${CATEGORY_COLORS[cat] || "#71717a"}99`
|
||
}
|
||
/>
|
||
))}
|
||
</Bar>
|
||
))}
|
||
|
||
{/* Savings rate line */}
|
||
{hasIncome && (
|
||
<Line
|
||
yAxisId="right"
|
||
dataKey="savingsRate"
|
||
stroke="#fbbf24"
|
||
strokeWidth={2}
|
||
dot={{ fill: "#fbbf24", r: 3 }}
|
||
activeDot={{ r: 5 }}
|
||
connectNulls={false}
|
||
/>
|
||
)}
|
||
</ComposedChart>
|
||
</ResponsiveContainer>
|
||
|
||
{/* Category color legend */}
|
||
{expenseCategories.length > 0 && (
|
||
<div className="flex flex-wrap gap-x-3 gap-y-1 mt-3 pt-3 border-t border-zinc-800">
|
||
{expenseCategories.map((cat) => (
|
||
<span key={cat} className="flex items-center gap-1 text-xs text-zinc-500">
|
||
<span
|
||
className="w-2 h-2 rounded-sm shrink-0"
|
||
style={{ background: CATEGORY_COLORS[cat] || "#71717a" }}
|
||
/>
|
||
{formatCategory(cat)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Spending by category — horizontal bar chart */}
|
||
{categoryChartData.length > 0 && (
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<h3 className="text-sm font-medium mb-4">
|
||
Spending by Category — {formatMonth(selectedMonth)}
|
||
</h3>
|
||
<ResponsiveContainer width="100%" height={chartHeight}>
|
||
<BarChart
|
||
data={categoryChartData}
|
||
layout="vertical"
|
||
margin={{ top: 0, right: 8, bottom: 0, left: 8 }}
|
||
>
|
||
<XAxis
|
||
type="number"
|
||
tick={{ fill: "#71717a", fontSize: 11 }}
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tickFormatter={(v) => `$${v}`}
|
||
/>
|
||
<YAxis
|
||
type="category"
|
||
dataKey="category"
|
||
width={120}
|
||
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
||
axisLine={false}
|
||
tickLine={false}
|
||
tickFormatter={formatCategory}
|
||
/>
|
||
<Tooltip
|
||
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
||
contentStyle={TOOLTIP_STYLE}
|
||
labelFormatter={(label) => formatCategory(String(label))}
|
||
labelStyle={{ color: "#a1a1aa" }}
|
||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||
/>
|
||
<Bar dataKey="spent" radius={[0, 4, 4, 0]}>
|
||
{categoryChartData.map((entry) => (
|
||
<Cell
|
||
key={entry.category}
|
||
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
||
/>
|
||
))}
|
||
</Bar>
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)}
|
||
|
||
{/* Category breakdown table — with expandable rows */}
|
||
{categoryRows.length > 0 && (
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||
<div className="px-4 py-3 border-b border-zinc-800">
|
||
<h3 className="text-sm font-medium">
|
||
Spending Breakdown — {formatMonth(selectedMonth)}
|
||
</h3>
|
||
</div>
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-zinc-800">
|
||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">Spent</th>
|
||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium"># Txns</th>
|
||
<th className="text-right px-4 py-2 text-xs text-zinc-500 font-medium">% of Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{categoryRows.map(({ category, spent, txCount }) => {
|
||
const isExpanded = expandedCategory === category;
|
||
return (
|
||
<Fragment key={category}>
|
||
<tr
|
||
className={`border-b border-zinc-800/50 cursor-pointer select-none transition-colors ${
|
||
isExpanded ? "bg-zinc-800/40" : "hover:bg-zinc-800/30"
|
||
}`}
|
||
onClick={() =>
|
||
setExpandedCategory(isExpanded ? null : category)
|
||
}
|
||
>
|
||
<td className="px-4 py-2.5 font-medium">
|
||
<span className="flex items-center gap-2">
|
||
<span
|
||
className="w-2 h-2 rounded-sm shrink-0"
|
||
style={{ background: CATEGORY_COLORS[category] || "#71717a" }}
|
||
/>
|
||
{formatCategory(category)}
|
||
<span className="text-zinc-600 text-xs ml-1">
|
||
{isExpanded ? "▲" : "▼"}
|
||
</span>
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-right tabular-nums">{fmtExact(spent)}</td>
|
||
<td className="px-4 py-2.5 text-right text-zinc-400">{txCount}</td>
|
||
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
|
||
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||
</td>
|
||
</tr>
|
||
{isExpanded && (
|
||
<CategoryPanel
|
||
category={category}
|
||
selectedMonth={selectedMonth}
|
||
/>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* 6-month trend table */}
|
||
{analytics.months.length > 0 && (
|
||
<div>
|
||
<h3 className="text-sm font-semibold text-zinc-400 mb-3">6-Month Trend</h3>
|
||
<div className="overflow-x-auto rounded-xl border border-zinc-700">
|
||
<table className="w-full text-xs border-collapse">
|
||
<thead>
|
||
<tr className="border-b border-zinc-800 bg-zinc-900">
|
||
<th className="text-left px-3 py-2 text-zinc-500 font-medium sticky left-0 bg-zinc-900 min-w-32">
|
||
Category
|
||
</th>
|
||
{analytics.months.map((m) => (
|
||
<th
|
||
key={m}
|
||
className={`text-right px-3 py-2 font-medium whitespace-nowrap cursor-pointer hover:text-zinc-300 ${
|
||
m === selectedMonth ? "text-indigo-400" : "text-zinc-500"
|
||
}`}
|
||
onClick={() => setSelectedMonth(m)}
|
||
>
|
||
{formatShortMonth(m)}
|
||
</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{analytics.rows.map((row) => (
|
||
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950">
|
||
<span className="flex items-center gap-1.5">
|
||
<span
|
||
className="w-1.5 h-1.5 rounded-sm shrink-0"
|
||
style={{ background: CATEGORY_COLORS[row.category] || "#71717a" }}
|
||
/>
|
||
{formatCategory(row.category)}
|
||
</span>
|
||
</td>
|
||
{analytics.months.map((m) => {
|
||
const spent = row.spent[m];
|
||
return (
|
||
<td
|
||
key={m}
|
||
className={`px-3 py-2 text-right tabular-nums ${
|
||
spent === undefined ? "text-zinc-700" : "text-zinc-300"
|
||
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||
>
|
||
{spent !== undefined ? fmt(spent) : "—"}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
{hasIncome && (
|
||
<tr className="border-b border-zinc-800/40">
|
||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-emerald-600">
|
||
Income
|
||
</td>
|
||
{analytics.months.map((m) => {
|
||
const inc = analytics.income[m];
|
||
return (
|
||
<td
|
||
key={m}
|
||
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${
|
||
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||
}`}
|
||
>
|
||
{inc ? fmt(inc) : "—"}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
)}
|
||
{hasInvestments && (
|
||
<tr className="border-b border-zinc-800/40">
|
||
<td className="px-3 py-2 font-medium sticky left-0 bg-zinc-950 text-indigo-500">
|
||
Invested
|
||
</td>
|
||
{analytics.months.map((m) => {
|
||
const inv = analytics.investments[m];
|
||
return (
|
||
<td
|
||
key={m}
|
||
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${
|
||
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||
}`}
|
||
>
|
||
{inv ? fmt(inv) : "—"}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
)}
|
||
<tr className="border-t-2 border-zinc-700 font-semibold bg-zinc-900/50">
|
||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||
{analytics.months.map((m) => {
|
||
const t = analytics.totals[m];
|
||
return (
|
||
<td
|
||
key={m}
|
||
className={`px-3 py-2 text-right tabular-nums ${
|
||
m === selectedMonth ? "bg-zinc-800/30 text-indigo-300" : ""
|
||
}`}
|
||
>
|
||
{fmt(t?.spent || 0)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
{hasIncome && (
|
||
<tr className="font-semibold bg-zinc-900/50">
|
||
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Net Cash</td>
|
||
{analytics.months.map((m) => {
|
||
const t = analytics.totals[m];
|
||
const net = t?.net || 0;
|
||
return (
|
||
<td
|
||
key={m}
|
||
className={`px-3 py-2 text-right tabular-nums ${
|
||
net >= 0 ? "text-emerald-400" : "text-red-400"
|
||
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||
>
|
||
{net >= 0 ? "+" : ""}
|
||
{fmt(net)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|