diff --git a/src/app/admin/accounts/reports/client.tsx b/src/app/admin/accounts/reports/client.tsx index a6b4fdc..34930d4 100644 --- a/src/app/admin/accounts/reports/client.tsx +++ b/src/app/admin/accounts/reports/client.tsx @@ -14,9 +14,13 @@ interface ReportData { export default function ReportsPageClient({ data }: { data: ReportData }) { const searchParams = useSearchParams(); - const [tab, setTab] = useState(searchParams.get('tab') ?? 'monthly-profit'); + const [tab, setTab] = useState(searchParams.get('tab') ?? 'kpi'); const [fromDate, setFromDate] = useState(''); const [toDate, setToDate] = useState(''); + const [expenseCategoryFilter, setExpenseCategoryFilter] = useState(''); + const [drillDownMonth, setDrillDownMonth] = useState(null); + const [sortBy, setSortBy] = useState<{ field: string; order: 'asc' | 'desc' }>({ field: 'date', order: 'desc' }); + const [exportFormat, setExportFormat] = useState<'all' | 'income' | 'expenses' | 'tax-year'>('all'); // Ensure dates are parsed as Date objects (they might be strings from server) const normalizedData = { @@ -39,6 +43,17 @@ export default function ReportsPageClient({ data }: { data: ReportData }) { })), }; + // Calculate YTD (current calendar year) + const now = new Date(); + const ytdStart = new Date(now.getFullYear(), 0, 1); + + // Calculate UK tax year (April-April) + const getCurrentTaxYear = () => { + const apr = new Date(now.getFullYear(), 3, 6); + if (now < apr) return new Date(now.getFullYear() - 1, 3, 6); + return apr; + }; + const monthlyProfit = useMemo(() => { const months = new Map(); @@ -68,6 +83,36 @@ export default function ReportsPageClient({ data }: { data: ReportData }) { })); }, [normalizedData]); + // Growth metrics + const growthMetrics = useMemo(() => { + if (monthlyProfit.length < 2) return { momGrowth: 0, marginTrend: [] }; + + const lastMonth = monthlyProfit[0].profit; + const prevMonth = monthlyProfit[1].profit; + const momGrowth = prevMonth !== 0 ? ((lastMonth - prevMonth) / Math.abs(prevMonth)) * 100 : 0; + + const marginTrend = monthlyProfit.map(m => ({ + month: m.month, + margin: m.revenue > 0 ? (m.profit / m.revenue) * 100 : 0, + })); + + return { momGrowth, marginTrend }; + }, [monthlyProfit]); + + // Income breakdown + const incomeBreakdown = useMemo(() => { + const webSales = normalizedData.orders.reduce((sum, o) => sum + o.total, 0); + const manualSales = normalizedData.manualSales.reduce((sum, s) => sum + s.total, 0); + const total = webSales + manualSales; + return { + webSales, + manualSales, + total, + webPercent: total > 0 ? (webSales / total) * 100 : 0, + manualPercent: total > 0 ? (manualSales / total) * 100 : 0, + }; + }, [normalizedData]); + const expenseBreakdown = useMemo(() => { const byCategory = new Map(); normalizedData.expenses.forEach((exp) => { @@ -78,180 +123,349 @@ export default function ReportsPageClient({ data }: { data: ReportData }) { .map(([category, total]) => ({ category, total })); }, [normalizedData]); - const cashFlow = useMemo(() => { - const flow = new Map(); - - [...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 }; - }); + const expenseCategories = useMemo(() => { + return [...new Set(normalizedData.expenses.map(e => e.category))]; }, [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; + // YTD calculations + const ytdStats = useMemo(() => { + const ytdOrders = normalizedData.orders.filter(o => o.createdAt >= ytdStart); + const ytdManual = normalizedData.manualSales.filter(s => s.soldAt >= ytdStart); + const ytdExpenses = normalizedData.expenses.filter(e => e.date >= ytdStart); + const ytdPurchases = normalizedData.purchases.filter(p => p.purchasedAt >= ytdStart); - // 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)); + const revenue = ytdOrders.reduce((sum, o) => sum + o.total, 0) + ytdManual.reduce((sum, s) => sum + s.total, 0); + const cogs = ytdPurchases.reduce((sum, p) => sum + p.total, 0); + const expenses = ytdExpenses.reduce((sum, e) => sum + e.amount, 0); + const profit = revenue - cogs - expenses; - return { - totalRevenue, - costOfGoodsSold, - grossProfit, - grossMargin: totalRevenue > 0 ? (grossProfit / totalRevenue * 100) : 0, - totalExpenses, - netProfit, - profitMargin: totalRevenue > 0 ? (netProfit / totalRevenue * 100) : 0, - estimatedTax, - }; - }, [normalizedData]); + return { revenue, cogs, expenses, profit }; + }, [normalizedData, ytdStart]); + + // Tax year calculations + const taxYearStart = getCurrentTaxYear(); + const taxYearStats = useMemo(() => { + const tyOrders = normalizedData.orders.filter(o => o.createdAt >= taxYearStart); + const tyManual = normalizedData.manualSales.filter(s => s.soldAt >= taxYearStart); + const tyExpenses = normalizedData.expenses.filter(e => e.date >= taxYearStart); + const tyPurchases = normalizedData.purchases.filter(p => p.purchasedAt >= taxYearStart); + + const revenue = tyOrders.reduce((sum, o) => sum + o.total, 0) + tyManual.reduce((sum, s) => sum + s.total, 0); + const cogs = tyPurchases.reduce((sum, p) => sum + p.total, 0); + const expenses = tyExpenses.reduce((sum, e) => sum + e.amount, 0); + const profit = revenue - cogs - expenses; + + return { revenue, cogs, expenses, profit }; + }, [normalizedData, taxYearStart]); + + // Drill-down data + const drillDownData = useMemo(() => { + if (!drillDownMonth) return []; + + const items: any[] = []; + + normalizedData.orders.forEach(o => { + const monthStr = o.createdAt.toISOString().slice(0, 7); + if (monthStr === drillDownMonth) { + items.push({ + date: o.createdAt, + type: 'INCOME', + description: 'Website order', + amount: o.total, + category: 'Sales', + id: o.id, + }); + } + }); + + normalizedData.manualSales.forEach(s => { + const monthStr = s.soldAt.toISOString().slice(0, 7); + if (monthStr === drillDownMonth) { + items.push({ + date: s.soldAt, + type: 'INCOME', + description: 'Manual sale', + amount: s.total, + category: 'Sales', + id: s.id, + }); + } + }); + + normalizedData.expenses.forEach(e => { + const monthStr = e.date.toISOString().slice(0, 7); + if (monthStr === drillDownMonth) { + items.push({ + date: e.date, + type: 'EXPENSE', + description: e.category, + amount: e.amount, + category: e.category, + id: e.id, + }); + } + }); + + normalizedData.purchases.forEach(p => { + const monthStr = p.purchasedAt.toISOString().slice(0, 7); + if (monthStr === drillDownMonth) { + items.push({ + date: p.purchasedAt, + type: 'EXPENSE', + description: 'Stock purchase', + amount: p.total, + category: 'Cost of goods', + id: p.id, + }); + } + }); + + // Sort + const sorted = [...items].sort((a, b) => { + if (sortBy.field === 'date') { + return sortBy.order === 'desc' ? b.date - a.date : a.date - b.date; + } else if (sortBy.field === 'amount') { + return sortBy.order === 'desc' ? b.amount - a.amount : a.amount - b.amount; + } + return 0; + }); + + return sorted; + }, [drillDownMonth, normalizedData, sortBy]); 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, - })), - ]; + let ledgerRows: any[] = []; + + if (exportFormat === 'tax-year') { + from = taxYearStart; + to = new Date(); + } + + if (exportFormat === 'all' || exportFormat === 'income') { + ledgerRows.push( + ...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', + })) + ); + } + + if (exportFormat === 'all' || exportFormat === 'expenses') { + ledgerRows.push( + ...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, + })) + ); + } + + let title = 'Ledger Export'; + if (exportFormat === 'income') title = 'Income Export'; + else if (exportFormat === 'expenses') title = 'Expenses Export'; + else if (exportFormat === 'tax-year') title = `Tax Year ${taxYearStart.getFullYear()}-${taxYearStart.getFullYear() + 1}`; 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`); + + const csv = generateLedgerCSV(ledgerRows, `${title}${dateRange}`); + downloadCSV(csv, `${title.toLowerCase().replace(/\s+/g, '-')}-${new Date().toISOString().split('T')[0]}.csv`); }; + const filteredExpenses = expenseCategoryFilter + ? normalizedData.expenses.filter(e => e.category === expenseCategoryFilter) + : normalizedData.expenses; + return ( <> - {/* Date Filtering */} + {/* Date Filtering & Export */}

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' }, - { id: 'hmrc', label: 'HMRC Report' }, - ].map((t) => ( - - ))} + {/* Tabs */} +
+
+ {[ + { id: 'kpi', label: 'KPIs' }, + { id: 'trends', label: 'Trends' }, + { id: 'monthly-profit', label: 'Monthly Profit' }, + { id: 'income-breakdown', label: 'Income' }, + { id: 'expense-breakdown', label: 'Expenses' }, + { id: 'revenue-vs-expenses', label: 'Revenue vs Expenses' }, + { id: 'cash-flow', label: 'Cash Flow' }, + { id: 'tax-year', label: 'Tax Year' }, + { id: 'hmrc', label: 'HMRC Report' }, + ].map((t) => ( + + ))} +
+ {/* KPI Dashboard */} + {tab === 'kpi' && ( +
+
+

Year-to-Date Summary (Calendar Year)

+
+
+

YTD Revenue

+

+ +

+
+
+

YTD COGS

+

+ +

+
+
+

YTD Operating Expenses

+

+ +

+
+
+

YTD Profit

+

= 0 ? 'text-green-600' : 'text-splash-pink'}`}> + +

+
+
+
+ +
+

Income Breakdown

+
+
+

Website Sales

+

+ +

+

{incomeBreakdown.webPercent.toFixed(1)}% of total

+
+
+

Manual Sales

+

+ +

+

{incomeBreakdown.manualPercent.toFixed(1)}% of total

+
+
+
+ +
+

Latest Month Growth

+
+

Month-over-Month Change

+

= 0 ? 'text-green-600' : 'text-splash-pink'}`}> + {growthMetrics.momGrowth >= 0 ? '+' : ''}{growthMetrics.momGrowth.toFixed(1)}% +

+
+
+
+ )} + + {/* Trends Chart */} {tab === 'trends' && (

Monthly Profit Trend

- {/* Y-axis */} - {/* X-axis */} {monthlyProfit.slice(-12).map((row, i) => { @@ -262,8 +476,7 @@ export default function ReportsPageClient({ data }: { data: ReportData }) { const isPositive = row.profit >= 0; return ( - - {/* Bar */} + setDrillDownMonth(row.month)} style={{ cursor: 'pointer' }}> - {/* Month label */} {row.month.slice(5)} - {/* Value label */}
+

💡 Click a bar to drill down into transaction details

@@ -325,20 +538,21 @@ export default function ReportsPageClient({ data }: { data: ReportData }) {
)} + {/* Monthly Profit Table */} {tab === 'monthly-profit' && (
- - - - + + + + {monthlyProfit.map((row) => ( - + setDrillDownMonth(row.month)}>
MonthRevenueExpensesProfit setSortBy({ field: 'month', order: sortBy.order === 'asc' ? 'desc' : 'asc' })}>Month {sortBy.field === 'month' ? (sortBy.order === 'desc' ? '▼' : '▲') : ''} setSortBy({ field: 'revenue', order: sortBy.order === 'asc' ? 'desc' : 'asc' })}>Revenue {sortBy.field === 'revenue' ? (sortBy.order === 'desc' ? '▼' : '▲') : ''} setSortBy({ field: 'expenses', order: sortBy.order === 'asc' ? 'desc' : 'asc' })}>Expenses {sortBy.field === 'expenses' ? (sortBy.order === 'desc' ? '▼' : '▲') : ''} setSortBy({ field: 'profit', order: sortBy.order === 'asc' ? 'desc' : 'asc' })}>Profit {sortBy.field === 'profit' ? (sortBy.order === 'desc' ? '▼' : '▲') : ''}
{row.month} @@ -356,23 +570,174 @@ export default function ReportsPageClient({ data }: { data: ReportData }) { )} - {tab === 'expense-breakdown' && ( -
- {expenseBreakdown.length === 0 ? ( -

No expenses recorded yet.

- ) : ( - expenseBreakdown.map((row) => ( -
- {row.category} - - - + {/* Income Breakdown */} + {tab === 'income-breakdown' && ( +
+
+

Revenue by Source

+
+
+

Website Orders

+

+ +

+

{incomeBreakdown.webPercent.toFixed(1)}% of revenue

- )) +
+

Manual Sales

+

+ +

+

{incomeBreakdown.manualPercent.toFixed(1)}% of revenue

+
+
+
+ + {/* Income pie chart */} +
+ + {incomeBreakdown.webSales > 0 && ( + + )} + {incomeBreakdown.manualSales > 0 && ( + + )} + + {incomeBreakdown.total > 0 ? `£${(incomeBreakdown.total / 100).toFixed(0)}` : 'No sales'} + + +
+
+
+ Website: +
+
+
+ Manual: +
+
+
+
+ )} + + {/* Expense Breakdown */} + {tab === 'expense-breakdown' && ( +
+
+

Expenses by Category

+ {expenseCategoryFilter && ( + + )} + {expenseBreakdown.length === 0 ? ( +

No expenses recorded yet.

+ ) : ( +
+ {expenseBreakdown.map((row) => ( +
setExpenseCategoryFilter(expenseCategoryFilter === row.category ? '' : row.category)} + > + {row.category} + + + +
+ ))} +
+ )} +
+ + {filteredExpenses.length > 0 && ( +
+

+ {expenseCategoryFilter ? `Expenses: ${expenseCategoryFilter}` : 'All Expense Transactions'} +

+
+ + + + + + + + + + + {filteredExpenses.map((exp) => ( + + + + + + + ))} + +
DateCategoryDescriptionAmount
{exp.date.toLocaleDateString('en-GB')}{exp.category}{exp.category} + +
+
+
)}
)} + {/* Revenue vs Expenses */} + {tab === 'revenue-vs-expenses' && ( +
+
+

Revenue vs Operating Expenses Trend

+
+ + + + + {monthlyProfit.slice(-12).map((row, i) => { + const maxValue = Math.max(...monthlyProfit.map((r) => Math.max(r.revenue, r.expenses)), 1); + const x = 50 + (i / 11) * 900; + + const revHeight = (row.revenue / maxValue) * 220; + const expHeight = (row.expenses / maxValue) * 220; + const revY = 300 - revHeight; + const expY = 300 - expHeight; + + return ( + + {/* Revenue bar (green) */} + + {/* Expenses bar (red) */} + + {/* Month label */} + + {row.month.slice(5)} + + + ); + })} + +
+
+
+
+ Revenue +
+
+
+ Operating Expenses +
+
+
+
+ )} + + {/* Cash Flow */} {tab === 'cash-flow' && (
@@ -385,55 +750,112 @@ export default function ReportsPageClient({ data }: { data: ReportData }) { - {cashFlow.map((row) => ( - - - - - - - ))} + {monthlyProfit.map((row) => { + // Recalculate cumulative for this view + const cumIn = monthlyProfit + .filter((m) => m.month <= row.month) + .reduce((sum, m) => sum + m.revenue, 0); + const cumOut = monthlyProfit + .filter((m) => m.month <= row.month) + .reduce((sum, m) => sum + m.expenses, 0); + const cumNet = cumIn - cumOut; + + return ( + + + + + + + ); + })}
{row.month} - - - - = 0 ? 'text-green-600' : 'text-splash-pink'}`}> - -
{row.month} + + + + = 0 ? 'text-green-600' : 'text-splash-pink'}`}> + +
)} + {/* Tax Year Summary */} + {tab === 'tax-year' && ( +
+
+ ⓘ UK Tax Year: April 6, {taxYearStart.getFullYear()} to April 5, {taxYearStart.getFullYear() + 1} +
+ +
+
+

Tax Year Revenue

+

+ +

+
+
+

Tax Year COGS

+

+ +

+
+
+

Tax Year Expenses

+

+ +

+
+
+

Tax Year Profit

+

= 0 ? 'text-green-600' : 'text-splash-pink'}`}> + +

+
+
+ +
+

+ Use the Tax Year export option above to download a CSV for your accountant. +

+
+
+ )} + + {/* HMRC Report */} {tab === 'hmrc' && (
ⓘ Summary for HMRC Self Assessment. Keep invoices and receipts as evidence.
+ {/* Use tax year stats */}

Total Turnover

- +

Cost of Goods Sold

- +

Gross Profit

- + +

+

+ {taxYearStats.revenue > 0 ? ((taxYearStats.revenue - taxYearStats.cogs) / taxYearStats.revenue * 100).toFixed(1) : 0}% margin

-

{hmrcReport.grossMargin.toFixed(1)}% margin

Operating Expenses

- +

@@ -441,36 +863,80 @@ export default function ReportsPageClient({ data }: { data: ReportData }) {

Net Profit (before tax)

-

= 0 ? 'text-green-600' : 'text-splash-pink'}`}> - +

= 0 ? 'text-green-600' : 'text-splash-pink'}`}> +

-

{hmrcReport.profitMargin.toFixed(1)}% of revenue

-
- -
-
-

Estimated Tax Liability

-

- -

-
-

- Based on 19% corporation tax on profits. Self-employed? Your actual rate may differ. Consult an accountant for accurate figures. +

+ {taxYearStats.revenue > 0 ? (taxYearStats.profit / taxYearStats.revenue * 100).toFixed(1) : 0}% of revenue

What to report to HMRC:

    -
  • Total turnover:
  • -
  • Cost of sales (purchases):
  • -
  • Running expenses:
  • -
  • Profit before tax:
  • +
  • Total turnover:
  • +
  • Cost of sales (purchases):
  • +
  • Running expenses:
  • +
  • Profit before tax:
)} + + {/* Drill-down Modal */} + {drillDownMonth && ( +
+
+
+

Transactions: {drillDownMonth}

+ +
+ +
+ + + + + + + + + + + {drillDownData.map((item) => ( + + + + + + + ))} + +
setSortBy({ field: 'date', order: sortBy.order === 'asc' ? 'desc' : 'asc' })}> + Date {sortBy.field === 'date' ? (sortBy.order === 'desc' ? '▼' : '▲') : ''} + TypeDescription setSortBy({ field: 'amount', order: sortBy.order === 'asc' ? 'desc' : 'asc' })}> + Amount {sortBy.field === 'amount' ? (sortBy.order === 'desc' ? '▼' : '▲') : ''} +
{item.date.toLocaleDateString('en-GB')} + + {item.type} + + {item.description} + +
+
+ +
+

Total: item.type === 'INCOME' ? sum + item.amount : sum - item.amount, 0)} />

+
+
+
+ )}
);