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'}`}> - -
-
- )} -
+ +
); }