feat/authentication #21
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";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
Video,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Search,
|
||||
CalendarCheck,
|
||||
X,
|
||||
Loader2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
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 { 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 Booking() {
|
||||
const router = useRouter();
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 isDark = theme === "dark";
|
||||
|
||||
@ -28,8 +51,6 @@ export default function Booking() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listAppointments();
|
||||
console.log("Fetched appointments:", data);
|
||||
console.log("Appointments count:", data?.length);
|
||||
setAppointments(data || []);
|
||||
} catch (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());
|
||||
};
|
||||
|
||||
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(
|
||||
(appointment) =>
|
||||
appointment.first_name
|
||||
@ -188,7 +296,8 @@ export default function Booking() {
|
||||
{filteredAppointments.map((appointment) => (
|
||||
<tr
|
||||
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">
|
||||
<div className="flex items-center">
|
||||
@ -262,7 +371,35 @@ export default function Booking() {
|
||||
{formatDate(appointment.created_at)}
|
||||
</td>
|
||||
<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 && (
|
||||
<a
|
||||
href={appointment.jitsi_meet_url}
|
||||
@ -274,17 +411,6 @@ export default function Booking() {
|
||||
<Video className="w-4 h-4" />
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@ -295,6 +421,166 @@ export default function Booking() {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -181,10 +181,14 @@ export default function BookNowPage() {
|
||||
const appointmentData = await create(payload);
|
||||
|
||||
// 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 = {
|
||||
ID: parseInt(appointmentData.id) || Math.floor(Math.random() * 1000),
|
||||
CreatedAt: appointmentData.created_at || new Date().toISOString(),
|
||||
UpdatedAt: appointmentData.updated_at || new Date().toISOString(),
|
||||
ID: appointmentId || 0,
|
||||
CreatedAt: appointmentData.created_at || now,
|
||||
UpdatedAt: appointmentData.updated_at || now,
|
||||
DeletedAt: null,
|
||||
user_id: 0, // API doesn't return user_id in this response
|
||||
user: {
|
||||
|
||||
@ -21,7 +21,7 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<body className={inter.className} suppressHydrationWarning>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
Loading…
Reference in New Issue
Block a user