198 lines
5.4 KiB
TypeScript
198 lines
5.4 KiB
TypeScript
import { ChartAreaInteractive } from "@/components/chart/chart-area-interactive";
|
|
import { AdminAppointmentsTable } from "@/components/admin/appointments-table";
|
|
import { SectionCards } from "@/components/layout/section-cards";
|
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
|
import type { Metadata } from "next";
|
|
import { requireAdmin } from "@/lib/auth-session/auth-server";
|
|
import { prisma } from "@/lib/types/prisma";
|
|
import { safeFindManyAppointments } from "@/lib/utils/appointment-helpers";
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Dashboard",
|
|
};
|
|
|
|
// Force dynamic rendering since this page uses authentication (headers)
|
|
export const dynamic = "force-dynamic";
|
|
|
|
export default async function Page() {
|
|
// Require admin role - will redirect to home page (/) if not admin
|
|
const { user } = await requireAdmin();
|
|
|
|
// Calculate date ranges once for reuse
|
|
const now = new Date();
|
|
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * DAY_IN_MS);
|
|
const sixtyDaysAgo = new Date(now.getTime() - 60 * DAY_IN_MS);
|
|
const ninetyDaysAgo = new Date(now.getTime() - 90 * DAY_IN_MS);
|
|
|
|
// Run all count queries in parallel for better performance
|
|
const [
|
|
totalAppointments,
|
|
previousAppointments,
|
|
newPatients,
|
|
previousPatients,
|
|
payments,
|
|
previousPayments,
|
|
completedAppointments,
|
|
previousCompleted,
|
|
appointmentsForChart,
|
|
appointments,
|
|
] = await Promise.all([
|
|
// Total appointments in last 30 days
|
|
prisma.appointment.count({
|
|
where: {
|
|
createdAt: { gte: thirtyDaysAgo },
|
|
},
|
|
}),
|
|
// Previous period appointments for comparison
|
|
prisma.appointment.count({
|
|
where: {
|
|
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
|
},
|
|
}),
|
|
// New patients in last 30 days
|
|
prisma.user.count({
|
|
where: {
|
|
role: "patient",
|
|
createdAt: { gte: thirtyDaysAgo },
|
|
},
|
|
}),
|
|
// Previous period patients
|
|
prisma.user.count({
|
|
where: {
|
|
role: "patient",
|
|
createdAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
|
},
|
|
}),
|
|
// Revenue this month
|
|
prisma.payment.aggregate({
|
|
where: {
|
|
status: "paid",
|
|
paidAt: { gte: thirtyDaysAgo },
|
|
},
|
|
_sum: {
|
|
amount: true,
|
|
},
|
|
}),
|
|
// Previous period revenue
|
|
prisma.payment.aggregate({
|
|
where: {
|
|
status: "paid",
|
|
paidAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
|
},
|
|
_sum: {
|
|
amount: true,
|
|
},
|
|
}),
|
|
// Calculate completed appointments for satisfaction (mock calculation)
|
|
prisma.appointment.count({
|
|
where: {
|
|
status: "completed",
|
|
updatedAt: { gte: thirtyDaysAgo },
|
|
},
|
|
}),
|
|
// Previous completed
|
|
prisma.appointment.count({
|
|
where: {
|
|
status: "completed",
|
|
updatedAt: { gte: sixtyDaysAgo, lt: thirtyDaysAgo },
|
|
},
|
|
}),
|
|
// Fetch appointments for chart (last 90 days)
|
|
prisma.appointment.findMany({
|
|
where: {
|
|
createdAt: { gte: ninetyDaysAgo },
|
|
},
|
|
select: {
|
|
createdAt: true,
|
|
},
|
|
orderBy: {
|
|
createdAt: "asc",
|
|
},
|
|
}),
|
|
// Fetch recent appointments for table
|
|
// Use safe find to filter out orphaned appointments
|
|
safeFindManyAppointments({
|
|
take: 20,
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
include: {
|
|
patient: true,
|
|
dentist: true,
|
|
service: true,
|
|
payment: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const revenue = payments._sum.amount || 0;
|
|
const previousRevenue = previousPayments._sum.amount || 0;
|
|
|
|
// Calculate percentage changes
|
|
const appointmentChange =
|
|
previousAppointments > 0
|
|
? ((totalAppointments - previousAppointments) / previousAppointments) *
|
|
100
|
|
: 0;
|
|
const patientChange =
|
|
previousPatients > 0
|
|
? ((newPatients - previousPatients) / previousPatients) * 100
|
|
: 0;
|
|
const revenueChange =
|
|
previousRevenue > 0
|
|
? ((revenue - previousRevenue) / previousRevenue) * 100
|
|
: 0;
|
|
const satisfactionChange =
|
|
previousCompleted > 0
|
|
? ((completedAppointments - previousCompleted) / previousCompleted) * 100
|
|
: 0;
|
|
|
|
// Mock satisfaction rate (in a real app, this would come from reviews/ratings)
|
|
const satisfactionRate = 98.5;
|
|
|
|
// Group appointments by date for chart
|
|
const chartData = appointmentsForChart.reduce(
|
|
(acc: Record<string, number>, appointment) => {
|
|
const date = appointment.createdAt.toISOString().split("T")[0];
|
|
acc[date] = (acc[date] || 0) + 1;
|
|
return acc;
|
|
},
|
|
{}
|
|
);
|
|
|
|
// Convert to array format for chart
|
|
const chartDataArray = Object.entries(chartData).map(([date, count]) => ({
|
|
date,
|
|
appointments: count,
|
|
}));
|
|
|
|
const dashboardStats = {
|
|
totalAppointments,
|
|
appointmentChange,
|
|
newPatients,
|
|
patientChange,
|
|
revenue,
|
|
revenueChange,
|
|
satisfactionRate,
|
|
satisfactionChange,
|
|
};
|
|
|
|
return (
|
|
<DashboardLayout
|
|
user={{ ...user, role: user.role || "admin" }}
|
|
role="admin"
|
|
>
|
|
<div className="flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
|
<SectionCards stats={dashboardStats} />
|
|
<div className="px-4 lg:px-6">
|
|
<ChartAreaInteractive data={chartDataArray} />
|
|
</div>
|
|
<div className="px-4 lg:px-6">
|
|
<AdminAppointmentsTable appointments={appointments} />
|
|
</div>
|
|
</div>
|
|
</DashboardLayout>
|
|
);
|
|
}
|