website/app/(admin)/admin/booking/[id]/page.tsx

1051 lines
48 KiB
TypeScript
Raw Normal View History

"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import {
Calendar,
Clock,
User,
Video,
CalendarCheck,
X,
Loader2,
ArrowLeft,
Mail,
Phone as PhoneIcon,
MessageSquare,
CheckCircle2,
ExternalLink,
Copy,
MapPin,
Pencil,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting, rescheduleAppointment, cancelAppointment } from "@/lib/actions/appointments";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
export default function AppointmentDetailPage() {
const params = useParams();
const router = useRouter();
const appointmentId = params.id as string;
const [appointment, setAppointment] = useState<Appointment | null>(null);
const [loading, setLoading] = useState(true);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [rescheduleDialogOpen, setRescheduleDialogOpen] = useState(false);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
const [rescheduleDate, setRescheduleDate] = useState<Date | undefined>(undefined);
const [rescheduleTime, setRescheduleTime] = useState<string>("09:00");
const [rescheduleDuration, setRescheduleDuration] = useState<number>(60);
const [rejectionReason, setRejectionReason] = useState<string>("");
const [isScheduling, setIsScheduling] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [isRescheduling, setIsRescheduling] = useState(false);
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
const [isEndingMeeting, setIsEndingMeeting] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { theme } = useAppTheme();
const isDark = theme === "dark";
useEffect(() => {
const fetchAppointment = async () => {
if (!appointmentId) return;
setLoading(true);
try {
// Fetch both detail and list to get selected_slots from list endpoint
const [detailData, listData] = await Promise.all([
getAppointmentDetail(appointmentId),
listAppointments().catch(() => []) // Fallback to empty array if list fails
]);
// Find matching appointment in list to get selected_slots
const listAppointment = Array.isArray(listData)
? listData.find((apt: Appointment) => apt.id === appointmentId)
: null;
// Merge selected_slots from list into detail data
if (listAppointment && listAppointment.selected_slots && Array.isArray(listAppointment.selected_slots) && listAppointment.selected_slots.length > 0) {
detailData.selected_slots = listAppointment.selected_slots;
}
setAppointment(detailData);
} catch (error) {
toast.error("Failed to load appointment details");
router.push("/admin/booking");
} finally {
setLoading(false);
}
};
fetchAppointment();
}, [appointmentId, router]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
const formatShortDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const getStatusColor = (status: string) => {
const normalized = status.toLowerCase();
if (isDark) {
switch (normalized) {
case "scheduled":
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
case "completed":
return "bg-green-500/20 text-green-300 border-green-500/30";
case "rejected":
case "cancelled":
return "bg-red-500/20 text-red-300 border-red-500/30";
case "pending_review":
case "pending":
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
default:
return "bg-gray-700 text-gray-200 border-gray-600";
}
}
switch (normalized) {
case "scheduled":
return "bg-blue-50 text-blue-700 border-blue-200";
case "completed":
return "bg-green-50 text-green-700 border-green-200";
case "rejected":
case "cancelled":
return "bg-red-50 text-red-700 border-red-200";
case "pending_review":
case "pending":
return "bg-yellow-50 text-yellow-700 border-yellow-200";
default:
return "bg-gray-100 text-gray-700 border-gray-300";
}
};
const formatStatus = (status: string) => {
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
};
const handleSchedule = async () => {
if (!appointment || !scheduledDate) return;
setIsScheduling(true);
try {
const dateTime = new Date(scheduledDate);
const [hours, minutes] = scheduledTime.split(":").map(Number);
dateTime.setHours(hours, minutes, 0, 0);
await scheduleAppointment(appointment.id, {
scheduled_datetime: dateTime.toISOString(),
scheduled_duration: scheduledDuration,
});
toast.success("Appointment scheduled successfully");
setScheduleDialogOpen(false);
// Refresh appointment data
const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated);
} catch (error: any) {
toast.error(error.message || "Failed to schedule appointment");
} finally {
setIsScheduling(false);
}
};
const handleReject = async () => {
if (!appointment) return;
setIsRejecting(true);
try {
await rejectAppointment(appointment.id, {
rejection_reason: rejectionReason || undefined,
});
toast.success("Appointment rejected successfully");
setRejectDialogOpen(false);
// Refresh appointment data
const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated);
} catch (error: any) {
toast.error(error.message || "Failed to reject appointment");
} finally {
setIsRejecting(false);
}
};
const handleReschedule = async () => {
if (!appointment || !rescheduleDate) return;
setIsRescheduling(true);
try {
const dateTime = new Date(rescheduleDate);
const [hours, minutes] = rescheduleTime.split(":").map(Number);
dateTime.setHours(hours, minutes, 0, 0);
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
await rescheduleAppointment(appointment.id, {
new_scheduled_datetime: dateTime.toISOString(),
new_scheduled_duration: rescheduleDuration,
timezone: userTimezone,
});
toast.success("Appointment rescheduled successfully");
setRescheduleDialogOpen(false);
// Refresh appointment data
const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated);
} catch (error: any) {
toast.error(error.message || "Failed to reschedule appointment");
} finally {
setIsRescheduling(false);
}
};
const handleCancelAppointment = async () => {
if (!appointment) return;
setIsCancelling(true);
try {
await cancelAppointment(appointment.id);
toast.success("Appointment cancelled successfully");
setCancelDialogOpen(false);
// Refetch appointment to get updated status
const updatedAppointment = await getAppointmentDetail(appointment.id);
setAppointment(updatedAppointment);
router.push("/admin/booking");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to cancel appointment";
toast.error(errorMessage);
} finally {
setIsCancelling(false);
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
};
const handleStartMeeting = async () => {
if (!appointment) return;
setIsStartingMeeting(true);
try {
const updated = await startMeeting(appointment.id);
setAppointment(updated);
toast.success("Meeting started successfully");
} catch (error: any) {
toast.error(error.message || "Failed to start meeting");
} finally {
setIsStartingMeeting(false);
}
};
const handleEndMeeting = async () => {
if (!appointment) return;
setIsEndingMeeting(true);
try {
const updated = await endMeeting(appointment.id);
setAppointment(updated);
toast.success("Meeting ended successfully");
} catch (error: any) {
toast.error(error.message || "Failed to end meeting");
} finally {
setIsEndingMeeting(false);
}
};
if (loading) {
return (
<div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<div className="text-center">
<Loader2 className={`w-12 h-12 animate-spin mx-auto mb-4 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-600"}`}>Loading appointment details...</p>
</div>
</div>
);
}
if (!appointment) {
return (
<div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<div className="text-center">
<p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p>
<Button
onClick={() => router.push("/admin/booking")}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Bookings
</Button>
</div>
</div>
);
}
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
{/* Page Header */}
<div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
<Button
variant="ghost"
onClick={() => router.push("/admin/booking")}
className={`flex items-center gap-2 w-fit ${isDark ? "text-gray-300 hover:bg-gray-800 hover:text-white" : "text-gray-600 hover:bg-gray-100"}`}
>
<ArrowLeft className="w-4 h-4" />
Back to Bookings
</Button>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<div className={`h-12 w-12 sm:h-16 sm:w-16 rounded-full flex items-center justify-center text-xl sm:text-2xl font-bold ${isDark ? "bg-gradient-to-br from-rose-500 to-pink-600 text-white" : "bg-gradient-to-br from-rose-100 to-pink-100 text-rose-600"}`}>
{appointment.first_name[0]}{appointment.last_name[0]}
</div>
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name}
</h1>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Appointment Request
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`px-3 sm:px-4 py-2 inline-flex items-center gap-2 text-xs sm:text-sm font-semibold rounded-full border ${getStatusColor(
appointment.status
)}`}
>
{appointment.status === "scheduled" && <CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4" />}
{formatStatus(appointment.status)}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - Left Column (2/3) */}
<div className="lg:col-span-2 space-y-6">
{/* Patient Information Card */}
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<User className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Patient Information
</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Full Name
</p>
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name}
</p>
</div>
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Mail className="w-3 h-3" />
Email Address
</p>
<div className="flex items-center gap-2">
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.email}
</p>
<button
onClick={() => copyToClipboard(appointment.email, "Email")}
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy email"
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
{appointment.phone && (
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<PhoneIcon className="w-3 h-3" />
Phone Number
</p>
<div className="flex items-center gap-2">
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.phone}
</p>
<button
onClick={() => copyToClipboard(appointment.phone!, "Phone")}
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy phone"
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Appointment Details Card */}
{appointment.scheduled_datetime && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<div className="flex items-center justify-between">
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Scheduled Appointment
</h2>
{appointment.status === "scheduled" && (
<button
onClick={() => {
// Initialize reschedule fields with current appointment values
if (appointment.scheduled_datetime) {
const scheduledDate = new Date(appointment.scheduled_datetime);
setRescheduleDate(scheduledDate);
const hours = scheduledDate.getHours().toString().padStart(2, "0");
const minutes = scheduledDate.getMinutes().toString().padStart(2, "0");
setRescheduleTime(`${hours}:${minutes}`);
}
if (appointment.scheduled_duration) {
setRescheduleDuration(appointment.scheduled_duration);
}
setRescheduleDialogOpen(true);
}}
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-blue-600 hover:text-blue-700 ${isDark ? "hover:bg-blue-900/20" : "hover:bg-blue-50"}`}
title="Reschedule Appointment"
>
<Pencil className="w-4 h-4 shrink-0 sm:hidden" />
<span className="hidden sm:inline text-sm font-medium">Reschedule</span>
</button>
)}
</div>
</div>
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`p-4 rounded-xl ${isDark ? "bg-blue-500/10 border border-blue-500/20" : "bg-blue-50 border border-blue-100"}`}>
<Calendar className={`w-6 h-6 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
</div>
<div className="flex-1">
<p className={`text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)}
</p>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-2">
<Clock className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{formatTime(appointment.scheduled_datetime)}
</p>
</div>
{appointment.scheduled_duration && (
<div className="flex items-center gap-2">
<span className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}></span>
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{appointment.scheduled_duration} minutes
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Selected Slots */}
{appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0 && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
Selected Time Slots
{appointment.are_preferences_available !== undefined && (
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
{appointment.are_preferences_available ? "All Available" : "Partially Available"}
</span>
)}
</h2>
</div>
<div className="p-6">
{(() => {
const dayNames: Record<number, string> = {
0: "Monday",
1: "Tuesday",
2: "Wednesday",
3: "Thursday",
4: "Friday",
5: "Saturday",
6: "Sunday",
};
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
// Time slot order: morning, afternoon (lunchtime), evening
const timeSlotOrder: Record<string, number> = {
morning: 0,
afternoon: 1,
evening: 2,
};
// Group slots by date
const slotsByDate: Record<string, typeof appointment.selected_slots> = {};
appointment.selected_slots.forEach((slot: any) => {
const date = slot.date || "";
if (!slotsByDate[date]) {
slotsByDate[date] = [];
}
slotsByDate[date].push(slot);
});
// Sort dates and slots within each date
const sortedDates = Object.keys(slotsByDate).sort((a, b) => {
return new Date(a).getTime() - new Date(b).getTime();
});
return (
<div className="space-y-4">
{sortedDates.map((date) => {
// Sort slots within this date by time slot order
const slots = slotsByDate[date].sort((a: any, b: any) => {
const aSlot = String(a.time_slot).toLowerCase().trim();
const bSlot = String(b.time_slot).toLowerCase().trim();
const aOrder = timeSlotOrder[aSlot] ?? 999;
const bOrder = timeSlotOrder[bSlot] ?? 999;
return aOrder - bOrder;
});
return (
<div key={date} className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<div className="mb-3">
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{formatShortDate(date)}
</p>
{slots.length > 0 && slots[0]?.day !== undefined && (
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{dayNames[slots[0].day] || `Day ${slots[0].day}`}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{slots.map((slot: any, idx: number) => {
const timeSlot = String(slot.time_slot).toLowerCase().trim();
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
return (
<span
key={idx}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
>
{timeLabel}
</span>
);
})}
</div>
</div>
);
})}
</div>
);
})()}
</div>
</div>
)}
{/* Reason */}
{appointment.reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<MessageSquare className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Reason for Appointment
</h2>
</div>
<div className="p-6">
<p className={`text-base leading-relaxed ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{appointment.reason}
</p>
</div>
</div>
)}
{/* Rejection Reason */}
{appointment.rejection_reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-red-900/20 border-red-800/50" : "bg-red-50 border-red-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-red-800/50" : "border-red-200"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-red-300" : "text-red-900"}`}>
Rejection Reason
</h2>
</div>
<div className="p-6">
<p className={`text-base leading-relaxed ${isDark ? "text-red-200" : "text-red-800"}`}>
{appointment.rejection_reason}
</p>
</div>
</div>
)}
{/* Meeting Information */}
{appointment.moderator_join_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-blue-800/30" : "border-blue-200"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Video className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
Video Meeting
</h2>
</div>
<div className="p-6 space-y-4">
{appointment.jitsi_room_id && (
<div>
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Room ID
</p>
<div className="flex items-center gap-2">
<p className={`text-sm font-mono px-3 py-2 rounded-lg ${isDark ? "bg-gray-800 text-gray-200" : "bg-white text-gray-900 border border-gray-200"}`}>
{appointment.jitsi_room_id}
</p>
<button
onClick={() => appointment.can_join_as_moderator && copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
disabled={!appointment.can_join_as_moderator}
className={`p-2 rounded-lg transition-colors ${appointment.can_join_as_moderator ? (isDark ? "hover:bg-gray-700" : "hover:bg-gray-100") : (isDark ? "opacity-50 cursor-not-allowed" : "opacity-50 cursor-not-allowed")}`}
title={appointment.can_join_as_moderator ? "Copy room ID" : "Meeting not available"}
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
)}
<div>
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Moderator Meeting Link
</p>
<div className="flex items-center gap-2">
<div className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800/50 text-gray-500 border border-gray-700" : "bg-gray-100 text-gray-400 border border-gray-300"}`}>
{appointment.moderator_join_url}
</div>
<button
disabled
className={`px-4 py-2 rounded-lg font-medium cursor-not-allowed ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<ExternalLink className="w-4 h-4" />
</button>
</div>
</div>
{appointment.can_join_as_moderator !== undefined && (
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_as_moderator ? (isDark ? "bg-green-500/20 border border-green-500/30" : "bg-green-50 border border-green-200") : (isDark ? "bg-gray-800 border border-gray-700" : "bg-gray-50 border border-gray-200")}`}>
<div className={`h-2 w-2 rounded-full ${appointment.can_join_as_moderator ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
<p className={`text-sm font-medium ${appointment.can_join_as_moderator ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
{appointment.can_join_as_moderator
? "Meeting is active - You can join as moderator"
: "Click here to join"}
</p>
</div>
)}
</div>
</div>
)}
</div>
{/* Sidebar - Right Column (1/3) */}
<div className="space-y-6">
{/* Quick Info Card */}
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
Quick Info
</h2>
</div>
<div className="p-6 space-y-4">
<div>
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Created
</p>
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{formatShortDate(appointment.created_at)}
</p>
<p className={`text-xs mt-0.5 ${isDark ? "text-gray-500" : "text-gray-500"}`}>
{formatTime(appointment.created_at)}
</p>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Status
</p>
<span
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-lg border ${getStatusColor(
appointment.status
)}`}
>
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 h-4" />}
{formatStatus(appointment.status)}
</span>
</div>
{appointment.scheduled_datetime && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Information
</p>
<div className="space-y-3">
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Start Time:
</p>
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)} at {formatTime(appointment.scheduled_datetime)}
</p>
</div>
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
How to Access:
</p>
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Up to 10 minutes before the meeting is scheduled to begin, click the "Start Meeting" button below to begin the session. Once started, participants can join using the meeting link. You can also use the{" "}
{appointment.moderator_join_url ? (
<a
href={appointment.moderator_join_url}
target="_blank"
rel="noopener noreferrer"
className={`font-medium underline hover:opacity-80 ${isDark ? "text-blue-400" : "text-blue-600"}`}
>
moderator link
</a>
) : (
"moderator link"
)}{" "}
to join directly.
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Action Buttons */}
{appointment.status === "pending_review" && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className="p-6 space-y-3">
<Button
onClick={() => setScheduleDialogOpen(true)}
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-12 text-base font-medium"
>
<CalendarCheck className="w-5 h-5 mr-2" />
Schedule Appointment
</Button>
<Button
onClick={() => setRejectDialogOpen(true)}
variant="outline"
className={`w-full h-12 text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
>
<X className="w-5 h-5 mr-2" />
Reject Request
</Button>
</div>
</div>
)}
{/* Cancel Appointment Button */}
{appointment.status === "scheduled" && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className="p-6">
<Button
onClick={() => setCancelDialogOpen(true)}
variant="outline"
className={`w-full h-12 text-sm sm:text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20 border-red-500" : ""}`}
>
<X className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
<span className="text-center">Cancel Appointment</span>
</Button>
</div>
</div>
)}
{/* Meeting Button (if scheduled) */}
{appointment.status === "scheduled" && appointment.moderator_join_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className="p-6 space-y-3">
{(() => {
// Check if meeting has ended
const endedAt = appointment.meeting_ended_at;
const hasEnded = endedAt != null && endedAt !== "";
// If meeting has ended, show "Meeting has ended"
if (hasEnded) {
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Meeting has ended</span>
</button>
);
}
// Check if can join as moderator (handle both boolean and string values)
const canJoinAsModerator = appointment.can_join_as_moderator === true || appointment.can_join_as_moderator === "true";
// Check if meeting has started (handle both field names)
const startedAt = appointment.started_at || appointment.meeting_started_at;
const hasStarted = startedAt != null && startedAt !== "";
// If can_join_as_moderator != true, display "Meeting would be available shortly"
if (!canJoinAsModerator) {
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Click here to join</span>
</button>
);
}
// If can_join_as_moderator == true && started_at != null, show "Join Now" button
if (hasStarted) {
return (
<>
<a
href={appointment.moderator_join_url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-sm sm:text-base font-medium transition-colors`}
>
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Join Now</span>
</a>
<Button
onClick={handleEndMeeting}
disabled={isEndingMeeting}
variant="outline"
className={`w-full h-12 text-sm sm:text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
>
{isEndingMeeting ? (
<>
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 animate-spin shrink-0" />
<span className="text-center">Ending...</span>
</>
) : (
<>
<X className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
<span className="text-center">End Meeting</span>
</>
)}
</Button>
</>
);
}
// If can_join_as_moderator == true && started_at == null, show "Start Meeting" button
return (
<Button
onClick={handleStartMeeting}
disabled={isStartingMeeting}
className="w-full bg-green-600 hover:bg-green-700 text-white h-12 text-sm sm:text-base font-medium"
>
{isStartingMeeting ? (
<>
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 animate-spin shrink-0" />
<span className="text-center">Starting...</span>
</>
) : (
<>
<Video className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
<span className="text-center">Start Meeting</span>
</>
)}
</Button>
);
})()}
</div>
</div>
)}
</div>
</div>
</main>
{/* Schedule Appointment Dialog */}
<ScheduleAppointmentDialog
open={scheduleDialogOpen}
onOpenChange={setScheduleDialogOpen}
appointment={appointment}
scheduledDate={scheduledDate}
setScheduledDate={setScheduledDate}
scheduledTime={scheduledTime}
setScheduledTime={setScheduledTime}
scheduledDuration={scheduledDuration}
setScheduledDuration={setScheduledDuration}
onSchedule={handleSchedule}
isScheduling={isScheduling}
isDark={isDark}
/>
{/* Reschedule Appointment Dialog */}
<ScheduleAppointmentDialog
open={rescheduleDialogOpen}
onOpenChange={setRescheduleDialogOpen}
appointment={appointment}
scheduledDate={rescheduleDate}
setScheduledDate={setRescheduleDate}
scheduledTime={rescheduleTime}
setScheduledTime={setRescheduleTime}
scheduledDuration={rescheduleDuration}
setScheduledDuration={setRescheduleDuration}
onSchedule={handleReschedule}
isScheduling={isRescheduling}
isDark={isDark}
title="Reschedule Appointment"
description={appointment ? `Change the date and time for ${appointment.first_name} ${appointment.last_name}'s appointment` : "Change the date and time for this appointment"}
/>
{/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<DialogContent className={`max-w-2xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<DialogHeader>
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
Reject Appointment Request
</DialogTitle>
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Reject appointment request from {appointment.first_name} {appointment.last_name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Rejection Reason (Optional)
</label>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Enter reason for rejection..."
rows={5}
className={`w-full rounded-xl border px-4 py-3 text-base ${
isDark
? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400"
: "bg-white border-gray-300 text-gray-900 placeholder:text-gray-500"
}`}
/>
</div>
</div>
<DialogFooter className="gap-3 pt-4">
<Button
variant="outline"
onClick={() => setRejectDialogOpen(false)}
disabled={isRejecting}
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
>
Cancel
</Button>
<Button
onClick={handleReject}
disabled={isRejecting}
className="h-12 px-6 bg-red-600 hover:bg-red-700 text-white"
>
{isRejecting ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Rejecting...
</>
) : (
<>
<X className="w-5 h-5 mr-2" />
Reject Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel Appointment Confirmation Dialog */}
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<DialogContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
<DialogHeader>
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
Cancel Appointment
</DialogTitle>
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
Are you sure you want to cancel this appointment? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialogOpen(false)}
disabled={isCancelling}
className={isDark ? "border-gray-600 text-gray-300 hover:bg-gray-700" : ""}
>
No, Keep Appointment
</Button>
<Button
onClick={handleCancelAppointment}
disabled={isCancelling}
className="bg-red-600 hover:bg-red-700 text-white"
>
{isCancelling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Cancelling...
</>
) : (
<>
<X className="w-4 h-4 mr-2" />
Yes, Cancel Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}