Add appointment cancellation and rescheduling features #67

Merged
Hammond merged 1 commits from feat/booking-panel into master 2025-12-05 18:19:59 +00:00
3 changed files with 176 additions and 5 deletions

View File

@ -18,9 +18,10 @@ import {
ExternalLink,
Copy,
MapPin,
Pencil,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting } from "@/lib/actions/appointments";
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting, rescheduleAppointment } from "@/lib/actions/appointments";
import { Button } from "@/components/ui/button";
import {
Dialog,
@ -43,12 +44,17 @@ export default function AppointmentDetailPage() {
const [loading, setLoading] = useState(true);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [rescheduleDialogOpen, setRescheduleDialogOpen] = useState(false);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
const [rescheduleDate, setRescheduleDate] = useState<Date | undefined>(undefined);
const [rescheduleTime, setRescheduleTime] = useState<string>("09:00");
const [rescheduleDuration, setRescheduleDuration] = useState<number>(60);
const [rejectionReason, setRejectionReason] = useState<string>("");
const [isScheduling, setIsScheduling] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [isRescheduling, setIsRescheduling] = useState(false);
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
const [isEndingMeeting, setIsEndingMeeting] = useState(false);
const { theme } = useAppTheme();
@ -745,10 +751,14 @@ 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 would be available to join starting at {meetingTime}
<Video className="w-4 h-4 sm:w-5 sm:h-5 flex-shrink-0" />
<span className="text-center px-1">
<span className="hidden sm:inline">Meeting would be available to join starting at </span>
<span className="sm:hidden">Available at </span>
<span className="font-semibold">{meetingTime}</span>
</span>
</button>
);
}

View File

@ -15,10 +15,19 @@ import {
MessageSquare,
CheckCircle2,
Copy,
X,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, listAppointments } from "@/lib/actions/appointments";
import { getAppointmentDetail, listAppointments, cancelAppointment } 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";
@ -30,6 +39,8 @@ 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";
@ -138,6 +149,26 @@ 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"}`}>
@ -610,9 +641,66 @@ export default function UserAppointmentDetailPage() {
</div>
</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

@ -801,3 +801,76 @@ export async function endMeeting(id: string): Promise<Appointment> {
// So we need to refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}
export interface RescheduleAppointmentInput {
new_scheduled_datetime: string; // ISO datetime string
new_scheduled_duration: number; // in minutes
timezone: string;
}
export async function rescheduleAppointment(id: string, input: RescheduleAppointmentInput): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Get user's timezone
const payload: any = {
new_scheduled_datetime: input.new_scheduled_datetime,
new_scheduled_duration: input.new_scheduled_duration,
timezone: input.timezone !== undefined ? input.timezone : userTimezone,
};
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reschedule/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// Refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}
export interface CancelAppointmentInput {
action?: string;
metadata?: string;
recording_url?: string;
}
export async function cancelAppointment(id: string, input?: CancelAppointmentInput): Promise<any> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const payload: any = {};
if (input?.action) payload.action = input.action;
if (input?.metadata) payload.metadata = input.metadata;
if (input?.recording_url) payload.recording_url = input.recording_url;
const response = await fetch(`${API_ENDPOINTS.meetings.base}meetings/${id}/cancel/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// Refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}