From 3a1e04bb42523e8041e0d086b26729f1a2abb631 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 4 Nov 2025 01:25:48 +0100 Subject: [PATCH] refactor: extract booking actions dropdown to separate component (#24872) * refactor: extract booking actions dropdown to separate component - Created BookingActionsDropdown component with all dropdown actions and dialogs - Component receives booking as prop and handles all action logic internally - Removed duplicate dialog states and mutations from BookingListItem - Fixed img tag to use Next.js Image component - Maintains same functionality and UI while improving code reusability Co-Authored-By: eunjae@cal.com * fix: restore cardCharged variable in BookingListItem The cardCharged variable was accidentally removed during the refactoring but is still needed for the actionContext in BookingListItem.tsx Co-Authored-By: eunjae@cal.com * refactor: move BookingItemProps type to separate types.ts file - Created types.ts to hold shared booking types - Updated BookingListItem.tsx to import from types.ts - Updated BookingActionsDropdown.tsx to import from types.ts - Updated bookingActions.ts to import from types.ts - Removed unused RouterInputs and RouterOutputs imports from BookingListItem.tsx - Removes unwanted dependency chain where BookingActionsDropdown imported from BookingListItem Co-Authored-By: eunjae@cal.com * remove unnecessary description * extract states as a zustand store * fix dialog issue * fix type errors --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../booking/BookingActionsDropdown.tsx | 593 ++++++++++++++++++ .../booking/BookingActionsStoreProvider.tsx | 39 ++ .../components/booking/BookingListItem.tsx | 404 +----------- apps/web/components/booking/bookingActions.ts | 2 +- apps/web/components/booking/store.ts | 106 ++++ apps/web/components/booking/types.ts | 17 + 6 files changed, 777 insertions(+), 384 deletions(-) create mode 100644 apps/web/components/booking/BookingActionsDropdown.tsx create mode 100644 apps/web/components/booking/BookingActionsStoreProvider.tsx create mode 100644 apps/web/components/booking/store.ts create mode 100644 apps/web/components/booking/types.ts diff --git a/apps/web/components/booking/BookingActionsDropdown.tsx b/apps/web/components/booking/BookingActionsDropdown.tsx new file mode 100644 index 0000000000..7a4665434f --- /dev/null +++ b/apps/web/components/booking/BookingActionsDropdown.tsx @@ -0,0 +1,593 @@ +import { useState } from "react"; +import type { z } from "zod"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog"; +import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; +import { + Dropdown, + DropdownItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuPortal, +} from "@calcom/ui/components/dropdown"; +import { TextAreaField } from "@calcom/ui/components/form"; +import type { ActionType } from "@calcom/ui/components/table"; +import { showToast } from "@calcom/ui/components/toast"; + +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 { useBookingActionsStoreContext } from "./BookingActionsStoreProvider"; +import { + getCancelEventAction, + getEditEventActions, + getAfterEventActions, + getReportAction, + shouldShowEditActions, + type BookingActionContext, +} from "./bookingActions"; +import type { BookingItemProps } from "./types"; + +interface BookingActionsDropdownProps { + booking: BookingItemProps; +} + +export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + // Use store for all dialog states + const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason); + const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason); + const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen); + const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen); + 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 rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen); + const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen); + + 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 mutation = trpc.viewer.bookings.confirm.useMutation({ + onSuccess: (data) => { + if (data?.status === "REJECTED") { + setRejectionDialogIsOpen(false); + showToast(t("booking_rejection_success"), "success"); + } else { + showToast(t("booking_confirmation_success"), "success"); + } + utils.viewer.bookings.invalidate(); + utils.viewer.me.bookingUnconfirmedCount.invalidate(); + }, + onError: () => { + showToast(t("booking_confirmation_failed"), "error"); + utils.viewer.bookings.invalidate(); + }, + }); + + 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 isRecurring = booking.recurringEventId !== null; + + const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => { + if (isCancelled) return "cancelled"; + if (isRejected) return "rejected"; + if (isBookingInPast) return "past"; + return "upcoming"; + }; + + const isTabRecurring = booking.listingStatus === "recurring"; + const isTabUnconfirmed = booking.listingStatus === "unconfirmed"; + + 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; + + 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 bookingConfirm = async (confirm: boolean) => { + let body = { + bookingId: booking.id, + confirmed: confirm, + reason: rejectionReason, + }; + + if ((isTabRecurring || isTabUnconfirmed) && isRecurring) { + body = Object.assign({}, body, { recurringEventId: booking.recurringEventId }); + } + mutation.mutate(body); + }; + + 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 + } + }; + + const actionContext: BookingActionContext = { + booking, + isUpcoming, + isOngoing, + isBookingInPast, + isCancelled, + isConfirmed, + isRejected, + isPending, + isRescheduled, + isRecurring, + isTabRecurring, + isTabUnconfirmed, + isBookingFromRoutingForm, + isDisabledCancelling, + isDisabledRescheduling, + isCalVideoLocation, + showPendingPayment: false, // This will be calculated below + isAttendee, + cardCharged, + attendeeList, + getSeatReferenceUid, + t, + } as BookingActionContext; + + const cancelEventAction = getCancelEventAction(actionContext); + + const baseEditEventActions = getEditEventActions(actionContext); + const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({ + ...action, + 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 && ( + + )} + + + + {booking.paid && booking.payment[0] && ( + + )} + {isCalVideoLocation && ( + + )} + {isCalVideoLocation && meetingSessionDetailsDialogIsOpen && ( + + )} + {isNoShowDialogOpen && ( + + )} + + +
+ + {t("rejection_reason")} + (Optional) + + } + value={rejectionReason} + onChange={(e) => setRejectionReason(e.target.value)} + /> +
+ + + + + +
+
+ {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, + }, + }} + /> + )} + + ); + + // Don't render dropdown if edit actions shouldn't be shown + if (!shouldShowEditActions(actionContext)) { + return dialogs; + } + + return ( + <> + {dialogs} + + + - - - ))} - - {t("done")} - - - - ); -}; - const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => { const [openDropdown, setOpenDropdown] = useState(false); const { t } = useLocale(); @@ -1369,4 +998,13 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig ); }; -export default BookingListItem; +// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking +const BookingListItemWithProvider = (props: BookingItemProps) => { + return ( + + + + ); +}; + +export default BookingListItemWithProvider; diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts index 46d0d492bc..d2eddd2d51 100644 --- a/apps/web/components/booking/bookingActions.ts +++ b/apps/web/components/booking/bookingActions.ts @@ -1,7 +1,7 @@ import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import type { ActionType } from "@calcom/ui/components/table"; -import type { BookingItemProps } from "./BookingListItem"; +import type { BookingItemProps } from "./types"; export interface BookingActionContext { booking: BookingItemProps; diff --git a/apps/web/components/booking/store.ts b/apps/web/components/booking/store.ts new file mode 100644 index 0000000000..9e703ed55c --- /dev/null +++ b/apps/web/components/booking/store.ts @@ -0,0 +1,106 @@ +"use client"; + +import type React from "react"; +import { createStore } from "zustand"; + +export type BookingActionsStore = { + // Dialog states + rejectionDialogIsOpen: boolean; + chargeCardDialogIsOpen: boolean; + viewRecordingsDialogIsOpen: boolean; + meetingSessionDetailsDialogIsOpen: boolean; + isNoShowDialogOpen: boolean; + isOpenRescheduleDialog: boolean; + isOpenReassignDialog: boolean; + isOpenSetLocationDialog: boolean; + isOpenAddGuestsDialog: boolean; + isOpenReportDialog: boolean; + rerouteDialogIsOpen: boolean; + + // Dialog setters + setRejectionDialogIsOpen: React.Dispatch>; + setChargeCardDialogIsOpen: React.Dispatch>; + setViewRecordingsDialogIsOpen: React.Dispatch>; + setMeetingSessionDetailsDialogIsOpen: React.Dispatch>; + setIsNoShowDialogOpen: React.Dispatch>; + setIsOpenRescheduleDialog: React.Dispatch>; + setIsOpenReassignDialog: React.Dispatch>; + setIsOpenLocationDialog: React.Dispatch>; + setIsOpenAddGuestsDialog: React.Dispatch>; + setIsOpenReportDialog: React.Dispatch>; + setRerouteDialogIsOpen: React.Dispatch>; + + // Rejection reason state + rejectionReason: string; + setRejectionReason: (reason: string) => void; +}; + +export const createBookingActionsStore = () => { + return createStore((set) => ({ + // Initial dialog states + rejectionDialogIsOpen: false, + chargeCardDialogIsOpen: false, + viewRecordingsDialogIsOpen: false, + meetingSessionDetailsDialogIsOpen: false, + isNoShowDialogOpen: false, + isOpenRescheduleDialog: false, + isOpenReassignDialog: false, + isOpenSetLocationDialog: false, + isOpenAddGuestsDialog: false, + isOpenReportDialog: false, + rerouteDialogIsOpen: false, + + // Dialog setters + setRejectionDialogIsOpen: (isOpen) => + set((state) => ({ + rejectionDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rejectionDialogIsOpen) : isOpen, + })), + setChargeCardDialogIsOpen: (isOpen) => + set((state) => ({ + chargeCardDialogIsOpen: typeof isOpen === "function" ? isOpen(state.chargeCardDialogIsOpen) : isOpen, + })), + setViewRecordingsDialogIsOpen: (isOpen) => + set((state) => ({ + viewRecordingsDialogIsOpen: + typeof isOpen === "function" ? isOpen(state.viewRecordingsDialogIsOpen) : isOpen, + })), + setMeetingSessionDetailsDialogIsOpen: (isOpen) => + set((state) => ({ + meetingSessionDetailsDialogIsOpen: + typeof isOpen === "function" ? isOpen(state.meetingSessionDetailsDialogIsOpen) : isOpen, + })), + setIsNoShowDialogOpen: (isOpen) => + set((state) => ({ + isNoShowDialogOpen: typeof isOpen === "function" ? isOpen(state.isNoShowDialogOpen) : isOpen, + })), + setIsOpenRescheduleDialog: (isOpen) => + set((state) => ({ + isOpenRescheduleDialog: typeof isOpen === "function" ? isOpen(state.isOpenRescheduleDialog) : isOpen, + })), + setIsOpenReassignDialog: (isOpen) => + set((state) => ({ + isOpenReassignDialog: typeof isOpen === "function" ? isOpen(state.isOpenReassignDialog) : isOpen, + })), + setIsOpenLocationDialog: (isOpen) => + set((state) => ({ + isOpenSetLocationDialog: + typeof isOpen === "function" ? isOpen(state.isOpenSetLocationDialog) : isOpen, + })), + setIsOpenAddGuestsDialog: (isOpen) => + set((state) => ({ + isOpenAddGuestsDialog: typeof isOpen === "function" ? isOpen(state.isOpenAddGuestsDialog) : isOpen, + })), + setIsOpenReportDialog: (isOpen) => + set((state) => ({ + isOpenReportDialog: typeof isOpen === "function" ? isOpen(state.isOpenReportDialog) : isOpen, + })), + setRerouteDialogIsOpen: (isOpen) => + set((state) => ({ + rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen, + })), + + // Rejection reason state + rejectionReason: "", + setRejectionReason: (reason: string) => set({ rejectionReason: reason }), + })); +}; diff --git a/apps/web/components/booking/types.ts b/apps/web/components/booking/types.ts new file mode 100644 index 0000000000..87a22251e0 --- /dev/null +++ b/apps/web/components/booking/types.ts @@ -0,0 +1,17 @@ +import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react"; + +type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"]; + +type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; + +export type BookingItemProps = BookingItem & { + listingStatus: BookingListingStatus; + recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined; + loggedInUser: { + userId: number | undefined; + userTimeZone: string | undefined; + userTimeFormat: number | null | undefined; + userEmail: string | undefined; + }; + isToday: boolean; +};