feat(analytics): cashflow view with income/investment/net — split-adjusted + multi-currency
- Add 'investment' category (shares, ETFs, super) - Analytics API: separate income, investment, expense queries; use amount_aud for FX-aware sums - Analytics page: cashflow summary (income/expenses/invested/net cash), grouped bar chart, income + invested rows in 6-month trend table - MonthlyAnalytics interface: add income, investments, net fields to totals - DB: amount_aud + exchange_rate_to_aud columns added and backfilled (in prior migration)
This commit is contained in:
@@ -16,6 +16,7 @@ export async function GET(req: NextRequest) {
|
|||||||
const startStr = startDate.toISOString().slice(0, 10);
|
const startStr = startDate.toISOString().slice(0, 10);
|
||||||
const endStr = endDate.toISOString().slice(0, 10);
|
const endStr = endDate.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// Expenses: debits excluding transfers and investments, split-adjusted
|
||||||
const spendRows = await queryRaw<{
|
const spendRows = await queryRaw<{
|
||||||
month: string;
|
month: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -27,8 +28,8 @@ export async function GET(req: NextRequest) {
|
|||||||
COALESCE(o.category_override, t.category) as category,
|
COALESCE(o.category_override, t.category) as category,
|
||||||
SUM(
|
SUM(
|
||||||
CASE
|
CASE
|
||||||
WHEN ts.share_percent IS NOT NULL THEN t.amount * ts.share_percent / 100
|
WHEN ts.share_percent IS NOT NULL THEN COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100
|
||||||
ELSE t.amount
|
ELSE COALESCE(t.amount_aud, t.amount)
|
||||||
END
|
END
|
||||||
)::numeric(12,2) as total_spent,
|
)::numeric(12,2) as total_spent,
|
||||||
COUNT(*)::int as transaction_count
|
COUNT(*)::int as transaction_count
|
||||||
@@ -38,6 +39,7 @@ export async function GET(req: NextRequest) {
|
|||||||
JOIN statements s ON s.id = t.statement_id
|
JOIN statements s ON s.id = t.statement_id
|
||||||
WHERE s.owner_id = $1
|
WHERE s.owner_id = $1
|
||||||
AND t.transaction_type = 'debit'
|
AND t.transaction_type = 'debit'
|
||||||
|
AND COALESCE(o.category_override, t.category) NOT IN ('transfers', 'investment')
|
||||||
AND t.transaction_date >= $2
|
AND t.transaction_date >= $2
|
||||||
AND t.transaction_date < $3
|
AND t.transaction_date < $3
|
||||||
GROUP BY 1, 2
|
GROUP BY 1, 2
|
||||||
@@ -45,10 +47,48 @@ export async function GET(req: NextRequest) {
|
|||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
const budgetRows = await queryRaw<{ month: string; category: string; amount_limit: number }>(
|
// Income: credits/payments categorised as income
|
||||||
`SELECT TO_CHAR(month, 'YYYY-MM') as month, category, amount_limit::numeric
|
const incomeRows = await queryRaw<{
|
||||||
FROM budgets
|
month: string;
|
||||||
WHERE owner_id = $1 AND month >= $2::date AND month < $3::date`,
|
total_income: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_income,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND t.transaction_type IN ('credit', 'payment')
|
||||||
|
AND COALESCE(o.category_override, t.category) = 'income'
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND t.transaction_date < $3
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC`,
|
||||||
|
[user.id, startStr, endStr]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Investments: any transaction categorised as investment
|
||||||
|
const investmentRows = await queryRaw<{
|
||||||
|
month: string;
|
||||||
|
total_invested: number;
|
||||||
|
transaction_count: number;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||||
|
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
|
||||||
|
COUNT(*)::int as transaction_count
|
||||||
|
FROM transactions t
|
||||||
|
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||||
|
JOIN statements s ON s.id = t.statement_id
|
||||||
|
WHERE s.owner_id = $1
|
||||||
|
AND COALESCE(o.category_override, t.category) = 'investment'
|
||||||
|
AND t.transaction_date >= $2
|
||||||
|
AND t.transaction_date < $3
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1 DESC`,
|
||||||
[user.id, startStr, endStr]
|
[user.id, startStr, endStr]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -62,50 +102,46 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const spendMap = new Map<string, number>();
|
const spendMap = new Map<string, number>();
|
||||||
const countMap = new Map<string, number>();
|
const countMap = new Map<string, number>();
|
||||||
const budgetMap = new Map<string, number>();
|
const incomeMap = new Map<string, number>();
|
||||||
|
const investMap = new Map<string, number>();
|
||||||
|
|
||||||
for (const r of spendRows) {
|
for (const r of spendRows) {
|
||||||
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
spendMap.set(`${r.category}:${r.month}`, Number(r.total_spent));
|
||||||
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
countMap.set(`${r.category}:${r.month}`, r.transaction_count);
|
||||||
}
|
}
|
||||||
for (const r of budgetRows) {
|
for (const r of incomeRows) incomeMap.set(r.month, Number(r.total_income));
|
||||||
budgetMap.set(`${r.category}:${r.month}`, Number(r.amount_limit));
|
for (const r of investmentRows) investMap.set(r.month, Number(r.total_invested));
|
||||||
}
|
|
||||||
|
|
||||||
const allCategories = new Set<string>();
|
const allCategories = new Set<string>();
|
||||||
for (const r of spendRows) allCategories.add(r.category);
|
for (const r of spendRows) allCategories.add(r.category);
|
||||||
for (const r of budgetRows) allCategories.add(r.category);
|
|
||||||
|
|
||||||
const rows = Array.from(allCategories)
|
const rows = Array.from(allCategories)
|
||||||
.sort()
|
.sort()
|
||||||
.map((cat) => {
|
.map((cat) => {
|
||||||
const spent: Record<string, number> = {};
|
const spent: Record<string, number> = {};
|
||||||
const budget: Record<string, number> = {};
|
|
||||||
const txCount: Record<string, number> = {};
|
const txCount: Record<string, number> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
const s = spendMap.get(`${cat}:${m}`);
|
const s = spendMap.get(`${cat}:${m}`);
|
||||||
const b = budgetMap.get(`${cat}:${m}`);
|
|
||||||
const c = countMap.get(`${cat}:${m}`);
|
const c = countMap.get(`${cat}:${m}`);
|
||||||
if (s !== undefined) spent[m] = s;
|
if (s !== undefined) spent[m] = s;
|
||||||
if (b !== undefined) budget[m] = b;
|
|
||||||
if (c !== undefined) txCount[m] = c;
|
if (c !== undefined) txCount[m] = c;
|
||||||
}
|
}
|
||||||
return { category: cat, spent, budget, txCount };
|
return { category: cat, spent, txCount };
|
||||||
});
|
});
|
||||||
|
|
||||||
const totals: Record<string, { spent: number; budget: number }> = {};
|
const totals: Record<string, { spent: number; income: number; investments: number; net: number }> = {};
|
||||||
for (const m of months) {
|
for (const m of months) {
|
||||||
let s = 0;
|
let spent = 0;
|
||||||
let b = 0;
|
for (const row of rows) spent += row.spent[m] || 0;
|
||||||
for (const row of rows) {
|
const income = incomeMap.get(m) || 0;
|
||||||
s += row.spent[m] || 0;
|
const investments = investMap.get(m) || 0;
|
||||||
b += row.budget[m] || 0;
|
|
||||||
}
|
|
||||||
totals[m] = {
|
totals[m] = {
|
||||||
spent: Math.round(s * 100) / 100,
|
spent: Math.round(spent * 100) / 100,
|
||||||
budget: Math.round(b * 100) / 100,
|
income: Math.round(income * 100) / 100,
|
||||||
|
investments: Math.round(investments * 100) / 100,
|
||||||
|
net: Math.round((income - spent - investments) * 100) / 100,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ months, rows, totals });
|
return NextResponse.json({ months, rows, income: Object.fromEntries(incomeMap), investments: Object.fromEntries(investMap), totals });
|
||||||
}
|
}
|
||||||
|
|||||||
+141
-65
@@ -9,6 +9,7 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
|
Legend,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { useMonthlyAnalytics } from "@/lib/hooks";
|
import { useMonthlyAnalytics } from "@/lib/hooks";
|
||||||
import { formatCategory } from "@/lib/categories";
|
import { formatCategory } from "@/lib/categories";
|
||||||
@@ -50,6 +51,19 @@ function fmtExact(n: number): string {
|
|||||||
return `$${n.toFixed(2)}`;
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
export default function AnalyticsPage() {
|
export default function AnalyticsPage() {
|
||||||
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
const [selectedMonth, setSelectedMonth] = useState(currentMonthStr);
|
||||||
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
const { data: analytics, isLoading } = useMonthlyAnalytics(6);
|
||||||
@@ -63,18 +77,14 @@ export default function AnalyticsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure selectedMonth is within the available range
|
const months = [...analytics.months].reverse(); // chronological
|
||||||
const months = [...analytics.months].reverse(); // chronological order
|
|
||||||
|
|
||||||
// Per-month totals (selected month)
|
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
const totalSpent = analytics.totals[selectedMonth]?.spent || 0;
|
const lastTotals = analytics.totals[prevMonth(selectedMonth)] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||||
|
|
||||||
// Last month for comparison
|
const spentDelta = totals.spent - lastTotals.spent;
|
||||||
const lastMonth = prevMonth(selectedMonth);
|
|
||||||
const lastMonthSpent = analytics.totals[lastMonth]?.spent || 0;
|
|
||||||
const delta = totalSpent - lastMonthSpent;
|
|
||||||
|
|
||||||
// Category rows for selected month, sorted by spend
|
// Category rows for selected month
|
||||||
const categoryRows = analytics.rows
|
const categoryRows = analytics.rows
|
||||||
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
.filter((r) => (r.spent[selectedMonth] || 0) > 0)
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
@@ -86,18 +96,22 @@ export default function AnalyticsPage() {
|
|||||||
|
|
||||||
const largestCategory = categoryRows[0];
|
const largestCategory = categoryRows[0];
|
||||||
|
|
||||||
// Data for monthly trend bar chart
|
// Cashflow bar chart data (chronological)
|
||||||
const trendData = months.map((m) => ({
|
const cashflowData = months.map((m) => ({
|
||||||
month: m,
|
month: m,
|
||||||
label: formatShortMonth(m),
|
label: formatShortMonth(m),
|
||||||
spent: analytics.totals[m]?.spent || 0,
|
income: analytics.totals[m]?.income || 0,
|
||||||
|
expenses: analytics.totals[m]?.spent || 0,
|
||||||
|
investments: analytics.totals[m]?.investments || 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Data for horizontal category bar chart
|
// Category horizontal bar chart
|
||||||
const categoryChartData = [...categoryRows].reverse(); // smallest at top for readability
|
const categoryChartData = [...categoryRows].reverse();
|
||||||
|
|
||||||
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
const chartHeight = Math.max(categoryChartData.length * 36, 120);
|
||||||
|
|
||||||
|
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||||
|
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header + month selector */}
|
{/* Header + month selector */}
|
||||||
@@ -122,16 +136,41 @@ export default function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary cards */}
|
{/* Cashflow summary cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<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">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Total Spent</p>
|
<p className="text-xs text-zinc-500 mb-1">Expenses</p>
|
||||||
<p className="text-2xl font-semibold">{fmtExact(totalSpent)}</p>
|
<p className="text-2xl font-semibold">{fmtExact(totals.spent)}</p>
|
||||||
<p className="text-xs text-zinc-500 mt-1">split-adjusted</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>
|
</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">
|
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Largest Category</p>
|
<p className="text-xs text-zinc-500 mb-1">{hasIncome ? "Net Cash" : "Largest Category"}</p>
|
||||||
{largestCategory ? (
|
{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-2xl font-semibold">{fmtExact(largestCategory.spent)}</p>
|
||||||
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
|
<p className="text-xs text-zinc-400 mt-1">{formatCategory(largestCategory.category)}</p>
|
||||||
@@ -140,33 +179,15 @@ export default function AnalyticsPage() {
|
|||||||
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
|
||||||
<p className="text-xs text-zinc-500 mb-1">vs Last Month</p>
|
|
||||||
{lastMonthSpent > 0 ? (
|
|
||||||
<>
|
|
||||||
<p
|
|
||||||
className={`text-2xl font-semibold ${
|
|
||||||
delta > 0 ? "text-red-400" : delta < 0 ? "text-emerald-400" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{delta > 0 ? "+" : ""}
|
|
||||||
{fmtExact(delta)}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
{delta > 0 ? "↑ more" : delta < 0 ? "↓ less" : "same"} than {formatShortMonth(lastMonth)}
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-2xl font-semibold text-zinc-600">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monthly trend bar chart */}
|
{/* Cashflow bar chart — income vs expenses vs investments */}
|
||||||
<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">Monthly Spending</h3>
|
<h3 className="text-sm font-medium mb-4">
|
||||||
<ResponsiveContainer width="100%" height={200}>
|
{hasIncome ? "Cashflow — 6 Months" : "Monthly Spending"}
|
||||||
<BarChart data={trendData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={cashflowData} margin={{ top: 0, right: 8, bottom: 0, left: 8 }}>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
tick={{ fill: "#71717a", fontSize: 12 }}
|
tick={{ fill: "#71717a", fontSize: 12 }}
|
||||||
@@ -181,26 +202,31 @@ export default function AnalyticsPage() {
|
|||||||
width={56}
|
width={56}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
formatter={(value, name) => [fmtExact(Number(value)), name === "expenses" ? "Expenses" : name === "income" ? "Income" : "Invested"]}
|
||||||
contentStyle={{
|
contentStyle={TOOLTIP_STYLE}
|
||||||
background: "#18181b",
|
|
||||||
border: "1px solid #3f3f46",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: "#a1a1aa" }}
|
labelStyle={{ color: "#a1a1aa" }}
|
||||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||||
/>
|
/>
|
||||||
|
{hasIncome && (
|
||||||
|
<>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => value === "income" ? "Income" : value === "expenses" ? "Expenses" : "Invested"}
|
||||||
|
wrapperStyle={{ fontSize: 12, color: "#71717a" }}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="income" fill="#34d399" radius={[4, 4, 0, 0]} />
|
||||||
|
{hasInvestments && <Bar dataKey="investments" fill="#818cf8" radius={[4, 4, 0, 0]} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="spent"
|
dataKey="expenses"
|
||||||
radius={[4, 4, 0, 0]}
|
radius={[4, 4, 0, 0]}
|
||||||
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
|
onClick={(data) => setSelectedMonth((data as unknown as { month: string }).month)}
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
>
|
>
|
||||||
{trendData.map((entry) => (
|
{cashflowData.map((entry) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.month}
|
key={entry.month}
|
||||||
fill={entry.month === selectedMonth ? "#6366f1" : "#3f3f46"}
|
fill={entry.month === selectedMonth ? "#6366f1" : (hasIncome ? "#ef4444" : "#3f3f46")}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
@@ -238,12 +264,7 @@ export default function AnalyticsPage() {
|
|||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
formatter={(value) => [fmtExact(Number(value)), "Spent"]}
|
||||||
contentStyle={{
|
contentStyle={TOOLTIP_STYLE}
|
||||||
background: "#18181b",
|
|
||||||
border: "1px solid #3f3f46",
|
|
||||||
borderRadius: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
labelFormatter={(label) => formatCategory(String(label))}
|
labelFormatter={(label) => formatCategory(String(label))}
|
||||||
labelStyle={{ color: "#a1a1aa" }}
|
labelStyle={{ color: "#a1a1aa" }}
|
||||||
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
cursor={{ fill: "rgba(255,255,255,0.04)" }}
|
||||||
@@ -258,7 +279,7 @@ export default function AnalyticsPage() {
|
|||||||
{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">Category 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>
|
||||||
@@ -276,7 +297,7 @@ export default function AnalyticsPage() {
|
|||||||
<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">
|
||||||
{totalSpent > 0 ? ((spent / totalSpent) * 100).toFixed(1) : "0.0"}%
|
{totals.spent > 0 ? ((spent / totals.spent) * 100).toFixed(1) : "0.0"}%
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -330,8 +351,44 @@ export default function AnalyticsPage() {
|
|||||||
})}
|
})}
|
||||||
</tr>
|
</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">
|
<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">Total</td>
|
<td className="px-3 py-2 sticky left-0 bg-zinc-900">Expenses</td>
|
||||||
{analytics.months.map((m) => {
|
{analytics.months.map((m) => {
|
||||||
const t = analytics.totals[m];
|
const t = analytics.totals[m];
|
||||||
return (
|
return (
|
||||||
@@ -346,6 +403,25 @@ export default function AnalyticsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const CATEGORIES = [
|
|||||||
"rent",
|
"rent",
|
||||||
"transfers",
|
"transfers",
|
||||||
"income",
|
"income",
|
||||||
|
"investment",
|
||||||
"personal_care",
|
"personal_care",
|
||||||
"pets",
|
"pets",
|
||||||
"gifts",
|
"gifts",
|
||||||
|
|||||||
+3
-1
@@ -451,7 +451,9 @@ export interface MonthlyAnalyticsRow {
|
|||||||
export interface MonthlyAnalytics {
|
export interface MonthlyAnalytics {
|
||||||
months: string[];
|
months: string[];
|
||||||
rows: MonthlyAnalyticsRow[];
|
rows: MonthlyAnalyticsRow[];
|
||||||
totals: Record<string, { spent: number; budget: number }>;
|
income: Record<string, number>;
|
||||||
|
investments: Record<string, number>;
|
||||||
|
totals: Record<string, { spent: number; income: number; investments: number; net: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBudgets(month: string) {
|
export function useBudgets(month: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user