Stage 3: Complete expense pages + reports

- Add Expense CRUD pages (/expenses list, /new form, /[id] detail) + actions
- Expense form: date/description/category/amount/receipt, auto-create from purchases
- Reports page: 3 tabs (monthly profit, expense breakdown, cash flow) with tables
- Add Expenses + Reports to AccountsNav tabs

All expense features wired. Reports skeleton ready for data integration.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-15 18:14:14 +01:00
co-authored by Claude Haiku 4.5
parent 41937ffc15
commit 7cb8d9b5a6
8 changed files with 488 additions and 1 deletions
+193
View File
@@ -0,0 +1,193 @@
'use client';
import { useState, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import Price from '@/components/Price';
export default function ReportsPage() {
const searchParams = useSearchParams();
const [tab, setTab] = useState(searchParams.get('tab') ?? 'monthly-profit');
// Mock data structure for now — in production, fetch from server action
const [data] = useState({
orders: [] as any[],
manualSales: [] as any[],
expenses: [] as any[],
});
const monthlyProfit = useMemo(() => {
// Group revenue by month, sum expenses, calculate net
const months = new Map<string, { revenue: number; expenses: number }>();
// Aggregate orders + manual sales by month
[...data.orders, ...data.manualSales].forEach((sale) => {
const date = new Date(sale.soldAt || sale.createdAt);
const key = date.toISOString().slice(0, 7); // YYYY-MM
if (!months.has(key)) months.set(key, { revenue: 0, expenses: 0 });
const m = months.get(key)!;
m.revenue += sale.total;
});
// Add expenses
data.expenses.forEach((exp) => {
const date = new Date(exp.date);
const key = date.toISOString().slice(0, 7);
if (!months.has(key)) months.set(key, { revenue: 0, expenses: 0 });
const m = months.get(key)!;
m.expenses += exp.amount;
});
return Array.from(months.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, { revenue, expenses }]) => ({
month,
revenue,
expenses,
profit: revenue - expenses,
}));
}, [data]);
const expenseBreakdown = useMemo(() => {
const byCategory = new Map<string, number>();
data.expenses.forEach((exp) => {
byCategory.set(exp.category, (byCategory.get(exp.category) ?? 0) + exp.amount);
});
return Array.from(byCategory.entries())
.sort((a, b) => b[1] - a[1])
.map(([category, total]) => ({ category, total }));
}, [data]);
const cashFlow = useMemo(() => {
const flow = new Map<string, { in: number; out: number }>();
[...data.orders, ...data.manualSales].forEach((sale) => {
const date = new Date(sale.soldAt || sale.createdAt);
const key = date.toISOString().slice(0, 7);
if (!flow.has(key)) flow.set(key, { in: 0, out: 0 });
flow.get(key)!.in += sale.total;
});
data.expenses.forEach((exp) => {
const date = new Date(exp.date);
const key = date.toISOString().slice(0, 7);
if (!flow.has(key)) flow.set(key, { in: 0, out: 0 });
flow.get(key)!.out += exp.amount;
});
let cumIn = 0,
cumOut = 0;
return Array.from(flow.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([month, { in: inFlow, out: outFlow }]) => {
cumIn += inFlow;
cumOut += outFlow;
return { month, in: inFlow, out: outFlow, cumNet: cumIn - cumOut };
});
}, [data]);
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<h1 className="font-display text-3xl">Reports</h1>
<p className="mt-2 text-sm text-muted">
Revenue from all sales (website + manual), minus expenses. Helps you see which months are profitable and where money is actually going.
</p>
<div className="mt-6 inline-flex border border-line">
{[
{ id: 'monthly-profit', label: 'Monthly Profit' },
{ id: 'expense-breakdown', label: 'Expenses' },
{ id: 'cash-flow', label: 'Cash Flow' },
].map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-1.5 text-sm ${tab === t.id ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
{t.label}
</button>
))}
</div>
<div className="mt-8">
{tab === 'monthly-profit' && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
<th className="text-left py-2 px-3 font-medium">Month</th>
<th className="text-right py-2 px-3 font-medium">Revenue</th>
<th className="text-right py-2 px-3 font-medium">Expenses</th>
<th className="text-right py-2 px-3 font-medium">Profit</th>
</tr>
</thead>
<tbody>
{monthlyProfit.map((row) => (
<tr key={row.month} className="border-b border-line hover:bg-surface">
<td className="py-2 px-3">{row.month}</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.revenue} />
</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.expenses} />
</td>
<td className={`text-right py-2 px-3 font-mono font-medium ${row.profit >= 0 ? 'text-green-600' : 'text-splash-pink'}`}>
<Price cents={row.profit} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tab === 'expense-breakdown' && (
<div className="space-y-3">
{expenseBreakdown.length === 0 ? (
<p className="text-sm text-muted">No expenses recorded yet.</p>
) : (
expenseBreakdown.map((row) => (
<div key={row.category} className="flex items-center justify-between border-b border-line pb-2">
<span className="text-sm">{row.category}</span>
<span className="font-mono">
<Price cents={row.total} />
</span>
</div>
))
)}
</div>
)}
{tab === 'cash-flow' && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
<th className="text-left py-2 px-3 font-medium">Month</th>
<th className="text-right py-2 px-3 font-medium">Cash In</th>
<th className="text-right py-2 px-3 font-medium">Cash Out</th>
<th className="text-right py-2 px-3 font-medium">Cumulative</th>
</tr>
</thead>
<tbody>
{cashFlow.map((row) => (
<tr key={row.month} className="border-b border-line hover:bg-surface">
<td className="py-2 px-3">{row.month}</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.in} />
</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.out} />
</td>
<td className={`text-right py-2 px-3 font-mono font-medium ${row.cumNet >= 0 ? 'text-green-600' : 'text-splash-pink'}`}>
<Price cents={row.cumNet} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}