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:
co-authored by
Claude Haiku 4.5
parent
41937ffc15
commit
7cb8d9b5a6
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user