feat(analytics): stacked category chart, savings rate line, expandable rows
- Replace grouped cashflow BarChart with ComposedChart: expense categories as colour-coded stacked bars + amber savings-rate % line on right Y-axis - Add category colour legend below chart (matches stacked bars) - Horizontal category bar chart now uses per-category colours - Breakdown table: click any category row to expand/collapse individual transactions; each transaction has an inline category dropdown that calls PATCH /api/transactions/:id → transaction_overrides, then invalidates analytics query so totals update immediately
This commit is contained in:
+356
-57
@@ -2,17 +2,19 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
ComposedChart,
|
||||||
BarChart,
|
BarChart,
|
||||||
Bar,
|
Bar,
|
||||||
|
Line,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useMonthlyAnalytics } from "@/lib/hooks";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { formatCategory } from "@/lib/categories";
|
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||||
|
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||||
|
|
||||||
function currentMonthStr(): string {
|
function currentMonthStr(): string {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -64,8 +66,165 @@ const TOOLTIP_STYLE = {
|
|||||||
fontSize: 12,
|
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() {
|
export default function AnalyticsPage() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||||
|
const [expandedCategory, setExpandedCategory] = useState<string | null>(null);
|
||||||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||||
|
|
||||||
if (isLoading || !analytics) {
|
if (isLoading || !analytics) {
|
||||||
@@ -96,22 +255,34 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
const largestCategory = categoryRows[0];
|
const largestCategory = categoryRows[0];
|
||||||
|
|
||||||
// Cashflow bar chart data (chronological)
|
|
||||||
const cashflowData = months.map((m) => ({
|
|
||||||
month: m,
|
|
||||||
label: formatShortMonth(m),
|
|
||||||
income: analytics.totals[m]?.income || 0,
|
|
||||||
expenses: analytics.totals[m]?.spent || 0,
|
|
||||||
investments: analytics.totals[m]?.investments || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Category horizontal bar chart
|
|
||||||
const categoryChartData = [...categoryRows].reverse();
|
|
||||||
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
|
||||||
|
|
||||||
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||||
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header + month selector */}
|
{/* Header + month selector */}
|
||||||
@@ -136,7 +307,7 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cashflow summary cards */}
|
{/* Summary cards */}
|
||||||
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
|
<div className={`grid gap-4 ${hasIncome ? "grid-cols-2 sm:grid-cols-4" : "grid-cols-3"}`}>
|
||||||
{hasIncome && (
|
{hasIncome && (
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
@@ -149,9 +320,11 @@ export default function AnalyticsPage() {
|
|||||||
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
|
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
|
||||||
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
||||||
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
<p className={`text-xs mt-1 ${deltaColor(spentDelta)}`}>
|
||||||
{spentDelta === 0 || lastTotals.spent === 0
|
{spentDelta === 0 || lastTotals.spent === 0 ? (
|
||||||
? <span className="text-zinc-500">split-adjusted</span>
|
<span className="text-zinc-500">split-adjusted</span>
|
||||||
: `${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`}
|
) : (
|
||||||
|
`${spentDelta > 0 ? "+" : ""}${fmtExact(spentDelta)} vs ${formatShortMonth(prevMonth(selectedMonth))}`
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{(hasInvestments || totals.investments > 0) && (
|
{(hasInvestments || totals.investments > 0) && (
|
||||||
@@ -165,8 +338,11 @@ export default function AnalyticsPage() {
|
|||||||
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
||||||
{hasIncome ? (
|
{hasIncome ? (
|
||||||
<>
|
<>
|
||||||
<p className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}>
|
<p
|
||||||
{totals.net >= 0 ? "+" : ""}{fmtExact(totals.net)}
|
className={`text-2xl font-semibold ${totals.net >= 0 ? "text-emerald-400" : "text-red-400"}`}
|
||||||
|
>
|
||||||
|
{totals.net >= 0 ? "+" : ""}
|
||||||
|
{fmtExact(totals.net)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
<p className="text-xs text-zinc-500 mt-1">income − expenses − invested</p>
|
||||||
</>
|
</>
|
||||||
@@ -181,13 +357,36 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cashflow bar chart — income vs expenses vs investments */}
|
{/* Cashflow chart — stacked expense categories + optional savings rate line */}
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<h3 className="text-sm font-medium mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending"}
|
<h3 className="text-sm font-medium">
|
||||||
|
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending by Category"}
|
||||||
</h3>
|
</h3>
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
{hasIncome && (
|
||||||
<BarChart data={cashflowData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
|
<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
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: "#71717a", fontSize: 12 }}
|
tick={{ fill: "#71717a", fontSize: 12 }}
|
||||||
@@ -195,43 +394,92 @@ export default function AnalyticsPage() {
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
|
yAxisId="left"
|
||||||
tick={{ fill: "#71717a", fontSize: 11 }}
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={(v) => `$${v}`}
|
tickFormatter={(v) => `$${v}`}
|
||||||
width={56}
|
width={56}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
|
||||||
formatter={(value, name) => [fmtExact(Number(value)), name === "expenses" ? "Expenses" : name === "income" ? "Income" : "Invested"]}
|
|
||||||
contentStyle={TOOLTIP_STYLE}
|
|
||||||
labelStyle={{ color: "#a1a1aa" }}
|
|
||||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
|
||||||
/>
|
|
||||||
{hasIncome && (
|
{hasIncome && (
|
||||||
<>
|
<YAxis
|
||||||
<Legend
|
yAxisId="right"
|
||||||
formatter={(value) => value === "income" ? "Income" : value === "expenses" ? "Expenses" : "Invested"}
|
orientation="right"
|
||||||
wrapperStyle={{ fontSize: 12, color: "#71717a" }}
|
tick={{ fill: "#71717a", fontSize: 11 }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
tickFormatter={(v) => `${v}%`}
|
||||||
|
domain={[-20, 100]}
|
||||||
|
width={36}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="income" fill="#34d399" radius={[4, 4, 0, 0]} />
|
|
||||||
{hasInvestments && <Bar dataKey="investments" fill="#818cf8" radius={[4, 4, 0, 0]} />}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
<Tooltip content={<CashflowTooltip />} cursor={{ fill: "rgba(255,255,255,0.04)" }} />
|
||||||
|
|
||||||
|
{/* Income bar (ungrouped, green) */}
|
||||||
|
{hasIncome && (
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="expenses"
|
yAxisId="left"
|
||||||
radius={[4, 4, 0, 0]}
|
dataKey="income"
|
||||||
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
|
fill="#22c55e"
|
||||||
style={{ cursor: "pointer" }}
|
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) => (
|
{cashflowData.map((entry) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.month}
|
key={entry.month as string}
|
||||||
fill={entry.month === selectedMonth ? "#6366f1" : (hasIncome ? "#ef4444" : "#3f3f46")}
|
fill={
|
||||||
|
entry.month === selectedMonth
|
||||||
|
? CATEGORY_COLORS[cat] || "#71717a"
|
||||||
|
: `${CATEGORY_COLORS[cat] || "#71717a"}99`
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
</BarChart>
|
))}
|
||||||
|
|
||||||
|
{/* 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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Spending by category — horizontal bar chart */}
|
{/* Spending by category — horizontal bar chart */}
|
||||||
@@ -269,17 +517,26 @@ export default function AnalyticsPage() {
|
|||||||
labelStyle={{ color: "#a1a1aa" }}
|
labelStyle={{ color: "#a1a1aa" }}
|
||||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="spent" fill="#6366f1" radius={[0, 4, 4, 0]} />
|
<Bar dataKey="spent" radius={[0, 4, 4, 0]}>
|
||||||
|
{categoryChartData.map((entry) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={CATEGORY_COLORS[entry.category] || "#6366f1"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Category breakdown table */}
|
{/* Category breakdown table — with expandable rows */}
|
||||||
{categoryRows.length > 0 && (
|
{categoryRows.length > 0 && (
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-hidden">
|
||||||
<div className="px-4 py-3 border-b border-zinc-800">
|
<div className="px-4 py-3 border-b border-zinc-800">
|
||||||
<h3 className="text-sm font-medium">Spending Breakdown — {formatMonth(selectedMonth)}</h3>
|
<h3 className="text-sm font-medium">
|
||||||
|
Spending Breakdown — {formatMonth(selectedMonth)}
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -291,16 +548,47 @@ export default function AnalyticsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{categoryRows.map(({ category, spent, txCount }) => (
|
{categoryRows.map(({ category, spent, txCount }) => {
|
||||||
<tr key={category} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
const isExpanded = expandedCategory === category;
|
||||||
<td className="px-4 py-2.5 font-medium">{formatCategory(category)}</td>
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={category}
|
||||||
|
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 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">{txCount}</td>
|
||||||
<td className="px-4 py-2.5 text-right text-zinc-400 tabular-nums">
|
<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"}%
|
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
{isExpanded && (
|
||||||
|
<CategoryPanel
|
||||||
|
key={`panel-${category}`}
|
||||||
|
category={category}
|
||||||
|
selectedMonth={selectedMonth}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,7 +622,13 @@ export default function AnalyticsPage() {
|
|||||||
{analytics.rows.map((row) => (
|
{analytics.rows.map((row) => (
|
||||||
<tr key={row.category} className="border-b border-zinc-800/40 hover:bg-zinc-900/30">
|
<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">
|
<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)}
|
{formatCategory(row.category)}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const spent = row.spent[m];
|
const spent = row.spent[m];
|
||||||
@@ -361,7 +655,9 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={m}
|
key={m}
|
||||||
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
className={`px-3 py-2 text-right tabular-nums text-emerald-500 ${
|
||||||
|
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{inc ? fmt(inc) : "—"}
|
{inc ? fmt(inc) : "—"}
|
||||||
</td>
|
</td>
|
||||||
@@ -379,7 +675,9 @@ export default function AnalyticsPage() {
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={m}
|
key={m}
|
||||||
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
className={`px-3 py-2 text-right tabular-nums text-indigo-400 ${
|
||||||
|
m === selectedMonth ? "bg-zinc-800/30" : ""
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{inv ? fmt(inv) : "—"}
|
{inv ? fmt(inv) : "—"}
|
||||||
</td>
|
</td>
|
||||||
@@ -416,7 +714,8 @@ export default function AnalyticsPage() {
|
|||||||
net >= 0 ? "text-emerald-400" : "text-red-400"
|
net >= 0 ? "text-emerald-400" : "text-red-400"
|
||||||
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
} ${m === selectedMonth ? "bg-zinc-800/30" : ""}`}
|
||||||
>
|
>
|
||||||
{net >= 0 ? "+" : ""}{fmt(net)}
|
{net >= 0 ? "+" : ""}
|
||||||
|
{fmt(net)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user