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:
co-authored by
Claude Haiku 4.5
parent
d56f942a88
commit
78487affbd
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import Price from '@/components/Price';
|
import Price from '@/components/Price';
|
||||||
|
import { generateLedgerCSV, downloadCSV, penceToPounds } from '@/lib/csvExport';
|
||||||
|
|
||||||
interface ReportData {
|
interface ReportData {
|
||||||
orders: Array<{ id: string; total: number; createdAt: Date }>;
|
orders: Array<{ id: string; total: number; createdAt: Date }>;
|
||||||
@@ -14,6 +15,8 @@ interface ReportData {
|
|||||||
export default function ReportsPageClient({ data }: { data: ReportData }) {
|
export default function ReportsPageClient({ data }: { data: ReportData }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [tab, setTab] = useState(searchParams.get('tab') ?? 'monthly-profit');
|
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)
|
// Ensure dates are parsed as Date objects (they might be strings from server)
|
||||||
const normalizedData = {
|
const normalizedData = {
|
||||||
@@ -127,10 +130,103 @@ export default function ReportsPageClient({ data }: { data: ReportData }) {
|
|||||||
};
|
};
|
||||||
}, [normalizedData]);
|
}, [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 (
|
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">
|
<div className="mt-6 inline-flex border border-line">
|
||||||
{[
|
{[
|
||||||
|
{ id: 'trends', label: 'Trends' },
|
||||||
{ id: 'monthly-profit', label: 'Monthly Profit' },
|
{ id: 'monthly-profit', label: 'Monthly Profit' },
|
||||||
{ id: 'expense-breakdown', label: 'Expenses' },
|
{ id: 'expense-breakdown', label: 'Expenses' },
|
||||||
{ id: 'cash-flow', label: 'Cash Flow' },
|
{ id: 'cash-flow', label: 'Cash Flow' },
|
||||||
@@ -147,6 +243,88 @@ export default function ReportsPageClient({ data }: { data: ReportData }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8">
|
<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' && (
|
{tab === 'monthly-profit' && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full border-collapse text-sm">
|
<table className="w-full border-collapse text-sm">
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Export financial data as CSV for accountants
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface LedgerRow {
|
||||||
|
date: Date;
|
||||||
|
type: 'INCOME' | 'EXPENSE';
|
||||||
|
description: string;
|
||||||
|
amount: number; // pence
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert pence to pounds string (e.g., 5000 -> "50.00")
|
||||||
|
*/
|
||||||
|
export function penceToPounds(pence: number): string {
|
||||||
|
return (pence / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSV from ledger data
|
||||||
|
*/
|
||||||
|
export function generateLedgerCSV(rows: LedgerRow[], title: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Header
|
||||||
|
lines.push(`${title}`);
|
||||||
|
lines.push(`Generated: ${new Date().toISOString().split('T')[0]}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Column headers
|
||||||
|
lines.push('Date,Type,Description,Category,Amount (£)');
|
||||||
|
|
||||||
|
// Data rows
|
||||||
|
const sortedRows = [...rows].sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
for (const row of sortedRows) {
|
||||||
|
const dateStr = row.date.toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const amountStr = penceToPounds(row.amount);
|
||||||
|
const category = row.category || '';
|
||||||
|
|
||||||
|
// Escape quotes in description
|
||||||
|
const description = `"${row.description.replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
lines.push(`${dateStr},${row.type},${description},${category},${amountStr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary
|
||||||
|
lines.push('');
|
||||||
|
const totalIncome = rows
|
||||||
|
.filter((r) => r.type === 'INCOME')
|
||||||
|
.reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
const totalExpense = rows
|
||||||
|
.filter((r) => r.type === 'EXPENSE')
|
||||||
|
.reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
const profit = totalIncome - totalExpense;
|
||||||
|
|
||||||
|
lines.push(`TOTAL INCOME,,,${penceToPounds(totalIncome)}`);
|
||||||
|
lines.push(`TOTAL EXPENSES,,,${penceToPounds(totalExpense)}`);
|
||||||
|
lines.push(`NET PROFIT,,,${penceToPounds(profit)}`);
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSV of audit changes for compliance
|
||||||
|
*/
|
||||||
|
export interface AuditRow {
|
||||||
|
date: Date;
|
||||||
|
action: string;
|
||||||
|
entity: string;
|
||||||
|
id: string;
|
||||||
|
amount?: number;
|
||||||
|
changedBy: string;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateAuditCSV(rows: AuditRow[]): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
lines.push('Financial Audit Trail');
|
||||||
|
lines.push(`Generated: ${new Date().toISOString().split('T')[0]}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('Date,Time,Action,Entity Type,Entity ID,Amount (£),Changed By,Reason');
|
||||||
|
|
||||||
|
const sortedRows = [...rows].sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
for (const row of sortedRows) {
|
||||||
|
const dateStr = row.date.toLocaleDateString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
});
|
||||||
|
const timeStr = row.date.toLocaleTimeString('en-GB', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
const amountStr = row.amount ? penceToPounds(row.amount) : '';
|
||||||
|
const reason = `"${(row.reason || '').replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
`${dateStr},${timeStr},${row.action},${row.entity},${row.id},${amountStr},${row.changedBy},${reason}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger browser download of CSV file
|
||||||
|
*/
|
||||||
|
export function downloadCSV(csv: string, filename: string) {
|
||||||
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
|
link.download = filename;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user