690 files changed
+12045
-33943
No files matched your search
@@ -0,0 +1,322 @@
|
||||
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)),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user