* refactor: migrate UnconfirmedBookingBadge from features to apps/web Move UnconfirmedBookingBadge.tsx from packages/features/bookings/ to apps/web/modules/bookings/components/ as part of the architectural refactor to remove trpc client imports from the features layer. Also removes unused preserveBookingsQueryParams function from Navigation.tsx. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: move shell navigation and badges to apps/web Move shell navigation components and trpc-using badges from packages/features to apps/web/modules to fix circular dependency: - Move navigation folder to apps/web/modules/shell/navigation/ - Move TeamInviteBadge.tsx to apps/web/modules/shell/ - Create Shell wrapper in apps/web that provides MobileNavigationContainer - Update all Shell imports in apps/web to use the new wrapper - Remove MobileNavigationContainer default from features Shell.tsx - Fix pre-existing lint warnings in touched files This establishes the pattern for migrating React components that use trpc hooks from the features layer to the web app layer, ensuring proper dependency direction: apps/web imports from packages/features, never the reverse. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: move SideBar.tsx to apps/web to fix build error SideBar.tsx was importing Navigation from the moved navigation folder, causing a build error. Moving SideBar.tsx to apps/web and updating the features Shell to not have a default SidebarContainer fixes this. The web Shell wrapper now provides both the default SidebarContainer and MobileNavigationContainer, maintaining the injection pattern. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * revert * revert * revert * wip * wip * wip * wip * wip * wip * not used anywhere * wip * wip * wip * wip * fix * fix * wip * wip * wip * wip * wip * fix * fix * fix * fix * migrate * migrate admin-adpi * wip * feat: migrate organization settings components from packages/features to apps/web/modules - Migrate profile.tsx, appearance.tsx, general.tsx, privacy.tsx, guest-notifications.tsx, delegationCredential.tsx, other-team-members-view.tsx, other-team-profile-view.tsx - Migrate attributes directory (AttributesForm.tsx, DeleteAttributeModal.tsx, ListSkeleton.tsx, attributes-create-view.tsx, attributes-edit-view.tsx, attributes-list-view.tsx) - Migrate admin directory (AdminOrgEditPage.tsx, AdminOrgPage.tsx, WorkspacePlatformPage.tsx) - Update all page imports to use new paths from ~/settings/organizations/ - Update relative imports in migrated files to use @calcom/features paths - Fix lint warnings in migrated files Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update test import path after migration Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: remove unnecessary test-setup import (already in vitest config) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * wip * refactor: delete original files after migration to apps/web/modules Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * fix * fix * fix * wip * refactor more * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * mv * update import paths * wip * wip * fix * fix * fix * fix * fix * fix * fix * mv * mv * mv * fix * wip * wip * fix * fix * fix * fix: make test mocks resilient to vi.resetAllMocks() Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: fix AttributeForm test failures Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * fix * refactor: move ee files to apps/web/modules/ee/ folder - Move teams, workflows, and organizations folders to apps/web/modules/ee/ - Add LICENSE file to apps/web/modules/ee/ - Update all import paths from ~/teams/ to ~/ee/teams/ - Update all import paths from ~/settings/organizations/ to ~/ee/organizations/ - Remove duplicate MemberInvitationModal copy.tsx file Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: move useHasPaidPlan and dependent files to apps/web/modules - Move useHasPaidPlan.ts from packages/features/billing/hooks to apps/web/modules/billing/hooks - Move intercom files from packages/features/ee/support to apps/web/modules/ee/support - Move ContactMenuItem.tsx and dependencies (freshchat, helpscout, zendesk) to apps/web/modules/ee/support - Move ViewRecordingsDialog.tsx and RecordingListSkeleton to apps/web/modules/ee/video - Move CalVideoSettings.tsx to apps/web/modules/eventtypes/components/locations - Move CreateOrEditOutOfOfficeModal.tsx to apps/web/modules/settings/outOfOffice - Refactor UpgradeTeamsBadge to accept props and create wrapper in apps/web/modules/billing - Update all callers to use new file locations - Add eslint-disable comments for pre-existing lint warnings This fixes the tRPC server build failure caused by circular dependency where the server build was traversing into packages/features and pulling in React hooks that depend on @calcom/trpc/react types. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: correct UpgradeTeamsBadge import path to use package export Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix * fix * fix * fix * fix * fix * fix * fix: pass plan state through SelectProps to UpgradeTeamsBadge - Add upgradeTeamsBadgeProps field to ExtendedOption type in Select component - Update OptionComponent to spread upgradeTeamsBadgeProps to UpgradeTeamsBadge - Update getOptions.ts to accept PlanState object and include upgradeTeamsBadgeProps - Update WorkflowStepContainer.tsx to pass planState to getWorkflowTriggerOptions/getWorkflowTemplateOptions - Update WorkflowDetailsPage.tsx to pass upgradeTeamsBadgeProps in transformed action options - Update AddActionDialog.tsx interface and mapping to include upgradeTeamsBadgeProps - Add eslint-disable comments for pre-existing React Hook dependency warnings This fixes the UpgradeTeamsBadge refactoring issue where the badge was always showing 'upgrade' text instead of the correct text based on plan state (trial_mode, inactive_team_plan, etc.) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update import paths to use /ee/ folder for workflows and organizations Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update workflow component imports to use /ee/ folder Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: add missing types.ts for LocationInput.tsx Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: extract BookingRedirectForm type to shared location Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * wip * wip * fix: update BookingRedirectForm import to use local types file Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * wip * fix * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
713 lines
26 KiB
TypeScript
713 lines
26 KiB
TypeScript
import { useState } from "react";
|
|
import type { z } from "zod";
|
|
|
|
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
|
import ViewRecordingsDialog from "~/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 classNames from "@calcom/ui/classNames";
|
|
import { Button } from "@calcom/ui/components/button";
|
|
import { Dialog, DialogContent, DialogFooter, DialogClose } 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 { 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 { useBookingConfirmation } from "../hooks/useBookingConfirmation";
|
|
import type { BookingItemProps } from "../types";
|
|
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
|
import {
|
|
getCancelEventAction,
|
|
getEditEventActions,
|
|
getAfterEventActions,
|
|
getReportAction,
|
|
shouldShowEditActions,
|
|
shouldShowPendingActions,
|
|
getPendingActions,
|
|
type BookingActionContext,
|
|
} 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 rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
|
|
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
|
|
const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen);
|
|
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
|
|
|
|
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);
|
|
|
|
// 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()}
|
|
/>
|
|
{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>
|
|
))}
|
|
<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>
|
|
</>
|
|
<DropdownMenuSeparator />
|
|
<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>
|
|
</DropdownMenuContent>
|
|
</ConditionalPortal>
|
|
</Dropdown>
|
|
</>
|
|
);
|
|
}
|