import type { PrismaClient } from "@/generated/prisma/client"; /** * Date-range operational reports for the owner — distinct from * owner-reporting.service.ts's single-day dashboard snapshot. Every * function here is read-only and scoped to one businessId + [from, to] * window; nothing writes anything. */ export interface ReportRange { from: Date; to: Date; } export interface AppointmentReport { totalAppointments: number; completed: number; cancelled: number; noShow: number; completionRate: number; cancellationRate: number; noShowRate: number; grossRevenue: string; discountsGiven: string; netRevenue: string; } export async function getAppointmentReport( prisma: PrismaClient, params: { businessId: string; range: ReportRange }, ): Promise { const appointments = await prisma.appointment.findMany({ where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } }, include: { services: true }, }); const completed = appointments.filter((a) => a.status === "COMPLETED"); const cancelled = appointments.filter((a) => a.status === "CANCELLED"); const noShow = appointments.filter((a) => a.status === "NO_SHOW"); const total = appointments.length; const grossRevenue = completed.reduce( (sum, a) => sum + a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0), 0, ); const discountsGiven = completed.reduce((sum, a) => sum + (a.discountAmount ? Number(a.discountAmount) : 0), 0); return { totalAppointments: total, completed: completed.length, cancelled: cancelled.length, noShow: noShow.length, completionRate: total > 0 ? Math.round((completed.length / total) * 100) : 0, cancellationRate: total > 0 ? Math.round((cancelled.length / total) * 100) : 0, noShowRate: total > 0 ? Math.round((noShow.length / total) * 100) : 0, grossRevenue: grossRevenue.toFixed(2), discountsGiven: discountsGiven.toFixed(2), netRevenue: (grossRevenue - discountsGiven).toFixed(2), }; } export interface ServiceReportRow { serviceId: string; name: string; timesCompleted: number; revenue: string; } /** Only counts COMPLETED appointments — a cancelled booking never earned revenue for a service. */ export async function getServiceReport( prisma: PrismaClient, params: { businessId: string; range: ReportRange }, ): Promise { const lines = await prisma.appointmentService.findMany({ where: { appointment: { businessId: params.businessId, status: "COMPLETED", startAt: { gte: params.range.from, lte: params.range.to }, }, }, include: { service: { select: { id: true, name: true } } }, }); const byService = new Map(); for (const line of lines) { const existing = byService.get(line.serviceId); const revenue = Number(line.priceAtBooking); if (existing) { existing.timesCompleted += 1; existing.revenue = (Number(existing.revenue) + revenue).toFixed(2); } else { byService.set(line.serviceId, { serviceId: line.serviceId, name: line.service.name, timesCompleted: 1, revenue: revenue.toFixed(2), }); } } return [...byService.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue)); } export interface StaffReportRow { staffId: string; name: string; completed: number; cancelled: number; noShow: number; revenue: string; } /** Revenue attributed to a staff member is their completed appointments' net total (subtotal minus any redeemed reward), same figure the appointment detail pages show as "Total". */ export async function getStaffReport( prisma: PrismaClient, params: { businessId: string; range: ReportRange }, ): Promise { const appointments = await prisma.appointment.findMany({ where: { businessId: params.businessId, startAt: { gte: params.range.from, lte: params.range.to } }, include: { services: true, staff: { include: { user: { select: { name: true } } } } }, }); const byStaff = new Map(); for (const a of appointments) { const row = byStaff.get(a.staffId) ?? { staffId: a.staffId, name: a.staff.user.name ?? "Staff", completed: 0, cancelled: 0, noShow: 0, revenue: "0.00", }; if (a.status === "COMPLETED") { row.completed += 1; const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0); const discount = a.discountAmount ? Number(a.discountAmount) : 0; row.revenue = (Number(row.revenue) + (subtotal - discount)).toFixed(2); } else if (a.status === "CANCELLED") { row.cancelled += 1; } else if (a.status === "NO_SHOW") { row.noShow += 1; } byStaff.set(a.staffId, row); } return [...byStaff.values()].sort((a, b) => Number(b.revenue) - Number(a.revenue)); } export interface CustomerReport { newCustomers: number; returningCustomers: number; topCustomers: { customerId: string; name: string; visits: number; spend: string }[]; } /** * "New" = a customer whose first-ever appointment with this business falls * inside the range; "returning" = a customer who visited inside the range * but already had an appointment before the range started. A customer with * no appointment in the range at all counts as neither. */ export async function getCustomerReport( prisma: PrismaClient, params: { businessId: string; range: ReportRange }, ): Promise { const appointmentsInRange = await prisma.appointment.findMany({ where: { businessId: params.businessId, status: { notIn: ["CANCELLED"] }, startAt: { gte: params.range.from, lte: params.range.to }, }, include: { services: true, customer: { select: { firstName: true, lastName: true } } }, }); const customerIds = [...new Set(appointmentsInRange.map((a) => a.customerId))]; if (customerIds.length === 0) { return { newCustomers: 0, returningCustomers: 0, topCustomers: [] }; } const priorAppointments = await prisma.appointment.groupBy({ by: ["customerId"], where: { businessId: params.businessId, customerId: { in: customerIds }, status: { notIn: ["CANCELLED"] }, startAt: { lt: params.range.from }, }, _count: true, }); const hadPriorVisit = new Set(priorAppointments.map((p) => p.customerId)); let newCustomers = 0; let returningCustomers = 0; for (const customerId of customerIds) { if (hadPriorVisit.has(customerId)) returningCustomers += 1; else newCustomers += 1; } const byCustomer = new Map(); for (const a of appointmentsInRange) { const row = byCustomer.get(a.customerId) ?? { customerId: a.customerId, name: `${a.customer.firstName} ${a.customer.lastName}`, visits: 0, spend: "0.00", }; row.visits += 1; if (a.status === "COMPLETED") { const subtotal = a.services.reduce((s, line) => s + Number(line.priceAtBooking), 0); const discount = a.discountAmount ? Number(a.discountAmount) : 0; row.spend = (Number(row.spend) + (subtotal - discount)).toFixed(2); } byCustomer.set(a.customerId, row); } const topCustomers = [...byCustomer.values()].sort((a, b) => Number(b.spend) - Number(a.spend)).slice(0, 10); return { newCustomers, returningCustomers, topCustomers }; } export interface LoyaltyReport { stampsIssued: number; stampsRemoved: number; rewardsEarned: number; rewardsRedeemed: number; rewardsExpired: number; } export async function getLoyaltyReport( prisma: PrismaClient, params: { businessId: string; range: ReportRange }, ): Promise { const accountScope = { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } } }; const inRange = { createdAt: { gte: params.range.from, lte: params.range.to } }; const [stampTransactions, rewardsEarned, rewardsRedeemed, rewardsExpired] = await Promise.all([ prisma.loyaltyTransaction.findMany({ where: { ...accountScope, ...inRange, type: { in: ["STAMP_ADDED", "STAMP_REMOVED"] } }, select: { amount: true }, }), prisma.loyaltyReward.count({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, ...inRange }, }), prisma.loyaltyReward.count({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, redeemedAt: { gte: params.range.from, lte: params.range.to }, }, }), // No expiredAt column exists — the periodic sweep only flips status, so // this approximates "expired in range" by expiresAt falling in range // for a reward that is now EXPIRED. Good enough for a trend, not exact // to the second the sweep ran. prisma.loyaltyReward.count({ where: { loyaltyAccount: { loyaltyProgramme: { businessId: params.businessId } }, status: "EXPIRED", expiresAt: { gte: params.range.from, lte: params.range.to }, }, }), ]); return { stampsIssued: stampTransactions.filter((t) => t.amount > 0).reduce((sum, t) => sum + t.amount, 0), stampsRemoved: Math.abs(stampTransactions.filter((t) => t.amount < 0).reduce((sum, t) => sum + t.amount, 0)), rewardsEarned, rewardsRedeemed, rewardsExpired, }; } export interface NotificationReportRow { type: string; sent: number; failed: number; } export interface NotificationReport { totalSent: number; totalFailed: number; byType: NotificationReportRow[]; byChannel: { channel: string; sent: number; failed: number }[]; } export async function getNotificationReport( prisma: PrismaClient, params: { businessId: string; range: ReportRange }, ): Promise { const deliveries = await prisma.notificationDelivery.findMany({ where: { notification: { businessId: params.businessId }, createdAt: { gte: params.range.from, lte: params.range.to }, }, select: { status: true, channel: true, notification: { select: { type: true } } }, }); const byType = new Map(); const byChannel = new Map(); let totalSent = 0; let totalFailed = 0; for (const d of deliveries) { const isSent = d.status === "SENT"; const isFailed = d.status === "FAILED"; if (isSent) totalSent += 1; if (isFailed) totalFailed += 1; const typeRow = byType.get(d.notification.type) ?? { type: d.notification.type, sent: 0, failed: 0 }; if (isSent) typeRow.sent += 1; if (isFailed) typeRow.failed += 1; byType.set(d.notification.type, typeRow); const channelRow = byChannel.get(d.channel) ?? { channel: d.channel, sent: 0, failed: 0 }; if (isSent) channelRow.sent += 1; if (isFailed) channelRow.failed += 1; byChannel.set(d.channel, channelRow); } return { totalSent, totalFailed, byType: [...byType.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)), byChannel: [...byChannel.values()].sort((a, b) => b.sent + b.failed - (a.sent + a.failed)), }; }