- Changed redirect paths for authenticated users to point to the admin booking page instead of the admin dashboard. - Updated appointment detail pages to provide clearer messaging regarding meeting availability and scheduled times. - Enhanced login and signup flows to ensure users are redirected to the appropriate booking page based on their role. - Improved user experience by refining text prompts related to meeting access and availability.
885 lines
41 KiB
TypeScript
885 lines
41 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"
|
|
: appointment.scheduled_datetime
|
|
? `Meeting would be available to join starting at ${formatTime(appointment.scheduled_datetime)}`
|
|
: "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"}`}>
|
|
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 moderator link near the bottom of the page 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) {
|
|
const meetingTime = appointment.scheduled_datetime
|
|
? formatTime(appointment.scheduled_datetime)
|
|
: "the scheduled time";
|
|
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 to join starting at {meetingTime}
|
|
</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>
|
|
);
|
|
}
|