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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ const TABS = [
|
||||
{ href: '/admin/accounts', label: 'Ledger' },
|
||||
{ href: '/admin/accounts/stock', label: 'Stock' },
|
||||
{ href: '/admin/accounts/purchases', label: 'Purchases' },
|
||||
{ href: '/admin/accounts/expenses', label: 'Expenses' },
|
||||
{ href: '/admin/accounts/reports', label: 'Reports' },
|
||||
] as const;
|
||||
|
||||
export default function AccountsNav({ active }: { active: (typeof TABS)[number]['href'] }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user