Wire Stage 3 reports to real data

Created getReportData server action to fetch Orders, ManualSales, and
Expenses from database. Split reports page into server component (fetches
data) and client component (renders charts). Reports now display actual
business data instead of mock data - monthly profit, expense breakdown,
and cash flow tables.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-15 19:24:56 +01:00
co-authored by Claude Haiku 4.5
parent 339b2d960e
commit 39f54dedee
4 changed files with 240 additions and 183 deletions
+201
View File
@@ -0,0 +1,201 @@
'use client';
import { useState, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import Price from '@/components/Price';
interface ReportData {
orders: Array<{ id: string; total: number; createdAt: Date }>;
manualSales: Array<{ id: string; total: number; soldAt: Date; createdAt: Date }>;
expenses: Array<{ id: string; amount: number; date: Date; category: string }>;
}
export default function ReportsPageClient({ data }: { data: ReportData }) {
const searchParams = useSearchParams();
const [tab, setTab] = useState(searchParams.get('tab') ?? 'monthly-profit');
// Ensure dates are parsed as Date objects (they might be strings from server)
const normalizedData = {
orders: data.orders.map(o => ({
...o,
createdAt: typeof o.createdAt === 'string' ? new Date(o.createdAt) : o.createdAt,
})),
manualSales: data.manualSales.map(m => ({
...m,
soldAt: typeof m.soldAt === 'string' ? new Date(m.soldAt) : m.soldAt,
createdAt: typeof m.createdAt === 'string' ? new Date(m.createdAt) : m.createdAt,
})),
expenses: data.expenses.map(e => ({
...e,
date: typeof e.date === 'string' ? new Date(e.date) : e.date,
})),
};
const monthlyProfit = useMemo(() => {
const months = new Map<string, { revenue: number; expenses: number }>();
[...normalizedData.orders, ...normalizedData.manualSales].forEach((sale: any) => {
const date = new Date(sale.soldAt || sale.createdAt);
const key = date.toISOString().slice(0, 7);
if (!months.has(key)) months.set(key, { revenue: 0, expenses: 0 });
const m = months.get(key)!;
m.revenue += sale.total;
});
normalizedData.expenses.forEach((exp) => {
const date = new Date(exp.date);
const key = date.toISOString().slice(0, 7);
if (!months.has(key)) months.set(key, { revenue: 0, expenses: 0 });
const m = months.get(key)!;
m.expenses += exp.amount;
});
return Array.from(months.entries())
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([month, { revenue, expenses }]) => ({
month,
revenue,
expenses,
profit: revenue - expenses,
}));
}, [normalizedData]);
const expenseBreakdown = useMemo(() => {
const byCategory = new Map<string, number>();
normalizedData.expenses.forEach((exp) => {
byCategory.set(exp.category, (byCategory.get(exp.category) ?? 0) + exp.amount);
});
return Array.from(byCategory.entries())
.sort((a, b) => b[1] - a[1])
.map(([category, total]) => ({ category, total }));
}, [normalizedData]);
const cashFlow = useMemo(() => {
const flow = new Map<string, { in: number; out: number }>();
[...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 };
});
}, [normalizedData]);
return (
<>
<div className="mt-6 inline-flex border border-line">
{[
{ id: 'monthly-profit', label: 'Monthly Profit' },
{ id: 'expense-breakdown', label: 'Expenses' },
{ id: 'cash-flow', label: 'Cash Flow' },
].map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-1.5 text-sm ${tab === t.id ? 'bg-ink text-paper' : 'hover:text-clay'}`}
>
{t.label}
</button>
))}
</div>
<div className="mt-8">
{tab === 'monthly-profit' && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
<th className="text-left py-2 px-3 font-medium">Month</th>
<th className="text-right py-2 px-3 font-medium">Revenue</th>
<th className="text-right py-2 px-3 font-medium">Expenses</th>
<th className="text-right py-2 px-3 font-medium">Profit</th>
</tr>
</thead>
<tbody>
{monthlyProfit.map((row) => (
<tr key={row.month} className="border-b border-line hover:bg-surface">
<td className="py-2 px-3">{row.month}</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.revenue} />
</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.expenses} />
</td>
<td className={`text-right py-2 px-3 font-mono font-medium ${row.profit >= 0 ? 'text-green-600' : 'text-splash-pink'}`}>
<Price cents={row.profit} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{tab === 'expense-breakdown' && (
<div className="space-y-3">
{expenseBreakdown.length === 0 ? (
<p className="text-sm text-muted">No expenses recorded yet.</p>
) : (
expenseBreakdown.map((row) => (
<div key={row.category} className="flex items-center justify-between border-b border-line pb-2">
<span className="text-sm">{row.category}</span>
<span className="font-mono">
<Price cents={row.total} />
</span>
</div>
))
)}
</div>
)}
{tab === 'cash-flow' && (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-line">
<th className="text-left py-2 px-3 font-medium">Month</th>
<th className="text-right py-2 px-3 font-medium">Cash In</th>
<th className="text-right py-2 px-3 font-medium">Cash Out</th>
<th className="text-right py-2 px-3 font-medium">Cumulative</th>
</tr>
</thead>
<tbody>
{cashFlow.map((row) => (
<tr key={row.month} className="border-b border-line hover:bg-surface">
<td className="py-2 px-3">{row.month}</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.in} />
</td>
<td className="text-right py-2 px-3 font-mono">
<Price cents={row.out} />
</td>
<td className={`text-right py-2 px-3 font-mono font-medium ${row.cumNet >= 0 ? 'text-green-600' : 'text-splash-pink'}`}>
<Price cents={row.cumNet} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</>
);
}