From 39f54dedee0355da6671e91ce7952d6ec5b01528 Mon Sep 17 00:00:00 2001 From: Andymick Date: Wed, 15 Jul 2026 19:24:56 +0100 Subject: [PATCH] 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 --- .claude/settings.local.json | 5 +- src/app/admin/accounts/reports/actions.ts | 26 +++ src/app/admin/accounts/reports/client.tsx | 201 ++++++++++++++++++++++ src/app/admin/accounts/reports/page.tsx | 191 +------------------- 4 files changed, 240 insertions(+), 183 deletions(-) create mode 100644 src/app/admin/accounts/reports/actions.ts create mode 100644 src/app/admin/accounts/reports/client.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 67f8a17..f89c5be 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,10 @@ "Bash(git checkout *)", "Bash(git commit -m ' *)", "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 *)" ] } } diff --git a/src/app/admin/accounts/reports/actions.ts b/src/app/admin/accounts/reports/actions.ts new file mode 100644 index 0000000..b25bc6b --- /dev/null +++ b/src/app/admin/accounts/reports/actions.ts @@ -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 }; +} diff --git a/src/app/admin/accounts/reports/client.tsx b/src/app/admin/accounts/reports/client.tsx new file mode 100644 index 0000000..d5d9f23 --- /dev/null +++ b/src/app/admin/accounts/reports/client.tsx @@ -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(); + + [...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(); + 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(); + + [...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 ( + <> +
+ {[ + { id: 'monthly-profit', label: 'Monthly Profit' }, + { id: 'expense-breakdown', label: 'Expenses' }, + { id: 'cash-flow', label: 'Cash Flow' }, + ].map((t) => ( + + ))} +
+ +
+ {tab === 'monthly-profit' && ( +
+ + + + + + + + + + + {monthlyProfit.map((row) => ( + + + + + + + ))} + +
MonthRevenueExpensesProfit
{row.month} + + + + = 0 ? 'text-green-600' : 'text-splash-pink'}`}> + +
+
+ )} + + {tab === 'expense-breakdown' && ( +
+ {expenseBreakdown.length === 0 ? ( +

No expenses recorded yet.

+ ) : ( + expenseBreakdown.map((row) => ( +
+ {row.category} + + + +
+ )) + )} +
+ )} + + {tab === 'cash-flow' && ( +
+ + + + + + + + + + + {cashFlow.map((row) => ( + + + + + + + ))} + +
MonthCash InCash OutCumulative
{row.month} + + + + = 0 ? 'text-green-600' : 'text-splash-pink'}`}> + +
+
+ )} +
+ + ); +} diff --git a/src/app/admin/accounts/reports/page.tsx b/src/app/admin/accounts/reports/page.tsx index 6d0c126..5af5e01 100644 --- a/src/app/admin/accounts/reports/page.tsx +++ b/src/app/admin/accounts/reports/page.tsx @@ -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'; -import { useSearchParams } from 'next/navigation'; -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(); - - // 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(); - 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(); - - [...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]); +export default async function ReportsPage() { + const data = await getReportData(); return (
+

Reports

Revenue from all sales (website + manual), minus expenses. Helps you see which months are profitable and where money is actually going.

- -
- {[ - { id: 'monthly-profit', label: 'Monthly Profit' }, - { id: 'expense-breakdown', label: 'Expenses' }, - { id: 'cash-flow', label: 'Cash Flow' }, - ].map((t) => ( - - ))} -
- -
- {tab === 'monthly-profit' && ( -
- - - - - - - - - - - {monthlyProfit.map((row) => ( - - - - - - - ))} - -
MonthRevenueExpensesProfit
{row.month} - - - - = 0 ? 'text-green-600' : 'text-splash-pink'}`}> - -
-
- )} - - {tab === 'expense-breakdown' && ( -
- {expenseBreakdown.length === 0 ? ( -

No expenses recorded yet.

- ) : ( - expenseBreakdown.map((row) => ( -
- {row.category} - - - -
- )) - )} -
- )} - - {tab === 'cash-flow' && ( -
- - - - - - - - - - - {cashFlow.map((row) => ( - - - - - - - ))} - -
MonthCash InCash OutCumulative
{row.month} - - - - = 0 ? 'text-green-600' : 'text-splash-pink'}`}> - -
-
- )} -
+ +
); }