diff --git a/packages/admin/src/components/AdminLayout.tsx b/packages/admin/src/components/AdminLayout.tsx index 5812947..ec2abc5 100644 --- a/packages/admin/src/components/AdminLayout.tsx +++ b/packages/admin/src/components/AdminLayout.tsx @@ -15,7 +15,16 @@ interface NavItem { const navItems: NavItem[] = [ { path: '/', label: 'Dashboard', icon: '\u25A1', roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'] }, { path: '/orders', label: 'Orders', icon: '\uD83D\uDCCB', roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'] }, - { path: '/reservations', label: 'Reservations', icon: '\uD83D\uDDD3', roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'] }, + { + path: '/reservations', + label: 'Reservations', + icon: '\uD83D\uDDD3', + roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'], + children: [ + { path: '/reservations', label: 'All Reservations' }, + { path: '/reservations/trends', label: 'Trends' }, + ], + }, { path: '/reviews', label: 'Reviews', icon: '\u2B50', roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'] }, { path: '/kitchen', label: 'Kitchen', icon: '\uD83C\uDF73', roles: ['SUPER_ADMIN', 'MANAGER', 'STAFF'] }, { path: '/locations', label: 'Locations', icon: '\u25CE', roles: ['SUPER_ADMIN', 'MANAGER'] }, diff --git a/packages/admin/src/main.tsx b/packages/admin/src/main.tsx index f230d0d..cfc9b29 100644 --- a/packages/admin/src/main.tsx +++ b/packages/admin/src/main.tsx @@ -17,6 +17,7 @@ import OrderList from './pages/OrderList.js'; import OrderDetailPage from './pages/OrderDetail.js'; import ReservationList from './pages/ReservationList.js'; import ReservationDetail from './pages/ReservationDetail.js'; +import ReservationTrends from './pages/ReservationTrends.js'; import CouponList from './pages/CouponList.js'; import CouponForm from './pages/CouponForm.js'; import ReviewList from './pages/ReviewList.js'; @@ -77,6 +78,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/admin/src/pages/ReservationTrends.tsx b/packages/admin/src/pages/ReservationTrends.tsx new file mode 100644 index 0000000..d50faba --- /dev/null +++ b/packages/admin/src/pages/ReservationTrends.tsx @@ -0,0 +1,321 @@ +import { useEffect, useState } from 'react'; +import { + AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, + XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, +} from 'recharts'; + +interface AnalyticsData { + summary: { + totalReservations: number; + totalGuests: number; + avgPartySize: number; + completionRate: number; + rangeStart: string; + rangeEnd: string; + }; + dailyBookings: { date: string; reservations: number; guests: number }[]; + dayOfWeekDistribution: { dow: number; reservations: number; guests: number }[]; + partySizeDistribution: { partySize: number; count: number }[]; + statusDistribution: { status: string; count: number }[]; + hourlyDistribution: { hour: number; reservations: number }[]; + leadTimeBuckets: { bucket: string; count: number }[]; +} + +interface Location { + id: string; + name: string; +} + +const CHART_COLORS = ['#ea580c', '#f97316', '#fb923c', '#fdba74', '#fed7aa', '#7c3aed', '#2563eb', '#059669']; + +const STATUS_COLORS: Record = { + PENDING: '#f59e0b', + CONFIRMED: '#2563eb', + SEATED: '#7c3aed', + COMPLETED: '#059669', + CANCELLED: '#dc2626', +}; + +const DOW_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + +const LEAD_TIME_LABELS: Record = { + 'same-day': 'Same day', + '1-2d': '1-2 days', + '3-7d': '3-7 days', + '8-14d': '1-2 weeks', + '15d+': '2+ weeks', +}; + +export default function ReservationTrends() { + const [data, setData] = useState(null); + const [locations, setLocations] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [days, setDays] = useState(30); + const [locationId, setLocationId] = useState(''); + + const token = localStorage.getItem('token') || ''; + + useEffect(() => { + fetch('/api/locations', { headers: { Authorization: `Bearer ${token}` } }) + .then((r) => r.json()) + .then((result) => { + if (result.success && Array.isArray(result.data)) { + setLocations(result.data.map((l: Location) => ({ id: l.id, name: l.name }))); + } + }) + .catch(() => { }); + }, [token]); + + useEffect(() => { + setLoading(true); + setError(''); + const params = new URLSearchParams({ days: String(days) }); + if (locationId) params.set('locationId', locationId); + fetch(`/api/reservations/analytics?${params.toString()}`, { + headers: { Authorization: `Bearer ${token}` }, + }) + .then((res) => { + if (!res.ok) throw new Error('Failed to load analytics'); + return res.json(); + }) + .then((result) => setData(result.data)) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [token, days, locationId]); + + const formatDate = (dateStr: string) => { + const d = new Date(dateStr + 'T00:00:00'); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + }; + + const formatHour = (hour: number) => { + if (hour === 0) return '12am'; + if (hour === 12) return '12pm'; + return hour < 12 ? `${hour}am` : `${hour - 12}pm`; + }; + + return ( +
+
+
+

Reservation Trends

+

Booking patterns, peak days, and guest distribution

+
+
+ {locations.length > 1 && ( + + )} +
+ {[7, 14, 30, 60, 90].map((d) => ( + + ))} +
+
+
+ + {loading && ( +
+
+
+ )} + + {error && !loading && ( +
{error}
+ )} + + {!loading && !error && data && ( +
+ {/* Summary cards */} +
+ + + + +
+ + {/* Daily bookings */} +
+

Daily Bookings

+ {data.dailyBookings.length === 0 ? ( + + ) : ( + + ({ ...d, label: formatDate(d.date) }))}> + + + + + + + + + + + + + + + + + + + + )} +
+ +
+ {/* Day of week */} +
+

Peak Days

+ {data.dayOfWeekDistribution.length === 0 ? ( + + ) : ( + + { + const found = data.dayOfWeekDistribution.find((d) => d.dow === i); + return { day: DOW_LABELS[i], reservations: found?.reservations ?? 0, guests: found?.guests ?? 0 }; + })} + > + + + + + + + + + + )} +
+ + {/* Party size */} +
+

Party Sizes

+ {data.partySizeDistribution.length === 0 ? ( + + ) : ( + + ({ label: `${d.partySize}`, count: d.count }))}> + + + + [value, 'Reservations']} + labelFormatter={(label) => `Party of ${label}`} + /> + + + + )} +
+
+ +
+ {/* Status breakdown */} +
+

Status Breakdown

+ {data.statusDistribution.length === 0 ? ( + + ) : ( + + + `${name} (${value})`} + labelLine={false} + > + {data.statusDistribution.map((d, i) => ( + + ))} + + + + + )} +
+ + {/* Lead time */} +
+

Booking Lead Time

+

How far in advance guests book

+ + ({ label: LEAD_TIME_LABELS[b.bucket] ?? b.bucket, count: b.count }))} + layout="vertical" + > + + + + + + + +
+
+ + {/* Hourly distribution */} +
+

Reservations by Hour

+ {data.hourlyDistribution.length === 0 ? ( + + ) : ( + + { + const found = data.hourlyDistribution.find((h) => h.hour === i); + return { hour: i, label: formatHour(i), reservations: found?.reservations ?? 0 }; + })} + > + + + + + + + + )} +
+
+ )} +
+ ); +} + +function SummaryCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function EmptyState() { + return

No reservations in this window.

; +} diff --git a/packages/server/src/__tests__/integration/reservation.test.ts b/packages/server/src/__tests__/integration/reservation.test.ts index c4abf04..a2bcd2b 100644 --- a/packages/server/src/__tests__/integration/reservation.test.ts +++ b/packages/server/src/__tests__/integration/reservation.test.ts @@ -11,7 +11,8 @@ vi.mock('../../lib/db.js', () => { menuItem: { findMany: vi.fn(), findUnique: vi.fn(), update: vi.fn() }, deliveryZone: { findMany: vi.fn(), findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, table: { findMany: vi.fn(), findFirst: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }, - reservation: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, + reservation: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn(), groupBy: vi.fn(), aggregate: vi.fn() }, + $queryRaw: vi.fn(), user: { findUnique: vi.fn() }, customer: { findUnique: vi.fn() }, category: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), count: vi.fn() }, @@ -196,6 +197,57 @@ describe('Reservation API', () => { }); }); + describe('GET /api/reservations/analytics', () => { + it('returns 401 without auth', async () => { + const res = await request(app).get('/api/reservations/analytics'); + expect(res.status).toBe(401); + }); + + it('returns 403 for non-staff', async () => { + const res = await request(app) + .get('/api/reservations/analytics') + .set('Authorization', `Bearer ${customerToken}`); + expect(res.status).toBe(403); + }); + + it('returns analytics shape for staff', async () => { + (mockedPrisma as any).$queryRaw + .mockResolvedValueOnce([{ date: '2026-03-10', reservations: 2n, guests: 6n }]) // dailyRows + .mockResolvedValueOnce([{ dow: 5, reservations: 2n, guests: 6n }]) // dowRows + .mockResolvedValueOnce([{ hour: 19, reservations: 2n }]) // hourlyRows + .mockResolvedValueOnce([{ bucket: '1-2d', count: 2n }]); // leadTimeRows + mockedPrisma.reservation.groupBy + .mockResolvedValueOnce([{ partySize: 4, _count: 2 }] as any) // partySizeRows + .mockResolvedValueOnce([{ status: 'PENDING', _count: 2 }] as any); // statusRows + mockedPrisma.reservation.aggregate.mockResolvedValueOnce({ + _count: 2, + _sum: { partySize: 8 }, + _avg: { partySize: 4 }, + } as any); + mockedPrisma.reservation.count.mockResolvedValueOnce(0); // completedCount + + const res = await request(app) + .get('/api/reservations/analytics?days=30') + .set('Authorization', `Bearer ${staffToken}`); + + expect(res.status).toBe(200); + expect(res.body.data.summary).toMatchObject({ + totalReservations: 2, + totalGuests: 8, + avgPartySize: 4, + completionRate: 0, + }); + expect(res.body.data.dailyBookings).toEqual([{ date: '2026-03-10', reservations: 2, guests: 6 }]); + expect(res.body.data.dayOfWeekDistribution).toEqual([{ dow: 5, reservations: 2, guests: 6 }]); + expect(res.body.data.partySizeDistribution).toEqual([{ partySize: 4, count: 2 }]); + expect(res.body.data.statusDistribution).toEqual([{ status: 'PENDING', count: 2 }]); + expect(res.body.data.hourlyDistribution).toEqual([{ hour: 19, reservations: 2 }]); + // leadTimeBuckets always returns 5 buckets, with counts filled in + expect(res.body.data.leadTimeBuckets).toHaveLength(5); + expect(res.body.data.leadTimeBuckets.find((b: any) => b.bucket === '1-2d').count).toBe(2); + }); + }); + describe('GET /api/reservations/availability', () => { it('returns 400 without required params', async () => { const res = await request(app).get('/api/reservations/availability'); diff --git a/packages/server/src/controllers/reservation.controller.ts b/packages/server/src/controllers/reservation.controller.ts index 068b31d..bc5dfb6 100644 --- a/packages/server/src/controllers/reservation.controller.ts +++ b/packages/server/src/controllers/reservation.controller.ts @@ -1,5 +1,6 @@ import { Request, Response } from 'express'; import { z } from 'zod'; +import { Prisma } from '@prisma/client'; import prisma from '../lib/db.js'; const createReservationSchema = z.object({ @@ -203,6 +204,155 @@ export async function listCustomerReservations(req: Request, res: Response): Pro }); } +export async function getReservationAnalytics(req: Request, res: Response): Promise { + const days = Math.min(365, Math.max(7, parseInt(req.query.days as string) || 30)); + const locationId = req.query.locationId as string | undefined; + + const now = new Date(); + const startDate = new Date(now); + startDate.setDate(startDate.getDate() - days); + startDate.setHours(0, 0, 0, 0); + const endDate = new Date(now); + endDate.setDate(endDate.getDate() + days); + endDate.setHours(23, 59, 59, 999); + + const locationFilter = locationId ? Prisma.sql`AND "locationId" = ${locationId}` : Prisma.empty; + const where: Prisma.ReservationWhereInput = { + date: { gte: startDate, lte: endDate }, + ...(locationId ? { locationId } : {}), + }; + + const [ + dailyRows, + dowRows, + partySizeRows, + statusRows, + hourlyRows, + leadTimeRows, + summaryAgg, + completedCount, + ] = await Promise.all([ + prisma.$queryRaw<{ date: string; reservations: bigint; guests: bigint }[]>( + Prisma.sql` + SELECT + TO_CHAR("date", 'YYYY-MM-DD') AS date, + COUNT(*)::bigint AS reservations, + COALESCE(SUM("partySize"), 0)::bigint AS guests + FROM "reservations" + WHERE "date" >= ${startDate} AND "date" <= ${endDate} ${locationFilter} + GROUP BY "date" + ORDER BY "date" + ` + ), + prisma.$queryRaw<{ dow: number; reservations: bigint; guests: bigint }[]>( + Prisma.sql` + SELECT + EXTRACT(DOW FROM "date")::int AS dow, + COUNT(*)::bigint AS reservations, + COALESCE(SUM("partySize"), 0)::bigint AS guests + FROM "reservations" + WHERE "date" >= ${startDate} AND "date" <= ${endDate} ${locationFilter} + GROUP BY EXTRACT(DOW FROM "date") + ORDER BY dow + ` + ), + prisma.reservation.groupBy({ + by: ['partySize'], + where, + _count: true, + orderBy: { partySize: 'asc' }, + }), + prisma.reservation.groupBy({ + by: ['status'], + where, + _count: true, + }), + prisma.$queryRaw<{ hour: number; reservations: bigint }[]>( + Prisma.sql` + SELECT + SPLIT_PART("time", ':', 1)::int AS hour, + COUNT(*)::bigint AS reservations + FROM "reservations" + WHERE "date" >= ${startDate} AND "date" <= ${endDate} ${locationFilter} + GROUP BY SPLIT_PART("time", ':', 1)::int + ORDER BY hour + ` + ), + prisma.$queryRaw<{ bucket: string; count: bigint }[]>( + Prisma.sql` + SELECT + CASE + WHEN ("date"::date - "createdAt"::date) <= 0 THEN 'same-day' + WHEN ("date"::date - "createdAt"::date) BETWEEN 1 AND 2 THEN '1-2d' + WHEN ("date"::date - "createdAt"::date) BETWEEN 3 AND 7 THEN '3-7d' + WHEN ("date"::date - "createdAt"::date) BETWEEN 8 AND 14 THEN '8-14d' + ELSE '15d+' + END AS bucket, + COUNT(*)::bigint AS count + FROM "reservations" + WHERE "date" >= ${startDate} AND "date" <= ${endDate} ${locationFilter} + GROUP BY bucket + ` + ), + prisma.reservation.aggregate({ + where, + _count: true, + _sum: { partySize: true }, + _avg: { partySize: true }, + }), + prisma.reservation.count({ + where: { ...where, status: { in: ['COMPLETED', 'SEATED'] } }, + }), + ]); + + const totalReservations = summaryAgg._count; + const completionRate = totalReservations > 0 ? completedCount / totalReservations : 0; + + const bucketOrder = ['same-day', '1-2d', '3-7d', '8-14d', '15d+']; + const leadTimeMap = new Map(leadTimeRows.map((r) => [r.bucket, Number(r.count)])); + const leadTimeBuckets = bucketOrder.map((bucket) => ({ + bucket, + count: leadTimeMap.get(bucket) ?? 0, + })); + + res.json({ + success: true, + data: { + summary: { + totalReservations, + totalGuests: Number(summaryAgg._sum.partySize ?? 0), + avgPartySize: Number((summaryAgg._avg.partySize ?? 0).toFixed(2)), + completionRate: Number(completionRate.toFixed(4)), + rangeStart: startDate.toISOString(), + rangeEnd: endDate.toISOString(), + }, + dailyBookings: dailyRows.map((d) => ({ + date: d.date, + reservations: Number(d.reservations), + guests: Number(d.guests), + })), + dayOfWeekDistribution: dowRows.map((d) => ({ + dow: d.dow, + reservations: Number(d.reservations), + guests: Number(d.guests), + })), + partySizeDistribution: partySizeRows.map((d) => ({ + partySize: d.partySize, + count: d._count, + })), + statusDistribution: statusRows.map((d) => ({ + status: d.status, + count: d._count, + })), + hourlyDistribution: hourlyRows.map((d) => ({ + hour: d.hour, + reservations: Number(d.reservations), + })), + leadTimeBuckets, + }, + }); +} + export async function checkAvailability(req: Request, res: Response): Promise { const { locationId, date, partySize } = req.query; diff --git a/packages/server/src/routes/reservation.routes.ts b/packages/server/src/routes/reservation.routes.ts index efb7318..8885c45 100644 --- a/packages/server/src/routes/reservation.routes.ts +++ b/packages/server/src/routes/reservation.routes.ts @@ -8,6 +8,7 @@ import { deleteReservation, listCustomerReservations, checkAvailability, + getReservationAnalytics, } from '../controllers/reservation.controller.js'; const router = Router(); @@ -18,6 +19,9 @@ router.get('/availability', checkAvailability); // Customer: own reservations router.get('/my-reservations', authenticate, listCustomerReservations); +// Staff: analytics (must be before /:id) +router.get('/analytics', authenticate, requireStaff, getReservationAnalytics); + // Customer: create reservation router.post('/', authenticate, createReservation);