Files
calendar/apps/web/components/booking/actions/BookingActionsDropdown.tsx
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Opus 4.5hariom@cal.com <hariombalhara@gmail.com>cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>alex@cal.com <me@alexvanandel.com>
7cfb053026 feat: add wrong routing tab under Insights for reviewing assignment reports (#27423)
* Add DB table for wrong assignment reports

* When report is submitted write to the db

* Prevent duplicate reportings

* test: add migration and tests for WrongAssignmentReport table

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: add unique constraint on bookingUid and booking access check for hasWrongAssignmentReport

- Add @unique constraint on bookingUid in WrongAssignmentReport model to prevent duplicate reports at DB level
- Add booking ownership check using BookingAccessService in hasWrongAssignmentReport endpoint
- Refactor hasWrongAssignmentReport into separate handler and schema files

Addresses Cubic AI review feedback on PR #27405

Co-Authored-By: unknown <>

* feat: add routingFormId to WrongAssignmentReport and fix Select clearing

- Add routingFormId field to WrongAssignmentReport model in schema.prisma
- Add relation to App_RoutingForms_Form with SetNull on delete
- Update WrongAssignmentReportRepository.createReport to accept routingFormId
- Update BookingRepository.findByUidIncludeEventTypeAndTeamAndAssignmentReason to include routedFromRoutingFormReponse
- Extract routingFormId from booking in reportWrongAssignment handler
- Fix Select clearing issue: handle null case when user clears team member selection
- Update tests to include routingFormId field

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* chore: add migration for routingFormId in WrongAssignmentReport

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add wrong assignment reports dashboard under routing tab

- Add reviewedById and reviewedAt fields to WrongAssignmentReport model
- Add repository methods for listing reports by status and updating status
- Create tRPC endpoints for fetching reports and updating status
- Create dashboard UI with pending/reviewed tabs showing routing form name
- Add translation keys for dashboard UI
- Integrate dashboard into routing insights page

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: move hooks before early return and fix indentation

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: address Udit's review comments

- hasWrongAssignmentReport: throw UNAUTHORIZED error instead of returning false
- reportWrongAssignment: add try-catch for Prisma P2002 unique constraint error
- WrongAssignmentReport: add Team relation to teamId field
- WrongAssignmentReportRepository: use findUnique instead of findFirst
- reportWrongAssignment: use i18n for error messages

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: revert hasWrongAssignmentReport to return false when user lacks access

Per PR checklist, hasWrongAssignmentReport should return { hasReport: false }
when user lacks access to booking, not throw an error. This allows the UI
to gracefully treat 'no access' as 'no report exists'.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: update mocks for findUnique and i18n in unit tests

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: throw UNAUTHORIZED in hasWrongAssignmentReport and squash migrations

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: move wrong assignment reports to its own tab under Insights

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Use data table for wrong reports

* Add option to view routing trace

* feat: add view routing form submission action to wrong assignment reports

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add RoutingFormResponseSheet component for viewing form submissions

- Create slide-out sheet to display routing form responses
- Map option IDs to display labels for select/multiselect fields
- Handle both legacy and modern option formats
- Add i18n strings: form_submission, no_responses_found

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: improve wrong assignment reports UX

- Integrate RoutingFormResponseSheet as slide-out panel instead of new tab
- Fix dropdown padding by using StartIcon prop instead of manual Icon
- Allow direct status changes for reviewed reports (no need to reopen first)
- Remove unused Icon import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: change User relation onDelete from Cascade to SetNull in WrongAssignmentReport

Address Hariom's review feedback:
- Changed reportedById from Int to Int? (nullable)
- Changed reportedBy relation from onDelete: Cascade to onDelete: SetNull
- Updated migration SQL to reflect these changes

This preserves wrong assignment reports even when the reporting user is deleted,
as the data is still useful for analysis.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add missing i18n strings for wrong assignment reports dashboard

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: move form response display value resolution server-side

Replace client-side option ID to label resolution in
RoutingFormResponseSheet with a new lean tRPC endpoint
(getFormResponseDisplay) that resolves values server-side using
the existing getHumanReadableFieldResponseValue utility. This
enforces DTO boundaries by returning a clean pre-resolved payload
instead of leaking internal option format details to the client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add report wrong assignment button to routing trace sheet

Wire up the WrongAssignmentDialog from the routing trace sheet header
so users can flag wrong assignments directly while viewing the trace.
The report button is disabled with a tooltip when a report already
exists.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: reuse existing WrongAssignmentDialog from parent

Replace the duplicate WrongAssignmentDialog in RoutingTraceSheet with
a callback to the existing instance in BookingActionsDropdown. This
reduces the prop surface from a 6-field reportContext object to an
onReport callback and hasExistingReport boolean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use repository instead of direct Prisma in getFormResponseDisplay

Replace direct Prisma query with PrismaRoutingFormResponseRepository's
findByIdIncludeForm method. Extend the method to also select form name,
description, userId, and teamId needed for display and auth checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: replace direct Prisma calls with repository methods

Use MembershipRepository.hasMembership() for auth checks and
TeamRepository.findAllByParentId() for child team queries instead
of direct Prisma calls. Replace direct user query with
UserRepository.getTimeZoneAndDefaultScheduleId(). Remove unused
seed script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: use PBAC services for wrong assignment report auth

Replace manual MembershipRepository.hasMembership() checks with
PBAC-aware permission checking. getWrongAssignmentReports uses
createTeamPbacProcedure middleware since teamId is in input.
updateWrongAssignmentReportStatus uses PermissionCheckService
directly since teamId is discovered from the report entity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: resolve type errors in RoutingFormResponseSheet and wrong-routing view

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: add seed script for wrong assignment reports test data

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Revert "feat: add seed script for wrong assignment reports test data"

This reverts commit 0bd60e9661858a59aab1573d14d57d81733b7991.

* Only update reviewed fields when not pending

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor: wrap handleStatusChange in useCallback to fix useMemo recalculation

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix routing sheet UI

* fix: use appropriate error message in getFormResponseDisplay handler

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: extract WrongAssignmentReportService from tRPC handlers

Move business logic (booking lookup, duplicate check, report creation,
webhook dispatch, org-level team resolution) into a dedicated service
in packages/features. Handlers become thin controllers that only handle
auth checks and delegate to the service.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: restrict report status updates to admin/owner roles

Remove MembershipRole.MEMBER from fallbackRoles in
updateWrongAssignmentReportStatus permission check. Updating report
status is an administrative action that should be limited to team
admins and owners.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: localize hard-coded success message in WrongAssignmentReportService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: rename reviewed tab to handled and add missing translations

Rename the "Reviewed" tab to "Handled" since it groups three distinct
statuses (Reviewed, Resolved, Dismissed). Also add missing translation
keys for "resolved" and "dismissed" status badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* merge: resolve conflicts with main branch

Merge main into devin/1769747741-wrong-assignment-dashboard, resolving conflicts in:
- BookingActionsDropdown.tsx: use main's booking prop with PR's fragment structure
- reportWrongAssignment.handler.ts: keep PR's service-based approach
- reportWrongAssignment.handler.test.ts: use main's class-based mocks with PR's additions
- WrongAssignmentReportService.ts: align with main's repo method and field names

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add guard when accessing assignmentReasonSortedByCreatedAt

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: address Cubic AI review feedback - select projection and useLocale refactor

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove stale TRPCError assertion in test (service throws ErrorWithCode)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* refactor: remove unused findByTeamIdAndStatus and findByTeamIdAndStatuses methods

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* perf: add composite index on WrongAssignmentReport and narrow findByIdIncludeForm select

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* perf: use lightweight findTeamIdById in update-status handler instead of findById

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: alex@cal.com <me@alexvanandel.com>
2026-02-12 13:02:33 +00:00

774 lines
29 KiB
TypeScript

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<string, string> = {
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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent title={t("mark_as_no_show")} enableOverflow>
<div className="stack-y-2">
{attendees.map((attendee, index) => (
<label key={attendee.email} className="flex items-center space-x-2">
<input
type="checkbox"
checked={noShowAttendees[index]?.noShow || false}
onChange={(e) => {
const newNoShowAttendees = [...noShowAttendees];
newNoShowAttendees[index] = {
email: attendee.email,
noShow: e.target.checked,
};
setNoShowAttendees(newNoShowAttendees);
}}
/>
<span>
{attendee.name} ({attendee.email})
</span>
</label>
))}
</div>
<DialogFooter>
<DialogClose />
<Button
onClick={() => {
noShowMutation.mutate({
bookingUid,
attendees: noShowAttendees,
});
}}>
{t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
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 = (
<>
<RescheduleDialog
isOpenDialog={isOpenRescheduleDialog}
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUid={booking.uid}
/>
{isOpenReassignDialog && (
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
bookingFromRoutingForm={isBookingFromRoutingForm}
isManagedEvent={booking.eventType?.parentId != null}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
isOpenDialog={isOpenSetLocationDialog}
setShowLocationModal={setIsOpenLocationDialog}
teamId={booking.eventType?.team?.id}
/>
<AddGuestsDialog
isOpenDialog={isOpenAddGuestsDialog}
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
<ReportBookingDialog
isOpenDialog={isOpenReportDialog}
setIsOpenDialog={setIsOpenReportDialog}
bookingUid={booking.uid}
isRecurring={isRecurring}
status={getBookingStatus()}
/>
{isBookingFromRoutingForm && (
<>
<WrongAssignmentDialog
isOpenDialog={isOpenWrongAssignmentDialog}
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
booking={booking}
/>
<RoutingTraceSheet
isOpen={isOpenRoutingTraceSheet}
setIsOpen={setIsOpenRoutingTraceSheet}
bookingUid={booking.uid}
onReport={() => setIsOpenWrongAssignmentDialog(true)}
hasExistingReport={!!booking.report}
/>
</>
)}
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
setIsOpenDialog={setChargeCardDialogIsOpen}
bookingId={booking.id}
paymentAmount={booking.payment[0].amount}
paymentCurrency={booking.payment[0].currency}
/>
)}
{isCalVideoLocation && (
<ViewRecordingsDialog
booking={booking}
isOpenDialog={viewRecordingsDialogIsOpen}
setIsOpenDialog={setViewRecordingsDialogIsOpen}
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
/>
)}
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
<MeetingSessionDetailsDialog
booking={booking}
isOpenDialog={meetingSessionDetailsDialogIsOpen}
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={setIsNoShowDialogOpen}
isOpen={isNoShowDialogOpen}
/>
)}
<CancelBookingDialog
isOpenDialog={isCancelDialogOpen}
setIsOpenDialog={setIsCancelDialogOpen}
booking={{
uid: booking.uid,
id: booking.id,
title: booking.title,
startTime: new Date(booking.startTime),
payment: booking.payment,
}}
profile={{
name: booking.user?.name || null,
slug: booking.user?.username || null,
}}
recurringEvent={booking.eventType?.recurringEvent || null}
team={booking.eventType?.team?.name}
teamId={booking.eventType?.team?.id}
allRemainingBookings={isTabRecurring && isRecurring}
seatReferenceUid={getSeatReferenceUid()}
currentUserEmail={booking.loggedInUser.userEmail}
bookingCancelledEventProps={{
booking: booking,
organizer: {
name: booking.user?.name || "Nameless",
email: booking.userPrimaryEmail || booking.user?.email || "Email-less",
timeZone: booking.user?.timeZone,
},
eventType: booking.eventType,
}}
isHost={isHost}
internalNotePresets={[]}
eventTypeMetadata={booking.eventType?.metadata}
/>
{isBookingFromRoutingForm &&
parsedBooking.eventType &&
parsedBooking.eventType.id !== undefined &&
parsedBooking.eventType.slug !== undefined &&
parsedBooking.eventType.title !== undefined &&
parsedBooking.routedFromRoutingFormReponse && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
setIsOpenDialog={setRerouteDialogIsOpen}
booking={{
...parsedBooking,
metadata: parsedBooking.metadata as z.infer<typeof bookingMetadataSchema>,
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}
<RejectionReasonDialog
isOpenDialog={rejectionDialogIsOpen}
setIsOpenDialog={setRejectionDialogIsOpen}
onConfirm={(reason) =>
bookingConfirm({
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
})
}
isPending={isConfirmPending}
/>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
<Button
type="button"
color="secondary"
size={size}
StartIcon="ellipsis"
className={classNames("px-2", className)}
// Prevent click from bubbling to parent row/container click handlers
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
<ConditionalPortal>
<DropdownMenuContent>
{pendingActions.length > 0 && (
<>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("booking_response")}</DropdownMenuLabel>
{pendingActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.disabled ? undefined : action.href}
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.disabled ? undefined : action.href}
disabled={action.disabled}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
{isBookingFromRoutingForm && (
<DropdownMenuItem className="rounded-lg" key="view_routing_trace">
<DropdownItem
type="button"
StartIcon="git-merge"
onClick={(e) => {
e.stopPropagation();
setIsOpenRoutingTraceSheet(true);
}}
data-testid="view_routing_trace">
{t("routing_trace")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.disabled ? undefined : action.href}
onClick={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
disabled={action.disabled}
data-booking-uid={action.bookingUid}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={reportActionWithHandler.id}
disabled={reportActionWithHandler.disabled}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={(e) => {
e.stopPropagation();
reportActionWithHandler.onClick?.();
}}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
{isBookingFromRoutingForm && (
<DropdownMenuItem className="rounded-lg" key="report_wrong_assignment">
<DropdownItem
type="button"
color="destructive"
StartIcon="user-x"
onClick={(e) => {
e.stopPropagation();
setIsOpenWrongAssignmentDialog(true);
}}
data-testid="report_wrong_assignment">
{t("report_wrong_assignment")}
</DropdownItem>
</DropdownMenuItem>
)}
</>
<DropdownMenuSeparator />
<Tooltip
content={isBookingInPast ? t("cannot_cancel_past_booking") : ""}
side="left"
open={showPastBookingCancelTooltip ? undefined : false}>
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
disabled={cancelEventAction.disabled}>
<DropdownItem
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
onClick={(e) => {
e.stopPropagation();
setIsCancelDialogOpen(true);
}}
disabled={cancelEventAction.disabled}
data-booking-uid={cancelEventAction.bookingUid}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</Tooltip>
</DropdownMenuContent>
</ConditionalPortal>
</Dropdown>
</>
);
}