Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
302 changes: 302 additions & 0 deletions app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"use client";

import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { supabase } from "@/lib/supabase";

type Appointment = {
id: string;
status: "active" | "done" | "cancelled";
created_at: string;
patients: { name: string; email: string };
doctors: { name: string; specialty: string };
slots: { start_time: string; end_time: string };
};

type Doctor = {
id: string;
name: string;
email: string;
specialty: string;
};

type Patient = {
id: string;
name: string;
email: string;
};

type Slot = {
id: string;
start_time: string;
end_time: string;
doctors: { name: string };
};

function formatDateTime(iso: string) {
return new Date(iso).toLocaleString("en-IN", {
dateStyle: "medium",
timeStyle: "short",
});
}

type Tab = "appointments" | "doctors" | "patients" | "slots";

export default function AdminDashboard() {
const router = useRouter();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [patients, setPatients] = useState<Patient[]>([]);
const [slots, setSlots] = useState<Slot[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<Tab>("appointments");

useEffect(() => {
async function load() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) { router.push("/admin/login"); return; }

const { data: admin } = await supabase
.from("system_admins").select("id").eq("id", user.id).single();
if (!admin) { router.push("/admin/login"); return; }

const [apptRes, doctorRes, patientRes, slotRes] = await Promise.all([
supabase.from("appointments")
.select("id, status, created_at, patients(name, email), doctors(name, specialty), slots(start_time, end_time)")
.order("created_at", { ascending: false }),
supabase.from("doctors").select("id, name, email, specialty").order("name"),
supabase.from("patients").select("id, name, email").order("name"),
supabase.from("slots").select("id, start_time, end_time, doctors(name)").order("start_time"),
]);

setAppointments((apptRes.data as Appointment[]) ?? []);
setDoctors((doctorRes.data as Doctor[]) ?? []);
setPatients((patientRes.data as Patient[]) ?? []);
setSlots((slotRes.data as Slot[]) ?? []);
setLoading(false);
}
load();
}, [router]);

async function handleLogout() {
await supabase.auth.signOut();
router.push("/admin/login");
}

if (loading) {
return (
<main className="flex min-h-screen items-center justify-center">
<p className="text-gray-500">Loading...</p>
</main>
);
}

const tabs: { key: Tab; label: string; count: number }[] = [
{ key: "appointments", label: "Appointments", count: appointments.length },
{ key: "doctors", label: "Doctors", count: doctors.length },
{ key: "patients", label: "Patients", count: patients.length },
{ key: "slots", label: "Slots", count: slots.length },
];

return (
<main className="min-h-screen bg-gray-50 p-6">
<div className="mx-auto max-w-6xl">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="text-sm text-gray-500">System overview</p>
</div>
<button onClick={handleLogout} className="rounded-lg border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100">
Logout
</button>
</div>

{/* Metrics */}
<div className="mb-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<div className="rounded-xl border bg-white p-4">
<p className="text-sm text-gray-500">Total Doctors</p>
<p className="text-2xl font-bold">{doctors.length}</p>
</div>
<div className="rounded-xl border bg-white p-4">
<p className="text-sm text-gray-500">Total Patients</p>
<p className="text-2xl font-bold">{patients.length}</p>
</div>
<div className="rounded-xl border bg-white p-4">
<p className="text-sm text-gray-500">Total Slots</p>
<p className="text-2xl font-bold">{slots.length}</p>
</div>
<div className="rounded-xl border bg-white p-4">
<p className="text-sm text-gray-500">Total Appointments</p>
<p className="text-2xl font-bold">{appointments.length}</p>
</div>
</div>

{/* Appointment status breakdown */}
<div className="mb-6 flex gap-3 text-sm">
<span className="rounded-full bg-blue-100 px-3 py-1 text-blue-700">
Active: {appointments.filter(a => a.status === "active").length}
</span>
<span className="rounded-full bg-green-100 px-3 py-1 text-green-700">
Done: {appointments.filter(a => a.status === "done").length}
</span>
<span className="rounded-full bg-gray-100 px-3 py-1 text-gray-600">
Cancelled: {appointments.filter(a => a.status === "cancelled").length}
</span>
</div>

{/* Tabs */}
<div className="mb-4 flex gap-2 border-b">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
activeTab === tab.key
? "border-gray-800 text-gray-800"
: "border-transparent text-gray-500 hover:text-gray-700"
}`}
>
{tab.label} ({tab.count})
</button>
))}
</div>

{/* Appointments Tab */}
{activeTab === "appointments" && (
<div className="overflow-hidden rounded-xl border bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-600">
<tr>
<th className="px-4 py-3 font-medium">Patient</th>
<th className="px-4 py-3 font-medium">Doctor</th>
<th className="px-4 py-3 font-medium">Slot</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Booked At</th>
</tr>
</thead>
<tbody className="divide-y">
{appointments.map((appt) => (
<tr key={appt.id}>
<td className="px-4 py-3">
<div>{appt.patients?.name}</div>
<div className="text-xs text-gray-400">{appt.patients?.email}</div>
</td>
<td className="px-4 py-3">
<div>{appt.doctors?.name}</div>
<div className="text-xs text-gray-400">{appt.doctors?.specialty}</div>
</td>
<td className="px-4 py-3">{formatDateTime(appt.slots?.start_time)}</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
appt.status === "active" ? "bg-blue-100 text-blue-700"
: appt.status === "done" ? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-600"
}`}>
{appt.status}
</span>
</td>
<td className="px-4 py-3 text-gray-500">{formatDateTime(appt.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{/* Doctors Tab */}
{activeTab === "doctors" && (
<div className="overflow-hidden rounded-xl border bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-600">
<tr>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Specialty</th>
<th className="px-4 py-3 font-medium">Appointments</th>
</tr>
</thead>
<tbody className="divide-y">
{doctors.map((doc) => (
<tr key={doc.id}>
<td className="px-4 py-3 font-medium">{doc.name}</td>
<td className="px-4 py-3 text-gray-500">{doc.email}</td>
<td className="px-4 py-3">{doc.specialty}</td>
<td className="px-4 py-3">
{appointments.filter(a => a.doctor_id === doc.id).length}
</td>
Comment on lines +224 to +226
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Name-based appointment counting is fragile—use IDs instead.

Filtering by a.doctors?.name === doc.name and a.patients?.name === pat.name may produce incorrect counts if names are duplicated. Include doctor_id/patient_id in the appointments query and match by ID.

Also applies to: 250-252

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/admin/dashboard/page.tsx` around lines 224 - 226, The appointment
counting currently compares names (appointments.filter(a => a.doctors?.name ===
doc.name) and similarly for patients) which breaks when names duplicate; instead
update the appointments query to include doctor_id and patient_id fields and
change the filters to compare IDs (e.g., a.doctor_id === doc.id and a.patient_id
=== pat.id) so use the appointment record's doctor_id/patient_id for matching
rather than a.doctors?.name or a.patients?.name; apply the same fix to the other
occurrence around the 250–252 area.

</tr>
))}
</tbody>
</table>
</div>
)}

{/* Patients Tab */}
{activeTab === "patients" && (
<div className="overflow-hidden rounded-xl border bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-600">
<tr>
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Email</th>
<th className="px-4 py-3 font-medium">Appointments</th>
</tr>
</thead>
<tbody className="divide-y">
{patients.map((pat) => (
<tr key={pat.id}>
<td className="px-4 py-3 font-medium">{pat.name}</td>
<td className="px-4 py-3 text-gray-500">{pat.email}</td>
<td className="px-4 py-3">
{appointments.filter(a.patient === pat.id).length}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}

{/* Slots Tab */}
{activeTab === "slots" && (
<div className="overflow-hidden rounded-xl border bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-gray-600">
<tr>
<th className="px-4 py-3 font-medium">Doctor</th>
<th className="px-4 py-3 font-medium">Date & Time</th>
<th className="px-4 py-3 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y">
{slots.map((slot) => {
const appt = appointments.find(a => a.slot_id === slot.id);
const status = appt ? appt.status : "available";
return (
<tr key={slot.id}>
<td className="px-4 py-3">{slot.doctors?.name}</td>
<td className="px-4 py-3">
{formatDateTime(slot.start_time)} —{" "}
{new Date(slot.end_time).toLocaleTimeString("en-IN", { timeStyle: "short" })}
</td>
<td className="px-4 py-3">
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${
status === "active" ? "bg-blue-100 text-blue-700"
: status === "done" ? "bg-green-100 text-green-700"
: status === "cancelled" ? "bg-gray-100 text-gray-600"
: "bg-green-50 text-green-600"
}`}>
{status === "available" ? "Available" : status}
</span>
</td>
</tr>
);
})}
Comment on lines +272 to +294
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Incorrect slot-to-appointment matching by start_time instead of slot_id.

Matching appointments to slots using a.slots?.start_time === slot.start_time is unreliable—multiple slots across different doctors could share the same start time. Use slot_id for accurate matching.

🐛 Proposed fix

First, update the appointments query to include slot_id:

 supabase.from("appointments")
-  .select("id, status, created_at, patients(name, email), doctors(name, specialty), slots(start_time, end_time)")
+  .select("id, status, created_at, slot_id, patients(name, email), doctors(name, specialty), slots(start_time, end_time)")

Update the Appointment type:

 type Appointment = {
   id: string;
   status: "active" | "done" | "cancelled";
   created_at: string;
+  slot_id: string;
   patients: { name: string; email: string };
   // ...
 };

Then fix the matching logic:

-const appt = appointments.find(a => a.slots?.start_time === slot.start_time);
+const appt = appointments.find(a => a.slot_id === slot.id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/admin/dashboard/page.tsx` around lines 272 - 294, The slot-to-appointment
matching is using a.slots?.start_time === slot.start_time which can incorrectly
match different slots with the same start_time; instead ensure the Appointment
objects include slot_id (update the appointments query and Appointment type to
select slot_id) and change the matching in the slots.map callback to use
appointments.find(a => a.slot_id === slot.id) (update any references to
a.slots?.start_time accordingly) so each slot is matched by its unique slot.id.

</tbody>
</table>
</div>
)}
</div>
</main>
);
}
86 changes: 86 additions & 0 deletions app/admin/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { supabase } from "@/lib/supabase";

export default function AdminLogin() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);

async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);

const { data: authData, error: authError } =
await supabase.auth.signInWithPassword({ email, password });

if (authError) {
setError(authError.message);
setLoading(false);
return;
}

const { data: admin } = await supabase
.from("system_admins")
.select("id")
.eq("id", authData.user.id)
.single();

if (!admin) {
await supabase.auth.signOut();
setError("No admin account found for this email.");
setLoading(false);
return;
}
Comment on lines +29 to +40
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle potential error from the system_admins query.

The query to check if the user is in system_admins ignores potential errors. If the query fails (e.g., network issue, RLS policy block), admin will be null and the user will be signed out with a misleading "No admin account found" error.

🛡️ Proposed fix to handle query errors
-    const { data: admin } = await supabase
+    const { data: admin, error: adminError } = await supabase
       .from("system_admins")
       .select("id")
       .eq("id", authData.user.id)
       .single();
 
+    if (adminError && adminError.code !== "PGRST116") {
+      // PGRST116 = no rows returned (not an admin)
+      await supabase.auth.signOut();
+      setError("Failed to verify admin status. Please try again.");
+      setLoading(false);
+      return;
+    }
+
     if (!admin) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data: admin } = await supabase
.from("system_admins")
.select("id")
.eq("id", authData.user.id)
.single();
if (!admin) {
await supabase.auth.signOut();
setError("No admin account found for this email.");
setLoading(false);
return;
}
const { data: admin, error: adminError } = await supabase
.from("system_admins")
.select("id")
.eq("id", authData.user.id)
.single();
if (adminError && adminError.code !== "PGRST116") {
// PGRST116 = no rows returned (not an admin)
await supabase.auth.signOut();
setError("Failed to verify admin status. Please try again.");
setLoading(false);
return;
}
if (!admin) {
await supabase.auth.signOut();
setError("No admin account found for this email.");
setLoading(false);
return;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/admin/login/page.tsx` around lines 29 - 40, The system_admins query
result is not checking for errors; update the supabase call in
app/admin/login/page.tsx to destructure and check the error (e.g., const { data:
admin, error } = await supabase.from("system_admins").select("id").eq("id",
authData.user.id).single()), and if error is present handle it separately (call
supabase.auth.signOut(), setError(error.message or a concise message),
setLoading(false) and return) before treating a null admin as "no admin
account"; keep the existing handling for the case where error is null but admin
is falsy.


router.push("/admin/dashboard");
}

return (
<main className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-full max-w-md rounded-xl border bg-white p-8 shadow-sm">
<h1 className="mb-6 text-2xl font-bold">Admin Login</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="admin@test.com"
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-500"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-lg border px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-500"
/>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading}
className="rounded-lg bg-gray-800 py-2 font-medium text-white hover:bg-gray-900 disabled:opacity-50"
>
{loading ? "Logging in..." : "Login"}
</button>
</form>
<p className="mt-4 text-center text-sm text-gray-500">
<Link href="/" className="text-gray-600 hover:underline">← Back to home</Link>
</p>
</div>
</main>
);
}
Loading