diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index f673aa9f6440fb..5a196c860a5002 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -52,6 +52,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; +import { RemoveBookingSeatsDialog } from "@components/dialog/RemoveBookingSeatsDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; @@ -70,15 +71,24 @@ type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"] type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; +type LoggedInUser = { + userId: number | undefined; + userTimeZone: string | undefined; + userTimeFormat: number | null | undefined; + userEmail: string | undefined; + userIsOrgAdminOrOwner: boolean | undefined; + teamsWhereUserIsAdminOrOwner: + | { + id: number; + teamId: number; + }[] + | undefined; +}; + export type BookingItemProps = BookingItem & { listingStatus: BookingListingStatus; recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined; - loggedInUser: { - userId: number | undefined; - userTimeZone: string | undefined; - userTimeFormat: number | null | undefined; - userEmail: string | undefined; - }; + loggedInUser: LoggedInUser; isToday: boolean; }; @@ -194,6 +204,30 @@ function BookingListItem(booking: BookingItemProps) { const location = booking.location as ReturnType; const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl; + const checkIfUserIsHost = (userId?: number | null) => { + if (!userId) return false; + + return ( + booking.user?.id === userId || + booking.eventType.hosts?.some( + (host) => + host.user?.id === userId && + booking.attendees.some((attendee) => attendee.email === host.user?.email) + ) + ); + }; + + const checkIfUserIsAuthorizedToCancelSeats = () => { + const { user, eventType } = booking; + const { userId, userIsOrgAdminOrOwner, teamsWhereUserIsAdminOrOwner } = booking.loggedInUser; + const isUserOwner = user?.id === userId; + const isUserTeamEventHost = checkIfUserIsHost(userId); + const isUserTeamAdminOrOwner = teamsWhereUserIsAdminOrOwner?.some( + (team) => team.teamId === eventType?.team?.id || team.teamId === eventType?.parent?.teamId + ); + return isUserOwner || isUserTeamEventHost || userIsOrgAdminOrOwner || isUserTeamAdminOrOwner; + }; + const { resolvedTheme, forcedTheme } = useGetTheme(); const hasDarkTheme = !forcedTheme && resolvedTheme === "dark"; const eventTypeColor = @@ -255,6 +289,7 @@ function BookingListItem(booking: BookingItemProps) { cardCharged, attendeeList, getSeatReferenceUid, + checkIfUserIsAuthorizedToCancelSeats, t, } as BookingActionContext; @@ -293,6 +328,7 @@ function BookingListItem(booking: BookingItemProps) { const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false); const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false); + const [isOpenRemoveSeatsDialog, setIsOpenRemoveSeatsDialog] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { showToast(t("location_updated"), "success"); @@ -368,6 +404,8 @@ function BookingListItem(booking: BookingItemProps) { ? () => setIsOpenLocationDialog(true) : action.id === "add_members" ? () => setIsOpenAddGuestsDialog(true) + : action.id === "remove_seats" + ? () => setIsOpenRemoveSeatsDialog(true) : action.id === "reassign" ? () => setIsOpenReassignDialog(true) : undefined, @@ -430,6 +468,20 @@ function BookingListItem(booking: BookingItemProps) { setIsOpenDialog={setIsOpenAddGuestsDialog} bookingId={booking.id} /> + {checkIfUserIsAuthorizedToCancelSeats() && ( + ({ + email: seat.attendee?.email || "", + name: seat.attendee?.name || null, + referenceUid: seat.referenceUid, + })) + .filter((attendee) => attendee.email)} + /> + )} {booking.paid && booking.payment[0] && ( boolean; attendeeList: Array<{ name: string; email: string; @@ -104,6 +105,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] isBookingFromRoutingForm, getSeatReferenceUid, isAttendee, + checkIfUserIsAuthorizedToCancelSeats, t, } = context; @@ -148,6 +150,14 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] icon: "user-plus", disabled: false, }, + booking.seatsReferences.length > 0 && !isBookingInPast && checkIfUserIsAuthorizedToCancelSeats() + ? { + id: "remove_seats", + label: t("remove_seats"), + icon: "user-x", + disabled: false, + } + : null, // Reassign if round robin with no or one host groups booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN && (!booking.eventType.hostGroups || booking.eventType.hostGroups?.length <= 1) diff --git a/apps/web/components/dialog/RemoveBookingSeatsDialog.tsx b/apps/web/components/dialog/RemoveBookingSeatsDialog.tsx new file mode 100644 index 00000000000000..93c07376ea1890 --- /dev/null +++ b/apps/web/components/dialog/RemoveBookingSeatsDialog.tsx @@ -0,0 +1,164 @@ +import { useSession } from "next-auth/react"; +import type { Dispatch, SetStateAction } from "react"; +import { useState } from "react"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog"; +import { Label, Select, TextArea } from "@calcom/ui/components/form"; +import type { Option } from "@calcom/ui/components/form/checkbox/MultiSelectCheckboxes"; +import { Icon } from "@calcom/ui/components/icon"; +import { showToast } from "@calcom/ui/components/toast"; + +type SeatOption = Option & { + data: { + referenceUid: string | null; + email: string; + name: string | null; + }; +}; + +interface IRemoveBookingSeatsDialog { + isOpenDialog: boolean; + setIsOpenDialog: Dispatch>; + bookingUid: string; + attendees: { + email: string; + name?: string | null; + referenceUid?: string; + }[]; +} + +export const RemoveBookingSeatsDialog = (props: IRemoveBookingSeatsDialog) => { + const { t } = useLocale(); + const { isOpenDialog, setIsOpenDialog, bookingUid, attendees } = props; + const { data: session } = useSession(); + const utils = trpc.useUtils(); + const [selectedOptions, setSelectedOptions] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + const [cancellationReason, setCancellationReason] = useState(""); + + const handleRemove = async () => { + setIsSubmitting(true); + + const seatReferenceUids = selectedOptions.map((option) => option.data.referenceUid).filter(Boolean); + + try { + const res = await fetch("/api/cancel", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + uid: bookingUid, + seatReferenceUid: seatReferenceUids, + cancellationReason: cancellationReason, + cancelledBy: session?.user?.email || undefined, + }), + }); + + if (res.ok) { + showToast(t("seats_removed"), "success"); + setIsOpenDialog(false); + setSelectedOptions([]); + utils.viewer.bookings.invalidate(); + } else { + showToast(t("error_removing_seats"), "error"); + } + } catch (error) { + console.error("Error removing seats", error); + showToast(t("error_removing_seats"), "error"); + } finally { + setIsSubmitting(false); + } + }; + + const selectOptions = attendees.map((attendee) => ({ + label: attendee.name ? `${attendee.name}` : attendee.email, + value: attendee.email, + data: { + referenceUid: attendee.referenceUid || null, + email: attendee.email, + name: attendee.name || null, + }, + })) as SeatOption[]; + + if (selectOptions.length === 0) { + return null; + } + + return ( + + +
+
+
+ +
+
+ +
+ {selectOptions.length > 0 ? ( +
+