Skip to content
196 changes: 194 additions & 2 deletions apps/web/components/booking/BookingListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

/* eslint-disable prettier/prettier */
import Image from "next/image";
import Link from "next/link";
import { useState, useEffect, useRef } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
Expand Down Expand Up @@ -40,6 +44,17 @@ import { Tooltip } from "@calcom/ui/components/tooltip";

import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";

import { useBookingItemState } from "@components/booking/hooks/useBookingItemState";
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";

import { BookingActionsDropdown } from "./BookingActionsDropdown";
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
import { WrongAssignmentDialog } from "../dialog/WrongAssignmentDialog";
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore";
Expand Down Expand Up @@ -146,6 +161,14 @@ function BookingListItem(booking: BookingItemProps) {
t,
i18n: { language },
} = useLocale();
const utils = trpc.useUtils();

// Use our centralized state hook
const { dialogState, openDialog, closeDialog, rejectionReason, setRejectionReason } = useBookingItemState();

const cardCharged = booking?.payment[0]?.success;
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);

// Get selected booking UID from store
// The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true)
Expand All @@ -162,6 +185,21 @@ function BookingListItem(booking: BookingItemProps) {
}
}, [isSelected]);

const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === BookingStatus.REJECTED) {
closeDialog("rejection");
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const attendeeList = booking.attendees.map((attendee) => ({
...attendee,
noShow: attendee.noShow || false,
Expand Down Expand Up @@ -239,6 +277,20 @@ function BookingListItem(booking: BookingItemProps) {
t,
} as BookingActionContext;

const basePendingActions = getPendingActions(actionContext);
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
onClick:
action.id === "reject"
? () => openDialog("rejection")
: action.id === "confirm"
? () => bookingConfirm(true)
: undefined,
disabled: action.disabled || mutation.isPending,
})) as ActionType[];

const cancelEventAction = getCancelEventAction(actionContext);

const RequestSentMessage = () => {
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
Expand Down Expand Up @@ -272,6 +324,51 @@ function BookingListItem(booking: BookingItemProps) {

const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;

const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
...action,
onClick:
action.id === "reschedule_request"
? () => openDialog("reschedule")
: action.id === "reroute"
? () => openDialog("reroute")
: action.id === "change_location"
? () => openDialog("editLocation")
: action.id === "add_members"
? () => openDialog("addGuests")
: action.id === "reassign"
? () => openDialog("reassign")
: undefined,
})) as ActionType[];

const baseAfterEventActions = getAfterEventActions(actionContext);
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
...action,
onClick:
action.id === "view_recordings"
? () => openDialog("viewRecordings")
: action.id === "meeting_session_details"
? () => openDialog("meetingSessionDetails")
: action.id === "charge_card"
? () => openDialog("chargeCard")
: 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;
}
openDialog("noShowAttendees");
}
: undefined,
disabled:
action.disabled ||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const isOpenWrongAssignmentDialog = useBookingActionsStoreContext(
Expand All @@ -288,6 +385,92 @@ function BookingListItem(booking: BookingItemProps) {
};

return (
<>
<RescheduleDialog
isOpenDialog={dialogState.reschedule}
setIsOpenDialog={(open) => (open ? openDialog("reschedule") : closeDialog("reschedule"))}
bookingUId={booking.uid}
/>
{dialogState.reassign && (
<ReassignDialog
isOpenDialog={dialogState.reassign}
setIsOpenDialog={(open) => (open ? openDialog("reassign") : closeDialog("reassign"))}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
isOpenDialog={dialogState.editLocation}
setShowLocationModal={(open) => (open ? openDialog("editLocation") : closeDialog("editLocation"))}
teamId={booking.eventType?.team?.id}
/>
<AddGuestsDialog
isOpenDialog={dialogState.addGuests}
setIsOpenDialog={(open) => (open ? openDialog("addGuests") : closeDialog("addGuests"))}
bookingId={booking.id}
/>
<ReportBookingDialog
isOpenDialog={isOpenReportDialog}
setIsOpenDialog={setIsOpenReportDialog}
bookingUid={booking.uid}
isRecurring={isRecurring}
status={getBookingStatus()}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={dialogState.chargeCard}
setIsOpenDialog={(open) => (open ? openDialog("chargeCard") : closeDialog("chargeCard"))}
bookingId={booking.id}
paymentAmount={booking.payment[0].amount}
paymentCurrency={booking.payment[0].currency}
/>
)}
{isCalVideoLocation && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={dialogState.viewRecordings}
setIsOpenDialog={(open) => (open ? openDialog("viewRecordings") : closeDialog("viewRecordings"))}
timeFormat={userTimeFormat ?? null}
/>
)}
{isCalVideoLocation && dialogState.meetingSessionDetails && (
<MeetingSessionDetailsDialog
booking={booking}
isOpenDialog={dialogState.meetingSessionDetails}
setIsOpenDialog={(open) =>
open ? openDialog("meetingSessionDetails") : closeDialog("meetingSessionDetails")
}
timeFormat={userTimeFormat ?? null}
/>
)}
{dialogState.noShowAttendees && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={(open) => (open ? openDialog("noShowAttendees") : closeDialog("noShowAttendees"))}
isOpen={dialogState.noShowAttendees}
/>
)}
<Dialog
open={dialogState.rejection}
onOpenChange={(open) => (open ? openDialog("rejection") : closeDialog("rejection"))}>
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
<div
ref={itemRef}
data-testid="booking-item"
Expand Down Expand Up @@ -511,6 +694,15 @@ function BookingListItem(booking: BookingItemProps) {
<BookingActionsDropdown booking={booking} context="list" />
</div>
</div>

{isBookingFromRoutingForm && (
<RerouteDialog
isOpenDialog={dialogState.reroute}
setIsOpenDialog={() => closeDialog("reroute")}
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
/>
)}
Comment on lines 698 to 704
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

RerouteDialog setter ignores the boolean param.

Align with other dialogs to handle both open and close paths.

-        <RerouteDialog
-          isOpenDialog={dialogState.reroute}
-          setIsOpenDialog={() => closeDialog("reroute")}
-          booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
-        />
+        <RerouteDialog
+          isOpenDialog={dialogState.reroute}
+          setIsOpenDialog={(open) => (open ? openDialog("reroute") : closeDialog("reroute"))}
+          booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
+        />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{isBookingFromRoutingForm && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
setIsOpenDialog={setRerouteDialogIsOpen}
isOpenDialog={dialogState.reroute}
setIsOpenDialog={() => closeDialog("reroute")}
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
/>
)}
{isBookingFromRoutingForm && (
<RerouteDialog
isOpenDialog={dialogState.reroute}
setIsOpenDialog={(open) => (open ? openDialog("reroute") : closeDialog("reroute"))}
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
/>
)}
🤖 Prompt for AI Agents
In apps/web/components/booking/BookingListItem.tsx around lines 730-736, the
RerouteDialog prop setIsOpenDialog currently ignores the boolean parameter;
change it to accept the boolean and forward that value to the dialog state
updater (i.e., call the existing closeDialog or dialog setter with the dialog
name and the boolean, or directly set the reroute boolean on dialog state) so
the handler supports both open and close paths consistent with the other
dialogs.

</>
<BookingItemBadges
booking={booking}
isPending={isPending}
Expand Down Expand Up @@ -653,7 +845,7 @@ const RecurringBookingsTooltip = ({
return (
recurringDate >= now &&
!booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
.map((date) => date.toString())
.map((date: { toString: () => any }) => date.toString())
.includes(recurringDate.toString())
);
}).length;
Expand All @@ -671,7 +863,7 @@ const RecurringBookingsTooltip = ({
const pastOrCancelled =
aDate < now ||
booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
.map((date) => date.toString())
.map((date: { toString: () => any }) => date.toString())
.includes(aDate.toString());
return (
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
Expand Down
77 changes: 77 additions & 0 deletions apps/web/components/booking/hooks/useBookingItemState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { useState } from "react";

export type DialogState = {
reschedule: boolean;
reassign: boolean;
editLocation: boolean;
addGuests: boolean;
chargeCard: boolean;
viewRecordings: boolean;
meetingSessionDetails: boolean;
noShowAttendees: boolean;
rejection: boolean;
reroute: boolean;
};

export function useBookingItemState() {
const [rejectionReason, setRejectionReason] = useState<string>("");
const [dialogState, setDialogState] = useState<DialogState>({
reschedule: false,
reassign: false,
editLocation: false,
addGuests: false,
chargeCard: false,
viewRecordings: false,
meetingSessionDetails: false,
noShowAttendees: false,
rejection: false,
reroute: false,
});

const openDialog = (dialog: keyof DialogState) => {
setDialogState((prevState) => ({
...prevState,
[dialog]: true,
}));
};

const closeDialog = (dialog: keyof DialogState) => {
setDialogState((prevState) => ({
...prevState,
[dialog]: false,
}));
};

const toggleDialog = (dialog: keyof DialogState) => {
setDialogState((prevState) => ({
...prevState,
[dialog]: !prevState[dialog],
}));
};

// Helper function to reset all dialogs (useful when one action should close others)
const resetDialogs = () => {
setDialogState({
reschedule: false,
reassign: false,
editLocation: false,
addGuests: false,
chargeCard: false,
viewRecordings: false,
meetingSessionDetails: false,
noShowAttendees: false,
rejection: false,
reroute: false,
});
};

return {
dialogState,
openDialog,
closeDialog,
toggleDialog,
resetDialogs,
rejectionReason,
setRejectionReason,
};
}
7 changes: 3 additions & 4 deletions apps/web/components/dialog/ChargeCardDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";

import { Dialog } from "@calcom/features/components/controlled-dialog";
Expand All @@ -9,15 +8,15 @@ import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/
import { Icon } from "@calcom/ui/components/icon";
import { showToast } from "@calcom/ui/components/toast";

interface IRescheduleDialog {
interface IChargeCardDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
setIsOpenDialog: (isOpen: boolean) => void;
bookingId: number;
paymentAmount: number;
paymentCurrency: string;
}

export const ChargeCardDialog = (props: IRescheduleDialog) => {
export const ChargeCardDialog = (props: IChargeCardDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
Expand Down
2 changes: 1 addition & 1 deletion packages/platform/atoms/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ DialogOverlay.displayName = DialogPrimitives.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitives.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitives.Content>
React.ComponentPropsWithoutRef<typeof DialogPrimitives.Content> & { enableOverflow?: boolean }
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding enableOverflow to the public props but leaving the signature as ({ className, children, ...props }) forwards the flag straight to DialogPrimitives.Content without any handling, so consumers get neither overflow behavior nor a safe DOM attribute. Please implement the overflow behavior (or strip the prop) before exposing it.

Prompt for AI agents
Address the following comment on packages/platform/atoms/src/components/ui/dialog.tsx at line 54:

<comment>Adding enableOverflow to the public props but leaving the signature as ({ className, children, ...props }) forwards the flag straight to DialogPrimitives.Content without any handling, so consumers get neither overflow behavior nor a safe DOM attribute. Please implement the overflow behavior (or strip the prop) before exposing it.</comment>

<file context>
@@ -51,7 +51,7 @@ DialogOverlay.displayName = DialogPrimitives.Overlay.displayName;
 const DialogContent = React.forwardRef&lt;
   React.ElementRef&lt;typeof DialogPrimitives.Content&gt;,
-  React.ComponentPropsWithoutRef&lt;typeof DialogPrimitives.Content&gt;
+  React.ComponentPropsWithoutRef&lt;typeof DialogPrimitives.Content&gt; &amp; { enableOverflow?: boolean }
 &gt;(({ className, children, ...props }, ref) =&gt; (
   &lt;&gt;
</file context>
Fix with Cubic

Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the enableOverflow prop without consuming it breaks parity with the existing DialogContent—dialogs that previously scrolled now overflow because the prop is forwarded instead of toggling overflow classes. Please handle enableOverflow in the atoms implementation the same way as before (e.g., toggle overflow-y-auto) or drop the prop entirely.

Prompt for AI agents
Address the following comment on packages/platform/atoms/src/components/ui/dialog.tsx at line 54:

<comment>Adding the enableOverflow prop without consuming it breaks parity with the existing DialogContent—dialogs that previously scrolled now overflow because the prop is forwarded instead of toggling overflow classes. Please handle enableOverflow in the atoms implementation the same way as before (e.g., toggle overflow-y-auto) or drop the prop entirely.</comment>

<file context>
@@ -51,7 +51,7 @@ DialogOverlay.displayName = DialogPrimitives.Overlay.displayName;
 const DialogContent = React.forwardRef&lt;
   React.ElementRef&lt;typeof DialogPrimitives.Content&gt;,
-  React.ComponentPropsWithoutRef&lt;typeof DialogPrimitives.Content&gt;
+  React.ComponentPropsWithoutRef&lt;typeof DialogPrimitives.Content&gt; &amp; { enableOverflow?: boolean }
 &gt;(({ className, children, ...props }, ref) =&gt; (
   &lt;&gt;
</file context>
Fix with Cubic

>(({ className, children, ...props }, ref) => (
<>
<DialogPortal>
Expand Down
Loading