Skip to content
Draft
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions apps/web/components/booking/RemoveBookingSeatsDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { useState } from "react";
import { useForm } from "react-hook-form";

import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { Form, TextAreaField, MultiSelectCheckbox } from "@calcom/ui/components/form";
import type { MultiSelectCheckboxesOptionType as Option } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";

import type { BookingItemProps } from "./types";

interface RemoveBookingSeatsDialogProps {
booking: BookingItemProps;
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}

interface FormValues {
cancellationReason: string;
}

export function RemoveBookingSeatsDialog({
booking,
isOpen,
onClose,
onSuccess,
}: RemoveBookingSeatsDialogProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
const [selected, setSelected] = useState<Option[]>([]);
const [loading, setLoading] = useState(false);

const form = useForm<FormValues>({
defaultValues: {
cancellationReason: "",
},
});

const userEmail = booking.loggedInUser?.userEmail;
const isUserOrganizer = userEmail === booking.user?.email;

const userSeat = userEmail
? booking.seatsReferences?.find((seat) => seat.attendee?.email === userEmail)
: null;
const isJustAttendee = !!userSeat && !isUserOrganizer;

const allSeatOptions = (booking.seatsReferences || [])
.map((seatRef) => {
if (!seatRef?.referenceUid) return null;

const attendee = seatRef.attendee;
const attendeeName = attendee?.name;
const attendeeEmail = attendee?.email;

if (isJustAttendee) {
if (seatRef.referenceUid !== userSeat?.referenceUid) {
return null;
}
if (attendeeName && attendeeEmail) {
return {
value: seatRef.referenceUid,
label: `${attendeeName} (${attendeeEmail})`,
};
} else if (attendeeEmail) {
return {
value: seatRef.referenceUid,
label: attendeeEmail,
};
} else if (attendeeName) {
return {
value: seatRef.referenceUid,
label: attendeeName,
};
}
return null;
}

let label: string;
if (attendeeName && attendeeEmail) {
label = `${attendeeName} (${attendeeEmail})`;
} else if (attendeeEmail) {
label = attendeeEmail;
} else if (attendeeName) {
label = attendeeName;
} else {
label = `Seat ${seatRef.referenceUid.slice(0, 8)}...`;
}

return {
value: seatRef.referenceUid,
label: label,
};
})
.filter(Boolean) as Option[];

const seatOptions = allSeatOptions;

const onSubmit = async (data: FormValues) => {
if (selected.length === 0) {
showToast(t("please_select_at_least_one_seat"), "error");
return;
}

setLoading(true);

try {
const seatReferenceUids = selected.map((option) => option.value);

const response = await fetch("/api/csrf?sameSite=none", { cache: "no-store" });
const { csrfToken } = await response.json();

const res = await fetch("/api/cancel", {
body: JSON.stringify({
uid: booking.uid,
seatReferenceUids: seatReferenceUids,
cancellationReason: data.cancellationReason,
csrfToken,
}),
headers: {
"Content-Type": "application/json",
},
method: "POST",
});

if (res.status >= 200 && res.status < 300) {
showToast(t("seats_removed_successfully"), "success");
await utils.viewer.bookings.invalidate();
onSuccess();
onClose();
form.reset();
setSelected([]);
} else {
let errorMessage = t("error_removing_seats");
try {
const responseText = await res.text();
try {
const error = JSON.parse(responseText);
errorMessage = error.message || errorMessage;
} catch {
console.error("Failed to parse error response as JSON. Raw response:", responseText);
errorMessage = responseText.trim() || errorMessage;
}
} catch {
console.error("Failed to read error response. Status:", res.status);
}
showToast(errorMessage, "error");
}
} catch {
showToast(t("error_removing_seats"), "error");
} finally {
setLoading(false);
}
};

return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent
title={t("remove_seats")}
description={t("remove_seats_description")}
type="creation"
className="max-w-lg">
<Form form={form} handleSubmit={onSubmit}>
<div className="space-y-4">
<div>
<label className="text-default mb-2 block text-sm font-medium">
{t("select_seats_to_remove")}
</label>
{seatOptions.length === 0 ? (
<div className="text-muted text-sm">{t("no_seats_available_to_remove")}</div>
) : (
<MultiSelectCheckbox
options={seatOptions}
selected={selected}
setSelected={setSelected}
setValue={(options) => {
setSelected(options);
}}
countText="count_selected"
className="w-full text-sm"
/>
)}
</div>

<TextAreaField
label={t("removal_reason_optional")}
placeholder={t("removal_reason_placeholder")}
{...form.register("cancellationReason")}
/>
</div>

<DialogFooter>
<DialogClose />
<Button type="submit" loading={loading} disabled={loading || selected.length === 0}>
{t("remove_selected_seats")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
}
44 changes: 42 additions & 2 deletions apps/web/components/booking/actions/BookingActionsDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,15 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";

import { RemoveBookingSeatsDialog } from "../RemoveBookingSeatsDialog";
import type { BookingItemProps } from "../types";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
getCancelEventAction,
getEditEventActions,
getAfterEventActions,
getReportAction,
getRemoveSeatsAction,
shouldShowEditActions,
type BookingActionContext,
} from "./bookingActions";
Expand Down Expand Up @@ -85,6 +87,10 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
const isRemoveSeatsDialogOpen = useBookingActionsStoreContext((state) => state.isRemoveSeatsDialogOpen);
const setIsRemoveSeatsDialogOpen = useBookingActionsStoreContext(
(state) => state.setIsRemoveSeatsDialogOpen
);

const cardCharged = booking?.payment[0]?.success;

Expand Down Expand Up @@ -237,6 +243,7 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
} as BookingActionContext;

const cancelEventAction = getCancelEventAction(actionContext);
const removeSeatsAction = getRemoveSeatsAction(actionContext);

const shouldShowEdit = shouldShowEditActions(actionContext);
const baseEditEventActions = getEditEventActions(actionContext);
Expand Down Expand Up @@ -409,6 +416,15 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
isRecurring={isRecurring}
status={getBookingStatus()}
/>
<RemoveBookingSeatsDialog
booking={booking}
isOpen={isRemoveSeatsDialogOpen}
onClose={() => setIsRemoveSeatsDialogOpen(false)}
onSuccess={() => {
utils.viewer.bookings.invalidate();
showToast(t("seats_removed_successfully"), "success");
}}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
Expand Down Expand Up @@ -511,11 +527,18 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
return hasAvailableEditAction || hasAvailableAfterAction;
}

// For booking-details-sheet context, also check report and cancel actions
// For booking-details-sheet context, also check report, cancel, and remove seats actions
const isReportAvailable = !reportActionWithHandler.disabled;
const isCancelAvailable = !cancelEventAction.disabled;
const isRemoveSeatsAvailable = removeSeatsAction !== null && !removeSeatsAction.disabled;

return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable;
return (
hasAvailableEditAction ||
hasAvailableAfterAction ||
isReportAvailable ||
isCancelAvailable ||
isRemoveSeatsAvailable
);
};

// Don't render dropdown if no actions are available
Expand Down Expand Up @@ -579,6 +602,23 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
</DropdownMenuItem>
</>
<DropdownMenuSeparator />
{removeSeatsAction && (
<DropdownMenuItem
className="rounded-lg"
key={removeSeatsAction.id}
disabled={removeSeatsAction.disabled}>
<DropdownItem
type="button"
color={removeSeatsAction.color}
StartIcon={removeSeatsAction.icon}
onClick={() => setIsRemoveSeatsDialogOpen(true)}
disabled={removeSeatsAction.disabled}
data-testid={removeSeatsAction.id}
className={removeSeatsAction.disabled ? "text-muted" : undefined}>
{removeSeatsAction.label}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/components/booking/actions/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ export function getCancelEventAction(context: BookingActionContext): ActionType
};
}

export function getRemoveSeatsAction(context: BookingActionContext): ActionType | null {
const { booking, isUpcoming, isCancelled, t } = context;

const isNotSeatedEvent = !booking.eventType?.seatsPerTimeSlot;
const hasNoSeatsToRemove = booking.seatsReferences.length === 0;
const shouldHideRemoveSeatsAction = isNotSeatedEvent || hasNoSeatsToRemove || !isUpcoming || isCancelled;

if (shouldHideRemoveSeatsAction) {
return null;
}

return {
id: "remove_seats",
label: t("remove_seats"),
icon: "user-x",
disabled: isActionDisabled("remove_seats", context),
};
}

export function getVideoOptionsActions(context: BookingActionContext): ActionType[] {
const { booking, isBookingInPast, isConfirmed, isCalVideoLocation, t } = context;

Expand Down Expand Up @@ -236,6 +255,8 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
case "cancel":
return isDisabledCancelling || isBookingInPast;
case "remove_seats":
return isBookingInPast || booking.seatsReferences.length === 0;
case "view_recordings":
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
case "meeting_session_details":
Expand Down
8 changes: 8 additions & 0 deletions apps/web/components/booking/actions/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type BookingActionsStore = {
isOpenAddGuestsDialog: boolean;
isOpenReportDialog: boolean;
rerouteDialogIsOpen: boolean;
isRemoveSeatsDialogOpen: boolean;

// Dialog setters
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
Expand All @@ -29,6 +30,7 @@ export type BookingActionsStore = {
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsRemoveSeatsDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;

// Rejection reason state
rejectionReason: string;
Expand All @@ -49,6 +51,7 @@ export const createBookingActionsStore = () => {
isOpenAddGuestsDialog: false,
isOpenReportDialog: false,
rerouteDialogIsOpen: false,
isRemoveSeatsDialogOpen: false,

// Dialog setters
setRejectionDialogIsOpen: (isOpen) =>
Expand Down Expand Up @@ -98,6 +101,11 @@ export const createBookingActionsStore = () => {
set((state) => ({
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
})),
setIsRemoveSeatsDialogOpen: (isOpen) =>
set((state) => ({
isRemoveSeatsDialogOpen:
typeof isOpen === "function" ? isOpen(state.isRemoveSeatsDialogOpen) : isOpen,
})),

// Rejection reason state
rejectionReason: "",
Expand Down
Loading
Loading