- Add CSV export utility for ledger data (generateLedgerCSV) - Export formatters: pence to pounds, proper CSV escaping - Date range filtering (from/to inputs) on reports page - Export CSV button with proper formatting for accountants - New \"Trends\" tab with visual profit trend chart - SVG bar chart shows last 12 months of profit - Charts color-coded: green (profit), red (loss) - Shows monthly values and best/worst month stats - Export functionality: - Filters by date range - Combines all income sources (orders + manual sales) - Includes COGS (purchases) and expenses - Generates PDF-ready CSV with summary rows - Downloads with proper date-stamped filename CSV exports are formatted for accountants and tax software: - Includes summary totals - Proper date formatting (DD/MM/YYYY) - Escaped descriptions for safe import - Clear categorization Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
478 lines
19 KiB
TypeScript
478 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import { useSearchParams } from 'next/navigation';
|
|
import Price from '@/components/Price';
|
|
import { generateLedgerCSV, downloadCSV, penceToPounds } from '@/lib/csvExport';
|
|
|
|
interface ReportData {
|
|
orders: Array<{ id: string; total: number; createdAt: Date }>;
|
|
manualSales: Array<{ id: string; total: number; soldAt: Date; createdAt: Date }>;
|
|
expenses: Array<{ id: string; amount: number; date: Date; category: string }>;
|
|
purchases: Array<{ id: string; total: number; purchasedAt: Date }>;
|
|
}
|
|
|
|
export default function ReportsPageClient({ data }: { data: ReportData }) {
|
|
const searchParams = useSearchParams();
|
|
const [tab, setTab] = useState(searchParams.get('tab') ?? 'monthly-profit');
|
|
const [fromDate, setFromDate] = useState('');
|
|
const [toDate, setToDate] = useState('');
|
|
|
|
// Ensure dates are parsed as Date objects (they might be strings from server)
|
|
const normalizedData = {
|
|
orders: data.orders.map(o => ({
|
|
...o,
|
|
createdAt: typeof o.createdAt === 'string' ? new Date(o.createdAt) : o.createdAt,
|
|
})),
|
|
manualSales: data.manualSales.map(m => ({
|
|
...m,
|
|
soldAt: typeof m.soldAt === 'string' ? new Date(m.soldAt) : m.soldAt,
|
|
createdAt: typeof m.createdAt === 'string' ? new Date(m.createdAt) : m.createdAt,
|
|
})),
|
|
expenses: data.expenses.map(e => ({
|
|
...e,
|
|
date: typeof e.date === 'string' ? new Date(e.date) : e.date,
|
|
})),
|
|
purchases: data.purchases.map(p => ({
|
|
...p,
|
|
purchasedAt: typeof p.purchasedAt === 'string' ? new Date(p.purchasedAt) : p.purchasedAt,
|
|
})),
|
|
};
|
|
|
|
const monthlyProfit = useMemo(() => {
|
|
const months = new Map<string, { revenue: number; expenses: number }>();
|
|
|
|
[...normalizedData.orders, ...normalizedData.manualSales].forEach((sale: any) => {
|
|
const date = new Date(sale.soldAt || sale.createdAt);
|
|
const key = date.toISOString().slice(0, 7);
|
|
if (!months.has(key)) months.set(key, { revenue: 0, expenses: 0 });
|
|
const m = months.get(key)!;
|
|
m.revenue += sale.total;
|
|
});
|
|
|
|
normalizedData.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,
|
|
}));
|
|
}, [normalizedData]);
|
|
|
|
const expenseBreakdown = useMemo(() => {
|
|
const byCategory = new Map<string, number>();
|
|
normalizedData.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 }));
|
|
}, [normalizedData]);
|
|
|
|
const cashFlow = useMemo(() => {
|
|
const flow = new Map<string, { in: number; out: number }>();
|
|
|
|
[...normalizedData.orders, ...normalizedData.manualSales].forEach((sale: any) => {
|
|
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;
|
|
});
|
|
|
|
normalizedData.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 };
|
|
});
|
|
}, [normalizedData]);
|
|
|
|
const hmrcReport = useMemo(() => {
|
|
const totalRevenue = [...normalizedData.orders, ...normalizedData.manualSales].reduce((sum, s: any) => sum + s.total, 0);
|
|
const costOfGoodsSold = normalizedData.purchases.reduce((sum, p) => sum + p.total, 0);
|
|
const grossProfit = totalRevenue - costOfGoodsSold;
|
|
const totalExpenses = normalizedData.expenses.reduce((sum, e) => sum + e.amount, 0);
|
|
const netProfit = grossProfit - totalExpenses;
|
|
|
|
// UK tax estimation: 19% corporation tax for profits (simplified)
|
|
// Self-employed allowance is £1,000
|
|
const taxableProfit = Math.max(0, netProfit - 100000); // Simplified threshold
|
|
const estimatedTax = Math.max(0, Math.round(taxableProfit * 0.19));
|
|
|
|
return {
|
|
totalRevenue,
|
|
costOfGoodsSold,
|
|
grossProfit,
|
|
grossMargin: totalRevenue > 0 ? (grossProfit / totalRevenue * 100) : 0,
|
|
totalExpenses,
|
|
netProfit,
|
|
profitMargin: totalRevenue > 0 ? (netProfit / totalRevenue * 100) : 0,
|
|
estimatedTax,
|
|
};
|
|
}, [normalizedData]);
|
|
|
|
const handleExportLedger = () => {
|
|
const from = fromDate ? new Date(fromDate) : null;
|
|
const to = toDate ? new Date(`${toDate}T23:59:59`) : null;
|
|
|
|
const ledgerRows = [
|
|
...normalizedData.orders
|
|
.filter((o) => (!from || o.createdAt >= from) && (!to || o.createdAt <= to))
|
|
.map((o) => ({
|
|
date: o.createdAt,
|
|
type: 'INCOME' as const,
|
|
description: 'Website order',
|
|
amount: o.total,
|
|
category: 'Sales',
|
|
})),
|
|
...normalizedData.manualSales
|
|
.filter((s) => (!from || s.soldAt >= from) && (!to || s.soldAt <= to))
|
|
.map((s) => ({
|
|
date: s.soldAt,
|
|
type: 'INCOME' as const,
|
|
description: 'Manual sale',
|
|
amount: s.total,
|
|
category: 'Sales',
|
|
})),
|
|
...normalizedData.purchases
|
|
.filter((p) => (!from || p.purchasedAt >= from) && (!to || p.purchasedAt <= to))
|
|
.map((p) => ({
|
|
date: p.purchasedAt,
|
|
type: 'EXPENSE' as const,
|
|
description: 'Stock purchase',
|
|
amount: p.total,
|
|
category: 'Cost of goods',
|
|
})),
|
|
...normalizedData.expenses
|
|
.filter((e) => (!from || e.date >= from) && (!to || e.date <= to))
|
|
.map((e) => ({
|
|
date: e.date,
|
|
type: 'EXPENSE' as const,
|
|
description: e.category,
|
|
amount: e.amount,
|
|
category: e.category,
|
|
})),
|
|
];
|
|
|
|
const dateRange = fromDate || toDate
|
|
? ` (${fromDate || 'start'} to ${toDate || 'today'})`
|
|
: '';
|
|
const csv = generateLedgerCSV(ledgerRows, `Ledger Export${dateRange}`);
|
|
downloadCSV(csv, `ledger-${new Date().toISOString().split('T')[0]}.csv`);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Date Filtering */}
|
|
<div className="mt-6 border border-line bg-surface p-4">
|
|
<p className="tag-label mb-3">Filter & Export</p>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<label className="flex items-center gap-1 text-sm">
|
|
From:
|
|
<input
|
|
type="date"
|
|
value={fromDate}
|
|
onChange={(e) => setFromDate(e.target.value)}
|
|
className="border border-line bg-paper px-2 py-1"
|
|
/>
|
|
</label>
|
|
<label className="flex items-center gap-1 text-sm">
|
|
To:
|
|
<input
|
|
type="date"
|
|
value={toDate}
|
|
onChange={(e) => setToDate(e.target.value)}
|
|
className="border border-line bg-paper px-2 py-1"
|
|
/>
|
|
</label>
|
|
<button
|
|
onClick={handleExportLedger}
|
|
className="border border-ink px-4 py-1.5 text-sm hover:border-clay hover:bg-clay hover:text-paper"
|
|
>
|
|
⬇ Export CSV
|
|
</button>
|
|
{(fromDate || toDate) && (
|
|
<button
|
|
onClick={() => {
|
|
setFromDate('');
|
|
setToDate('');
|
|
}}
|
|
className="text-sm text-muted hover:text-ink"
|
|
>
|
|
Clear dates
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 inline-flex border border-line">
|
|
{[
|
|
{ id: 'trends', label: 'Trends' },
|
|
{ id: 'monthly-profit', label: 'Monthly Profit' },
|
|
{ id: 'expense-breakdown', label: 'Expenses' },
|
|
{ id: 'cash-flow', label: 'Cash Flow' },
|
|
{ id: 'hmrc', label: 'HMRC Report' },
|
|
].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 === 'trends' && (
|
|
<div className="space-y-8">
|
|
<div>
|
|
<h3 className="font-display text-lg mb-4">Monthly Profit Trend</h3>
|
|
<div className="overflow-x-auto">
|
|
<svg viewBox="0 0 1000 300" className="w-full border border-line bg-surface p-4">
|
|
{/* Y-axis */}
|
|
<line x1="50" y1="20" x2="50" y2="260" stroke="currentColor" strokeWidth="2" className="text-line" />
|
|
{/* X-axis */}
|
|
<line x1="50" y1="260" x2="950" y2="260" stroke="currentColor" strokeWidth="2" className="text-line" />
|
|
|
|
{monthlyProfit.slice(-12).map((row, i) => {
|
|
const maxProfit = Math.max(...monthlyProfit.map((r) => Math.abs(r.profit)), 1);
|
|
const x = 50 + (i / 11) * 900;
|
|
const barHeight = (Math.abs(row.profit) / maxProfit) * 200;
|
|
const y = 260 - barHeight;
|
|
const isPositive = row.profit >= 0;
|
|
|
|
return (
|
|
<g key={row.month}>
|
|
{/* Bar */}
|
|
<rect
|
|
x={x - 30}
|
|
y={y}
|
|
width="60"
|
|
height={barHeight}
|
|
fill={isPositive ? '#10b981' : '#ec4899'}
|
|
opacity="0.8"
|
|
/>
|
|
{/* Month label */}
|
|
<text
|
|
x={x}
|
|
y="280"
|
|
textAnchor="middle"
|
|
fontSize="12"
|
|
className="text-muted"
|
|
fill="currentColor"
|
|
>
|
|
{row.month.slice(5)}
|
|
</text>
|
|
{/* Value label */}
|
|
<text
|
|
x={x}
|
|
y={y - 5}
|
|
textAnchor="middle"
|
|
fontSize="11"
|
|
fontFamily="monospace"
|
|
className="text-muted"
|
|
fill="currentColor"
|
|
>
|
|
£{(row.profit / 100).toFixed(0)}
|
|
</text>
|
|
</g>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-3">
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Avg Monthly Profit</p>
|
|
<p className="mt-2 font-mono text-xl">
|
|
<Price cents={Math.round(monthlyProfit.reduce((sum, m) => sum + m.profit, 0) / Math.max(monthlyProfit.length, 1))} />
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Best Month</p>
|
|
<p className="mt-2 font-mono text-xl text-green-600">
|
|
<Price cents={Math.max(...monthlyProfit.map((m) => m.profit), 0)} />
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Worst Month</p>
|
|
<p className="mt-2 font-mono text-xl text-splash-pink">
|
|
<Price cents={Math.min(...monthlyProfit.map((m) => m.profit), 0)} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{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>
|
|
)}
|
|
|
|
{tab === 'hmrc' && (
|
|
<div className="space-y-6">
|
|
<div className="border border-splash-blue/40 bg-splash-blue/5 px-4 py-3 text-sm text-splash-blue">
|
|
ⓘ Summary for HMRC Self Assessment. Keep invoices and receipts as evidence.
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Total Turnover</p>
|
|
<p className="mt-2 font-mono text-2xl">
|
|
<Price cents={hmrcReport.totalRevenue} />
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Cost of Goods Sold</p>
|
|
<p className="mt-2 font-mono text-2xl">
|
|
<Price cents={hmrcReport.costOfGoodsSold} />
|
|
</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Gross Profit</p>
|
|
<p className="mt-2 font-mono text-2xl">
|
|
<Price cents={hmrcReport.grossProfit} />
|
|
</p>
|
|
<p className="mt-1 text-xs text-muted">{hmrcReport.grossMargin.toFixed(1)}% margin</p>
|
|
</div>
|
|
<div className="border border-line p-4">
|
|
<p className="tag-label">Operating Expenses</p>
|
|
<p className="mt-2 font-mono text-2xl">
|
|
<Price cents={hmrcReport.totalExpenses} />
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-2 border-line p-6">
|
|
<div className="flex items-baseline justify-between">
|
|
<p className="font-display text-lg">Net Profit (before tax)</p>
|
|
<p className={`font-mono text-3xl font-medium ${hmrcReport.netProfit >= 0 ? 'text-green-600' : 'text-splash-pink'}`}>
|
|
<Price cents={hmrcReport.netProfit} />
|
|
</p>
|
|
</div>
|
|
<p className="mt-2 text-xs text-muted">{hmrcReport.profitMargin.toFixed(1)}% of revenue</p>
|
|
</div>
|
|
|
|
<div className="border-2 border-splash-pink p-6">
|
|
<div className="flex items-baseline justify-between">
|
|
<p className="font-display text-lg">Estimated Tax Liability</p>
|
|
<p className="font-mono text-3xl font-medium text-splash-pink">
|
|
<Price cents={hmrcReport.estimatedTax} />
|
|
</p>
|
|
</div>
|
|
<p className="mt-3 text-xs text-muted">
|
|
Based on 19% corporation tax on profits. Self-employed? Your actual rate may differ. Consult an accountant for accurate figures.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="text-xs text-muted space-y-2">
|
|
<p><strong>What to report to HMRC:</strong></p>
|
|
<ul className="list-disc pl-5 space-y-1">
|
|
<li>Total turnover: <span className="font-mono"><Price cents={hmrcReport.totalRevenue} /></span></li>
|
|
<li>Cost of sales (purchases): <span className="font-mono"><Price cents={hmrcReport.costOfGoodsSold} /></span></li>
|
|
<li>Running expenses: <span className="font-mono"><Price cents={hmrcReport.totalExpenses} /></span></li>
|
|
<li>Profit before tax: <span className="font-mono"><Price cents={hmrcReport.netProfit} /></span></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|