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
@@ -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