import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import classNames from "@calcom/ui/classNames"; import { Button } from "@calcom/ui/components/button"; import { Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog"; import { Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, } from "@calcom/ui/components/dropdown"; import type { ActionType } from "@calcom/ui/components/table"; import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; import { MeetingSessionDetailsDialog } from "@calcom/web/modules/ee/video/components/MeetingSessionDetailsDialog"; import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { CancelBookingDialog } from "@components/dialog/CancelBookingDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; import { RejectionReasonDialog } from "@components/dialog/RejectionReasonDialog"; import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; import { WrongAssignmentDialog } from "@components/dialog/WrongAssignmentDialog"; import { useState } from "react"; import type { z } from "zod"; import ViewRecordingsDialog from "~/ee/video/components/ViewRecordingsDialog"; import { useBookingConfirmation } from "../hooks/useBookingConfirmation"; import { RoutingTraceSheet } from "../RoutingTraceSheet"; import type { BookingItemProps } from "../types"; import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider"; import { type BookingActionContext, getAfterEventActions, getCancelEventAction, getEditEventActions, getPendingActions, getReportAction, shouldShowEditActions, shouldShowPendingActions, } from "./bookingActions"; interface BookingActionsDropdownProps { booking: BookingItemProps; size?: "xs" | "sm" | "base" | "lg"; className?: string; /** * Whether to use a portal for the dropdown menu. * Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context. * @default true */ usePortal?: boolean; /** * Context where the dropdown is being used. * - "list": Used in booking list view (hides Confirm and Reject actions) * - "details": Used in booking details view (shows all actions) */ context: "list" | "details"; } export function BookingActionsDropdown({ booking, size = "base", className, usePortal = true, context, }: BookingActionsDropdownProps) { const { t } = useLocale(); const utils = trpc.useUtils(); const isRecurring = booking.recurringEventId !== null; const isTabRecurring = booking.listingStatus === "recurring"; const isTabUnconfirmed = booking.listingStatus === "unconfirmed"; // Use the booking confirmation hook for confirm/reject logic const { bookingConfirm, handleReject, rejectionDialogIsOpen, setRejectionDialogIsOpen, isPending: isConfirmPending, } = useBookingConfirmation({ isRecurring, isTabRecurring, isTabUnconfirmed, }); // Use store for all other dialog states const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen); const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen); const viewRecordingsDialogIsOpen = useBookingActionsStoreContext( (state) => state.viewRecordingsDialogIsOpen ); const setViewRecordingsDialogIsOpen = useBookingActionsStoreContext( (state) => state.setViewRecordingsDialogIsOpen ); const meetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext( (state) => state.meetingSessionDetailsDialogIsOpen ); const setMeetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext( (state) => state.setMeetingSessionDetailsDialogIsOpen ); const isNoShowDialogOpen = useBookingActionsStoreContext((state) => state.isNoShowDialogOpen); const setIsNoShowDialogOpen = useBookingActionsStoreContext((state) => state.setIsNoShowDialogOpen); const isOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.isOpenRescheduleDialog); const setIsOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.setIsOpenRescheduleDialog); const isOpenReassignDialog = useBookingActionsStoreContext((state) => state.isOpenReassignDialog); const setIsOpenReassignDialog = useBookingActionsStoreContext((state) => state.setIsOpenReassignDialog); const isOpenSetLocationDialog = useBookingActionsStoreContext((state) => state.isOpenSetLocationDialog); const setIsOpenLocationDialog = useBookingActionsStoreContext((state) => state.setIsOpenLocationDialog); const isOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.isOpenAddGuestsDialog); const setIsOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.setIsOpenAddGuestsDialog); const isOpenReportDialog = useBookingActionsStoreContext((state) => state.isOpenReportDialog); const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog); const isOpenWrongAssignmentDialog = useBookingActionsStoreContext( (state) => state.isOpenWrongAssignmentDialog ); const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext( (state) => state.setIsOpenWrongAssignmentDialog ); const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen); const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen); const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen); const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen); const isOpenRoutingTraceSheet = useBookingActionsStoreContext((state) => state.isOpenRoutingTraceSheet); const setIsOpenRoutingTraceSheet = useBookingActionsStoreContext( (state) => state.setIsOpenRoutingTraceSheet ); const cardCharged = booking?.payment[0]?.success; const attendeeList = booking.attendees.map((attendee) => { return { name: attendee.name, email: attendee.email, id: attendee.id, noShow: attendee.noShow || false, phoneNumber: attendee.phoneNumber, }; }); const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({ onSuccess: async (data) => { showToast(data.message, "success"); await utils.viewer.bookings.invalidate(); }, onError: (err) => { showToast(err.message, "error"); }, }); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { showToast(t("location_updated"), "success"); setIsOpenLocationDialog(false); utils.viewer.bookings.invalidate(); }, onError: (e) => { const errorMessages: Record = { UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"), BAD_REQUEST: e.message, }; const message = errorMessages[e.data?.code as string] || t("location_update_failed"); showToast(message, "error"); }, }); const isUpcoming = new Date(booking.endTime) >= new Date(); const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime); const isBookingInPast = new Date(booking.endTime) < new Date(); const isCancelled = booking.status === "CANCELLED"; const isConfirmed = booking.status === "ACCEPTED"; const isRejected = booking.status === "REJECTED"; const isPending = booking.status === "PENDING"; const isRescheduled = booking.fromReschedule !== null; const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => { if (isCancelled) return "cancelled"; if (isRejected) return "rejected"; if (isBookingInPast) return "past"; return "upcoming"; }; const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team; const userEmail = booking.loggedInUser.userEmail; const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail); const isAttendee = !!userSeat; // Check if the logged-in user is the host/owner of the booking const isHost = booking.loggedInUser.userId === booking.user?.id; const isCalVideoLocation = !booking.location || booking.location === "integrations:daily" || (typeof booking.location === "string" && booking.location.trim() === ""); const isDisabledCancelling = booking.eventType.disableCancelling; const isDisabledRescheduling = booking.eventType.disableRescheduling; const getSeatReferenceUid = () => { return userSeat?.referenceUid; }; const saveLocation = async ({ newLocation, credentialId, }: { newLocation: string; credentialId: number | null; }) => { try { await setLocationMutation.mutateAsync({ bookingId: booking.id, newLocation, credentialId, }); } catch { // Errors are shown through the mutation onError handler } }; // Calculate showPendingPayment based on payment logic const hasPayment = booking.payment.length > 0; const showPendingPayment = hasPayment; const actionContext: BookingActionContext = { booking, isUpcoming, isOngoing, isBookingInPast, isCancelled, isConfirmed, isRejected, isPending, isRescheduled, isRecurring, isTabRecurring, isTabUnconfirmed, isBookingFromRoutingForm, isDisabledCancelling, isDisabledRescheduling, isCalVideoLocation, showPendingPayment, isAttendee, cardCharged, attendeeList, getSeatReferenceUid, t, } as BookingActionContext; const cancelEventAction = getCancelEventAction(actionContext); const showPastBookingCancelTooltip = isBookingInPast && cancelEventAction.disabled; // Get pending actions (accept/reject) - only for details context const shouldShowPending = shouldShowPendingActions(actionContext); const basePendingActions = shouldShowPending && context === "details" ? getPendingActions(actionContext) : []; const pendingActions: ActionType[] = basePendingActions.map((action) => ({ ...action, disabled: isConfirmPending, onClick: action.id === "confirm" ? () => bookingConfirm({ bookingId: booking.id, confirmed: true, recurringEventId: booking.recurringEventId, }) : action.id === "reject" ? () => handleReject() : undefined, })) as ActionType[]; const shouldShowEdit = shouldShowEditActions(actionContext); const baseEditEventActions = getEditEventActions(actionContext); const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({ ...action, disabled: !shouldShowEdit || action.disabled, // Disable all edit actions if shouldn't show edit actions onClick: action.id === "reschedule_request" ? () => setIsOpenRescheduleDialog(true) : action.id === "reroute" ? () => setRerouteDialogIsOpen(true) : action.id === "change_location" ? () => setIsOpenLocationDialog(true) : action.id === "add_members" ? () => setIsOpenAddGuestsDialog(true) : action.id === "reassign" ? () => setIsOpenReassignDialog(true) : undefined, })) as ActionType[]; const baseAfterEventActions = getAfterEventActions(actionContext); const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({ ...action, onClick: action.id === "view_recordings" ? () => setViewRecordingsDialogIsOpen(true) : action.id === "meeting_session_details" ? () => setMeetingSessionDetailsDialogIsOpen(true) : action.id === "charge_card" ? () => setChargeCardDialogIsOpen(true) : action.id === "no_show" ? () => { if (attendeeList.length === 1) { const attendee = attendeeList[0]; noShowMutation.mutate({ bookingUid: booking.uid, attendees: [{ email: attendee.email, noShow: !attendee.noShow }], }); return; } setIsNoShowDialogOpen(true); } : undefined, disabled: action.disabled || (action.id === "no_show" && !(isBookingInPast || isOngoing)) || (action.id === "view_recordings" && !booking.isRecorded), })) as ActionType[]; const reportAction = getReportAction(actionContext); const reportActionWithHandler = { ...reportAction, onClick: () => setIsOpenReportDialog(true), }; const NoShowAttendeesDialog = ({ bookingUid, attendees, setIsOpen, isOpen, }: { bookingUid: string; attendees: Array<{ name: string; email: string; id: number; noShow: boolean; phoneNumber: string | null; }>; setIsOpen: (open: boolean) => void; isOpen: boolean; }) => { const [noShowAttendees, setNoShowAttendees] = useState< Array<{ email: string; noShow: boolean; }> >(attendees.map((attendee) => ({ email: attendee.email, noShow: attendee.noShow }))); const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({ onSuccess: async (data) => { showToast(data.message, "success"); setIsOpen(false); await utils.viewer.bookings.invalidate(); }, onError: (err) => { showToast(err.message, "error"); }, }); return (
{attendees.map((attendee, index) => ( ))}
); }; const parsedBooking = { ...booking, eventType: booking.eventType.id ? booking.eventType : null, }; // Render dialogs that might be triggered from BookingListItem even if dropdown is not shown const dialogs = ( <> {isOpenReassignDialog && ( )} {isBookingFromRoutingForm && ( <> setIsOpenWrongAssignmentDialog(true)} hasExistingReport={!!booking.report} /> )} {booking.paid && booking.payment[0] && ( )} {isCalVideoLocation && ( )} {isCalVideoLocation && meetingSessionDetailsDialogIsOpen && ( )} {isNoShowDialogOpen && ( )} {isBookingFromRoutingForm && parsedBooking.eventType && parsedBooking.eventType.id !== undefined && parsedBooking.eventType.slug !== undefined && parsedBooking.eventType.title !== undefined && parsedBooking.routedFromRoutingFormReponse && ( , routedFromRoutingFormReponse: parsedBooking.routedFromRoutingFormReponse, eventType: { length: parsedBooking.eventType.length ?? 0, schedulingType: parsedBooking.eventType.schedulingType ?? null, title: parsedBooking.eventType.title, id: parsedBooking.eventType.id, slug: parsedBooking.eventType.slug, team: parsedBooking.eventType.team ?? null, }, }} /> )} ); // Check if there are any available actions across all action groups const hasAnyAvailableActions = () => { // Check if any pending action is available const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled); // Check if any edit action is available const hasAvailableEditAction = editEventActions.some((action) => !action.disabled); // Check if any after event action is available const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled); // Check report and cancel actions const isReportAvailable = !reportActionWithHandler.disabled; const isCancelAvailable = !cancelEventAction.disabled; return ( hasAvailablePendingAction || hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable ); }; // Don't render dropdown if no actions are available if (!hasAnyAvailableActions()) { return dialogs; } // Conditional portal wrapper to avoid portal when inside Sheet/Dialog const ConditionalPortal = usePortal ? DropdownMenuPortal : ({ children }: { children: React.ReactNode }) => <>{children}; return ( <> {dialogs} bookingConfirm({ bookingId: booking.id, confirmed: false, recurringEventId: booking.recurringEventId, reason, }) } isPending={isConfirmPending} />