Enhance Booking component with appointment scheduling and rejection functionality. Integrate dialogs for scheduling and rejecting appointments, improving user interaction. Update layout to suppress hydration warnings and refine appointment data handling in BookNowPage for consistent ID management.
This commit is contained in:
parent
4f6e64bf99
commit
c7871cfb46
800
app/(admin)/admin/booking/[id]/page.tsx
Normal file
800
app/(admin)/admin/booking/[id]/page.tsx
Normal file
@ -0,0 +1,800 @@
|
|||||||
|
"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 } from "@/lib/actions/appointments";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DatePicker } from "@/components/DatePicker";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
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 { theme } = useAppTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAppointment = async () => {
|
||||||
|
if (!appointmentId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getAppointmentDetail(appointmentId);
|
||||||
|
setAppointment(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch appointment details:", 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 timeSlots = Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const hour = i.toString().padStart(2, "0");
|
||||||
|
return `${hour}:00`;
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
console.error("Failed to schedule appointment:", error);
|
||||||
|
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) {
|
||||||
|
console.error("Failed to reject appointment:", error);
|
||||||
|
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`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen 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-screen 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"}`}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/admin/booking")}
|
||||||
|
className={`flex items-center gap-2 mb-6 ${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-16 w-16 rounded-full flex items-center justify-center 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-3xl sm:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{appointment.first_name} {appointment.last_name}
|
||||||
|
</h1>
|
||||||
|
<p className={`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-4 py-2 inline-flex items-center gap-2 text-sm font-semibold rounded-full border ${getStatusColor(
|
||||||
|
appointment.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preferred Dates & Times */}
|
||||||
|
{(appointment.preferred_dates?.length > 0 || appointment.preferred_time_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 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
Preferred Availability
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{appointment.preferred_dates && appointment.preferred_dates.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Preferred Dates
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{appointment.preferred_dates.map((date, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
||||||
|
>
|
||||||
|
{formatShortDate(date)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Preferred Time Slots
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{appointment.preferred_time_slots.map((slot, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
||||||
|
>
|
||||||
|
{slot}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</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.jitsi_meet_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={() => copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
|
||||||
|
className={`p-2 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
|
||||||
|
title="Copy room ID"
|
||||||
|
>
|
||||||
|
<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"}`}>
|
||||||
|
Meeting Link
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
href={appointment.jitsi_meet_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800 text-blue-400 hover:bg-gray-700" : "bg-white text-blue-600 hover:bg-gray-50 border border-gray-200"}`}
|
||||||
|
>
|
||||||
|
{appointment.jitsi_meet_url}
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={appointment.jitsi_meet_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${isDark ? "bg-blue-600 hover:bg-blue-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"}`}
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{appointment.can_join_meeting !== undefined && (
|
||||||
|
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_meeting ? (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_meeting ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
|
||||||
|
<p className={`text-sm font-medium ${appointment.can_join_meeting ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
|
||||||
|
{appointment.can_join_meeting ? "Meeting is active - You can join now" : "Meeting is not available yet"}
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Join Meeting Button (if scheduled) */}
|
||||||
|
{appointment.status === "scheduled" && appointment.jitsi_meet_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">
|
||||||
|
<a
|
||||||
|
href={appointment.jitsi_meet_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 Meeting
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Google Meet Style Schedule Dialog */}
|
||||||
|
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||||
|
<DialogContent className={`max-w-3xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<DialogHeader className="pb-4">
|
||||||
|
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
Schedule Appointment
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Set date and time for {appointment.first_name} {appointment.last_name}'s appointment
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Date Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Select Date *
|
||||||
|
</label>
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<DatePicker
|
||||||
|
date={scheduledDate}
|
||||||
|
setDate={setScheduledDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Select Time *
|
||||||
|
</label>
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
||||||
|
<SelectTrigger className={`h-12 text-base ${isDark ? "bg-gray-800 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
|
||||||
|
<SelectValue placeholder="Choose a time" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||||
|
{timeSlots.map((time) => (
|
||||||
|
<SelectItem
|
||||||
|
key={time}
|
||||||
|
value={time}
|
||||||
|
className={`h-12 text-base ${isDark ? "focus:bg-gray-700" : ""}`}
|
||||||
|
>
|
||||||
|
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Duration
|
||||||
|
</label>
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{[30, 60, 90, 120].map((duration) => (
|
||||||
|
<button
|
||||||
|
key={duration}
|
||||||
|
onClick={() => setScheduledDuration(duration)}
|
||||||
|
className={`px-4 py-3 rounded-lg text-sm font-medium transition-all ${
|
||||||
|
scheduledDuration === duration
|
||||||
|
? isDark
|
||||||
|
? "bg-blue-600 text-white"
|
||||||
|
: "bg-blue-600 text-white"
|
||||||
|
: isDark
|
||||||
|
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
|
||||||
|
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{duration} min
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{scheduledDate && (
|
||||||
|
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
|
||||||
|
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
||||||
|
Appointment Preview
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{formatDate(scheduledDate.toISOString())}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
{new Date(`2000-01-01T${scheduledTime}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})} • {scheduledDuration} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setScheduleDialogOpen(false)}
|
||||||
|
disabled={isScheduling}
|
||||||
|
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSchedule}
|
||||||
|
disabled={isScheduling || !scheduledDate}
|
||||||
|
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{isScheduling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Scheduling...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CalendarCheck className="w-5 h-5 mr-2" />
|
||||||
|
Schedule Appointment
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,25 +1,48 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
User,
|
|
||||||
Video,
|
Video,
|
||||||
FileText,
|
|
||||||
MoreVertical,
|
|
||||||
Search,
|
Search,
|
||||||
|
CalendarCheck,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { listAppointments } from "@/lib/actions/appointments";
|
import { listAppointments, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { DatePicker } from "@/components/DatePicker";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
|
|
||||||
export default function Booking() {
|
export default function Booking() {
|
||||||
|
const router = useRouter();
|
||||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||||
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||||
|
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||||
|
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 { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
@ -28,8 +51,6 @@ export default function Booking() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listAppointments();
|
const data = await listAppointments();
|
||||||
console.log("Fetched appointments:", data);
|
|
||||||
console.log("Appointments count:", data?.length);
|
|
||||||
setAppointments(data || []);
|
setAppointments(data || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch appointments:", error);
|
console.error("Failed to fetch appointments:", error);
|
||||||
@ -99,6 +120,93 @@ export default function Booking() {
|
|||||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleViewDetails = (appointment: Appointment) => {
|
||||||
|
router.push(`/admin/booking/${appointment.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScheduleClick = (appointment: Appointment) => {
|
||||||
|
setSelectedAppointment(appointment);
|
||||||
|
setScheduledDate(undefined);
|
||||||
|
setScheduledTime("09:00");
|
||||||
|
setScheduledDuration(60);
|
||||||
|
setScheduleDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectClick = (appointment: Appointment) => {
|
||||||
|
setSelectedAppointment(appointment);
|
||||||
|
setRejectionReason("");
|
||||||
|
setRejectDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchedule = async () => {
|
||||||
|
if (!selectedAppointment || !scheduledDate) {
|
||||||
|
toast.error("Please select a date and time");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsScheduling(true);
|
||||||
|
try {
|
||||||
|
// Combine date and time into ISO datetime string
|
||||||
|
const [hours, minutes] = scheduledTime.split(":");
|
||||||
|
const datetime = new Date(scheduledDate);
|
||||||
|
datetime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||||
|
const isoString = datetime.toISOString();
|
||||||
|
|
||||||
|
await scheduleAppointment(selectedAppointment.id, {
|
||||||
|
scheduled_datetime: isoString,
|
||||||
|
scheduled_duration: scheduledDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Appointment scheduled successfully!");
|
||||||
|
setScheduleDialogOpen(false);
|
||||||
|
|
||||||
|
// Refresh appointments list
|
||||||
|
const data = await listAppointments();
|
||||||
|
setAppointments(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to schedule appointment:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsScheduling(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!selectedAppointment) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRejecting(true);
|
||||||
|
try {
|
||||||
|
await rejectAppointment(selectedAppointment.id, {
|
||||||
|
rejection_reason: rejectionReason || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Appointment rejected successfully");
|
||||||
|
setRejectDialogOpen(false);
|
||||||
|
|
||||||
|
// Refresh appointments list
|
||||||
|
const data = await listAppointments();
|
||||||
|
setAppointments(data || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reject appointment:", error);
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsRejecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate time slots
|
||||||
|
const timeSlots = [];
|
||||||
|
for (let hour = 8; hour <= 18; hour++) {
|
||||||
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
|
const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||||
|
timeSlots.push(timeString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const filteredAppointments = appointments.filter(
|
const filteredAppointments = appointments.filter(
|
||||||
(appointment) =>
|
(appointment) =>
|
||||||
appointment.first_name
|
appointment.first_name
|
||||||
@ -188,7 +296,8 @@ export default function Booking() {
|
|||||||
{filteredAppointments.map((appointment) => (
|
{filteredAppointments.map((appointment) => (
|
||||||
<tr
|
<tr
|
||||||
key={appointment.id}
|
key={appointment.id}
|
||||||
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
className={`transition-colors cursor-pointer ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
||||||
|
onClick={() => handleViewDetails(appointment)}
|
||||||
>
|
>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4">
|
<td className="px-3 sm:px-4 md:px-6 py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -262,7 +371,35 @@ export default function Booking() {
|
|||||||
{formatDate(appointment.created_at)}
|
{formatDate(appointment.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
<div className="flex items-center justify-end gap-1 sm:gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{appointment.status === "pending_review" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleScheduleClick(appointment)}
|
||||||
|
className={`px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isDark
|
||||||
|
? "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
}`}
|
||||||
|
title="Schedule Appointment"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Schedule</span>
|
||||||
|
<CalendarCheck className="w-4 h-4 sm:hidden" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectClick(appointment)}
|
||||||
|
className={`px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isDark
|
||||||
|
? "bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
: "bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
}`}
|
||||||
|
title="Reject Appointment"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Reject</span>
|
||||||
|
<X className="w-4 h-4 sm:hidden" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{appointment.jitsi_meet_url && (
|
{appointment.jitsi_meet_url && (
|
||||||
<a
|
<a
|
||||||
href={appointment.jitsi_meet_url}
|
href={appointment.jitsi_meet_url}
|
||||||
@ -274,17 +411,6 @@ export default function Booking() {
|
|||||||
<Video className="w-4 h-4" />
|
<Video className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{appointment.reason && (
|
|
||||||
<button
|
|
||||||
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
|
|
||||||
title={appointment.reason}
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -295,6 +421,166 @@ export default function Booking() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Schedule Appointment Dialog */}
|
||||||
|
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||||
|
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
|
||||||
|
Schedule Appointment
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
|
||||||
|
{selectedAppointment && (
|
||||||
|
<>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name}</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Date *
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
date={scheduledDate}
|
||||||
|
setDate={setScheduledDate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Time *
|
||||||
|
</label>
|
||||||
|
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
||||||
|
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
|
||||||
|
<SelectValue placeholder="Select time" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||||
|
{timeSlots.map((time) => (
|
||||||
|
<SelectItem
|
||||||
|
key={time}
|
||||||
|
value={time}
|
||||||
|
className={isDark ? "focus:bg-gray-700" : ""}
|
||||||
|
>
|
||||||
|
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Duration (minutes)
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={scheduledDuration.toString()}
|
||||||
|
onValueChange={(value) => setScheduledDuration(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
|
||||||
|
<SelectValue placeholder="Select duration" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||||
|
<SelectItem value="30" className={isDark ? "focus:bg-gray-700" : ""}>30 minutes</SelectItem>
|
||||||
|
<SelectItem value="60" className={isDark ? "focus:bg-gray-700" : ""}>60 minutes</SelectItem>
|
||||||
|
<SelectItem value="90" className={isDark ? "focus:bg-gray-700" : ""}>90 minutes</SelectItem>
|
||||||
|
<SelectItem value="120" className={isDark ? "focus:bg-gray-700" : ""}>120 minutes</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setScheduleDialogOpen(false)}
|
||||||
|
disabled={isScheduling}
|
||||||
|
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSchedule}
|
||||||
|
disabled={isScheduling || !scheduledDate}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
>
|
||||||
|
{isScheduling ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Scheduling...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Schedule Appointment"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Reject Appointment Dialog */}
|
||||||
|
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||||
|
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
|
||||||
|
Reject Appointment
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
|
||||||
|
{selectedAppointment && (
|
||||||
|
<>Reject appointment request from {selectedAppointment.first_name} {selectedAppointment.last_name}</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`text-sm font-medium ${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={4}
|
||||||
|
className={`w-full rounded-md border px-3 py-2 text-sm ${
|
||||||
|
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>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialogOpen(false)}
|
||||||
|
disabled={isRejecting}
|
||||||
|
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isRejecting}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{isRejecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Rejecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Reject Appointment"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -181,10 +181,14 @@ export default function BookNowPage() {
|
|||||||
const appointmentData = await create(payload);
|
const appointmentData = await create(payload);
|
||||||
|
|
||||||
// Convert API response to Booking format for display
|
// Convert API response to Booking format for display
|
||||||
|
// Use a stable ID - if appointmentData.id exists, use it, otherwise use 0
|
||||||
|
const appointmentId = appointmentData.id ? parseInt(appointmentData.id, 10) : 0;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const bookingData: Booking = {
|
const bookingData: Booking = {
|
||||||
ID: parseInt(appointmentData.id) || Math.floor(Math.random() * 1000),
|
ID: appointmentId || 0,
|
||||||
CreatedAt: appointmentData.created_at || new Date().toISOString(),
|
CreatedAt: appointmentData.created_at || now,
|
||||||
UpdatedAt: appointmentData.updated_at || new Date().toISOString(),
|
UpdatedAt: appointmentData.updated_at || now,
|
||||||
DeletedAt: null,
|
DeletedAt: null,
|
||||||
user_id: 0, // API doesn't return user_id in this response
|
user_id: 0, // API doesn't return user_id in this response
|
||||||
user: {
|
user: {
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className={inter.className} suppressHydrationWarning>
|
||||||
<Providers>
|
<Providers>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user