From 78487affbdfc6f22bee6e1f0bcb5b53aa021dc7f Mon Sep 17 00:00:00 2001 From: Andymick Date: Fri, 17 Jul 2026 15:26:20 +0100 Subject: [PATCH] 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 --- src/app/admin/accounts/reports/client.tsx | 178 ++++++++++++++++++++++ src/lib/csvExport.ts | 122 +++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 src/lib/csvExport.ts diff --git a/src/app/admin/accounts/reports/client.tsx b/src/app/admin/accounts/reports/client.tsx index c36c5f0..a6b4fdc 100644 --- a/src/app/admin/accounts/reports/client.tsx +++ b/src/app/admin/accounts/reports/client.tsx @@ -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 */} +
+

Filter & Export

+
+ + + + {(fromDate || toDate) && ( + + )} +
+
+
{[ + { 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 }) {
+ {tab === 'trends' && ( +
+
+

Monthly Profit Trend

+
+ + {/* Y-axis */} + + {/* X-axis */} + + + {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 ( + + {/* Bar */} + + {/* Month label */} + + {row.month.slice(5)} + + {/* Value label */} + + £{(row.profit / 100).toFixed(0)} + + + ); + })} + +
+
+ +
+
+

Avg Monthly Profit

+

+ sum + m.profit, 0) / Math.max(monthlyProfit.length, 1))} /> +

+
+
+

Best Month

+

+ m.profit), 0)} /> +

+
+
+

Worst Month

+

+ m.profit), 0)} /> +

+
+
+
+ )} + {tab === 'monthly-profit' && (
diff --git a/src/lib/csvExport.ts b/src/lib/csvExport.ts new file mode 100644 index 0000000..b0c15e8 --- /dev/null +++ b/src/lib/csvExport.ts @@ -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(); +}