-
Notifications
You must be signed in to change notification settings - Fork 12
feat: implement booking/cancel APIs, admin module, tests, and race condition handling #9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,324 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useState } from "react"; | ||
| import { useRouter } from "next/navigation"; | ||
| import { createClient } from "@supabase/supabase-js"; | ||
|
|
||
| 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; | ||
| is_booked: boolean; | ||
| 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 [activeTab, setActiveTab] = useState<Tab>("appointments"); | ||
| 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 [adminEmail, setAdminEmail] = useState(""); | ||
| const [filter, setFilter] = useState("all"); | ||
|
|
||
| useEffect(() => { | ||
| const isLoggedIn = localStorage.getItem("admin_logged_in"); | ||
| if (!isLoggedIn) { | ||
| router.push("/admin/login"); | ||
| return; | ||
| } | ||
|
Comment on lines
+57
to
+62
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Client-side localStorage check is trivially bypassable. Any user can open the browser console, run This is acceptable for demo purposes if labeled, but in production the dashboard should validate a signed session server-side. |
||
|
|
||
| setAdminEmail(localStorage.getItem("admin_email") ?? ""); | ||
|
|
||
| async function loadData() { | ||
| const supabase = createClient( | ||
| process.env.NEXT_PUBLIC_SUPABASE_URL!, | ||
| process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! | ||
| ); | ||
|
|
||
| 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("*") | ||
| .order("name"), | ||
| supabase | ||
| .from("patients") | ||
| .select("*") | ||
| .order("name"), | ||
| supabase | ||
| .from("slots") | ||
| .select("id, start_time, end_time, is_booked, doctors(name)") | ||
| .eq("is_booked",false) | ||
| .gt("start_time",new Date().toISOString()) | ||
| .order("start_time"), | ||
| ]); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| setAppointments((apptRes.data as unknown as Appointment[]) ?? []); | ||
| setDoctors((doctorRes.data as Doctor[]) ?? []); | ||
| setPatients((patientRes.data as Patient[]) ?? []); | ||
| setSlots((slotRes.data as unknown as Slot[]) ?? []); | ||
|
Comment on lines
+93
to
+96
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Unsafe type assertions with Double casting through Consider using Supabase's generated types or adding runtime validation. 🤖 Prompt for AI Agents |
||
| setLoading(false); | ||
| } | ||
|
|
||
| loadData(); | ||
| }, [router]); | ||
|
|
||
| function handleLogout() { | ||
| localStorage.removeItem("admin_logged_in"); | ||
| localStorage.removeItem("admin_email"); | ||
| 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: "Available Slots", count: slots.length}, | ||
| ]; | ||
|
|
||
| return ( | ||
| <main className="min-h-screen bg-gray-50 p-6"> | ||
| <div className="mx-auto max-w-6xl"> | ||
|
|
||
| {/* Header */} | ||
| <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">{adminEmail}</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> | ||
|
|
||
| {/* Stats */} | ||
| <div className="mb-6 grid grid-cols-4 gap-4"> | ||
| {tabs.map((tab) => ( | ||
| <div key={tab.key} className="rounded-xl border bg-white p-4"> | ||
| <p className="text-sm text-gray-500">{tab.label}</p> | ||
| <p className="text-2xl font-bold">{tab.count}</p> | ||
| </div> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Tabs */} | ||
| <div className="mb-4 flex gap-2"> | ||
| {tabs.map((tab) => ( | ||
| <button | ||
| key={tab.key} | ||
| onClick={() => setActiveTab(tab.key)} | ||
| className={`rounded-lg px-4 py-2 text-sm font-medium ${ | ||
| activeTab === tab.key | ||
| ? "bg-blue-600 text-white" | ||
| : "bg-white border text-gray-600 hover:bg-gray-50" | ||
| }`} | ||
| > | ||
| {tab.label} | ||
| </button> | ||
| ))} | ||
| </div> | ||
|
|
||
| {/* Appointments Tab */} | ||
| {activeTab === "appointments" && ( | ||
| <div> | ||
| {/* Filter buttons */} | ||
| <div className="mb-3 flex gap-2"> | ||
| {["all", "active", "done", "cancelled"].map((f) => ( | ||
| <button | ||
| key={f} | ||
| onClick={() => setFilter(f)} | ||
| className={`rounded-lg px-3 py-1 text-xs font-medium capitalize ${ | ||
| filter === f | ||
| ? "bg-blue-600 text-white" | ||
| : "bg-white border text-gray-600 hover:bg-gray-50" | ||
| }`} | ||
| > | ||
| {f === "all" ? `All (${appointments.length})` : | ||
| f === "active" ? `Active (${appointments.filter(a => a.status === "active").length})` : | ||
| f === "done" ? `Done (${appointments.filter(a => a.status === "done").length})` : | ||
| `Cancelled (${appointments.filter(a => a.status === "cancelled").length})`} | ||
| </button> | ||
| ))} | ||
| </div> | ||
|
|
||
| <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">Specialty</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 On</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {appointments | ||
| .filter(a => filter === "all" || a.status === filter) | ||
| .length === 0 ? ( | ||
| <tr><td colSpan={6} className="px-4 py-6 text-center text-gray-500">No appointments found.</td></tr> | ||
| ) : appointments | ||
| .filter(a => filter === "all" || a.status === filter) | ||
| .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">{appt.doctors?.name}</td> | ||
| <td className="px-4 py-3 text-gray-500">{appt.doctors?.specialty}</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> | ||
| </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> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {doctors.length === 0 ? ( | ||
| <tr><td colSpan={3} className="px-4 py-6 text-center text-gray-500">No doctors found.</td></tr> | ||
| ) : 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> | ||
| </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> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {patients.length === 0 ? ( | ||
| <tr><td colSpan={2} className="px-4 py-6 text-center text-gray-500">No patients found.</td></tr> | ||
| ) : 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> | ||
| </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">Start Time</th> | ||
| <th className="px-4 py-3 font-medium">End Time</th> | ||
| <th className="px-4 py-3 font-medium">Status</th> | ||
| </tr> | ||
| </thead> | ||
| <tbody className="divide-y"> | ||
| {slots.length === 0 ? ( | ||
| <tr><td colSpan={4} className="px-4 py-6 text-center text-gray-500">No slots found.</td></tr> | ||
| ) : slots.map((slot) => ( | ||
| <tr key={slot.id}> | ||
| <td className="px-4 py-3">{slot.doctors?.name}</td> | ||
| <td className="px-4 py-3">{formatDateTime(slot.start_time)}</td> | ||
| <td className="px-4 py-3">{formatDateTime(slot.end_time)}</td> | ||
| <td className="px-4 py-3"> | ||
| <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${ | ||
| slot.is_booked ? "bg-red-100 text-red-700" : "bg-green-100 text-green-700" | ||
| }`}> | ||
| {slot.is_booked ? "Booked" : "Available"} | ||
| </span> | ||
| </td> | ||
| </tr> | ||
| ))} | ||
| </tbody> | ||
| </table> | ||
| </div> | ||
| )} | ||
|
|
||
| </div> | ||
| </main> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix markdown formatting issues flagged by linter.
The fenced code blocks should be surrounded by blank lines, and the ordered list should use consistent prefix style. The file is also missing a trailing newline.
📝 Proposed fix for markdown formatting
4. Install dependencies: + ```bash npm installVerify each finding against the current code and only fix it if needed.
In
@README.mdaround lines 9 - 19, Add blank lines before and after each fencedcode block and make the ordered list numbering style consistent (use "5." and
"6." as shown) so the fenced blocks for the commands "npm install", "npm run
seed", and "npm run dev" are each separated by an empty line from surrounding
text; also ensure each ```bash fenced block is correctly opened/closed and the
README.md ends with a single trailing newline character.