diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 7f712dcfbd33cc..da1d1fc23724fb 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -18,7 +18,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useGetTheme } from "@calcom/lib/hooks/useTheme"; import isSmsCalEmail from "@calcom/lib/isSmsCalEmail"; import { getEveryFreqFor } from "@calcom/lib/recurringStrings"; -import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; +import { BookingStatus } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -41,8 +41,8 @@ import { import { TextAreaField } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { MeetingTimeInTimezones } from "@calcom/ui/components/popover"; -import type { ActionType } from "@calcom/ui/components/table"; import { TableActions } from "@calcom/ui/components/table"; +import type { ActionType } from "@calcom/ui/components/table"; import { showToast } from "@calcom/ui/components/toast"; import { Tooltip } from "@calcom/ui/components/tooltip"; @@ -55,11 +55,22 @@ import { ReassignDialog } from "@components/dialog/ReassignDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; +import { + getPendingActions, + getCancelEventAction, + getEditEventActions, + getAfterEventActions, + shouldShowPendingActions, + shouldShowEditActions, + shouldShowRecurringCancelAction, + type BookingActionContext, +} from "./bookingActions"; + type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"]; type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; -type BookingItemProps = BookingItem & { +export type BookingItemProps = BookingItem & { listingStatus: BookingListingStatus; recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined; loggedInUser: { @@ -191,6 +202,9 @@ function BookingListItem(booking: BookingItemProps) { ); const provider = guessEventLocationType(location); + const isDisabledCancelling = booking.eventType.disableCancelling; + const isDisabledRescheduling = booking.eventType.disableRescheduling; + const bookingConfirm = async (confirm: boolean) => { let body = { bookingId: booking.id, @@ -214,174 +228,44 @@ function BookingListItem(booking: BookingItemProps) { return booking.seatsReferences[0].referenceUid; }; - const pendingActions: ActionType[] = [ - { - id: "reject", - label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"), - onClick: () => { - setRejectionDialogIsOpen(true); - }, - icon: "ban", - disabled: mutation.isPending, - }, - // For bookings with payment, only confirm if the booking is paid for - ...((isPending && !paymentAppData.enabled) || - (paymentAppData.enabled && !!paymentAppData.price && booking.paid) - ? [ - { - id: "confirm", - bookingId: booking.id, - label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"), - onClick: () => { - bookingConfirm(true); - }, - icon: "check" as const, - disabled: mutation.isPending, - }, - ] - : []), - ]; - - const editBookingActions: ActionType[] = [ - ...(isBookingInPast && !booking.eventType.allowReschedulingPastBookings - ? [] - : [ - { - id: "reschedule", - icon: "clock" as const, - label: t("reschedule_booking"), - href: `/reschedule/${booking.uid}${ - booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" - }`, - }, - { - id: "reschedule_request", - icon: "send" as const, - iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", - label: t("send_reschedule_request"), - onClick: () => { - setIsOpenRescheduleDialog(true); - }, - }, - ]), - ...(isBookingFromRoutingForm - ? [ - { - id: "reroute", - label: t("reroute"), - onClick: () => { - setRerouteDialogIsOpen(true); - }, - icon: "waypoints" as const, - }, - ] - : []), - { - id: "change_location", - label: t("edit_location"), - onClick: () => { - setIsOpenLocationDialog(true); - }, - icon: "map-pin" as const, - }, - ...(booking.eventType?.disableGuests - ? [] - : [ - { - id: "add_members", - label: t("additional_guests"), - onClick: () => { - setIsOpenAddGuestsDialog(true); - }, - icon: "user-plus" as const, - }, - ]), - ]; - - if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) { - editBookingActions.push({ - id: "reassign ", - label: t("reassign"), - onClick: () => { - setIsOpenReassignDialog(true); - }, - icon: "users" as const, - }); - } - - if (isBookingInPast || isOngoing) { - editBookingActions.push({ - id: "no_show", - label: - attendeeList.length === 1 && attendeeList[0].noShow ? t("unmark_as_no_show") : t("mark_as_no_show"), - onClick: () => { - // If there's only one attendee, mark them as no-show directly without showing the dialog - if (attendeeList.length === 1) { - const attendee = attendeeList[0]; - noShowMutation.mutate({ - bookingUid: booking.uid, - attendees: [{ email: attendee.email, noShow: !attendee.noShow }], - }); - return; - } - - setIsNoShowDialogOpen(true); - }, - icon: attendeeList.length === 1 && attendeeList[0].noShow ? "eye" : ("eye-off" as const), - }); - } - - let bookedActions: ActionType[] = [ - { - id: "cancel", - label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"), - /* When cancelling we need to let the UI and the API know if the intention is to - cancel all remaining bookings or just that booking instance. */ - href: `/booking/${booking.uid}?cancel=true${ - isTabRecurring && isRecurring ? "&allRemainingBookings=true" : "" - }${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""} - `, - icon: "x" as const, - }, - { - id: "edit_booking", - label: t("edit"), - actions: editBookingActions, - }, - ]; - - const chargeCardActions: ActionType[] = [ - { - id: "charge_card", - label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"), - disabled: cardCharged, - onClick: () => { - setChargeCardDialogIsOpen(true); - }, - icon: "credit-card" as const, - }, - ]; - - const isDisabledCancelling = booking.eventType.disableCancelling; - const isDisabledRescheduling = booking.eventType.disableRescheduling; - - if (isTabRecurring && isRecurring) { - bookedActions = bookedActions.filter((action) => action.id !== "edit_booking"); - } - - if (isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed)) { - bookedActions = bookedActions.filter((action) => action.id !== "cancel"); - } - - if (isDisabledRescheduling) { - bookedActions.forEach((action) => { - if (action.id === "edit_booking") { - action.actions = action.actions?.filter( - ({ id }) => id !== "reschedule" && id !== "reschedule_request" - ); - } - }); - } + const actionContext: BookingActionContext = { + booking, + isUpcoming, + isOngoing, + isBookingInPast, + isCancelled, + isConfirmed, + isRejected, + isPending, + isRescheduled, + isRecurring, + isTabRecurring, + isTabUnconfirmed, + isBookingFromRoutingForm, + isDisabledCancelling, + isDisabledRescheduling, + isCalVideoLocation: + !booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "", + showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid, + cardCharged, + attendeeList, + getSeatReferenceUid, + t, + } as BookingActionContext; + + const basePendingActions = getPendingActions(actionContext); + const pendingActions: ActionType[] = basePendingActions.map((action) => ({ + ...action, + onClick: + action.id === "reject" + ? () => setRejectionDialogIsOpen(true) + : action.id === "confirm" + ? () => bookingConfirm(true) + : undefined, + disabled: action.disabled || mutation.isPending, + })) as ActionType[]; + + const cancelEventAction = getCancelEventAction(actionContext); const RequestSentMessage = () => { return ( @@ -460,38 +344,57 @@ function BookingListItem(booking: BookingItemProps) { const title = booking.title; - const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed); - const showCheckRecordingButton = - isBookingInPast && - isConfirmed && - !booking.isRecorded && - (!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === ""); - const isCalVideoLocation = !booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === ""; - const videoOptionsActions: ActionType[] = [ - { - id: "view_recordings", - label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"), - onClick: () => { - setViewRecordingsDialogIsOpen(true); - }, - color: showCheckRecordingButton ? "secondary" : "primary", - disabled: mutation.isPending, - }, - { - id: "meeting_session_details", - label: t("get_meeting_session_details"), - onClick: () => { - setMeetingSessionDetailsDialogIsOpen(true); - }, - disabled: mutation.isPending, - }, - ]; - 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" + ? () => 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[]; + return ( <>
- {isUpcoming && !isCancelled ? ( - <> - {isPending && } - {isConfirmed && } - {isRejected &&
{t("rejected")}
} - - ) : null} - {isBookingInPast && isPending && !isConfirmed ? : null} - {isBookingInPast && isConfirmed ? : null} - {isCalVideoLocation && ( - + {shouldShowPendingActions(actionContext) && } + {shouldShowEditActions(actionContext) && ( + + +
key; + +function createMockContext(overrides: Partial = {}): BookingActionContext { + const now = new Date(); + const startTime = new Date(now.getTime() + 24 * 60 * 60 * 1000); // Tomorrow + const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour later + + return { + booking: { + id: 1, + uid: "booking-123", + title: "Test Meeting", + description: "Test meeting description", + startTime: startTime.toISOString(), + endTime: endTime.toISOString(), + createdAt: now, + updatedAt: now, + status: BookingStatus.ACCEPTED, + paid: false, + fromReschedule: null, + recurringEventId: null, + rescheduled: false, + isRecorded: false, + rescheduler: null, + userPrimaryEmail: "organizer@example.com", + customInputs: {}, + responses: {}, + references: [], + attendees: [ + { + id: 1, + name: "John Doe", + email: "john@example.com", + timeZone: "America/New_York", + phoneNumber: null, + locale: "en", + bookingId: 1, + noShow: false, + } as any, + ], + user: { + id: 1, + name: "Organizer", + email: "organizer@example.com", + }, + eventType: { + id: 1, + title: "Test Event Type", + slug: "test-event", + length: 60, + schedulingType: SchedulingType.COLLECTIVE, + team: null, + eventTypeColor: { + lightEventTypeColor: "#000000", + darkEventTypeColor: "#ffffff", + }, + disableCancelling: false, + disableRescheduling: false, + disableGuests: false, + allowReschedulingPastBookings: false, + recurringEvent: null, + price: 0, + currency: "usd", + metadata: {}, + }, + location: "integrations:daily", + payment: [], + seatsReferences: [], + assignmentReason: [], + metadata: null, + routedFromRoutingFormReponse: null, + listingStatus: "upcoming", + recurringInfo: undefined, + loggedInUser: { + userId: 1, + userTimeZone: "America/New_York", + userTimeFormat: 12, + userEmail: "organizer@example.com", + }, + isToday: false, + }, + isUpcoming: true, + isOngoing: false, + isBookingInPast: false, + isCancelled: false, + isConfirmed: true, + isRejected: false, + isPending: false, + isRescheduled: false, + isRecurring: false, + isTabRecurring: false, + isTabUnconfirmed: false, + isBookingFromRoutingForm: false, + isDisabledCancelling: false, + isDisabledRescheduling: false, + isCalVideoLocation: true, + showPendingPayment: false, + cardCharged: false, + attendeeList: [ + { + name: "John Doe", + email: "john@example.com", + id: 1, + noShow: false, + phoneNumber: null, + }, + ], + getSeatReferenceUid: () => undefined, + t: mockT, + ...overrides, + } as BookingActionContext; +} + +describe("Booking Actions", () => { + describe("getPendingActions", () => { + it("should return reject action for pending booking", () => { + const context = createMockContext({ isPending: true }); + const actions = getPendingActions(context); + + expect(actions).toHaveLength(2); + expect(actions[0]).toEqual({ + id: "reject", + label: "reject", + icon: "ban", + disabled: false, + }); + expect(actions[1]).toEqual({ + id: "confirm", + bookingId: 1, + label: "confirm", + icon: "check", + disabled: false, + }); + }); + + it("should return reject action only for non-pending booking", () => { + const context = createMockContext({ isPending: false }); + const actions = getPendingActions(context); + + expect(actions).toHaveLength(1); + expect(actions[0].id).toBe("reject"); + }); + + it("should use correct labels for recurring bookings", () => { + const context = createMockContext({ + isPending: true, + isRecurring: true, + isTabRecurring: true, + }); + const actions = getPendingActions(context); + + expect(actions[0].label).toBe("reject_all"); + expect(actions[1].label).toBe("confirm_all"); + }); + }); + + describe("getCancelEventAction", () => { + it("should return cancel action with correct properties", () => { + const context = createMockContext(); + const action = getCancelEventAction(context); + + expect(action).toEqual({ + id: "cancel", + label: "cancel_event", + href: "/booking/booking-123?cancel=true", + icon: "circle-x", + color: "destructive", + disabled: false, + }); + }); + + it("should be disabled when cancellation is disabled", () => { + const context = createMockContext({ isDisabledCancelling: true }); + const action = getCancelEventAction(context); + + expect(action.disabled).toBe(true); + }); + + it("should be disabled for past pending bookings", () => { + const context = createMockContext({ + isBookingInPast: true, + isPending: true, + isConfirmed: false, + }); + const action = getCancelEventAction(context); + + expect(action.disabled).toBe(true); + }); + + it("should include recurring parameters for recurring bookings", () => { + const context = createMockContext({ + isRecurring: true, + isTabRecurring: true, + }); + const action = getCancelEventAction(context); + + expect(action.href).toContain("allRemainingBookings=true"); + expect(action.label).toBe("cancel_all_remaining"); + }); + }); + + describe("getVideoOptionsActions", () => { + it("should return video actions for past confirmed bookings", () => { + const context = createMockContext({ + isBookingInPast: true, + isConfirmed: true, + isCalVideoLocation: true, + booking: { + ...createMockContext().booking, + isRecorded: true, + }, + }); + const actions = getVideoOptionsActions(context); + + expect(actions).toHaveLength(2); + expect(actions[0].id).toBe("view_recordings"); + expect(actions[1].id).toBe("meeting_session_details"); + expect(actions[0].disabled).toBe(false); + expect(actions[1].disabled).toBe(false); + }); + + it("should disable video actions for upcoming bookings", () => { + const context = createMockContext({ isBookingInPast: false }); + const actions = getVideoOptionsActions(context); + + expect(actions[0].disabled).toBe(true); + expect(actions[1].disabled).toBe(true); + }); + + it("should disable video actions for non-Cal video locations", () => { + const context = createMockContext({ + isBookingInPast: true, + isConfirmed: true, + isCalVideoLocation: false, + }); + const actions = getVideoOptionsActions(context); + + expect(actions[0].disabled).toBe(true); + expect(actions[1].disabled).toBe(true); + }); + }); + + describe("getEditEventActions", () => { + it("should return basic edit actions", () => { + const context = createMockContext(); + const actions = getEditEventActions(context); + + const actionIds = actions.map((a) => a.id); + expect(actionIds).toContain("reschedule"); + expect(actionIds).toContain("reschedule_request"); + expect(actionIds).toContain("change_location"); + expect(actionIds).toContain("add_members"); + }); + + it("should include reroute action for routing form bookings", () => { + const context = createMockContext({ isBookingFromRoutingForm: true }); + const actions = getEditEventActions(context); + + const rerouteAction = actions.find((a) => a.id === "reroute"); + expect(rerouteAction).toBeDefined(); + }); + + it("should include reassign action for round robin events", () => { + const context = createMockContext({ + booking: { + ...createMockContext().booking, + eventType: { + ...createMockContext().booking.eventType, + schedulingType: SchedulingType.ROUND_ROBIN, + }, + }, + }); + const actions = getEditEventActions(context); + + const reassignAction = actions.find((a) => a.id === "reassign"); + expect(reassignAction).toBeDefined(); + }); + + it("should exclude add_members when guests are disabled", () => { + const context = createMockContext({ + booking: { + ...createMockContext().booking, + eventType: { + ...createMockContext().booking.eventType, + disableGuests: true, + }, + }, + }); + const actions = getEditEventActions(context); + + const addMembersAction = actions.find((a) => a.id === "add_members"); + expect(addMembersAction).toBeUndefined(); + }); + + it("should disable reschedule actions when rescheduling is disabled", () => { + const context = createMockContext({ isDisabledRescheduling: true }); + const actions = getEditEventActions(context); + + const rescheduleAction = actions.find((a) => a.id === "reschedule"); + const rescheduleRequestAction = actions.find((a) => a.id === "reschedule_request"); + + expect(rescheduleAction?.disabled).toBe(true); + expect(rescheduleRequestAction?.disabled).toBe(true); + }); + }); + + describe("getAfterEventActions", () => { + it("should include video actions and no-show action", () => { + const context = createMockContext({ isBookingInPast: true, isConfirmed: true }); + const actions = getAfterEventActions(context); + + const actionIds = actions.map((a) => a.id); + expect(actionIds).toContain("view_recordings"); + expect(actionIds).toContain("meeting_session_details"); + expect(actionIds).toContain("no_show"); + }); + + it("should include charge card action for held payments", () => { + const context = createMockContext({ + booking: { + ...createMockContext().booking, + status: BookingStatus.ACCEPTED, + paid: true, + payment: [{ paymentOption: "HOLD", amount: 1000, currency: "usd", success: true }], + }, + }); + const actions = getAfterEventActions(context); + + const chargeCardAction = actions.find((a) => a.id === "charge_card"); + expect(chargeCardAction).toBeDefined(); + }); + + it("should show correct no-show label for single attendee", () => { + const context = createMockContext({ + attendeeList: [{ name: "John", email: "john@example.com", id: 1, noShow: true, phoneNumber: null }], + }); + const actions = getAfterEventActions(context); + + const noShowAction = actions.find((a) => a.id === "no_show"); + expect(noShowAction?.label).toBe("unmark_as_no_show"); + expect(noShowAction?.icon).toBe("eye"); + }); + }); + + describe("shouldShowPendingActions", () => { + it("should return true for pending upcoming bookings", () => { + const context = createMockContext({ isPending: true, isUpcoming: true, isCancelled: false }); + expect(shouldShowPendingActions(context)).toBe(true); + }); + + it("should return false for cancelled bookings", () => { + const context = createMockContext({ isPending: true, isUpcoming: true, isCancelled: true }); + expect(shouldShowPendingActions(context)).toBe(false); + }); + + it("should return false for past bookings", () => { + const context = createMockContext({ isPending: true, isUpcoming: false, isCancelled: false }); + expect(shouldShowPendingActions(context)).toBe(false); + }); + }); + + describe("shouldShowEditActions", () => { + it("should return true for confirmed upcoming bookings", () => { + const context = createMockContext({ isPending: false, isCancelled: false }); + expect(shouldShowEditActions(context)).toBe(true); + }); + + it("should return false for pending bookings", () => { + const context = createMockContext({ isPending: true }); + expect(shouldShowEditActions(context)).toBe(false); + }); + + it("should return false for cancelled bookings", () => { + const context = createMockContext({ isCancelled: true }); + expect(shouldShowEditActions(context)).toBe(false); + }); + }); + + describe("shouldShowRecurringCancelAction", () => { + it("should return true for recurring bookings in recurring tab", () => { + const context = createMockContext({ isTabRecurring: true, isRecurring: true }); + expect(shouldShowRecurringCancelAction(context)).toBe(true); + }); + + it("should return false for non-recurring bookings", () => { + const context = createMockContext({ isTabRecurring: true, isRecurring: false }); + expect(shouldShowRecurringCancelAction(context)).toBe(false); + }); + }); + + describe("isActionDisabled", () => { + it("should disable reschedule actions when rescheduling is disabled", () => { + const context = createMockContext({ isDisabledRescheduling: true }); + + expect(isActionDisabled("reschedule", context)).toBe(true); + expect(isActionDisabled("reschedule_request", context)).toBe(true); + }); + + it("should disable cancel action when cancellation is disabled", () => { + const context = createMockContext({ isDisabledCancelling: true }); + + expect(isActionDisabled("cancel", context)).toBe(true); + }); + + it("should disable video actions for non-past bookings", () => { + const context = createMockContext({ isBookingInPast: false }); + + expect(isActionDisabled("view_recordings", context)).toBe(true); + expect(isActionDisabled("meeting_session_details", context)).toBe(true); + }); + + it("should disable charge card action when already charged", () => { + const context = createMockContext({ cardCharged: true }); + + expect(isActionDisabled("charge_card", context)).toBe(true); + }); + }); + + describe("getActionLabel", () => { + it("should return correct labels for different actions", () => { + const context = createMockContext(); + + expect(getActionLabel("reject", context)).toBe("reject"); + expect(getActionLabel("confirm", context)).toBe("confirm"); + expect(getActionLabel("cancel", context)).toBe("cancel_event"); + }); + + it("should return correct labels for recurring bookings", () => { + const context = createMockContext({ isRecurring: true, isTabRecurring: true }); + + expect(getActionLabel("reject", context)).toBe("reject_all"); + expect(getActionLabel("confirm", context)).toBe("confirm_all"); + expect(getActionLabel("cancel", context)).toBe("cancel_all_remaining"); + }); + + it("should return correct no-show label based on attendee state", () => { + const contextWithNoShow = createMockContext({ + attendeeList: [{ name: "John", email: "john@example.com", id: 1, noShow: true, phoneNumber: null }], + }); + + expect(getActionLabel("no_show", contextWithNoShow)).toBe("unmark_as_no_show"); + }); + }); +}); diff --git a/apps/web/components/booking/bookingActions.ts b/apps/web/components/booking/bookingActions.ts new file mode 100644 index 00000000000000..e0fbed746bd217 --- /dev/null +++ b/apps/web/components/booking/bookingActions.ts @@ -0,0 +1,243 @@ +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; +import type { ActionType } from "@calcom/ui/components/table"; + +import type { BookingItemProps } from "./BookingListItem"; + +export interface BookingActionContext { + booking: BookingItemProps; + isUpcoming: boolean; + isOngoing: boolean; + isBookingInPast: boolean; + isCancelled: boolean; + isConfirmed: boolean; + isRejected: boolean; + isPending: boolean; + isRescheduled: boolean; + isRecurring: boolean; + isTabRecurring: boolean; + isTabUnconfirmed: boolean; + isBookingFromRoutingForm: boolean; + isDisabledCancelling: boolean; + isDisabledRescheduling: boolean; + isCalVideoLocation: boolean; + showPendingPayment: boolean; + cardCharged: boolean; + attendeeList: Array<{ + name: string; + email: string; + id: number; + noShow: boolean; + phoneNumber: string | null; + }>; + getSeatReferenceUid: () => string | undefined; + t: (key: string) => string; +} + +export function getPendingActions(context: BookingActionContext): ActionType[] { + const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } = + context; + + const actions: ActionType[] = [ + { + id: "reject", + label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"), + icon: "ban", + disabled: false, // This would be controlled by mutation state in the component + }, + ]; + + // For bookings with payment, only confirm if the booking is paid for + // Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid) + if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) { + actions.push({ + id: "confirm", + bookingId: booking.id, + label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"), + icon: "check" as const, + disabled: false, // This would be controlled by mutation state in the component + }); + } + + return actions; +} + +export function getCancelEventAction(context: BookingActionContext): ActionType { + const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context; + + return { + id: "cancel", + label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"), + href: `/booking/${booking.uid}?cancel=true${ + isTabRecurring && isRecurring ? "&allRemainingBookings=true" : "" + }${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""}`, + icon: "circle-x", + color: "destructive", + disabled: isActionDisabled("cancel", context), + }; +} + +export function getVideoOptionsActions(context: BookingActionContext): ActionType[] { + const { booking, isBookingInPast, isConfirmed, isCalVideoLocation, t } = context; + + return [ + { + id: "view_recordings", + label: t("view_recordings"), + icon: "video", + disabled: !(isBookingInPast && isConfirmed && isCalVideoLocation && booking.isRecorded), + }, + { + id: "meeting_session_details", + label: t("view_session_details"), + icon: "info", + disabled: !(isBookingInPast && isConfirmed && isCalVideoLocation), + }, + ]; +} + +export function getEditEventActions(context: BookingActionContext): ActionType[] { + const { + booking, + isBookingInPast, + isDisabledRescheduling, + isBookingFromRoutingForm, + getSeatReferenceUid, + t, + } = context; + + const actions: (ActionType | null)[] = [ + { + id: "reschedule", + icon: "clock", + label: t("reschedule_booking"), + href: `/reschedule/${booking.uid}${ + booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" + }`, + disabled: + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling, + }, + { + id: "reschedule_request", + icon: "send", + iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", + label: t("send_reschedule_request"), + disabled: + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling, + }, + isBookingFromRoutingForm + ? { + id: "reroute", + label: t("reroute"), + icon: "waypoints", + disabled: false, + } + : null, + { + id: "change_location", + label: t("edit_location"), + icon: "map-pin", + disabled: false, + }, + booking.eventType?.disableGuests + ? null + : { + id: "add_members", + label: t("additional_guests"), + icon: "user-plus", + disabled: false, + }, + // Reassign (if round robin) + booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN + ? { + id: "reassign", + label: t("reassign"), + icon: "users", + disabled: false, + } + : null, + ]; + + return actions.filter(Boolean) as ActionType[]; +} + +export function getAfterEventActions(context: BookingActionContext): ActionType[] { + const { booking, cardCharged, attendeeList, t } = context; + + const actions: (ActionType | null)[] = [ + ...getVideoOptionsActions(context), + booking.status === BookingStatus.ACCEPTED && booking.paid && booking.payment[0]?.paymentOption === "HOLD" + ? { + id: "charge_card", + label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"), + icon: "credit-card", + disabled: cardCharged, + } + : null, + { + id: "no_show", + label: + attendeeList.length === 1 && attendeeList[0].noShow ? t("unmark_as_no_show") : t("mark_as_no_show"), + icon: attendeeList.length === 1 && attendeeList[0].noShow ? "eye" : "eye-off", + disabled: false, // This would be controlled by booking state in the component + }, + ]; + + return actions.filter(Boolean) as ActionType[]; +} + +export function shouldShowPendingActions(context: BookingActionContext): boolean { + const { isPending, isUpcoming, isCancelled } = context; + return isPending && isUpcoming && !isCancelled; +} + +export function shouldShowEditActions(context: BookingActionContext): boolean { + const { isPending, isTabRecurring, isRecurring, isCancelled } = context; + return !isPending && !(isTabRecurring && isRecurring) && !isCancelled; +} + +export function shouldShowRecurringCancelAction(context: BookingActionContext): boolean { + const { isTabRecurring, isRecurring } = context; + return isTabRecurring && isRecurring; +} + +export function isActionDisabled(actionId: string, context: BookingActionContext): boolean { + const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } = + context; + + switch (actionId) { + case "reschedule": + case "reschedule_request": + return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling; + case "cancel": + return isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed); + case "view_recordings": + return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation); + case "meeting_session_details": + return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation); + case "charge_card": + return context.cardCharged; + default: + return false; + } +} + +export function getActionLabel(actionId: string, context: BookingActionContext): string { + const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context; + + switch (actionId) { + case "reject": + return (isTabRecurring || context.isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"); + case "confirm": + return (isTabRecurring || context.isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"); + case "cancel": + return isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"); + case "no_show": + return attendeeList.length === 1 && attendeeList[0].noShow + ? t("unmark_as_no_show") + : t("mark_as_no_show"); + case "charge_card": + return cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"); + default: + return t(actionId); + } +} diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 8da6410a341cd6..a7740a53abea43 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -155,7 +155,8 @@ test.describe("pro user", () => { await pro.apiLogin(); await page.goto("/bookings/upcoming"); await page.waitForSelector('[data-testid="bookings"]'); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); await page.locator('[data-testid="reschedule"]').click(); await page.waitForURL((url) => { const bookingId = url.searchParams.get("rescheduleUid"); @@ -205,6 +206,9 @@ test.describe("pro user", () => { await pro.apiLogin(); await page.goto("/bookings/upcoming"); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); + // Click the cancel option in the dropdown await page.locator('[data-testid="cancel"]').click(); await page.waitForURL((url) => { return url.pathname.startsWith("/booking/"); @@ -237,6 +241,9 @@ test.describe("pro user", () => { await pro.apiLogin(); await page.goto("/bookings/upcoming"); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); + // Click the cancel option in the dropdown await page.locator('[data-testid="cancel"]').click(); await page.waitForURL((url) => { return url.pathname.startsWith("/booking/"); diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index dfad39877d9769..b58c65427135a1 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -363,7 +363,10 @@ test.describe("Bookings", () => { .locator(`[data-testid="select-filter-options-userId"] [role="option"]:has-text("${thirdUser.name}")`) .click(); await bookingsGetResponse2; - await expect(page.locator('text="Cancel event"').nth(0)).toBeVisible(); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); + // Check that the cancel option is visible in the dropdown + await expect(page.locator('[data-testid="cancel"]')).toBeVisible(); //expect only 3 bookings (out of 4 total) to be shown in list. //where ThirdUser is either organizer or attendee diff --git a/apps/web/playwright/dynamic-booking-pages.e2e.ts b/apps/web/playwright/dynamic-booking-pages.e2e.ts index 059bdacba13db5..0daa92b9337d4f 100644 --- a/apps/web/playwright/dynamic-booking-pages.e2e.ts +++ b/apps/web/playwright/dynamic-booking-pages.e2e.ts @@ -36,7 +36,9 @@ test("dynamic booking", async ({ page, users }) => { await test.step("can reschedule a booking", async () => { // Logged in await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); + // Click the reschedule option in the dropdown await page.locator('[data-testid="reschedule"]').click(); await page.waitForURL((url) => { const bookingId = url.searchParams.get("rescheduleUid"); @@ -54,6 +56,9 @@ test("dynamic booking", async ({ page, users }) => { await test.step("Can cancel the recently created booking", async () => { await page.goto("/bookings/upcoming"); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); + // Click the cancel option in the dropdown await page.locator('[data-testid="cancel"]').click(); await page.waitForURL((url) => { return url.pathname.startsWith("/booking"); diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 0ec9ccb16c2b3c..3ea39b49f1d40f 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -33,7 +33,8 @@ test.describe("Reschedule Tests", async () => { await user.apiLogin(); await page.goto("/bookings/upcoming"); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); await page.locator('[data-testid="reschedule_request"]').click(); @@ -75,7 +76,8 @@ test.describe("Reschedule Tests", async () => { await user.apiLogin(); await page.goto("/bookings/past"); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); await expect(page.locator('[data-testid="reschedule"]')).toBeVisible(); await expect(page.locator('[data-testid="reschedule_request"]')).toBeVisible(); @@ -91,10 +93,14 @@ test.describe("Reschedule Tests", async () => { await page.reload(); - await page.locator('[data-testid="edit_booking"]').nth(0).click(); + // Click the ellipsis menu button to open the dropdown + await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click(); - await expect(page.locator('[data-testid="reschedule"]')).toBeHidden(); - await expect(page.locator('[data-testid="reschedule_request"]')).toBeHidden(); + // Check that the reschedule options are visible but disabled + await expect(page.locator('[data-testid="reschedule"]')).toBeVisible(); + await expect(page.locator('[data-testid="reschedule_request"]')).toBeVisible(); + await expect(page.locator('[data-testid="reschedule"]')).toBeDisabled(); + await expect(page.locator('[data-testid="reschedule_request"]')).toBeDisabled(); }); test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => { diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 2c908b8f7794d9..bbe2ba6358207e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -50,7 +50,8 @@ "enable_automatic_transcription": "Enable automatic transcription after joining the meeting", "enable_automatic_recording": "Enable automatic recording after organizer joins the meeting", "video_options": "Video Options", - "get_meeting_session_details": "Get Meeting Session Details", + "edit_event": "Edit event", + "view_session_details": "View Session Details", "meeting_session_details": "Meeting Session Details", "meeting_session": "Meeting Session", "session_id": "Session ID",