- Implemented logic to display a "Meeting has ended" button on both admin and user appointment detail pages when the meeting has concluded. - Updated the dashboard to include a new statistic for active upcoming meetings, enhancing the overview of current appointments. - Adjusted appointment stats model to accommodate the new active upcoming meetings metric, ensuring accurate data representation.
878 lines
40 KiB
TypeScript
878 lines
40 KiB
TypeScript
"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,
|
|
} from "lucide-react";
|
|
import { useAppTheme } from "@/components/ThemeProvider";
|
|
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting } 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 [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
|
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
|
|
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
|
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
|
const [isScheduling, setIsScheduling] = useState(false);
|
|
const [isRejecting, setIsRejecting] = useState(false);
|
|
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
|
|
const [isEndingMeeting, setIsEndingMeeting] = 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 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"}`}>
|
|
<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>
|
|
</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" : "Meeting would be available shortly"}
|
|
</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"}`}>
|
|
Click the "Start Meeting" button below to begin the session. Once started, participants can join using the meeting link. You can also use the moderator link above 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>
|
|
)}
|
|
|
|
{/* 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-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
|
>
|
|
<Video className="w-5 h-5" />
|
|
Meeting has ended
|
|
</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-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
|
>
|
|
<Video className="w-5 h-5" />
|
|
Meeting would be available shortly
|
|
</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-base font-medium transition-colors`}
|
|
>
|
|
<Video className="w-5 h-5" />
|
|
Join Now
|
|
</a>
|
|
<Button
|
|
onClick={handleEndMeeting}
|
|
disabled={isEndingMeeting}
|
|
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" : ""}`}
|
|
>
|
|
{isEndingMeeting ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
Ending...
|
|
</>
|
|
) : (
|
|
<>
|
|
<X className="w-5 h-5 mr-2" />
|
|
End Meeting
|
|
</>
|
|
)}
|
|
</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-base font-medium"
|
|
>
|
|
{isStartingMeeting ? (
|
|
<>
|
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
|
Starting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Video className="w-5 h-5 mr-2" />
|
|
Start Meeting
|
|
</>
|
|
)}
|
|
</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}
|
|
/>
|
|
|
|
{/* 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>
|
|
</div>
|
|
);
|
|
}
|