Phase 2: Better reporting UI with charts and exports

- 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>
This commit is contained in:
Andymick
2026-07-17 15:26:20 +01:00
co-authored by Claude Haiku 4.5
parent d56f942a88
commit 78487affbd
2 changed files with 300 additions and 0 deletions
+178
View File
@@ -3,6 +3,7 @@
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 }>;
@@ -14,6 +15,8 @@ interface ReportData {
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 = {
@@ -127,10 +130,103 @@ export default function ReportsPageClient({ data }: { data: ReportData }) {
};
}, [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' },
@@ -147,6 +243,88 @@ export default function ReportsPageClient({ data }: { data: ReportData }) {
</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">