Implement appointment cancellation and rescheduling in admin view #68

Merged
Hammond merged 1 commits from feat/booking-panel into master 2025-12-05 18:35:57 +00:00
3 changed files with 180 additions and 112 deletions

View File

@ -21,7 +21,7 @@ import {
Pencil,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting, rescheduleAppointment } from "@/lib/actions/appointments";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting, rescheduleAppointment, cancelAppointment } from "@/lib/actions/appointments";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -57,6 +57,8 @@ export default function AppointmentDetailPage() {
const [isRescheduling, setIsRescheduling] = useState(false);
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
const [isEndingMeeting, setIsEndingMeeting] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { theme } = useAppTheme();
const isDark = theme === "dark";
@ -210,6 +212,56 @@ export default function AppointmentDetailPage() {
}
};
const handleReschedule = async () => {
if (!appointment || !rescheduleDate) return;
setIsRescheduling(true);
try {
const dateTime = new Date(rescheduleDate);
const [hours, minutes] = rescheduleTime.split(":").map(Number);
dateTime.setHours(hours, minutes, 0, 0);
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
await rescheduleAppointment(appointment.id, {
new_scheduled_datetime: dateTime.toISOString(),
new_scheduled_duration: rescheduleDuration,
timezone: userTimezone,
});
toast.success("Appointment rescheduled successfully");
setRescheduleDialogOpen(false);
// Refresh appointment data
const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated);
} catch (error: any) {
toast.error(error.message || "Failed to reschedule appointment");
} finally {
setIsRescheduling(false);
}
};
const handleCancelAppointment = async () => {
if (!appointment) return;
setIsCancelling(true);
try {
await cancelAppointment(appointment.id);
toast.success("Appointment cancelled successfully");
setCancelDialogOpen(false);
// Refetch appointment to get updated status
const updatedAppointment = await getAppointmentDetail(appointment.id);
setAppointment(updatedAppointment);
router.push("/admin/booking");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to cancel appointment";
toast.error(errorMessage);
} finally {
setIsCancelling(false);
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
@ -384,10 +436,34 @@ export default function AppointmentDetailPage() {
{appointment.scheduled_datetime && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<div className="flex items-center justify-between">
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Scheduled Appointment
</h2>
{appointment.status === "scheduled" && (
<button
onClick={() => {
// Initialize reschedule fields with current appointment values
if (appointment.scheduled_datetime) {
const scheduledDate = new Date(appointment.scheduled_datetime);
setRescheduleDate(scheduledDate);
const hours = scheduledDate.getHours().toString().padStart(2, "0");
const minutes = scheduledDate.getMinutes().toString().padStart(2, "0");
setRescheduleTime(`${hours}:${minutes}`);
}
if (appointment.scheduled_duration) {
setRescheduleDuration(appointment.scheduled_duration);
}
setRescheduleDialogOpen(true);
}}
className={`p-2 rounded-lg transition-colors ${isDark ? "hover:bg-gray-700 text-gray-300 hover:text-white" : "hover:bg-gray-100 text-gray-600 hover:text-gray-900"}`}
title="Reschedule Appointment"
>
<Pencil className="w-4 h-4" />
</button>
)}
</div>
</div>
<div className="p-6">
<div className="flex items-start gap-4">
@ -715,6 +791,22 @@ export default function AppointmentDetailPage() {
</div>
)}
{/* Cancel Appointment Button */}
{appointment.status === "scheduled" && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className="p-6">
<Button
onClick={() => setCancelDialogOpen(true)}
variant="outline"
className={`w-full h-12 text-sm sm:text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20 border-red-500" : ""}`}
>
<X className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
<span className="text-center">Cancel Appointment</span>
</Button>
</div>
</div>
)}
{/* Meeting Button (if scheduled) */}
{appointment.status === "scheduled" && appointment.moderator_join_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
@ -729,10 +821,10 @@ export default function AppointmentDetailPage() {
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-5 h-5" />
Meeting has ended
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Meeting has ended</span>
</button>
);
}
@ -771,26 +863,26 @@ export default function AppointmentDetailPage() {
href={appointment.moderator_join_url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-sm sm:text-base font-medium transition-colors`}
>
<Video className="w-5 h-5" />
Join Now
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Join Now</span>
</a>
<Button
onClick={handleEndMeeting}
disabled={isEndingMeeting}
variant="outline"
className={`w-full h-12 text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
className={`w-full h-12 text-sm sm:text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
>
{isEndingMeeting ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Ending...
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 animate-spin shrink-0" />
<span className="text-center">Ending...</span>
</>
) : (
<>
<X className="w-5 h-5 mr-2" />
End Meeting
<X className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
<span className="text-center">End Meeting</span>
</>
)}
</Button>
@ -803,17 +895,17 @@ export default function AppointmentDetailPage() {
<Button
onClick={handleStartMeeting}
disabled={isStartingMeeting}
className="w-full bg-green-600 hover:bg-green-700 text-white h-12 text-base font-medium"
className="w-full bg-green-600 hover:bg-green-700 text-white h-12 text-sm sm:text-base font-medium"
>
{isStartingMeeting ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Starting...
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 animate-spin shrink-0" />
<span className="text-center">Starting...</span>
</>
) : (
<>
<Video className="w-5 h-5 mr-2" />
Start Meeting
<Video className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
<span className="text-center">Start Meeting</span>
</>
)}
</Button>
@ -842,6 +934,24 @@ export default function AppointmentDetailPage() {
isDark={isDark}
/>
{/* Reschedule Appointment Dialog */}
<ScheduleAppointmentDialog
open={rescheduleDialogOpen}
onOpenChange={setRescheduleDialogOpen}
appointment={appointment}
scheduledDate={rescheduleDate}
setScheduledDate={setRescheduleDate}
scheduledTime={rescheduleTime}
setScheduledTime={setRescheduleTime}
scheduledDuration={rescheduleDuration}
setScheduledDuration={setRescheduleDuration}
onSchedule={handleReschedule}
isScheduling={isRescheduling}
isDark={isDark}
title="Reschedule Appointment"
description={appointment ? `Change the date and time for ${appointment.first_name} ${appointment.last_name}'s appointment` : "Change the date and time for this appointment"}
/>
{/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<DialogContent className={`max-w-2xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
@ -902,6 +1012,47 @@ export default function AppointmentDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Cancel Appointment Confirmation Dialog */}
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<DialogContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
<DialogHeader>
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
Cancel Appointment
</DialogTitle>
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
Are you sure you want to cancel this appointment? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialogOpen(false)}
disabled={isCancelling}
className={isDark ? "border-gray-600 text-gray-300 hover:bg-gray-700" : ""}
>
No, Keep Appointment
</Button>
<Button
onClick={handleCancelAppointment}
disabled={isCancelling}
className="bg-red-600 hover:bg-red-700 text-white"
>
{isCancelling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Cancelling...
</>
) : (
<>
<X className="w-4 h-4 mr-2" />
Yes, Cancel Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -15,19 +15,10 @@ import {
MessageSquare,
CheckCircle2,
Copy,
X,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, listAppointments, cancelAppointment } from "@/lib/actions/appointments";
import { getAppointmentDetail, listAppointments } from "@/lib/actions/appointments";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Navbar } from "@/components/Navbar";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
@ -39,8 +30,6 @@ export default function UserAppointmentDetailPage() {
const [appointment, setAppointment] = useState<Appointment | null>(null);
const [loading, setLoading] = useState(true);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [isCancelling, setIsCancelling] = useState(false);
const { theme } = useAppTheme();
const isDark = theme === "dark";
@ -149,26 +138,6 @@ export default function UserAppointmentDetailPage() {
toast.success(`${label} copied to clipboard`);
};
const handleCancelAppointment = async () => {
if (!appointment) return;
setIsCancelling(true);
try {
await cancelAppointment(appointment.id);
toast.success("Appointment cancelled successfully");
setCancelDialogOpen(false);
// Refetch appointment to get updated status
const updatedAppointment = await getAppointmentDetail(appointmentId);
setAppointment(updatedAppointment);
router.push("/user/dashboard");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to cancel appointment";
toast.error(errorMessage);
} finally {
setIsCancelling(false);
}
};
if (loading) {
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
@ -642,65 +611,9 @@ export default function UserAppointmentDetailPage() {
</div>
)}
{/* Cancel Appointment Button */}
{appointment.status === "scheduled" && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className="p-6">
<Button
onClick={() => setCancelDialogOpen(true)}
variant="outline"
className={`w-full h-12 text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20 border-red-500" : ""}`}
>
<X className="w-5 h-5 mr-2" />
Cancel Appointment
</Button>
</div>
</div>
)}
</div>
</div>
</main>
{/* Cancel Appointment Confirmation Dialog */}
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
<DialogContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
<DialogHeader>
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
Cancel Appointment
</DialogTitle>
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
Are you sure you want to cancel this appointment? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setCancelDialogOpen(false)}
disabled={isCancelling}
className={isDark ? "border-gray-600 text-gray-300 hover:bg-gray-700" : ""}
>
No, Keep Appointment
</Button>
<Button
onClick={handleCancelAppointment}
disabled={isCancelling}
className="bg-red-600 hover:bg-red-700 text-white"
>
{isCancelling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Cancelling...
</>
) : (
<>
<X className="w-4 h-4 mr-2" />
Yes, Cancel Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -29,6 +29,8 @@ interface ScheduleAppointmentDialogProps {
onSchedule: () => Promise<void>;
isScheduling: boolean;
isDark?: boolean;
title?: string;
description?: string;
}
export function ScheduleAppointmentDialog({
@ -44,6 +46,8 @@ export function ScheduleAppointmentDialog({
onSchedule,
isScheduling,
isDark = false,
title,
description,
}: ScheduleAppointmentDialogProps) {
const formatDate = (date: Date) => {
return date.toLocaleDateString("en-US", {
@ -71,12 +75,12 @@ export function ScheduleAppointmentDialog({
<DialogContent className={`max-w-4xl max-h-[90vh] overflow-y-auto ${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
{title || "Schedule Appointment"}
</DialogTitle>
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment
{description || (appointment
? `Set date and time for ${appointment.first_name} ${appointment.last_name}'s appointment`
: "Set date and time for this appointment"}
: "Set date and time for this appointment")}
</DialogDescription>
</DialogHeader>