Add appointment cancellation and rescheduling features
- Implemented functionality to cancel appointments, including a confirmation dialog for user actions. - Added rescheduling capabilities with new state management for reschedule dialogs and inputs. - Updated appointment detail pages for both admin and user views to reflect these new features, enhancing user experience and appointment management. - Introduced new API actions for handling appointment cancellations and rescheduling, ensuring seamless integration with the existing appointment workflow.
This commit is contained in:
parent
cb9be9405e
commit
048cb1fcc9
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user