323 lines
11 KiB
TypeScript
323 lines
11 KiB
TypeScript
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<AppointmentReport> {
|
|
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<ServiceReportRow[]> {
|
|
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<string, ServiceReportRow>();
|
|
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<StaffReportRow[]> {
|
|
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<string, StaffReportRow>();
|
|
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<CustomerReport> {
|
|
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<string, { customerId: string; name: string; visits: number; spend: string }>();
|
|
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<LoyaltyReport> {
|
|
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<NotificationReport> {
|
|
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<string, NotificationReportRow>();
|
|
const byChannel = new Map<string, { channel: string; sent: number; failed: number }>();
|
|
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)),
|
|
};
|
|
}
|