Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
613511d
feat: remove seats from booking
kart1ka May 11, 2025
c713c3a
fix
kart1ka May 11, 2025
36bc023
test: add tests for removing multiple seats in one go
kart1ka May 13, 2025
e34d062
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka May 13, 2025
29ebbe2
chore
kart1ka May 13, 2025
146389a
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka May 13, 2025
a1b33e5
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka May 16, 2025
0d1e1b2
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka May 21, 2025
755b93d
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka May 25, 2025
550a133
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Jun 22, 2025
ad79071
fix
kart1ka Jun 22, 2025
1d6fc94
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Jul 5, 2025
5894d9e
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Jul 19, 2025
08aab25
address review comments
kart1ka Jul 21, 2025
689cc2c
chore
kart1ka Jul 21, 2025
d3f4942
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Jul 21, 2025
5bee492
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Jul 21, 2025
00ed114
fix test
kart1ka Jul 21, 2025
ad6b5ec
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Jul 31, 2025
049e771
fix
kart1ka Jul 31, 2025
28d12ca
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Sep 17, 2025
dfe0614
fix: type error
kart1ka Sep 17, 2025
dfc5eb0
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Sep 20, 2025
41f8e81
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Oct 6, 2025
bf6e346
Merge branch 'main' into feat/remove-seats-from-booking
kart1ka Oct 7, 2025
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
64 changes: 58 additions & 6 deletions apps/web/components/booking/BookingListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
};

Expand Down Expand Up @@ -194,6 +204,30 @@ function BookingListItem(booking: BookingItemProps) {
const location = booking.location as ReturnType<typeof getEventLocationValue>;
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 =
Expand Down Expand Up @@ -255,6 +289,7 @@ function BookingListItem(booking: BookingItemProps) {
cardCharged,
attendeeList,
getSeatReferenceUid,
checkIfUserIsAuthorizedToCancelSeats,
t,
} as BookingActionContext;

Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -430,6 +468,20 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
{checkIfUserIsAuthorizedToCancelSeats() && (
<RemoveBookingSeatsDialog
isOpenDialog={isOpenRemoveSeatsDialog}
setIsOpenDialog={setIsOpenRemoveSeatsDialog}
bookingUid={booking.uid}
attendees={booking.seatsReferences
.map((seat) => ({
email: seat.attendee?.email || "",
name: seat.attendee?.name || null,
referenceUid: seat.referenceUid,
}))
.filter((attendee) => attendee.email)}
/>
)}
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/components/booking/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface BookingActionContext {
showPendingPayment: boolean;
isAttendee: boolean;
cardCharged: boolean;
checkIfUserIsAuthorizedToCancelSeats: () => boolean;
attendeeList: Array<{
name: string;
email: string;
Expand Down Expand Up @@ -104,6 +105,7 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
isBookingFromRoutingForm,
getSeatReferenceUid,
isAttendee,
checkIfUserIsAuthorizedToCancelSeats,
t,
} = context;

Expand Down Expand Up @@ -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)
Expand Down
164 changes: 164 additions & 0 deletions apps/web/components/dialog/RemoveBookingSeatsDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<boolean>>;
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<SeatOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [cancellationReason, setCancellationReason] = useState<string>("");

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 (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent className="flex flex-col">
<div className="flex-grow">
<div className="flex flex-row space-x-3">
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full">
<Icon name="user-x" className="m-auto h-6 w-6" />
</div>
<div className="w-full pt-1">
<DialogHeader title={t("remove_seats")} />
<div className="bg-default pb-4">
{selectOptions.length > 0 ? (
<div style={{ position: "relative", zIndex: 1 }}>
<Select
isMulti
grow
options={selectOptions}
value={selectedOptions}
onChange={(newValue) => setSelectedOptions([...newValue])}
placeholder={t("select_seats")}
closeMenuOnSelect={false}
/>
</div>
) : (
<div className="text-default py-2">{t("no_seats_available")}</div>
)}

<div className="mt-4">
<Label>{t("cancellation_reason_host")}</Label>
<TextArea
data-testid="cancellation_reason_host"
name="cancellationReason"
value={cancellationReason}
onChange={(e) => setCancellationReason(e.target.value)}
className="mb-2 mt-2 w-full"
rows={3}
placeholder={t("cancellation_reason_placeholder")}
/>
<div className="flex items-center gap-2">
<Icon name="info" className="text-subtle h-4 w-4" />
<p className="text-subtle text-sm leading-none">
{t("notify_attendee_cancellation_reason_warning")}
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter showDivider className="mt-8">
<Button
onClick={() => {
setSelectedOptions([]);
setCancellationReason("");
setIsOpenDialog(false);
}}
type="button"
color="secondary"
disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button
data-testid="remove_seats"
loading={isSubmitting}
disabled={selectedOptions.length === 0 || !cancellationReason}
onClick={handleRemove}>
{t("remove")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
};
2 changes: 2 additions & 0 deletions apps/web/modules/bookings/views/bookings-listing-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,8 @@ function BookingsContent({ status, permissions }: BookingsProps) {
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
teamsWhereUserIsAdminOrOwner: user?.teamsWhereUserIsAdminOrOwner,
userIsOrgAdminOrOwner: user?.organization?.isOrgAdmin,
}}
listingStatus={status}
recurringInfo={recurringInfo}
Expand Down
5 changes: 5 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,10 @@
"all_booked_today": "All booked.",
"slots_load_fail": "Could not load the available time slots.",
"additional_guests": "Add guests",
"remove_seats": "Remove seats",
"seats_removed": "Seats removed successfully",
"error_removing_seats": "Error removing seats",
"select_seats": "Select seats",
"your_name": "Your name",
"your_full_name": "Your full name",
"no_name": "No name",
Expand Down Expand Up @@ -2185,6 +2189,7 @@
"attendee_no_longer_attending_subject": "An attendee is no longer attending {{title}} at {{date}}",
"attendee_no_longer_attending": "An attendee is no longer attending your event",
"attendee_no_longer_attending_subtitle": "{{name}} has canceled. This means a seat has opened up for this time slot",
"attendee_removed_subtitle": "{{name}} was removed. This means a seat has opened up for this time slot",
"create_event_on": "Create event on",
"create_routing_form_on": "Create routing form on",
"default_app_link_title": "Set a default app link",
Expand Down
10 changes: 8 additions & 2 deletions packages/emails/email-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,21 +389,27 @@ export const sendScheduledSeatsEmailsAndSMS = async (
export const sendCancelledSeatEmailsAndSMS = async (
calEvent: CalendarEvent,
cancelledAttendee: Person,
eventTypeMetadata?: EventTypeMetadata
eventTypeMetadata?: EventTypeMetadata,
isCancelledByHost?: boolean
) => {
const formattedCalEvent = formatCalEvent(calEvent);
const clonedCalEvent = cloneDeep(formattedCalEvent);
const emailsToSend: Promise<unknown>[] = [];

if (!eventTypeDisableAttendeeEmail(eventTypeMetadata))
emailsToSend.push(sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)));
emailsToSend.push(
sendEmail(
() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee, undefined, isCancelledByHost)
)
);
if (!eventTypeDisableHostEmail(eventTypeMetadata))
emailsToSend.push(
sendEmail(
() =>
new OrganizerAttendeeCancelledSeatEmail({
calEvent: formattedCalEvent,
attendee: cancelledAttendee,
isCancelledByHost,
})
)
);
Expand Down
23 changes: 13 additions & 10 deletions packages/emails/src/templates/AttendeeCancelledSeatEmail.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { AttendeeScheduledEmail } from "./AttendeeScheduledEmail";

export const AttendeeCancelledSeatEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => (
<AttendeeScheduledEmail
title="no_longer_attending"
headerType="xCircle"
subject="event_no_longer_attending_subject"
subtitle=""
callToAction={null}
{...props}
/>
);
export const AttendeeCancelledSeatEmail = (props: React.ComponentProps<typeof AttendeeScheduledEmail>) => {
const title = props.isCancelledByHost ? "event_request_cancelled" : "no_longer_attending";
return (
<AttendeeScheduledEmail
title={title}
headerType="xCircle"
subject="event_no_longer_attending_subject"
subtitle=""
callToAction={null}
{...props}
/>
);
};
Loading
Loading