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
@@ -0,0 +1,60 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import Price from '@/components/Price';
export default async function ExpenseDetailPage({ params }: { params: { id: string } }) {
const expense = await prisma.expense.findUnique({ where: { id: params.id } });
if (!expense) notFound();
const isPdf = expense.receiptDataUrl?.startsWith('data:application/pdf');
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">{expense.description}</h1>
<span className="font-mono text-sm text-muted">
{expense.date.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })}
</span>
</div>
<div className="mt-6 space-y-2 border-t border-line py-4">
<p className="text-sm">
<span className="text-muted">Category:</span> {expense.category}
</p>
<p className="font-mono text-lg">
<Price cents={expense.amount} />
</p>
{expense.notes && <p className="border-l-2 border-splash-orange pl-4 text-sm italic">{expense.notes}</p>}
</div>
{expense.receiptDataUrl && (
<div className="mt-8">
<p className="tag-label mb-3">Receipt{expense.receiptFileName ? `${expense.receiptFileName}` : ''}</p>
{isPdf ? (
<iframe src={expense.receiptDataUrl} title="Receipt" className="h-[600px] w-full border border-line" />
) : (
<img
src={expense.receiptDataUrl}
alt="Receipt"
className="max-h-[600px] w-full border border-line object-contain"
/>
)}
<a
href={expense.receiptDataUrl}
download={expense.receiptFileName ?? 'receipt'}
className="mt-2 inline-block tag-label text-clay hover:text-clay-dark"
>
Download receipt
</a>
</div>
)}
<Link href="/admin/accounts/expenses" className="mt-8 inline-block tag-label hover:text-ink">
All expenses
</Link>
</div>
);
}
@@ -0,0 +1,50 @@
'use server';
import { redirect } from 'next/navigation';
import { prisma } from '@/lib/prisma';
export async function createExpense(formData: FormData) {
const dateInput = String(formData.get('date') ?? '').trim();
const description = String(formData.get('description') ?? '').trim();
const category = String(formData.get('category') ?? 'Other').trim();
const amountInput = String(formData.get('amount') ?? '0').trim();
const notes = String(formData.get('notes') ?? '').trim();
const receipt = formData.get('receipt') as File | null;
if (!description) throw new Error('Description is required.');
const amount = Math.max(0, Math.round(Number(amountInput || '0') * 100));
if (!amount) throw new Error('Amount must be greater than 0.');
let receiptDataUrl: string | null = null;
let receiptFileName: string | null = null;
if (receipt && receipt.size > 0) {
if (receipt.size > 8 * 1024 * 1024) {
throw new Error('Receipt file is too large — keep it under 8 MB.');
}
const bytes = Buffer.from(await receipt.arrayBuffer());
const mimeType = receipt.type || 'application/octet-stream';
receiptDataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`;
receiptFileName = receipt.name || 'receipt';
}
await prisma.expense.create({
data: {
date: dateInput ? new Date(dateInput) : new Date(),
description,
category,
amount,
receiptDataUrl,
receiptFileName,
notes: notes || null,
},
});
redirect('/admin/accounts/expenses');
}
export async function deleteExpense(formData: FormData) {
const id = String(formData.get('id') ?? '');
if (!id) return;
await prisma.expense.delete({ where: { id } });
redirect('/admin/accounts/expenses');
}
@@ -0,0 +1,15 @@
import AdminNav from '@/components/AdminNav';
import ExpenseForm from '@/components/ExpenseForm';
export default function NewExpensePage() {
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<AdminNav />
<h1 className="font-display text-3xl">Record an expense</h1>
<p className="mt-2 text-sm text-muted">
Any cost that reduces profit postage, utilities, materials not purchased from suppliers, equipment, etc.
</p>
<ExpenseForm />
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import AdminNav from '@/components/AdminNav';
import AccountsNav from '@/components/AccountsNav';
import Price from '@/components/Price';
import { deleteExpense } from './actions';
export default async function ExpensesPage() {
const expenses = await prisma.expense.findMany({
orderBy: { date: 'desc' },
});
return (
<div className="mx-auto max-w-4xl px-6 py-12">
<AdminNav />
<div className="flex items-baseline justify-between gap-3">
<h1 className="font-display text-3xl">Expenses</h1>
<Link
href="/admin/accounts/expenses/new"
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper"
>
+ Record an expense
</Link>
</div>
<AccountsNav active="/admin/accounts/expenses" />
<p className="mt-4 text-sm text-muted">
Every expense that reduces profit. Auto-created from supplier invoices, plus manual entries for postage, utilities, and other costs.
</p>
{expenses.length === 0 ? (
<p className="mt-8 text-sm text-muted">No expenses recorded yet.</p>
) : (
<div className="mt-8 divide-y divide-line border-t border-line">
{expenses.map((e) => (
<div key={e.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-4">
<span className="w-24 shrink-0 font-mono text-xs text-muted">
{e.date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{e.description}</p>
<p className="truncate text-xs text-muted">{e.category}</p>
</div>
{e.receiptFileName && <span className="tag-label hidden text-muted sm:inline">Receipt</span>}
<span className="w-20 shrink-0 text-right font-mono text-sm">
<Price cents={e.amount} />
</span>
{e.receiptDataUrl && (
<Link href={`/admin/accounts/expenses/${e.id}`} className="tag-label text-muted hover:text-ink">
View
</Link>
)}
<form action={deleteExpense}>
<input type="hidden" name="id" value={e.id} />
<button type="submit" className="tag-label text-muted hover:text-splash-pink">
Delete
</button>
</form>
</div>
))}
</div>
)}
</div>
);
}
+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>
);
}
+2
View File
@@ -4,6 +4,8 @@ const TABS = [
{ href: '/admin/accounts', label: 'Ledger' }, { href: '/admin/accounts', label: 'Ledger' },
{ href: '/admin/accounts/stock', label: 'Stock' }, { href: '/admin/accounts/stock', label: 'Stock' },
{ href: '/admin/accounts/purchases', label: 'Purchases' }, { href: '/admin/accounts/purchases', label: 'Purchases' },
{ href: '/admin/accounts/expenses', label: 'Expenses' },
{ href: '/admin/accounts/reports', label: 'Reports' },
] as const; ] as const;
export default function AccountsNav({ active }: { active: (typeof TABS)[number]['href'] }) { export default function AccountsNav({ active }: { active: (typeof TABS)[number]['href'] }) {
+103
View File
@@ -0,0 +1,103 @@
'use client';
import { createExpense } from '@/app/admin/accounts/expenses/actions';
function todayInput() {
return new Date().toISOString().slice(0, 10);
}
export default function ExpenseForm() {
return (
<form action={createExpense} className="mt-10 space-y-6 max-w-2xl" encType="multipart/form-data">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label htmlFor="date" className="tag-label mb-2 block">
Date
</label>
<input
id="date"
name="date"
type="date"
defaultValue={todayInput()}
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="category" className="tag-label mb-2 block">
Category
</label>
<select
id="category"
name="category"
defaultValue="Stock purchases"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
>
<option>Stock purchases</option>
<option>Postage</option>
<option>Other</option>
</select>
</div>
</div>
<div>
<label htmlFor="description" className="tag-label mb-2 block">
Description
</label>
<input
id="description"
name="description"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="e.g. Monthly internet bill, Royal Mail postage"
/>
</div>
<div>
<label htmlFor="amount" className="tag-label mb-2 block">
Amount (£)
</label>
<input
id="amount"
name="amount"
type="number"
step="0.01"
min="0"
required
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="0.00"
/>
</div>
<div>
<label htmlFor="receipt" className="tag-label mb-2 block">
Receipt (optional)
</label>
<input
id="receipt"
name="receipt"
type="file"
accept="application/pdf,image/*"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">PDF or photo, up to 8 MB.</p>
</div>
<div>
<label htmlFor="notes" className="tag-label mb-2 block">
Notes (optional)
</label>
<input
id="notes"
name="notes"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
placeholder="Any additional details"
/>
</div>
<button type="submit" className="bg-clay px-6 py-3 text-sm font-medium text-paper hover:bg-clay-dark">
Record expense
</button>
</form>
);
}
+1 -1
View File
File diff suppressed because one or more lines are too long