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:
co-authored by
Claude Haiku 4.5
parent
339b2d960e
commit
39f54dedee
@@ -14,7 +14,10 @@
|
|||||||
"Bash(git checkout *)",
|
"Bash(git checkout *)",
|
||||||
"Bash(git commit -m ' *)",
|
"Bash(git commit -m ' *)",
|
||||||
"Bash(git commit -m 'Remove AdminMenu from public site header *)",
|
"Bash(git commit -m 'Remove AdminMenu from public site header *)",
|
||||||
"Bash(git push *)"
|
"Bash(git push *)",
|
||||||
|
"Bash(rm -rf .next)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(git commit -m 'Wire Stage 3 reports to real data *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { prisma } from '@/lib/prisma';
|
||||||
|
import { isAdminAuthenticated } from '@/lib/adminAuth';
|
||||||
|
|
||||||
|
export async function getReportData() {
|
||||||
|
const isAuthenticated = await isAdminAuthenticated();
|
||||||
|
if (!isAuthenticated) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
const [orders, manualSales, expenses] = await Promise.all([
|
||||||
|
prisma.order.findMany({
|
||||||
|
select: { id: true, total: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
}),
|
||||||
|
prisma.manualSale.findMany({
|
||||||
|
select: { id: true, total: true, soldAt: true, createdAt: true },
|
||||||
|
orderBy: { soldAt: 'desc' },
|
||||||
|
}),
|
||||||
|
prisma.expense.findMany({
|
||||||
|
select: { id: true, amount: true, date: true, category: true },
|
||||||
|
orderBy: { date: 'desc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { orders, manualSales, expenses };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,193 +1,20 @@
|
|||||||
'use client';
|
import { getReportData } from './actions';
|
||||||
|
import ReportsPageClient from './client';
|
||||||
|
import AdminNav from '@/components/AdminNav';
|
||||||
|
import AccountsNav from '@/components/AccountsNav';
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
export default async function ReportsPage() {
|
||||||
import { useSearchParams } from 'next/navigation';
|
const data = await getReportData();
|
||||||
import Price from '@/components/Price';
|
|
||||||
|
|
||||||
export default function ReportsPage() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [tab, setTab] = useState(searchParams.get('tab') ?? 'monthly-profit');
|
|
||||||
|
|
||||||
// Mock data structure for now — in production, fetch from server action
|
|
||||||
const [data] = useState({
|
|
||||||
orders: [] as any[],
|
|
||||||
manualSales: [] as any[],
|
|
||||||
expenses: [] as any[],
|
|
||||||
});
|
|
||||||
|
|
||||||
const monthlyProfit = useMemo(() => {
|
|
||||||
// Group revenue by month, sum expenses, calculate net
|
|
||||||
const months = new Map<string, { revenue: number; expenses: number }>();
|
|
||||||
|
|
||||||
// Aggregate orders + manual sales by month
|
|
||||||
[...data.orders, ...data.manualSales].forEach((sale) => {
|
|
||||||
const date = new Date(sale.soldAt || sale.createdAt);
|
|
||||||
const key = date.toISOString().slice(0, 7); // YYYY-MM
|
|
||||||
if (!months.has(key)) months.set(key, { revenue: 0, expenses: 0 });
|
|
||||||
const m = months.get(key)!;
|
|
||||||
m.revenue += sale.total;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add expenses
|
|
||||||
data.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,
|
|
||||||
}));
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const expenseBreakdown = useMemo(() => {
|
|
||||||
const byCategory = new Map<string, number>();
|
|
||||||
data.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 }));
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const cashFlow = useMemo(() => {
|
|
||||||
const flow = new Map<string, { in: number; out: number }>();
|
|
||||||
|
|
||||||
[...data.orders, ...data.manualSales].forEach((sale) => {
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
data.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 };
|
|
||||||
});
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl px-6 py-12">
|
<div className="mx-auto max-w-4xl px-6 py-12">
|
||||||
|
<AdminNav />
|
||||||
<h1 className="font-display text-3xl">Reports</h1>
|
<h1 className="font-display text-3xl">Reports</h1>
|
||||||
<p className="mt-2 text-sm text-muted">
|
<p className="mt-2 text-sm text-muted">
|
||||||
Revenue from all sales (website + manual), minus expenses. Helps you see which months are profitable and where money is actually going.
|
Revenue from all sales (website + manual), minus expenses. Helps you see which months are profitable and where money is actually going.
|
||||||
</p>
|
</p>
|
||||||
|
<AccountsNav active="/admin/accounts/reports" />
|
||||||
<div className="mt-6 inline-flex border border-line">
|
<ReportsPageClient data={data} />
|
||||||
{[
|
|
||||||
{ 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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user