feat(bookings): improve bookings redesign (#25251)
* use booking.uid instead of booking.id for url param * show timezone on calendar * fix type * restore horizontal tab and remove header and subtitle * clean up sidebar items * fix event propagation from attendees * fetch all statuses except for cancelled on calendar view * clean up styles of the badges on BookingListItem * fix useMediaQuery * add close button to the header * add assignment reason to the details sheet * use separator row * use ToggleGroup for the top bookings tab * move ViewToggleButton * resize the action button * remove wrong prop * fix type error * fix type error * hide view toggle button on mobile (and fix the breakpoint) * remove unused e2e tests * fix e2e tests * hide toggle button when feature flag is off * update skeleton * improve attendees on booking list item and slide over * improve attendee dropdown * fix type error * move query to containers * select attendee email * infinite fetching for calendar view * update styles * fix compatibility * fix: add backward compatibility for status field in getAllUserBookings * increase calendar height * fix type error * support Member filter only for admin / owners * add debug log (TEMP) * add event border color * show Reject / Accept buttons on BookingDetailsSheet * move description section to the top * update When section * update style of Who section * add CancelBookingDialog WIP * fix CancelBookingDialog * increase clickable area * add schedule info section WIP * fix flaky reject button * fixing reschedule info WIP * add fromReschedule index to Booking * improve rescheduled information * improve reassignment * fix type error * fix unit test * respect user's weekStart value on the booking calendar view * update debug log * improve payment section * clean up * fix log message * reposition filters on list view * fix bookings controller api2 e2e test * clean up file by extracting logic into custom hooks * rename files * merge BookingCalendar into its container * extract logic into separate hook files * remove redundant logic * rearrange items on calendar view * add WeekPicker * extract filter button * responsive header on list view * horizontal scroll for ToggleGroup WIP * fix type error * fix cancelling recurring event * address feedback * fix e2e tests * fix unit test * fix e2e tests * make hover style more visible for ToggleGroup * fix margin on CancelBookingDialog * update styles on the slide over (mostly font weight) * update style of CancelBookingDialog * update styles * update margin top for the header * refactor getBookingDetails handler * fix gap in who section * auto-filter the current user on the calendar view * calculate calendar height considering top banners * improve booking details sheet interaction without overlay * update calendar event styles * update reject dialog style * put uid first in the query params * fix class name * memoize functions in useMediaQuery * query attendee with id instead of email * update margins * replace TRPCError with ErrorWithCode * move calculation outside loop * remove dead code
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
@@ -16,8 +16,6 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import BookingsList from "~/bookings/views/bookings-view";
|
||||
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
const querySchema = z.object({
|
||||
status: z.enum(validStatuses),
|
||||
});
|
||||
@@ -36,7 +34,6 @@ const Page = async ({ params }: PageProps) => {
|
||||
if (!parsed.success) {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
let canReadOthersBookings = false;
|
||||
@@ -64,10 +61,7 @@ const Page = async ({ params }: PageProps) => {
|
||||
: false;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("bookings")}
|
||||
subtitle={t("bookings_description")}
|
||||
CTA={bookingsV3Enabled ? <ViewToggleButton /> : null}>
|
||||
<ShellMainAppDir>
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
|
||||
@@ -6,12 +6,13 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { ConfirmDialog } from "./confirmDialog";
|
||||
|
||||
interface IWipeMyCalActionButtonProps {
|
||||
className?: string;
|
||||
bookingsEmpty: boolean;
|
||||
bookingStatus: "upcoming" | "recurring" | "past" | "cancelled" | "unconfirmed";
|
||||
}
|
||||
|
||||
const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
||||
const { bookingsEmpty, bookingStatus } = props;
|
||||
const { className, bookingsEmpty, bookingStatus } = props;
|
||||
const [openDialog, setOpenDialog] = useState(false);
|
||||
const { isSuccess, isPending, data } = trpc.viewer.apps.integrations.useQuery({
|
||||
variant: "other",
|
||||
@@ -28,7 +29,7 @@ const WipeMyCalActionButton = (props: IWipeMyCalActionButtonProps) => {
|
||||
return (
|
||||
<>
|
||||
{data && isSuccess && !isPending && credentialId && (
|
||||
<div className="mb-4">
|
||||
<div className={className}>
|
||||
<ConfirmDialog isOpenDialog={openDialog} setIsOpenDialog={setOpenDialog} />
|
||||
<Button color="primary" onClick={() => setOpenDialog(true)} data-testid="wipe-today-button">
|
||||
Wipe Today
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
|
||||
|
||||
interface AcceptBookingButtonProps {
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
recurringEventId?: string | null;
|
||||
isRecurring?: boolean;
|
||||
isTabRecurring?: boolean;
|
||||
isTabUnconfirmed?: boolean;
|
||||
size?: "sm" | "base" | "lg";
|
||||
color?: "primary" | "secondary" | "minimal" | "destructive";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AcceptBookingButton({
|
||||
bookingId,
|
||||
bookingUid,
|
||||
recurringEventId,
|
||||
isRecurring = false,
|
||||
isTabRecurring = false,
|
||||
isTabUnconfirmed = false,
|
||||
size = "base",
|
||||
color = "primary",
|
||||
className,
|
||||
}: AcceptBookingButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { bookingConfirm, isPending } = useBookingConfirmation({
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
});
|
||||
|
||||
const confirmLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm");
|
||||
|
||||
return (
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={() => bookingConfirm({ bookingId, confirmed: true, recurringEventId })}
|
||||
disabled={isPending}
|
||||
data-booking-uid={bookingUid}
|
||||
data-testid="confirm">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/a
|
||||
import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -23,7 +22,6 @@ import type { Ensure } from "@calcom/types/utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -35,30 +33,29 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
|
||||
import { TableActions } from "@calcom/ui/components/table";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
|
||||
import type { BookingAttendee } from "../../modules/bookings/types";
|
||||
import { AcceptBookingButton } from "./AcceptBookingButton";
|
||||
import { RejectBookingButton } from "./RejectBookingButton";
|
||||
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
|
||||
import {
|
||||
useBookingActionsStoreContext,
|
||||
BookingActionsStoreProvider,
|
||||
} from "./actions/BookingActionsStoreProvider";
|
||||
import {
|
||||
getPendingActions,
|
||||
getCancelEventAction,
|
||||
shouldShowPendingActions,
|
||||
shouldShowRecurringCancelAction,
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
getReportAction,
|
||||
isActionDisabled,
|
||||
} from "./actions/bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
@@ -146,36 +143,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
t,
|
||||
i18n: { language },
|
||||
} = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
};
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
const attendeeList = booking.attendees.map((attendee) => ({
|
||||
...attendee,
|
||||
noShow: attendee.noShow || false,
|
||||
}));
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
|
||||
@@ -217,22 +189,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
@@ -265,20 +221,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
t,
|
||||
} as BookingActionContext;
|
||||
|
||||
const basePendingActions = getPendingActions(actionContext);
|
||||
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reject"
|
||||
? () => setRejectionDialogIsOpen(true)
|
||||
: action.id === "confirm"
|
||||
? () => bookingConfirm(true)
|
||||
: undefined,
|
||||
disabled: action.disabled || mutation.isPending,
|
||||
})) as ActionType[];
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
const RequestSentMessage = () => {
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
@@ -313,6 +255,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
@@ -321,232 +264,236 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
className="hover:bg-cal-muted group w-full">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
|
||||
<div className="flex h-full items-center">
|
||||
{eventTypeColor && (
|
||||
<div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />
|
||||
)}
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
|
||||
<div className="cursor-pointer py-4">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label ||
|
||||
(typeof locationToDisplay === "string" &&
|
||||
locationToDisplay?.startsWith("https://"))) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider?.label })
|
||||
: t("join_meeting")}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="title-and-attendees" className={`w-full px-4${isRejected ? "line-through" : ""}`}>
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{showPendingPayment && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="booking-item"
|
||||
data-today={String(booking.isToday)}
|
||||
data-booking-list-item="true"
|
||||
className="hover:bg-cal-muted group w-full">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="sm:min-w-48 hidden align-top ltr:pl-3 rtl:pr-6 sm:table-cell">
|
||||
<div className="flex h-full items-center">
|
||||
{eventTypeColor && <div className="h-[70%] w-0.5" style={{ backgroundColor: eventTypeColor }} />}
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="ml-3">
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
<span> </span>
|
||||
|
||||
{showPendingPayment && (
|
||||
<Badge className="hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
{!isPending && (
|
||||
<div>
|
||||
{(provider?.label ||
|
||||
(typeof locationToDisplay === "string" && locationToDisplay?.startsWith("https://"))) &&
|
||||
locationToDisplay.startsWith("http") && (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
rel="noreferrer"
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider?.label })
|
||||
: t("join_meeting")}
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
<div className="stack-y-2 sm:stack-y-0 flex w-full flex-col flex-wrap items-end justify-end space-x-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
<BookingActionsDropdown booking={booking} context="list" />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className="min-h-[34px] min-w-[34px]"
|
||||
tooltip={reportActionWithHandler.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BookingItemBadges
|
||||
booking={booking}
|
||||
isPending={isPending}
|
||||
recurringDates={recurringDates}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
<div
|
||||
data-testid="title-and-attendees"
|
||||
className={classNames("flex-1 px-4", isRejected && "line-through")}>
|
||||
<ConditionalLink onClick={onClick} bookingLink={bookingLink} className="flex h-full flex-col">
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
<div className="flex w-full items-center justify-between sm:hidden">
|
||||
<div className="text-emphasis text-sm leading-6">{startTime}</div>
|
||||
<div className="text-subtle pr-2 text-sm">
|
||||
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "}
|
||||
{formatTime(booking.endTime, userTimeFormat, userTimeZone)}
|
||||
<MeetingTimeInTimezones
|
||||
timeFormat={userTimeFormat}
|
||||
userTimezone={userTimeZone}
|
||||
startTime={booking.startTime}
|
||||
endTime={booking.endTime}
|
||||
attendees={booking.attendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPending && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("unconfirmed")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
</Badge>
|
||||
)}
|
||||
{showPendingPayment && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2 sm:hidden" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
{isRescheduled && (
|
||||
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
||||
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2 sm:hidden">
|
||||
{t("rescheduled")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{recurringDates !== undefined && (
|
||||
<div className="text-muted text-sm sm:hidden">
|
||||
<RecurringBookingsTooltip
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
booking={booking}
|
||||
recurringDates={recurringDates}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="cursor-pointer py-4">
|
||||
<div
|
||||
title={title}
|
||||
className={classNames(
|
||||
"max-w-10/12 sm:max-w-56 text-emphasis break-words text-sm font-medium leading-6 md:max-w-full",
|
||||
isCancelled ? "line-through" : ""
|
||||
)}>
|
||||
{title}
|
||||
<span> </span>
|
||||
|
||||
{showPendingPayment && (
|
||||
<Badge className="hidden sm:inline-flex" variant="orange">
|
||||
{t("pending_payment")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{booking.description && (
|
||||
<div
|
||||
className="max-w-10/12 sm:max-w-32 md:max-w-52 xl:max-w-80 text-default truncate text-sm"
|
||||
title={booking.description}>
|
||||
"{booking.description}"
|
||||
</div>
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="mt-2 inline-block md:hidden">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ConditionalLink>
|
||||
</div>
|
||||
<div className="flex flex-col flex-wrap items-end justify-end gap-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-shrink-0 sm:flex-row sm:flex-nowrap sm:items-start sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && (
|
||||
<div className="flex space-x-2 rtl:space-x-reverse">
|
||||
<RejectBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={isRecurring}
|
||||
isTabRecurring={isTabRecurring}
|
||||
isTabUnconfirmed={isTabUnconfirmed}
|
||||
/>
|
||||
<AcceptBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={isRecurring}
|
||||
isTabRecurring={isTabRecurring}
|
||||
isTabUnconfirmed={isTabUnconfirmed}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowRecurringCancelAction(actionContext) && (
|
||||
<Button
|
||||
className="whitespace-nowrap"
|
||||
key="cancel"
|
||||
data-testid="cancel"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsCancelDialogOpen(true);
|
||||
}}
|
||||
StartIcon="circle-x"
|
||||
disabled={isActionDisabled("cancel", actionContext)}
|
||||
data-booking-uid={booking.uid}
|
||||
color="destructive">
|
||||
{t("cancel_all_remaining")}
|
||||
</Button>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
</div>
|
||||
)}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color="destructive"
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className="min-h-[34px] min-w-[34px]"
|
||||
tooltip={reportActionWithHandler.label}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BookingActionsDropdown booking={booking} context="list" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<BookingItemBadges
|
||||
booking={booking}
|
||||
isPending={isPending}
|
||||
isRejected={isRejected}
|
||||
recurringDates={recurringDates}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userTimeZone={userTimeZone}
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BookingItemBadges = ({
|
||||
booking,
|
||||
isPending,
|
||||
isRejected,
|
||||
recurringDates,
|
||||
userTimeFormat,
|
||||
userTimeZone,
|
||||
@@ -554,6 +501,7 @@ const BookingItemBadges = ({
|
||||
}: {
|
||||
booking: BookingItemProps;
|
||||
isPending: boolean;
|
||||
isRejected: boolean;
|
||||
recurringDates: Date[] | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
@@ -575,6 +523,11 @@ const BookingItemBadges = ({
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isRejected && !isRescheduled && booking.assignmentReason.length === 0 && (
|
||||
<Badge variant="gray" className="ltr:mr-2 rtl:ml-2">
|
||||
{t("rejected")}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType?.team && (
|
||||
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
|
||||
{booking.eventType.team.name}
|
||||
@@ -727,21 +680,13 @@ const FirstAttendee = ({
|
||||
);
|
||||
};
|
||||
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
type NoShowProps = {
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
};
|
||||
|
||||
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber } = attendeeProps;
|
||||
const Attendee = (attendeeProps: BookingAttendee & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow, phoneNumber, user } = attendeeProps;
|
||||
const { t } = useLocale();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -758,6 +703,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
},
|
||||
});
|
||||
|
||||
const displayName = user?.name || name || user?.email || email;
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -767,10 +714,10 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
{displayName} <Icon name="eye-off" className="inline h-4" />
|
||||
</>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
<>{displayName}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -825,20 +772,12 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
};
|
||||
|
||||
type GroupedAttendeeProps = {
|
||||
attendees: AttendeeProps[];
|
||||
attendees: BookingAttendee[];
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
const { bookingUid } = groupedAttendeeProps;
|
||||
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
|
||||
return {
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
};
|
||||
});
|
||||
const { bookingUid, attendees } = groupedAttendeeProps;
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
@@ -850,11 +789,14 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
const { control, handleSubmit } = useForm<{
|
||||
attendees: AttendeeProps[];
|
||||
}>({
|
||||
|
||||
type FormValues = {
|
||||
attendees: Array<{ id: number; email: string; noShow: boolean }>;
|
||||
};
|
||||
|
||||
const { control, handleSubmit } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
attendees,
|
||||
attendees: attendees.map((a) => ({ id: a.id, email: a.email, noShow: a.noShow || false })),
|
||||
},
|
||||
mode: "onBlur",
|
||||
});
|
||||
@@ -864,8 +806,11 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
name: "attendees",
|
||||
});
|
||||
|
||||
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
|
||||
const filteredData = data.attendees.slice(1);
|
||||
const onSubmit = (data: FormValues) => {
|
||||
const filteredData = data.attendees.slice(1).map((attendee) => ({
|
||||
email: attendee.email,
|
||||
noShow: attendee.noShow,
|
||||
}));
|
||||
noShowMutation.mutate({ bookingUid, attendees: filteredData });
|
||||
setOpenDropdown(false);
|
||||
};
|
||||
@@ -882,30 +827,46 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
{t("plus_more", { count: attendees.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent className="min-w-[300px]">
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">
|
||||
{t("mark_as_no_show_title")}
|
||||
</DropdownMenuLabel>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{fields.slice(1).map((field, index) => (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="pr-8 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<span className={value ? "line-through" : ""}>{field.email}</span>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
{fields.slice(1).map((field, index) => {
|
||||
const attendee = attendees[index + 1];
|
||||
const displayName =
|
||||
attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
|
||||
const hasName = attendee.name || attendee.user?.name;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
key={field.id}
|
||||
name={`attendees.${index + 1}.noShow`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value || false}
|
||||
onCheckedChange={onChange}
|
||||
className="focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(!value);
|
||||
}}>
|
||||
<div className={`w-full ${value ? "line-through" : ""}`}>
|
||||
{hasName ? (
|
||||
<>
|
||||
<div>{displayName}</div>
|
||||
<div className="text-subtle text-xs">{field.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<div>{field.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
@@ -924,7 +885,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const GroupedGuests = ({ guests }: { guests: BookingAttendee[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
@@ -944,23 +905,37 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
{t("plus_more", { count: guests.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuContent className="min-w-[300px]">
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
|
||||
{guests.slice(1).map((guest) => (
|
||||
<DropdownMenuItem key={guest.id}>
|
||||
<DropdownItem
|
||||
className="pr-6 focus:outline-none"
|
||||
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedEmail(guest.email);
|
||||
}}>
|
||||
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{guests.slice(1).map((guest) => {
|
||||
const displayName = guest.user?.name || guest.name || guest.user?.email || guest.email;
|
||||
const hasName = guest.name || guest.user?.name;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem key={guest.id}>
|
||||
<DropdownItem
|
||||
className="pr-6 focus:outline-none"
|
||||
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedEmail(guest.email);
|
||||
}}>
|
||||
<div className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>
|
||||
{hasName ? (
|
||||
<>
|
||||
<div>{displayName}</div>
|
||||
<div className="text-subtle text-xs">{guest.email}</div>
|
||||
</>
|
||||
) : (
|
||||
<div>{guest.email}</div>
|
||||
)}
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end space-x-2 p-2">
|
||||
<div className="flex justify-end gap-2 p-2">
|
||||
<Link href={`mailto:${selectedEmail}`}>
|
||||
<Button
|
||||
color="secondary"
|
||||
@@ -995,7 +970,7 @@ const DisplayAttendees = ({
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
attendees: BookingAttendee[];
|
||||
user: UserProps | null;
|
||||
currentEmail?: string | null;
|
||||
bookingUid: string;
|
||||
@@ -1005,7 +980,7 @@ const DisplayAttendees = ({
|
||||
attendees.sort((a, b) => a.id - b.id);
|
||||
|
||||
return (
|
||||
<div className="text-emphasis text-sm">
|
||||
<div className="text-emphasis text-sm" onClick={(e) => e.stopPropagation()}>
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
|
||||
@@ -7,9 +7,11 @@ import { shouldChargeNoShowCancellationFee } from "@calcom/features/bookings/lib
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface InternalNotePresetsSelectProps {
|
||||
internalNotePresets: { id: number; name: string }[];
|
||||
@@ -40,7 +42,7 @@ const InternalNotePresetsSelect = ({
|
||||
setCancellationReason("");
|
||||
} else {
|
||||
setShowOtherInput(false);
|
||||
onPresetSelect && onPresetSelect(option);
|
||||
onPresetSelect?.(option);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +52,7 @@ const InternalNotePresetsSelect = ({
|
||||
<Select
|
||||
className="mb-2"
|
||||
options={[
|
||||
...internalNotePresets?.map((preset) => ({
|
||||
...internalNotePresets.map((preset) => ({
|
||||
label: preset.name,
|
||||
value: preset.id,
|
||||
})),
|
||||
@@ -105,7 +107,10 @@ type Props = {
|
||||
};
|
||||
isHost: boolean;
|
||||
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
|
||||
renderContext: "booking-single-view" | "dialog";
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
showErrorAsToast?: boolean;
|
||||
onCanceled?: () => void;
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
@@ -168,12 +173,13 @@ export default function CancelBooking(props: Props) {
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const isRenderedAsCancelDialog = props.renderContext === "dialog";
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
{error && !props.showErrorAsToast && (
|
||||
<div className="mt-8">
|
||||
<div className="bg-error mx-auto flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<Icon name="x" className="h-6 w-6 text-red-600" />
|
||||
@@ -186,7 +192,7 @@ export default function CancelBooking(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
{!error && (
|
||||
<div className="mt-5 sm:mt-6">
|
||||
<div className={classNames(isRenderedAsCancelDialog ? "-mt-2 mb-8" : "mt-5 sm:mt-6")}>
|
||||
{props.isHost && props.internalNotePresets.length > 0 && (
|
||||
<>
|
||||
<InternalNotePresetsSelect
|
||||
@@ -219,13 +225,13 @@ export default function CancelBooking(props: Props) {
|
||||
placeholder={t("cancellation_reason_placeholder")}
|
||||
value={cancellationReason}
|
||||
onChange={(e) => setCancellationReason(e.target.value)}
|
||||
className="mb-4 mt-2 w-full "
|
||||
className={classNames("mb-4 w-full", !isRenderedAsCancelDialog && "mt-2")}
|
||||
rows={3}
|
||||
/>
|
||||
{isCancellationUserHost ? (
|
||||
<div className="-mt-2 mb-4 flex items-center gap-2">
|
||||
<Icon name="info" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default text-subtle text-sm leading-none">
|
||||
<p className="text-subtle text-sm leading-none">
|
||||
{t("notify_attendee_cancellation_reason_warning")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -248,7 +254,11 @@ export default function CancelBooking(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col-reverse rtl:space-x-reverse ">
|
||||
<div className="ml-auto flex w-full space-x-4 ">
|
||||
<div
|
||||
className={classNames(
|
||||
"ml-auto flex w-full space-x-4",
|
||||
isRenderedAsCancelDialog && "justify-end"
|
||||
)}>
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
@@ -295,15 +305,23 @@ export default function CancelBooking(props: Props) {
|
||||
booking: bookingWithCancellationReason,
|
||||
});
|
||||
refreshData();
|
||||
if (props.onCanceled) {
|
||||
props.onCanceled();
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setLoading(false);
|
||||
setError(
|
||||
const errorMessage =
|
||||
data.message ||
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`
|
||||
);
|
||||
`${t("error_with_status_code_occured", { status: res.status })} ${t(
|
||||
"please_try_again"
|
||||
)}`;
|
||||
|
||||
if (props.showErrorAsToast) {
|
||||
showToast(errorMessage, "error");
|
||||
} else {
|
||||
setError(errorMessage);
|
||||
}
|
||||
}
|
||||
}}
|
||||
loading={loading}>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { RejectionReasonDialog } from "../dialog/RejectionReasonDialog";
|
||||
import { useBookingConfirmation } from "./hooks/useBookingConfirmation";
|
||||
|
||||
interface RejectBookingButtonProps {
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
recurringEventId?: string | null;
|
||||
isRecurring?: boolean;
|
||||
isTabRecurring?: boolean;
|
||||
isTabUnconfirmed?: boolean;
|
||||
size?: "sm" | "base" | "lg";
|
||||
color?: "primary" | "secondary" | "minimal" | "destructive";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RejectBookingButton({
|
||||
bookingId,
|
||||
bookingUid,
|
||||
recurringEventId,
|
||||
isRecurring = false,
|
||||
isTabRecurring = false,
|
||||
isTabUnconfirmed = false,
|
||||
size = "base",
|
||||
color = "secondary",
|
||||
className,
|
||||
}: RejectBookingButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { bookingConfirm, handleReject, isPending, rejectionDialogIsOpen, setRejectionDialogIsOpen } =
|
||||
useBookingConfirmation({
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
});
|
||||
|
||||
const rejectLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject");
|
||||
|
||||
return (
|
||||
<>
|
||||
<RejectionReasonDialog
|
||||
isOpenDialog={rejectionDialogIsOpen}
|
||||
setIsOpenDialog={setRejectionDialogIsOpen}
|
||||
onConfirm={(reason) => bookingConfirm({ bookingId, confirmed: false, recurringEventId, reason })}
|
||||
isPending={isPending}
|
||||
/>
|
||||
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
className={className}
|
||||
onClick={handleReject}
|
||||
disabled={isPending}
|
||||
data-booking-uid={bookingUid}
|
||||
data-testid="reject">
|
||||
{rejectLabel}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,18 +5,12 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
{/* TODAY header */}
|
||||
<li className="bg-muted flex items-center px-6 py-4">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
<SkeletonHeader />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
|
||||
{/* NEXT header */}
|
||||
<li className="bg-muted px-4 py-2">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
<SkeletonHeader />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
@@ -25,6 +19,14 @@ function SkeletonLoader() {
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonHeader() {
|
||||
return (
|
||||
<li className="bg-muted flex items-center px-6 py-4">
|
||||
<SkeletonText className="h-4 w-16" />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
|
||||
|
||||
@@ -125,6 +125,7 @@ const mockProps = {
|
||||
eventType: {},
|
||||
},
|
||||
internalNotePresets: [],
|
||||
renderContext: "booking-single-view" as const,
|
||||
};
|
||||
|
||||
const mockBookingWithoutCancellationFee = {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/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 { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
@@ -19,18 +19,20 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
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 {
|
||||
@@ -72,11 +74,24 @@ export function BookingActionsDropdown({
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Use store for all dialog states
|
||||
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
|
||||
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
|
||||
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
|
||||
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
|
||||
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(
|
||||
@@ -105,6 +120,8 @@ export function BookingActionsDropdown({
|
||||
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;
|
||||
|
||||
@@ -128,23 +145,6 @@ export function BookingActionsDropdown({
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
@@ -170,7 +170,6 @@ export function BookingActionsDropdown({
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
@@ -179,15 +178,15 @@ export function BookingActionsDropdown({
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
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" ||
|
||||
@@ -200,19 +199,6 @@ export function BookingActionsDropdown({
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
@@ -268,12 +254,17 @@ export function BookingActionsDropdown({
|
||||
shouldShowPending && context === "details" ? getPendingActions(actionContext) : [];
|
||||
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
|
||||
...action,
|
||||
disabled: mutation.isPending,
|
||||
disabled: isConfirmPending,
|
||||
onClick:
|
||||
action.id === "confirm"
|
||||
? () => bookingConfirm(true)
|
||||
? () =>
|
||||
bookingConfirm({
|
||||
bookingId: booking.id,
|
||||
confirmed: true,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
})
|
||||
: action.id === "reject"
|
||||
? () => setRejectionDialogIsOpen(true)
|
||||
? () => handleReject()
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
@@ -482,35 +473,39 @@ export function BookingActionsDropdown({
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<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 &&
|
||||
@@ -575,6 +570,19 @@ export function BookingActionsDropdown({
|
||||
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
|
||||
@@ -582,7 +590,7 @@ export function BookingActionsDropdown({
|
||||
color="secondary"
|
||||
size={size}
|
||||
StartIcon="ellipsis"
|
||||
className={className}
|
||||
className={classNames("px-2", className)}
|
||||
// Prevent click from bubbling to parent row/container click handlers
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
@@ -604,7 +612,7 @@ export function BookingActionsDropdown({
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
@@ -627,7 +635,7 @@ export function BookingActionsDropdown({
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
@@ -648,7 +656,7 @@ export function BookingActionsDropdown({
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
@@ -685,13 +693,12 @@ export function BookingActionsDropdown({
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEventAction.onClick?.(e);
|
||||
setIsCancelDialogOpen(true);
|
||||
}}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-booking-uid={cancelEventAction.bookingUid}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
|
||||
@@ -138,7 +138,7 @@ describe("Booking Actions", () => {
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
id: "confirm",
|
||||
bookingId: 1,
|
||||
bookingUid: "booking-123",
|
||||
label: "confirm",
|
||||
icon: "check",
|
||||
disabled: false,
|
||||
@@ -180,10 +180,10 @@ describe("Booking Actions", () => {
|
||||
expect(action).toEqual({
|
||||
id: "cancel",
|
||||
label: "cancel_event",
|
||||
href: "/booking/booking-123?cancel=true",
|
||||
icon: "circle-x",
|
||||
color: "destructive",
|
||||
disabled: false,
|
||||
bookingUid: "booking-123",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -212,7 +212,6 @@ describe("Booking Actions", () => {
|
||||
});
|
||||
const action = getCancelEventAction(context);
|
||||
|
||||
expect(action.href).toContain("allRemainingBookings=true");
|
||||
expect(action.label).toBe("cancel_all_remaining");
|
||||
});
|
||||
});
|
||||
@@ -394,7 +393,16 @@ describe("Booking Actions", () => {
|
||||
...createMockContext().booking,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
paid: true,
|
||||
payment: [{ paymentOption: "HOLD", amount: 1000, currency: "usd", success: true }],
|
||||
payment: [
|
||||
{
|
||||
paymentOption: "HOLD",
|
||||
amount: 1000,
|
||||
currency: "usd",
|
||||
success: true,
|
||||
appId: null,
|
||||
refunded: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const actions = getAfterEventActions(context);
|
||||
|
||||
@@ -45,7 +45,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
if ((isPending && !showPendingPayment) || (showPendingPayment && booking.paid)) {
|
||||
actions.push({
|
||||
id: "confirm",
|
||||
bookingId: booking.id,
|
||||
bookingUid: booking.uid,
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
icon: "check" as const,
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
@@ -63,18 +63,15 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
}
|
||||
|
||||
export function getCancelEventAction(context: BookingActionContext): ActionType {
|
||||
const { booking, isTabRecurring, isRecurring, getSeatReferenceUid, t } = context;
|
||||
const seatReferenceUid = getSeatReferenceUid();
|
||||
const { booking, isTabRecurring, isRecurring, t } = context;
|
||||
|
||||
return {
|
||||
id: "cancel",
|
||||
label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"),
|
||||
href: `/booking/${booking.uid}?cancel=true${
|
||||
isTabRecurring && isRecurring ? "&allRemainingBookings=true" : ""
|
||||
}${booking.seatsReferences.length && seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}`,
|
||||
icon: "circle-x",
|
||||
color: "destructive",
|
||||
disabled: isActionDisabled("cancel", context),
|
||||
bookingUid: booking.uid,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ export type BookingActionsStore = {
|
||||
isOpenAddGuestsDialog: boolean;
|
||||
isOpenReportDialog: boolean;
|
||||
rerouteDialogIsOpen: boolean;
|
||||
isCancelDialogOpen: boolean;
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
@@ -29,10 +30,7 @@ export type BookingActionsStore = {
|
||||
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: string;
|
||||
setRejectionReason: (reason: string) => void;
|
||||
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const createBookingActionsStore = () => {
|
||||
@@ -49,6 +47,7 @@ export const createBookingActionsStore = () => {
|
||||
isOpenAddGuestsDialog: false,
|
||||
isOpenReportDialog: false,
|
||||
rerouteDialogIsOpen: false,
|
||||
isCancelDialogOpen: false,
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: (isOpen) =>
|
||||
@@ -98,9 +97,9 @@ export const createBookingActionsStore = () => {
|
||||
set((state) => ({
|
||||
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
|
||||
})),
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: "",
|
||||
setRejectionReason: (reason: string) => set({ rejectionReason: reason }),
|
||||
setIsCancelDialogOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
isCancelDialogOpen: typeof isOpen === "function" ? isOpen(state.isCancelDialogOpen) : isOpen,
|
||||
})),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
interface UseBookingConfirmationOptions {
|
||||
isRecurring?: boolean;
|
||||
isTabRecurring?: boolean;
|
||||
isTabUnconfirmed?: boolean;
|
||||
}
|
||||
|
||||
interface BookingConfirmParams {
|
||||
bookingId: number;
|
||||
confirmed: boolean;
|
||||
recurringEventId?: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export function useBookingConfirmation(options: UseBookingConfirmationOptions = {}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
|
||||
const { isRecurring = false, isTabRecurring = false, isTabUnconfirmed = false } = options;
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message || t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const bookingConfirm = ({ bookingId, confirmed, recurringEventId, reason }: BookingConfirmParams) => {
|
||||
let body = {
|
||||
bookingId,
|
||||
confirmed,
|
||||
reason: reason || "",
|
||||
};
|
||||
|
||||
/**
|
||||
* Only pass down the recurring event id when we need to confirm the entire series, which happens in
|
||||
* the "Recurring" tab and "Unconfirmed" tab, to support confirming discretionally in the "Recurring" tab.
|
||||
*/
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring && recurringEventId) {
|
||||
body = Object.assign({}, body, { recurringEventId });
|
||||
}
|
||||
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
setRejectionDialogIsOpen(true);
|
||||
};
|
||||
|
||||
return {
|
||||
bookingConfirm,
|
||||
handleReject,
|
||||
rejectionDialogIsOpen,
|
||||
setRejectionDialogIsOpen,
|
||||
isPending: mutation.isPending,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
export type BookingListingStatus = NonNullable<
|
||||
RouterInputs["viewer"]["bookings"]["get"]["filters"]["statuses"]
|
||||
>[number];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import { DialogContent, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import CancelBooking from "@components/booking/CancelBooking";
|
||||
|
||||
interface ICancelBookingDialog {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
booking: {
|
||||
uid: string;
|
||||
id: number;
|
||||
title: string;
|
||||
startTime: Date;
|
||||
payment?: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
success: boolean;
|
||||
paymentOption?: string | null;
|
||||
appId?: string | null;
|
||||
refunded?: boolean;
|
||||
}[];
|
||||
};
|
||||
profile: {
|
||||
name: string | null;
|
||||
slug: string | null;
|
||||
};
|
||||
recurringEvent: RecurringEvent | null;
|
||||
team?: string | null;
|
||||
teamId?: number;
|
||||
allRemainingBookings?: boolean;
|
||||
seatReferenceUid?: string;
|
||||
currentUserEmail?: string;
|
||||
bookingCancelledEventProps: {
|
||||
booking: unknown;
|
||||
organizer: {
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone?: string;
|
||||
};
|
||||
eventType: unknown;
|
||||
};
|
||||
isHost: boolean;
|
||||
internalNotePresets?: { id: number; name: string; cancellationReason: string | null }[];
|
||||
eventTypeMetadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const CancelBookingDialog = (props: ICancelBookingDialog) => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
booking,
|
||||
profile,
|
||||
recurringEvent,
|
||||
team,
|
||||
teamId,
|
||||
allRemainingBookings = false,
|
||||
seatReferenceUid,
|
||||
currentUserEmail,
|
||||
bookingCancelledEventProps,
|
||||
isHost,
|
||||
internalNotePresets = [],
|
||||
eventTypeMetadata,
|
||||
} = props;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Get the first payment if it exists and map to expected format
|
||||
const payment =
|
||||
booking.payment && booking.payment.length > 0
|
||||
? {
|
||||
amount: booking.payment[0].amount,
|
||||
currency: booking.payment[0].currency,
|
||||
appId: booking.payment[0].appId || null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const handleCanceled = () => {
|
||||
utils.viewer.bookings.invalidate();
|
||||
showToast(t("booking_cancelled"), "success");
|
||||
setIsOpenDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow data-testid="cancel-dialog">
|
||||
<DialogHeader title={allRemainingBookings ? t("cancel_all_remaining") : t("cancel_event")} />
|
||||
<CancelBooking
|
||||
renderContext="dialog"
|
||||
booking={{
|
||||
uid: booking.uid,
|
||||
id: booking.id,
|
||||
title: booking.title,
|
||||
startTime: booking.startTime,
|
||||
payment: payment,
|
||||
}}
|
||||
profile={profile}
|
||||
recurringEvent={recurringEvent}
|
||||
team={team}
|
||||
teamId={teamId}
|
||||
setIsCancellationMode={setIsOpenDialog}
|
||||
theme={null}
|
||||
allRemainingBookings={allRemainingBookings}
|
||||
seatReferenceUid={seatReferenceUid}
|
||||
currentUserEmail={currentUserEmail}
|
||||
bookingCancelledEventProps={bookingCancelledEventProps}
|
||||
isHost={isHost}
|
||||
internalNotePresets={internalNotePresets}
|
||||
eventTypeMetadata={eventTypeMetadata}
|
||||
showErrorAsToast={true}
|
||||
onCanceled={handleCanceled}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
|
||||
interface RejectionReasonDialogProps {
|
||||
isOpenDialog: boolean;
|
||||
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
|
||||
onConfirm: (reason: string) => void;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
export function RejectionReasonDialog({
|
||||
isOpenDialog,
|
||||
setIsOpenDialog,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: RejectionReasonDialogProps) {
|
||||
const { t } = useLocale();
|
||||
const [rejectionReason, setRejectionReason] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpenDialog) {
|
||||
setRejectionReason("");
|
||||
}
|
||||
}, [isOpenDialog]);
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(rejectionReason);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> ({t("optional")})</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose color="secondary" />
|
||||
<Button disabled={isPending} data-testid="rejection-confirm" onClick={handleConfirm}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
|
||||
import DynamicPostHogProvider from "@calcom/features/ee/event-tracking/lib/posthog/providerDynamic";
|
||||
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
@@ -16,12 +17,11 @@ import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import useIsThemeSupported from "@lib/hooks/useIsThemeSupported";
|
||||
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
|
||||
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
|
||||
|
||||
import DynamicPostHogPageView from "@calcom/features/ee/event-tracking/lib/posthog/web/pageViewDynamic";
|
||||
|
||||
import { getThemeProviderProps } from "./getThemeProviderProps";
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
@@ -112,6 +112,7 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isBookingPage = useIsBookingPage();
|
||||
const isThemeSupported = useIsThemeSupported();
|
||||
const nuqsParams = useNuqsParams();
|
||||
|
||||
const RemainingProviders = (
|
||||
<>
|
||||
@@ -121,7 +122,7 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<NuqsAdapter>
|
||||
<NuqsAdapter {...nuqsParams}>
|
||||
<FeatureFlagsProvider>
|
||||
{props.isBookingPage || isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/**
|
||||
* PAGES ROUTER ONLY - Used exclusively by Next.js Pages Router (_app.tsx)
|
||||
*
|
||||
*
|
||||
* Currently only serves the /router endpoint (routing forms redirect page).
|
||||
* DO NOT add new features here - this file will be deprecated once we remove apps/web/pages.
|
||||
*
|
||||
*
|
||||
* For App Router, use app-providers-app-dir.tsx instead.
|
||||
*/
|
||||
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
@@ -15,7 +14,6 @@ import { appWithTranslation } from "next-i18next";
|
||||
import type { SSRConfig } from "next-i18next/dist/types/types";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps, AppProps as NextJsAppProps } from "next/app";
|
||||
import dynamic from "next/dynamic";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/pages";
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import type { PropsWithChildren, ReactNode } from "react";
|
||||
@@ -28,6 +26,7 @@ import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import { useNuqsParams } from "@lib/hooks/useNuqsParams";
|
||||
import type { WithLocaleProps } from "@lib/withLocale";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
@@ -61,7 +60,6 @@ export type AppProps = Omit<
|
||||
err?: Error;
|
||||
};
|
||||
|
||||
|
||||
type AppPropsWithChildren = AppProps & {
|
||||
children: ReactNode;
|
||||
};
|
||||
@@ -136,8 +134,8 @@ const enum ThemeSupport {
|
||||
|
||||
type CalcomThemeProps = PropsWithChildren<
|
||||
Pick<AppProps, "router"> &
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
Pick<AppProps["pageProps"], "themeBasis"> &
|
||||
Pick<AppProps["Component"], "isBookingPage" | "isThemeSupported">
|
||||
>;
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
@@ -211,8 +209,8 @@ function getThemeProviderProps({
|
||||
? ThemeSupport.Booking
|
||||
: // if isThemeSupported is explicitly false, we don't use theme there
|
||||
props.isThemeSupported === false
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
const themeBasis = props.themeBasis;
|
||||
@@ -236,13 +234,13 @@ function getThemeProviderProps({
|
||||
|
||||
const storageKey = isEmbedMode
|
||||
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
: themeSupport === ThemeSupport.App
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
@@ -278,6 +276,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
(typeof props.Component.isBookingPage === "function"
|
||||
? props.Component.isBookingPage({ router: props.router })
|
||||
: props.Component.isBookingPage) || isBookingPage;
|
||||
const nuqsParams = useNuqsParams();
|
||||
|
||||
const RemainingProviders = (
|
||||
<CustomI18nextProvider {...props}>
|
||||
@@ -287,7 +286,7 @@ const AppProviders = (props: AppPropsWithChildren) => {
|
||||
isThemeSupported={props.Component.isThemeSupported}
|
||||
isBookingPage={props.Component.isBookingPage || isBookingPage}
|
||||
router={props.router}>
|
||||
<NuqsAdapter>
|
||||
<NuqsAdapter {...nuqsParams}>
|
||||
<FeatureFlagsProvider>
|
||||
{_isBookingPage ? (
|
||||
<OrgBrandProvider>{props.children}</OrgBrandProvider>
|
||||
|
||||
@@ -11,7 +11,7 @@ const assignmentReasonBadgeTitleMap = (assignmentReason: AssignmentReasonEnum) =
|
||||
case AssignmentReasonEnum.REROUTED:
|
||||
return "rerouted";
|
||||
case AssignmentReasonEnum.SALESFORCE_ASSIGNMENT:
|
||||
return "salesforce_assignment";
|
||||
return "salesforce_assigned";
|
||||
default:
|
||||
return "routed";
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// lets refactor and move this into packages/lib/hooks/
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
};
|
||||
|
||||
export default useMediaQuery;
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function useNuqsParams() {
|
||||
const pathname = usePathname();
|
||||
const isBookingsPage = pathname?.startsWith("/bookings/");
|
||||
|
||||
return useMemo(() => {
|
||||
if (isBookingsPage) {
|
||||
return { processUrlSearchParams: putUidFirstInQueryParams };
|
||||
}
|
||||
return {};
|
||||
}, [isBookingsPage]);
|
||||
}
|
||||
|
||||
function putUidFirstInQueryParams(searchParams: URLSearchParams) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const entries = Array.from(params.entries());
|
||||
|
||||
// Clear existing params
|
||||
entries.forEach(([key]) => params.delete(key));
|
||||
|
||||
// Sort entries with "uid" first, then alphabetically
|
||||
const sortedEntries = entries.sort(([keyA], [keyB]) => {
|
||||
if (keyA === "uid") return -1;
|
||||
if (keyB === "uid") return 1;
|
||||
return keyA.localeCompare(keyB);
|
||||
});
|
||||
|
||||
// Re-add sorted entries
|
||||
sortedEntries.forEach(([key, value]) => params.append(key, value));
|
||||
|
||||
return params;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import React, { useMemo, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DataTableFilters } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useBookingCalendarData } from "~/bookings/hooks/useBookingCalendarData";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useCalendarAllowedFilters } from "~/bookings/hooks/useCalendarAllowedFilters";
|
||||
import { useCurrentWeekStart } from "~/bookings/hooks/useCurrentWeekStart";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { getWeekStart } from "../lib/weekUtils";
|
||||
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
|
||||
import { BookingCalendarView } from "./BookingCalendarView";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
import { WeekPicker } from "./WeekPicker";
|
||||
|
||||
// For calendar view, fetch all statuses except cancelled
|
||||
const STATUSES: BookingListingStatus[] = ["upcoming", "unconfirmed", "recurring", "past"];
|
||||
|
||||
interface BookingCalendarContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface BookingCalendarInnerProps extends BookingCalendarContainerProps {
|
||||
data?: {
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
recurringInfo: BookingsGetOutput["recurringInfo"];
|
||||
totalCount: BookingsGetOutput["totalCount"];
|
||||
};
|
||||
allowedFilterIds: string[];
|
||||
isPending: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function BookingCalendarInner({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
allowedFilterIds,
|
||||
hasError,
|
||||
errorMessage,
|
||||
}: BookingCalendarInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const { currentWeekStart, setCurrentWeekStart, userWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const goToPreviousWeek = () => {
|
||||
setCurrentWeekStart(currentWeekStart.subtract(1, "week"));
|
||||
};
|
||||
|
||||
const goToNextWeek = () => {
|
||||
setCurrentWeekStart(currentWeekStart.add(1, "week"));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentWeekStart(getWeekStart(dayjs(), userWeekStart));
|
||||
};
|
||||
|
||||
const ErrorView = errorMessage ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
|
||||
) : undefined;
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status }).filter((column) =>
|
||||
allowedFilterIds.includes(column.id || "")
|
||||
);
|
||||
}, [allowedFilterIds, t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const rowData = useBookingCalendarData({ data, status });
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data
|
||||
const bookings = useMemo(() => {
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [rowData]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<WeekPicker
|
||||
currentWeekStart={currentWeekStart}
|
||||
userWeekStart={userWeekStart}
|
||||
onDateChange={setCurrentWeekStart}
|
||||
/>
|
||||
{allowedFilterIds.length > 0 && <DataTableFilters.FilterBar table={table} />}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize">
|
||||
{t("today")}
|
||||
</Button>
|
||||
<ButtonGroup combined>
|
||||
<Button color="secondary" onClick={goToPreviousWeek}>
|
||||
<span className="sr-only">{t("view_previous_week")}</span>
|
||||
<Icon name="chevron-left" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button color="secondary" onClick={goToNextWeek}>
|
||||
<span className="sr-only">{t("view_next_week")}</span>
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<ViewToggleButton />
|
||||
</div>
|
||||
</div>
|
||||
{hasError && ErrorView ? (
|
||||
ErrorView
|
||||
) : (
|
||||
<BookingCalendarView
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={setCurrentWeekStart}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingCalendarContainer(props: BookingCalendarContainerProps) {
|
||||
const { canReadOthersBookings } = props.permissions;
|
||||
const { userIds } = useBookingFilters();
|
||||
const { currentWeekStart } = useCurrentWeekStart();
|
||||
|
||||
const allowedFilterIds = useCalendarAllowedFilters({
|
||||
canReadOthersBookings,
|
||||
});
|
||||
|
||||
const query = trpc.viewer.bookings.get.useInfiniteQuery(
|
||||
{
|
||||
limit: 100, // Use max limit for calendar view
|
||||
filters: {
|
||||
statuses: STATUSES,
|
||||
userIds,
|
||||
// Always fetch only the current week for calendar view
|
||||
afterStartDate: currentWeekStart.startOf("day").toISOString(),
|
||||
beforeEndDate: currentWeekStart.add(6, "day").endOf("day").toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
|
||||
// Automatically fetch all pages until no more data
|
||||
const { hasNextPage, isFetchingNextPage, fetchNextPage } = query;
|
||||
useEffect(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
// Flatten all pages into a single data object
|
||||
const data = useMemo(() => {
|
||||
if (!query.data?.pages) return undefined;
|
||||
|
||||
// Combine all bookings from all pages
|
||||
const allBookings = query.data.pages.flatMap((page) => page.bookings);
|
||||
|
||||
// recurringInfo is the same across all pages (queried globally by user, not per page)
|
||||
const recurringInfo = query.data.pages[0]?.recurringInfo ?? [];
|
||||
|
||||
// totalCount is the same across all pages
|
||||
const totalCount = query.data.pages[0]?.totalCount ?? 0;
|
||||
|
||||
return { bookings: allBookings, recurringInfo, totalCount };
|
||||
}, [query.data?.pages]);
|
||||
|
||||
const bookings = useMemo(() => data?.bookings ?? [], [data?.bookings]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingCalendarInner
|
||||
{...props}
|
||||
data={data}
|
||||
allowedFilterIds={allowedFilterIds}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
+16
-73
@@ -6,44 +6,27 @@ import dayjs from "@calcom/dayjs";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { Calendar } from "@calcom/features/calendars/weeklyview";
|
||||
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useBanners } from "@calcom/features/shell/banners/useBanners";
|
||||
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
type BookingsCalendarViewProps = {
|
||||
type BookingCalendarViewProps = {
|
||||
bookings: BookingOutput[];
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export function BookingsCalendarView({
|
||||
export function BookingCalendarView({
|
||||
bookings,
|
||||
currentWeekStart,
|
||||
onWeekStartChange,
|
||||
isPending = false,
|
||||
}: BookingsCalendarViewProps) {
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
const { t } = useLocale();
|
||||
}: BookingCalendarViewProps) {
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const { timezone } = useTimePreferences();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
|
||||
const goToPreviousWeek = () => {
|
||||
onWeekStartChange(currentWeekStart.subtract(1, "week"));
|
||||
};
|
||||
|
||||
const goToNextWeek = () => {
|
||||
onWeekStartChange(currentWeekStart.add(1, "week"));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
onWeekStartChange(dayjs().startOf("week"));
|
||||
};
|
||||
const { bannersHeight } = useBanners();
|
||||
|
||||
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
|
||||
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
|
||||
@@ -84,58 +67,17 @@ export function BookingsCalendarView({
|
||||
options: {
|
||||
status: booking.status,
|
||||
...(eventTypeColor && { color: eventTypeColor }),
|
||||
bookingId: booking.id,
|
||||
bookingUid: booking.uid,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [bookings, currentWeekStart, resolvedTheme, forcedTheme]);
|
||||
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
const startMonth = weekStart.format("MMM");
|
||||
const endMonth = weekEnd.format("MMM");
|
||||
const year = weekEnd.format("YYYY");
|
||||
|
||||
const weekRange =
|
||||
startMonth === endMonth ? (
|
||||
<>
|
||||
<span className="text-emphasis">{`${startMonth} ${weekStart.format("D")} - ${weekEnd.format(
|
||||
"D"
|
||||
)}`}</span>
|
||||
<span className="text-muted">, {year}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-emphasis">{`${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}`}</span>
|
||||
<span className="text-muted">, {year}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-subtle flex h-[calc(100vh-260px)] min-h-[600px] flex-col rounded-2xl border">
|
||||
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">{weekRange}</h2>
|
||||
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
|
||||
{t("today")}
|
||||
</Button>
|
||||
<ButtonGroup combined>
|
||||
<Button color="secondary" onClick={goToPreviousWeek}>
|
||||
<span className="sr-only">{t("view_previous_week")}</span>
|
||||
<Icon name="chevron-left" className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button color="secondary" onClick={goToNextWeek}>
|
||||
<span className="sr-only">{t("view_next_week")}</span>
|
||||
<Icon name="chevron-right" className="h-4 w-4" />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden rounded-2xl">
|
||||
<>
|
||||
<div
|
||||
className="border-subtle flex flex-1 flex-col overflow-y-auto overflow-x-hidden rounded-2xl border"
|
||||
style={{ height: `calc(100vh - 6rem - ${bannersHeight}px)` }}>
|
||||
<Calendar
|
||||
timezone={timezone}
|
||||
sortEvents
|
||||
@@ -150,14 +92,15 @@ export function BookingsCalendarView({
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
onEventClick={(event) => {
|
||||
const bookingId = event.options?.bookingId;
|
||||
if (bookingId) {
|
||||
setSelectedBookingId(bookingId);
|
||||
const bookingUid = event.options?.bookingUid;
|
||||
if (bookingUid) {
|
||||
setSelectedBookingUid(bookingUid);
|
||||
}
|
||||
}}
|
||||
showTimezone
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -8,9 +9,16 @@ import { useBookingLocation } from "@calcom/features/bookings/hooks";
|
||||
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
|
||||
import { formatPrice } from "@calcom/lib/currencyConversions";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { bookingMetadataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import {
|
||||
bookingMetadataSchema,
|
||||
eventTypeBookingFields,
|
||||
EventTypeMetaDataSchema,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RecurringEvent } from "@calcom/types/Calendar";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
@@ -26,9 +34,14 @@ import {
|
||||
SheetTitle,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
|
||||
import { RejectBookingButton } from "../../../components/booking/RejectBookingButton";
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import {
|
||||
useBookingDetailsSheetStore,
|
||||
useBookingDetailsSheetStoreApi,
|
||||
@@ -86,35 +99,52 @@ function BookingDetailsSheetInner({
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Fetch additional booking details for reschedule information
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
enabled: Boolean(booking.rescheduled || booking.fromReschedule),
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// Get navigation state directly from the store
|
||||
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
|
||||
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedBookingId(null);
|
||||
setSelectedBookingUid(null);
|
||||
};
|
||||
|
||||
const storeApi = useBookingDetailsSheetStoreApi();
|
||||
|
||||
const handleNext = () => {
|
||||
const nextId = storeApi.getState().getNextBookingId();
|
||||
if (nextId !== null) {
|
||||
setSelectedBookingId(nextId);
|
||||
const nextUid = storeApi.getState().getNextBookingUid();
|
||||
if (nextUid !== null) {
|
||||
setSelectedBookingUid(nextUid);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const prevId = storeApi.getState().getPreviousBookingId();
|
||||
if (prevId !== null) {
|
||||
setSelectedBookingId(prevId);
|
||||
const prevUid = storeApi.getState().getPreviousBookingUid();
|
||||
if (prevUid !== null) {
|
||||
setSelectedBookingUid(prevUid);
|
||||
}
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
const endTime = dayjs(booking.endTime).tz(userTimeZone);
|
||||
|
||||
const getStatusBadge = () => {
|
||||
const statusBadge = useMemo(() => {
|
||||
if (booking.rescheduled) {
|
||||
return {
|
||||
variant: "red" as const,
|
||||
label: t("rescheduled"),
|
||||
};
|
||||
}
|
||||
|
||||
switch (booking.status) {
|
||||
case "ACCEPTED":
|
||||
return { variant: "green" as const, label: t("confirmed") };
|
||||
@@ -127,9 +157,9 @@ function BookingDetailsSheetInner({
|
||||
default:
|
||||
return { variant: "gray" as const, label: booking.status };
|
||||
}
|
||||
};
|
||||
}, [booking.status, booking.rescheduled, t]);
|
||||
|
||||
const statusBadge = getStatusBadge();
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
@@ -148,16 +178,41 @@ function BookingDetailsSheetInner({
|
||||
.map(([question, answer]) => [question, answer] as [string, unknown])
|
||||
: [];
|
||||
|
||||
const reason = booking.assignmentReason?.[0];
|
||||
const reasonTitle = reason && assignmentReasonBadgeTitleMap(reason.reasonEnum);
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleClose}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader
|
||||
showCloseButton={false}
|
||||
rightContent={
|
||||
<Sheet open={true} onOpenChange={handleClose} modal={false}>
|
||||
<SheetContent
|
||||
className="overflow-y-auto"
|
||||
hideOverlay
|
||||
onInteractOutside={(e) => {
|
||||
// Check if the click is on a booking list item
|
||||
const target = e.target as HTMLElement;
|
||||
const isBookingListItem = target.closest("[data-booking-list-item]");
|
||||
|
||||
if (isBookingListItem) {
|
||||
// Prevent closing when clicking a booking list item
|
||||
// The item's onClick will handle opening the sheet with the new booking
|
||||
e.preventDefault();
|
||||
}
|
||||
// If clicking elsewhere, allow the default behavior (close the sheet)
|
||||
}}>
|
||||
<SheetHeader showCloseButton={false} className="mt-0 w-full">
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<div className="flex min-w-0 flex-col gap-y-1">
|
||||
<BookingHeaderBadges
|
||||
statusBadge={statusBadge}
|
||||
reasonTitle={reasonTitle}
|
||||
booking={booking}
|
||||
recurringInfo={recurringInfo}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!hasPrevious}
|
||||
onClick={(e) => {
|
||||
@@ -167,7 +222,8 @@ function BookingDetailsSheetInner({
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!hasNext}
|
||||
onClick={(e) => {
|
||||
@@ -175,33 +231,45 @@ function BookingDetailsSheetInner({
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={statusBadge.variant} className="capitalize">
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{recurringInfo && (
|
||||
<Badge variant="gray">
|
||||
<Icon name="repeat" className="mr-1 h-3 w-3" />
|
||||
{recurringInfo.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="stack-y-6">
|
||||
<div className="stack-y-6">
|
||||
<div className="stack-y-1">
|
||||
<SheetTitle className="text-2xl font-semibold">{booking.title}</SheetTitle>
|
||||
<p className="text-subtle text-sm">
|
||||
{startTime.format("dddd, MMMM D, YYYY h:mma")} - {endTime.format("h:mma")} (
|
||||
{userTimeZone || startTime.format("Z")})
|
||||
</p>
|
||||
<SheetBody className="-mt-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
|
||||
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
|
||||
<span>{booking.title}</span>
|
||||
</SheetTitle>
|
||||
</div>
|
||||
|
||||
<RescheduleRequestMessage booking={booking} />
|
||||
<WhenSection
|
||||
rescheduled={booking.rescheduled || false}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
timeZone={userTimeZone}
|
||||
previousBooking={bookingDetails?.previousBooking}
|
||||
/>
|
||||
|
||||
<OldRescheduledBookingInfo
|
||||
booking={booking}
|
||||
rescheduledToBooking={bookingDetails?.rescheduledToBooking}
|
||||
/>
|
||||
|
||||
<NewRescheduledBookingInfo booking={booking} />
|
||||
|
||||
<CancelledBookingInfo booking={booking} />
|
||||
|
||||
<WhoSection booking={booking} />
|
||||
|
||||
@@ -209,49 +277,64 @@ function BookingDetailsSheetInner({
|
||||
|
||||
<RecurringInfoSection recurringInfo={recurringInfo} />
|
||||
|
||||
<PaymentSection booking={booking} />
|
||||
<AssignmentReasonSection booking={booking} />
|
||||
|
||||
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
|
||||
|
||||
<SlotsSection booking={booking} />
|
||||
|
||||
<AdditionalNotesSection booking={booking} />
|
||||
|
||||
<CustomQuestionsSection
|
||||
customResponses={customResponses}
|
||||
bookingFields={booking.eventType?.bookingFields}
|
||||
/>
|
||||
|
||||
<DescriptionSection booking={booking} />
|
||||
</div>
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
|
||||
<Button color="secondary" StartIcon="x" onClick={handleClose}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
<div className="flex w-full min-w-0 flex-row flex-wrap items-center justify-end gap-2 px-4 pb-4 pt-4">
|
||||
{isPending ? (
|
||||
<>
|
||||
<RejectBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={!!booking.recurringEventId}
|
||||
/>
|
||||
<AcceptBookingButton
|
||||
bookingId={booking.id}
|
||||
bookingUid={booking.uid}
|
||||
recurringEventId={booking.recurringEventId}
|
||||
isRecurring={!!booking.recurringEventId}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
!booking.rescheduled && (
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
context="details"
|
||||
/>
|
||||
</div>
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
context="details"
|
||||
/>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -259,17 +342,62 @@ function BookingDetailsSheetInner({
|
||||
);
|
||||
}
|
||||
|
||||
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
|
||||
function WhenSection({
|
||||
rescheduled,
|
||||
startTime,
|
||||
endTime,
|
||||
timeZone,
|
||||
previousBooking,
|
||||
}: {
|
||||
rescheduled: boolean;
|
||||
startTime: dayjs.Dayjs;
|
||||
endTime: dayjs.Dayjs;
|
||||
timeZone?: string;
|
||||
previousBooking?: { uid: string; startTime: Date; endTime: Date } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Section title={t("when")}>
|
||||
{previousBooking?.startTime && previousBooking?.endTime && (
|
||||
<div className="text-default flex flex-col text-sm line-through opacity-60">
|
||||
<DisplayTimestamp
|
||||
startTime={previousBooking.startTime}
|
||||
endTime={previousBooking.endTime}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis flex flex-col text-sm font-medium",
|
||||
rescheduled && "line-through"
|
||||
)}>
|
||||
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function DisplayTimestamp({
|
||||
startTime,
|
||||
endTime,
|
||||
timeZone,
|
||||
}: {
|
||||
startTime: Date | dayjs.Dayjs;
|
||||
endTime: Date | dayjs.Dayjs;
|
||||
timeZone?: string;
|
||||
}) {
|
||||
const start = startTime instanceof Date ? dayjs(startTime).tz(timeZone) : startTime;
|
||||
const end = endTime instanceof Date ? dayjs(endTime).tz(timeZone) : endTime;
|
||||
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
<>
|
||||
<span>{start.format("dddd, MMMM D, YYYY")}</span>
|
||||
<span>
|
||||
{start.format("h:mma")} - {end.format("h:mma")} ({timeZone || start.format("Z")})
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,35 +405,52 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Section title={t("who")}>
|
||||
<div className="stack-y-4">
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
{booking.user && (
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={getPlaceholderAvatar(null, booking.user.name)}
|
||||
alt={booking.user.name || ""}
|
||||
imageSrc={
|
||||
booking.user.avatarUrl
|
||||
? getUserAvatarUrl(booking.user)
|
||||
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
|
||||
}
|
||||
alt={booking.user.name || booking.user.email || ""}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{booking.user.name}</p>
|
||||
<Badge variant="blue" size="sm">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">
|
||||
{booking.user.name || booking.user.email}
|
||||
</p>
|
||||
<Badge variant="purple" size="sm" className="capitalize">
|
||||
{t("host")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-default truncate text-sm">{booking.user.email}</p>
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.map((attendee, idx) => (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar size="md" imageSrc={getPlaceholderAvatar(null, attendee.name)} alt={attendee.name} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm font-medium">{attendee.name}</p>
|
||||
<p className="text-default truncate text-sm">{attendee.email}</p>
|
||||
{booking.attendees.map((attendee, idx) => {
|
||||
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
imageSrc={
|
||||
attendee.user?.avatarUrl
|
||||
? getUserAvatarUrl(attendee.user)
|
||||
: getPlaceholderAvatar(null, name)
|
||||
}
|
||||
alt={name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">{name}</p>
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{attendee.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -321,6 +466,10 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
bookingStatus: booking.status,
|
||||
});
|
||||
|
||||
if (booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!locationToDisplay) {
|
||||
return null;
|
||||
}
|
||||
@@ -345,7 +494,7 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<span className="text-default shrink-0">{provider?.label}:</span>
|
||||
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
@@ -372,7 +521,7 @@ function RecurringInfoSection({
|
||||
|
||||
return (
|
||||
<Section title={t("recurring_event")}>
|
||||
<p className="text-default text-sm">
|
||||
<p className="text-emphasis text-sm font-medium">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: recurringInfo.recurringEvent,
|
||||
@@ -383,19 +532,60 @@ function RecurringInfoSection({
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentSection({ booking }: { booking: BookingOutput }) {
|
||||
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.paid || !Array.isArray(booking.payment) || booking.payment.length === 0) {
|
||||
if (!booking.assignmentReason || booking.assignmentReason.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payment = booking.payment[0];
|
||||
// we fetch only one assignment reason.
|
||||
const reason = booking.assignmentReason[0];
|
||||
if (!reason.reasonString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("assignment_reason")}>
|
||||
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentSection({
|
||||
booking,
|
||||
payment,
|
||||
}: {
|
||||
booking: BookingOutput;
|
||||
payment: NonNullable<BookingOutput["payment"]>[number];
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Get refund policy from event type metadata
|
||||
const parsedEventTypeMetadata = booking.eventType?.metadata
|
||||
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
|
||||
: null;
|
||||
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
|
||||
|
||||
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
|
||||
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
|
||||
|
||||
const paymentStatusMessage = usePaymentStatus({
|
||||
bookingStatus: booking.status,
|
||||
startTime: booking.startTime,
|
||||
eventTypeTeamId: booking.eventType?.teamId,
|
||||
userId: booking.user?.id,
|
||||
payment,
|
||||
refundPolicy,
|
||||
refundDaysCount,
|
||||
});
|
||||
|
||||
const formattedPrice = formatPrice(payment.amount, payment.currency);
|
||||
|
||||
return (
|
||||
<Section title={t("payment")}>
|
||||
<p className="text-default text-sm">{formattedPrice}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
|
||||
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -416,7 +606,7 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
|
||||
|
||||
return (
|
||||
<Section title={t("slots")}>
|
||||
<p className="text-default text-sm">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -470,7 +660,7 @@ function CustomQuestionsSection({
|
||||
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
|
||||
return (
|
||||
<Section key={idx} title={title}>
|
||||
<p className="text-default text-sm">{String(answer)}</p>
|
||||
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
@@ -478,7 +668,124 @@ function CustomQuestionsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function DescriptionSection({ booking }: { booking: BookingOutput }) {
|
||||
// Old booking that was rescheduled away
|
||||
function OldRescheduledBookingInfo({
|
||||
booking,
|
||||
rescheduledToBooking,
|
||||
}: {
|
||||
booking: BookingOutput;
|
||||
rescheduledToBooking?: { uid: string } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for old rescheduled bookings
|
||||
if (!booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduledBy;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
return (
|
||||
<>
|
||||
{rescheduledToBooking?.uid && (
|
||||
<Section title={t("rescheduled")}>
|
||||
<Link href={`/booking/${rescheduledToBooking.uid}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
{t("view_booking")}
|
||||
<Icon name="external-link" className="h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</Section>
|
||||
)}
|
||||
{rescheduledBy && (
|
||||
<Section title={t("rescheduled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// New booking that replaced an old one
|
||||
function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for new bookings that replaced an old one
|
||||
if (!booking.fromReschedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduler;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t("rescheduled_by")}>
|
||||
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
|
||||
<Link href={`/booking/${booking.fromReschedule}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
{t("original_booking")}
|
||||
<Icon name="external-link" className="h-4 w-4" />
|
||||
</div>
|
||||
</Link>
|
||||
</Section>
|
||||
{cancellationReason && (
|
||||
<Section title={t("reschedule_reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Purely cancelled or rejected (not rescheduled)
|
||||
function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for cancelled/rejected bookings that were NOT rescheduled
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
const wasRescheduled = booking.rescheduled === true;
|
||||
|
||||
if (!isCancelled || wasRescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
if (!cancellationReason && !cancelledBy) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.description) {
|
||||
@@ -486,12 +793,54 @@ function DescriptionSection({ booking }: { booking: BookingOutput }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("description")}>
|
||||
<p className="text-default whitespace-pre-wrap text-sm">{booking.description}</p>
|
||||
<Section title={t("additional_notes")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingHeaderBadges({
|
||||
statusBadge,
|
||||
reasonTitle,
|
||||
booking,
|
||||
recurringInfo,
|
||||
}: {
|
||||
statusBadge: { variant: "red" | "green" | "orange" | "gray"; label: string };
|
||||
reasonTitle: string | undefined;
|
||||
booking: BookingOutput;
|
||||
recurringInfo: { count: number; recurringEvent: RecurringEvent } | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const payment = booking.payment?.[0];
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge variant={statusBadge.variant} className="capitalize">
|
||||
{statusBadge.label}
|
||||
</Badge>
|
||||
{reasonTitle && (
|
||||
<Badge variant="gray" className="capitalize">
|
||||
{reasonTitle}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{booking.paid && !payment ? (
|
||||
<Badge variant="orange">{t("error_collecting_card")}</Badge>
|
||||
) : booking.paid ? (
|
||||
<Badge variant="green" data-testid="paid_badge">
|
||||
{payment?.paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{recurringInfo && (
|
||||
<Badge variant="gray">
|
||||
<Icon name="repeat" className="mr-1 h-3 w-3" />
|
||||
{recurringInfo.count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
className,
|
||||
@@ -502,8 +851,8 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("stack-y-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-semibold">{title}</h3>
|
||||
<div className={classNames("flex flex-col gap-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-medium">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-16
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
|
||||
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
|
||||
import { DataTableWrapper } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
|
||||
@@ -18,7 +18,7 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
unconfirmed: "unconfirmed_bookings",
|
||||
};
|
||||
|
||||
type BookingsListViewProps = {
|
||||
type BookingListViewProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending: boolean;
|
||||
@@ -27,14 +27,14 @@ type BookingsListViewProps = {
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
export function BookingsList({
|
||||
export function BookingList({
|
||||
status,
|
||||
table,
|
||||
isPending,
|
||||
totalRowCount,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsListViewProps) {
|
||||
}: BookingListViewProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
@@ -48,18 +48,7 @@ export function BookingsList({
|
||||
totalRowCount={totalRowCount}
|
||||
variant="compact"
|
||||
paginationMode="standard"
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
</>
|
||||
}
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</>
|
||||
}
|
||||
separatorClassName="py-4 pl-6 text-xs uppercase leading-4"
|
||||
LoaderView={<SkeletonLoader />}
|
||||
EmptyView={
|
||||
<div className="flex items-center justify-center pt-2 xl:pt-0">
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useDataTable,
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDisplayedFilterCount,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
|
||||
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
|
||||
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
useBookingDetailsSheetStore,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingList } from "./BookingList";
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
interface FilterButtonProps {
|
||||
table: ReturnType<typeof useReactTable<RowData>>;
|
||||
displayedFilterCount: number;
|
||||
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
}
|
||||
|
||||
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (displayedFilterCount === 0) {
|
||||
return <DataTableFilters.AddFilterButton table={table} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon="list-filter"
|
||||
className="h-full"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters((value) => !value)}>
|
||||
{t("filter")}
|
||||
<Badge variant="gray" className="ml-1">
|
||||
{displayedFilterCount}
|
||||
</Badge>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookingListContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
bookingsV3Enabled: boolean;
|
||||
}
|
||||
|
||||
interface BookingListInnerProps extends BookingListContainerProps {
|
||||
data?: BookingsGetOutput;
|
||||
isPending: boolean;
|
||||
hasError: boolean;
|
||||
errorMessage?: string;
|
||||
totalRowCount?: number;
|
||||
}
|
||||
|
||||
function BookingListInner({
|
||||
status,
|
||||
permissions,
|
||||
bookingsV3Enabled,
|
||||
data,
|
||||
isPending,
|
||||
hasError,
|
||||
errorMessage,
|
||||
totalRowCount,
|
||||
}: BookingListInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
const ErrorView = errorMessage ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
|
||||
) : undefined;
|
||||
|
||||
const handleBookingClick = useCallback(
|
||||
(bookingUid: string) => {
|
||||
setSelectedBookingUid(bookingUid);
|
||||
},
|
||||
[setSelectedBookingUid]
|
||||
);
|
||||
|
||||
const columns = useBookingListColumns({
|
||||
user,
|
||||
status,
|
||||
canReadOthersBookings: permissions.canReadOthersBookings,
|
||||
bookingsV3Enabled,
|
||||
handleBookingClick,
|
||||
});
|
||||
|
||||
const finalData = useBookingListData({ data, status, userTimeZone: user?.timeZone });
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const displayedFilterCount = useDisplayedFilterCount();
|
||||
const { currentTab, tabOptions } = useBookingStatusTab();
|
||||
|
||||
useEffect(() => {
|
||||
if (displayedFilterCount === 0) {
|
||||
// reset to true, so it shows filters as soon as any filter is applied
|
||||
setShowFilters(true);
|
||||
}
|
||||
}, [displayedFilterCount]);
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
const isEmpty = !data?.bookings || data.bookings.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Desktop: full width on first row, Mobile: full width on first row with horizontal scroll */}
|
||||
<div className="w-full md:w-auto">
|
||||
<div className="overflow-x-auto md:overflow-visible">
|
||||
<ToggleGroup
|
||||
value={currentTab}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const selectedTab = tabOptions.find((tab) => tab.value === value);
|
||||
if (selectedTab?.href) {
|
||||
router.push(selectedTab.href);
|
||||
}
|
||||
}}
|
||||
options={tabOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: second item on first row, Mobile: first item on second row */}
|
||||
<FilterButton
|
||||
table={table}
|
||||
displayedFilterCount={displayedFilterCount}
|
||||
setShowFilters={setShowFilters}
|
||||
/>
|
||||
|
||||
{/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
|
||||
<div className="hidden grow md:block" />
|
||||
|
||||
<DataTableSegment.Select shortLabel />
|
||||
{bookingsV3Enabled && <ViewToggleButton />}
|
||||
</div>
|
||||
{displayedFilterCount > 0 && showFilters && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} variant="minimal" />
|
||||
|
||||
{/* Desktop: auto-pushed to right via flex-grow spacer */}
|
||||
<div className="hidden flex-grow md:block" />
|
||||
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
</div>
|
||||
)}
|
||||
{status === "upcoming" && !isEmpty && (
|
||||
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<BookingList
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{bookingsV3Enabled && (
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset } = useDataTable();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
limit,
|
||||
offset,
|
||||
filters: {
|
||||
statuses: [props.status],
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
|
||||
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
data={query.data}
|
||||
isPending={query.isPending}
|
||||
hasError={!!query.error}
|
||||
errorMessage={query.error?.message}
|
||||
totalRowCount={query.data?.totalCount}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
useDataTable,
|
||||
ColumnFilterType,
|
||||
} from "@calcom/features/data-table";
|
||||
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
|
||||
import { BookingsCalendarView } from "./BookingsCalendarView";
|
||||
|
||||
type BookingsCalendarProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending?: boolean;
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
setCurrentWeekStart: (
|
||||
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
|
||||
) => Promise<URLSearchParams>;
|
||||
bookings: BookingOutput[];
|
||||
ErrorView?: React.ReactNode;
|
||||
hasError?: boolean;
|
||||
};
|
||||
|
||||
const COLUMN_IDS_TO_HIDE = ["dateRange"];
|
||||
|
||||
export function BookingsCalendar({
|
||||
table,
|
||||
isPending = false,
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
bookings,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsCalendarProps) {
|
||||
const { updateFilter } = useDataTable();
|
||||
|
||||
const handleWeekStartChange = useCallback(
|
||||
(newWeekStart: dayjs.Dayjs) => {
|
||||
setCurrentWeekStart(newWeekStart);
|
||||
|
||||
// Always set the date range to match the current week exactly
|
||||
const startDate = newWeekStart.toDate();
|
||||
const endDate = newWeekStart.add(6, "day").toDate();
|
||||
|
||||
updateFilter("dateRange", {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
data: {
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
preset: CUSTOM_PRESET.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateFilter, setCurrentWeekStart]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<DataTableFilters.FilterBar table={table} columnIdsToHide={COLUMN_IDS_TO_HIDE} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</div>
|
||||
</div>
|
||||
{hasError && ErrorView ? (
|
||||
ErrorView
|
||||
) : (
|
||||
<BookingsCalendarView
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={handleWeekStartChange}
|
||||
isPending={isPending}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
|
||||
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsCalendar } from "./BookingsCalendar";
|
||||
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
interface BookingsCalendarContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data?: BookingsGetOutput;
|
||||
isPending?: boolean;
|
||||
ErrorView?: React.ReactNode;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
export function BookingsCalendarContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending = false,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsCalendarContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status });
|
||||
}, [t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
/**
|
||||
* Transform raw booking data into RowData format for the calendar view
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - Attaches recurring info and isToday flag to each booking
|
||||
*/
|
||||
const rowData = useMemo<RowData[]>(() => {
|
||||
if (!data?.bookings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
|
||||
// Key: recurringEventId, Value: array of all bookings in that series
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
// Deduplicate recurring bookings for specific status tabs
|
||||
// This ensures we show only ONE booking per recurring series instead of all occurrences
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
// Non-recurring bookings are always shown
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've already encountered this recurring series
|
||||
if (
|
||||
shownBookings[booking.recurringEventId] !== undefined &&
|
||||
shownBookings[booking.recurringEventId].length > 0
|
||||
) {
|
||||
// Store this occurrence but DON'T display it (return false to filter out)
|
||||
shownBookings[booking.recurringEventId].push(booking);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First occurrence of this recurring series - show it and start tracking
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return data.bookings.filter(filterBookings).map((booking) => {
|
||||
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
|
||||
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
|
||||
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
|
||||
|
||||
return {
|
||||
type: "data" as const,
|
||||
booking,
|
||||
isToday: bookingDateStr === today,
|
||||
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
};
|
||||
});
|
||||
}, [data, status, user?.timeZone]);
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: rowData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: getFilterColumnVisibility(),
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
// Extract bookings from table data and filter by current week
|
||||
const bookings = useMemo(() => {
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
|
||||
return rowData
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking)
|
||||
.filter((booking) => {
|
||||
const bookingStart = dayjs(booking.startTime);
|
||||
return (
|
||||
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
|
||||
bookingStart.isBefore(weekEnd.endOf("day"))
|
||||
);
|
||||
});
|
||||
}, [rowData, currentWeekStart]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingsCalendar
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
currentWeekStart={currentWeekStart}
|
||||
setCurrentWeekStart={setCurrentWeekStart}
|
||||
bookings={bookings}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
|
||||
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
useBookingDetailsSheetStore,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingsList } from "./BookingsList";
|
||||
|
||||
interface BookingsListContainerProps {
|
||||
status: BookingListingStatus;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
data?: BookingsGetOutput;
|
||||
isPending: boolean;
|
||||
enableDetailsSheet: boolean;
|
||||
totalRowCount?: number;
|
||||
ErrorView?: React.ReactNode;
|
||||
hasError?: boolean;
|
||||
}
|
||||
|
||||
function BookingsListInner({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending,
|
||||
enableDetailsSheet,
|
||||
totalRowCount,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsListContainerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
|
||||
const handleBookingClick = useCallback(
|
||||
(bookingId: number) => {
|
||||
setSelectedBookingId(bookingId);
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
// Define table columns for filtering (hidden columns used for filter UI)
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: permissions.canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
if (props.row.original.type === "data") {
|
||||
const { booking, recurringInfo, isToday } = props.row.original;
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
isToday={isToday}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...(enableDetailsSheet && { onClick: () => handleBookingClick(booking.id) })}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
} else if (props.row.original.type === "today") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("today")}
|
||||
</p>
|
||||
);
|
||||
} else if (props.row.original.type === "next") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("next")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t, permissions.canReadOthersBookings, enableDetailsSheet, handleBookingClick]);
|
||||
|
||||
/**
|
||||
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
|
||||
*/
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
|
||||
// Key: recurringEventId, Value: array of all bookings in that series
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
// Deduplicate recurring bookings for specific status tabs
|
||||
// This ensures we show only ONE booking per recurring series instead of all occurrences
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
// Non-recurring bookings are always shown
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've already encountered this recurring series
|
||||
if (
|
||||
shownBookings[booking.recurringEventId] !== undefined &&
|
||||
shownBookings[booking.recurringEventId].length > 0
|
||||
) {
|
||||
// Store this occurrence but DON'T display it (return false to filter out)
|
||||
shownBookings[booking.recurringEventId].push(booking);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First occurrence of this recurring series - show it and start tracking
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
|
||||
return (
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data",
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [data, status, user?.timeZone]);
|
||||
|
||||
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return (data?.bookings ?? [])
|
||||
.filter(
|
||||
(booking: BookingOutput) =>
|
||||
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
|
||||
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
||||
)
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
isToday: true,
|
||||
}));
|
||||
}, [data, user?.timeZone]);
|
||||
|
||||
// Combine data with section separators for "upcoming" tab
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
// For other statuses, just return the flat list
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
|
||||
// For "upcoming" status, organize into "Today" and "Next" sections
|
||||
const merged: RowData[] = [];
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "today" as const }, ...bookingsToday);
|
||||
}
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "next" as const }, ...flatData);
|
||||
}
|
||||
return merged;
|
||||
}, [bookingsToday, flatData, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BookingsList
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
|
||||
{enableDetailsSheet && (
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BookingsListContainer({
|
||||
status,
|
||||
permissions,
|
||||
data,
|
||||
isPending,
|
||||
enableDetailsSheet,
|
||||
totalRowCount,
|
||||
ErrorView,
|
||||
hasError,
|
||||
}: BookingsListContainerProps) {
|
||||
// Extract bookings from data for BookingDetailsSheet
|
||||
const bookings = useMemo(() => data?.bookings ?? [], [data]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingsListInner
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={data}
|
||||
isPending={isPending}
|
||||
enableDetailsSheet={enableDetailsSheet}
|
||||
totalRowCount={totalRowCount}
|
||||
ErrorView={ErrorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -54,11 +54,7 @@ export function JoinMeetingButton({
|
||||
onClick={handleClick}>
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
<img src={provider.iconUrl} className="h-4 w-4 shrink-0 rounded-sm" alt={`${provider.label} logo`} />
|
||||
)}
|
||||
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
|
||||
</Button>
|
||||
|
||||
+8
-15
@@ -3,13 +3,12 @@
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
|
||||
import { viewParser, type BookingView } from "../lib/viewParser";
|
||||
|
||||
export function ViewToggleButton() {
|
||||
const { t } = useLocale();
|
||||
@@ -17,17 +16,18 @@ export function ViewToggleButton() {
|
||||
"view",
|
||||
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
|
||||
);
|
||||
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
const isMobile = !useMediaQuery("(min-width: 640px)");
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
useEffect(() => {
|
||||
// Force list view on mobile
|
||||
if (isMobile && view === "calendar") {
|
||||
// Clear dateRange filter when forcing calendar to list view, same as manual toggle
|
||||
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
|
||||
setView("list");
|
||||
}
|
||||
}, [isMobile, view, setView, setActiveFilters]);
|
||||
}, [isMobile, view, setView]);
|
||||
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hidden sm:block">
|
||||
@@ -35,14 +35,7 @@ export function ViewToggleButton() {
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const newView = value as BookingView;
|
||||
|
||||
// When switching from calendar to list view, remove the dateRange filter
|
||||
if (view === "calendar" && newView === "list") {
|
||||
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
|
||||
}
|
||||
|
||||
setView(newView);
|
||||
setView(value as BookingView);
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DatePicker } from "@calcom/ui/components/form";
|
||||
|
||||
import { getWeekStart } from "../lib/weekUtils";
|
||||
|
||||
interface WeekPickerProps {
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
userWeekStart: number;
|
||||
onDateChange: (date: dayjs.Dayjs) => void;
|
||||
}
|
||||
|
||||
export function WeekPicker({ currentWeekStart, userWeekStart, onDateChange }: WeekPickerProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date>(currentWeekStart.toDate());
|
||||
useEffect(() => {
|
||||
setSelectedDate(currentWeekStart.toDate());
|
||||
}, [currentWeekStart]);
|
||||
|
||||
const handleDateChange = (date: Date) => {
|
||||
setSelectedDate(date);
|
||||
const selectedDayjs = dayjs(date);
|
||||
const newWeekStart = getWeekStart(selectedDayjs, userWeekStart);
|
||||
onDateChange(newWeekStart);
|
||||
};
|
||||
|
||||
const weekStart = currentWeekStart;
|
||||
const weekEnd = currentWeekStart.add(6, "day");
|
||||
const startMonth = weekStart.format("MMM");
|
||||
const endMonth = weekEnd.format("MMM");
|
||||
const year = weekEnd.format("YYYY");
|
||||
|
||||
const weekRange =
|
||||
startMonth === endMonth
|
||||
? `${startMonth} ${weekStart.format("D")} - ${weekEnd.format("D")}, ${year}`
|
||||
: `${weekStart.format("MMM D")} - ${weekEnd.format("MMM D")}, ${year}`;
|
||||
|
||||
return <DatePicker date={selectedDate} onDatesChange={handleDateChange} minDate={null} label={weekRange} />;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
import type { BookingListingStatus, BookingOutput, BookingsGetOutput, RowData } from "../types";
|
||||
|
||||
interface UseBookingCalendarDataParams {
|
||||
data?: {
|
||||
bookings: BookingsGetOutput["bookings"];
|
||||
recurringInfo: BookingsGetOutput["recurringInfo"];
|
||||
totalCount: BookingsGetOutput["totalCount"];
|
||||
};
|
||||
status: BookingListingStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to transform raw booking data into RowData format for the calendar view
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - Attaches recurring info and isToday flag to each booking
|
||||
*/
|
||||
export function useBookingCalendarData({ data, status }: UseBookingCalendarDataParams) {
|
||||
const user = useMeQuery().data;
|
||||
|
||||
const rowData = useMemo<RowData[]>(() => {
|
||||
if (!data?.bookings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
|
||||
// Key: recurringEventId, Value: array of all bookings in that series
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
// Deduplicate recurring bookings for specific status tabs
|
||||
// This ensures we show only ONE booking per recurring series instead of all occurrences
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
// Non-recurring bookings are always shown
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've already encountered this recurring series
|
||||
if (
|
||||
shownBookings[booking.recurringEventId] !== undefined &&
|
||||
shownBookings[booking.recurringEventId].length > 0
|
||||
) {
|
||||
// Store this occurrence but DON'T display it (return false to filter out)
|
||||
shownBookings[booking.recurringEventId].push(booking);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First occurrence of this recurring series - show it and start tracking
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return data.bookings.filter(filterBookings).map((booking) => {
|
||||
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
|
||||
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
|
||||
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
|
||||
|
||||
return {
|
||||
type: "data" as const,
|
||||
booking,
|
||||
isToday: bookingDateStr === today,
|
||||
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
};
|
||||
});
|
||||
}, [data, status, user?.timeZone]);
|
||||
|
||||
return rowData;
|
||||
}
|
||||
@@ -4,29 +4,29 @@ import type { BookingOutput } from "../types";
|
||||
|
||||
export function useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
selectedBookingUid,
|
||||
setSelectedBookingUid,
|
||||
}: {
|
||||
bookings: BookingOutput[];
|
||||
selectedBookingId: number | null;
|
||||
setSelectedBookingId: (bookingId: number | null) => void;
|
||||
selectedBookingUid: string | null;
|
||||
setSelectedBookingUid: (bookingUid: string | null) => void;
|
||||
}) {
|
||||
const currentIndex = useMemo(
|
||||
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
|
||||
[bookings, selectedBookingId]
|
||||
() => bookings.findIndex((booking) => selectedBookingUid && booking.uid === selectedBookingUid),
|
||||
[bookings, selectedBookingUid]
|
||||
);
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
if (currentIndex >= 1) {
|
||||
setSelectedBookingId(bookings[currentIndex - 1].id);
|
||||
setSelectedBookingUid(bookings[currentIndex - 1].uid);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
}, [bookings, currentIndex, setSelectedBookingUid]);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
|
||||
setSelectedBookingId(bookings[currentIndex + 1].id);
|
||||
setSelectedBookingUid(bookings[currentIndex + 1].uid);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
}, [bookings, currentIndex, setSelectedBookingUid]);
|
||||
|
||||
return {
|
||||
onPrevious,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
useFilterValue,
|
||||
ZMultiSelectFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ZTextFilterValue,
|
||||
} from "@calcom/features/data-table";
|
||||
|
||||
export function useBookingFilters() {
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const userIds = useFilterValue("userId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
||||
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
||||
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
|
||||
|
||||
return {
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
dateRange,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
|
||||
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
|
||||
export function useBookingListColumns({
|
||||
user,
|
||||
status,
|
||||
canReadOthersBookings,
|
||||
bookingsV3Enabled,
|
||||
handleBookingClick,
|
||||
}: {
|
||||
user: ReturnType<typeof useMeQuery>["data"];
|
||||
status: BookingListingStatus;
|
||||
canReadOthersBookings: boolean;
|
||||
bookingsV3Enabled: boolean;
|
||||
handleBookingClick: (bookingUid: string) => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return useMemo(() => {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.eventType.id, {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.eventType.team?.id, {
|
||||
id: "teamId",
|
||||
header: t("team"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: canReadOthersBookings,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => row, {
|
||||
id: "dateRange",
|
||||
header: t("date_range"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.DATE_RANGE,
|
||||
dateRangeOptions: {
|
||||
range: status === "past" ? "past" : "custom",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor((row) => !isSeparatorRow(row) && row.booking.uid, {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "customView",
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
|
||||
// Separator rows are handled automatically by DataTable
|
||||
if (isSeparatorRow(row)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { booking, recurringInfo, isToday } = row;
|
||||
return (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
isToday={isToday}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
{...(bookingsV3Enabled && { onClick: () => handleBookingClick(booking.uid) })}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t, canReadOthersBookings, bookingsV3Enabled, handleBookingClick]);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import type {
|
||||
RowData,
|
||||
BookingRowData,
|
||||
BookingListingStatus,
|
||||
BookingOutput,
|
||||
BookingsGetOutput,
|
||||
} from "../types";
|
||||
|
||||
/**
|
||||
* Transform raw bookings into final data structure with separators
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - For "upcoming" status, organizes into "Today" and "Next" sections
|
||||
*/
|
||||
export function useBookingListData({
|
||||
data,
|
||||
status,
|
||||
userTimeZone,
|
||||
}: {
|
||||
data?: BookingsGetOutput;
|
||||
status: BookingListingStatus;
|
||||
userTimeZone?: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
/**
|
||||
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
|
||||
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
|
||||
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
|
||||
*/
|
||||
const flatData = useMemo<BookingRowData[]>(() => {
|
||||
const todayDateString = dayjs().tz(userTimeZone).format("YYYY-MM-DD");
|
||||
|
||||
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
|
||||
// Key: recurringEventId, Value: array of all bookings in that series
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
// Deduplicate recurring bookings for specific status tabs
|
||||
// This ensures we show only ONE booking per recurring series instead of all occurrences
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
// Non-recurring bookings are always shown
|
||||
if (!booking.recurringEventId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we've already encountered this recurring series
|
||||
if (
|
||||
shownBookings[booking.recurringEventId] !== undefined &&
|
||||
shownBookings[booking.recurringEventId].length > 0
|
||||
) {
|
||||
// Store this occurrence but DON'T display it (return false to filter out)
|
||||
shownBookings[booking.recurringEventId].push(booking);
|
||||
return false;
|
||||
}
|
||||
|
||||
// First occurrence of this recurring series - show it and start tracking
|
||||
shownBookings[booking.recurringEventId] = [booking];
|
||||
} else if (status === "upcoming") {
|
||||
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
|
||||
return dayjs(booking.startTime).tz(userTimeZone).format("YYYY-MM-DD") !== todayDateString;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [data, status, userTimeZone]);
|
||||
|
||||
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
|
||||
const bookingsToday = useMemo<BookingRowData[]>(() => {
|
||||
const todayDateString = dayjs().tz(userTimeZone).format("YYYY-MM-DD");
|
||||
|
||||
return (data?.bookings ?? [])
|
||||
.filter(
|
||||
(booking: BookingOutput) =>
|
||||
dayjs(booking.startTime).tz(userTimeZone).format("YYYY-MM-DD") === todayDateString
|
||||
)
|
||||
.map((booking) => ({
|
||||
type: "data" as const,
|
||||
booking,
|
||||
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
|
||||
isToday: true,
|
||||
}));
|
||||
}, [data, userTimeZone]);
|
||||
|
||||
// Combine data with section separators for "upcoming" tab
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
// For other statuses, just return the flat list
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
|
||||
// For "upcoming" status, organize into "Today" and "Next" sections
|
||||
const merged: RowData[] = [];
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "separator" as const, label: t("today") }, ...bookingsToday);
|
||||
}
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "separator" as const, label: t("next") }, ...flatData);
|
||||
}
|
||||
return merged;
|
||||
}, [bookingsToday, flatData, status, t]);
|
||||
|
||||
return finalData;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export function useBookingStatusTab() {
|
||||
const { t } = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
|
||||
const tabOptions = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
|
||||
const baseTabConfigs = [
|
||||
{
|
||||
value: "upcoming",
|
||||
label: "upcoming",
|
||||
path: "/bookings/upcoming",
|
||||
dataTestId: "upcoming",
|
||||
},
|
||||
{
|
||||
value: "unconfirmed",
|
||||
label: "unconfirmed",
|
||||
path: "/bookings/unconfirmed",
|
||||
dataTestId: "unconfirmed",
|
||||
},
|
||||
{
|
||||
value: "recurring",
|
||||
label: "recurring",
|
||||
path: "/bookings/recurring",
|
||||
dataTestId: "recurring",
|
||||
},
|
||||
{
|
||||
value: "past",
|
||||
label: "past",
|
||||
path: "/bookings/past",
|
||||
dataTestId: "past",
|
||||
},
|
||||
{
|
||||
value: "cancelled",
|
||||
label: "cancelled",
|
||||
path: "/bookings/cancelled",
|
||||
dataTestId: "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return baseTabConfigs.map((tabConfig) => ({
|
||||
value: tabConfig.value,
|
||||
label: t(tabConfig.label),
|
||||
dataTestId: tabConfig.dataTestId,
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
}));
|
||||
}, [searchParams, t]);
|
||||
|
||||
const currentTab = useMemo(() => {
|
||||
const pathMatch = pathname?.match(/\/bookings\/(\w+)/);
|
||||
return pathMatch?.[1] || "upcoming";
|
||||
}, [pathname]);
|
||||
|
||||
return {
|
||||
currentTab,
|
||||
tabOptions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMemo, useEffect, useRef } from "react";
|
||||
|
||||
import { ColumnFilterType, useDataTable } from "@calcom/features/data-table";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
/**
|
||||
* Custom hook to manage allowed filters for calendar view
|
||||
* - Users without permission to read others' bookings: no filters
|
||||
* - Users with permission to read others' bookings: userId filter with their own ID as default
|
||||
*/
|
||||
export function useCalendarAllowedFilters({ canReadOthersBookings }: { canReadOthersBookings: boolean }) {
|
||||
const { data: user } = useMeQuery();
|
||||
const { updateFilter, clearAll } = useDataTable();
|
||||
const allowedFilterIds = useMemo(() => (canReadOthersBookings ? ["userId"] : []), [canReadOthersBookings]);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || hasInitialized.current) return;
|
||||
|
||||
hasInitialized.current = true;
|
||||
|
||||
// Clear all filters first (in case coming from list view with different filters)
|
||||
clearAll();
|
||||
|
||||
// Then set the appropriate filter for users with permission
|
||||
if (canReadOthersBookings) {
|
||||
updateFilter("userId", {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [user.id],
|
||||
});
|
||||
}
|
||||
}, [user, canReadOthersBookings, updateFilter, clearAll]);
|
||||
|
||||
return allowedFilterIds;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { weekdayToWeekIndex } from "@calcom/lib/dayjs";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
|
||||
import { getWeekStart } from "../lib/weekUtils";
|
||||
|
||||
/**
|
||||
* Parser for the weekStart query parameter
|
||||
* This parser simply parses the date from the URL and ensures it's at the start of the day.
|
||||
* The week start logic based on user preference is applied when determining the default value.
|
||||
*/
|
||||
const weekStartParser = createParser({
|
||||
parse: (value: string) => {
|
||||
const parsed = dayjs(value);
|
||||
return parsed.isValid() ? parsed.startOf("day") : dayjs().startOf("day");
|
||||
},
|
||||
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom hook to manage the current week start based on user preferences
|
||||
* @returns Object containing currentWeekStart state and userWeekStart preference
|
||||
*/
|
||||
export function useCurrentWeekStart() {
|
||||
const user = useMeQuery().data;
|
||||
|
||||
// Get the user's preferred week start day (0-6, where 0 = Sunday)
|
||||
const userWeekStart = weekdayToWeekIndex(user?.weekStart);
|
||||
|
||||
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(getWeekStart(dayjs(), userWeekStart))
|
||||
);
|
||||
|
||||
return {
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
userWeekStart,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { RefundPolicy } from "@calcom/lib/payment/types";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
interface Payment {
|
||||
success: boolean;
|
||||
refunded: boolean;
|
||||
paymentOption?: string | null;
|
||||
}
|
||||
|
||||
interface UsePaymentStatusParams {
|
||||
bookingStatus: BookingStatus;
|
||||
startTime: Date | string;
|
||||
eventTypeTeamId?: number | null;
|
||||
userId?: number | null;
|
||||
payment: Payment;
|
||||
refundPolicy?: string | null;
|
||||
refundDaysCount?: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to determine the appropriate payment status message for a booking
|
||||
* based on payment status and refund policy.
|
||||
*/
|
||||
export function usePaymentStatus({
|
||||
bookingStatus,
|
||||
startTime,
|
||||
eventTypeTeamId,
|
||||
userId,
|
||||
payment,
|
||||
refundPolicy,
|
||||
refundDaysCount,
|
||||
}: UsePaymentStatusParams) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const message = useMemo(() => {
|
||||
const isCancelled = bookingStatus === BookingStatus.CANCELLED || bookingStatus === BookingStatus.REJECTED;
|
||||
|
||||
if (!isCancelled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Payment not completed and not refunded
|
||||
if (!payment.success && !payment.refunded) {
|
||||
return t("booking_with_payment_cancelled");
|
||||
}
|
||||
|
||||
// Payment completed but not refunded
|
||||
if (payment.success && !payment.refunded) {
|
||||
// Handle missing team or event type owner (same in processPaymentRefund.ts)
|
||||
if (!eventTypeTeamId && !userId) {
|
||||
return t("booking_with_payment_cancelled_no_refund");
|
||||
}
|
||||
|
||||
// Handle DAYS policy with expired refund window
|
||||
if (refundPolicy === RefundPolicy.DAYS && refundDaysCount) {
|
||||
const bookingStartTime = new Date(startTime);
|
||||
const cancelTime = new Date();
|
||||
const daysDiff = Math.floor(
|
||||
(cancelTime.getTime() - bookingStartTime.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff > refundDaysCount) {
|
||||
return t("booking_with_payment_cancelled_refund_window_expired");
|
||||
}
|
||||
return t("booking_with_payment_cancelled_already_paid");
|
||||
}
|
||||
|
||||
// Handle NEVER policy
|
||||
if (refundPolicy === RefundPolicy.NEVER) {
|
||||
return t("booking_with_payment_cancelled_no_refund");
|
||||
}
|
||||
|
||||
// Handle ALWAYS policy or default
|
||||
return t("booking_with_payment_cancelled_already_paid");
|
||||
}
|
||||
|
||||
// Payment refunded
|
||||
if (payment.refunded) {
|
||||
return t("booking_with_payment_cancelled_refunded");
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [bookingStatus, startTime, eventTypeTeamId, userId, payment, refundPolicy, refundDaysCount, t]);
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
export function useSelectedBookingUid() {
|
||||
return useQueryState("uid", {
|
||||
defaultValue: null,
|
||||
parse: (value) => (value ? value : null),
|
||||
serialize: (value) => (value ? String(value) : ""),
|
||||
clearOnDefault: true,
|
||||
});
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
export const validStatuses = ["upcoming", "recurring", "past", "cancelled", "unconfirmed"] as const;
|
||||
|
||||
export type BookingListingStatus = (typeof validStatuses)[number];
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
|
||||
/**
|
||||
* Gets the start of the week for a given date based on the user's preferred week start day.
|
||||
* This is useful when dayjs's built-in startOf('week') doesn't respect user preferences.
|
||||
*
|
||||
* @param date - The date to get the week start for
|
||||
* @param weekStart - The day the week starts on (0 = Sunday, 1 = Monday, etc.)
|
||||
* @returns The start of the week as a dayjs object
|
||||
*
|
||||
* @example
|
||||
* // If today is Thursday (day 4) and user prefers Monday start (1):
|
||||
* const weekStart = getWeekStart(dayjs(), 1); // Returns the Monday of current week
|
||||
*/
|
||||
export const getWeekStart = (date: Dayjs, weekStart: number = 0): Dayjs => {
|
||||
const currentDay = date.day();
|
||||
const diff = (currentDay - weekStart + 7) % 7;
|
||||
return date.subtract(diff, "day").startOf("day");
|
||||
};
|
||||
@@ -3,23 +3,23 @@
|
||||
import React from "react";
|
||||
import { createStore, useStore } from "zustand";
|
||||
|
||||
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
|
||||
import { useSelectedBookingUid } from "../hooks/useSelectedBookingUid";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
interface BookingDetailsSheetStore {
|
||||
// State
|
||||
selectedBookingId: number | null;
|
||||
selectedBookingUid: string | null;
|
||||
bookings: BookingOutput[];
|
||||
|
||||
// Actions
|
||||
setSelectedBookingId: (id: number | null) => void;
|
||||
setSelectedBookingUid: (uid: string | null) => void;
|
||||
setBookings: (bookings: BookingOutput[]) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// Computed getters (used via selectors)
|
||||
getSelectedBooking: () => BookingOutput | null;
|
||||
getNextBookingId: () => number | null;
|
||||
getPreviousBookingId: () => number | null;
|
||||
getNextBookingUid: () => string | null;
|
||||
getPreviousBookingUid: () => string | null;
|
||||
hasNext: () => boolean;
|
||||
hasPrevious: () => boolean;
|
||||
}
|
||||
@@ -29,54 +29,54 @@ type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetS
|
||||
const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => {
|
||||
return createStore<BookingDetailsSheetStore>((set, get) => ({
|
||||
// Initial state
|
||||
selectedBookingId: null,
|
||||
selectedBookingUid: null,
|
||||
bookings: initialBookings,
|
||||
|
||||
// Actions
|
||||
setSelectedBookingId: (id) => set({ selectedBookingId: id }),
|
||||
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
|
||||
setBookings: (bookings) => set({ bookings }),
|
||||
clearSelection: () => set({ selectedBookingId: null }),
|
||||
clearSelection: () => set({ selectedBookingUid: null }),
|
||||
|
||||
// Computed getters
|
||||
getSelectedBooking: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return null;
|
||||
return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null;
|
||||
if (!state.selectedBookingUid) return null;
|
||||
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
|
||||
},
|
||||
|
||||
getNextBookingId: () => {
|
||||
getNextBookingUid: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return null;
|
||||
if (!state.selectedBookingUid) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
|
||||
|
||||
return state.bookings[currentIndex + 1].id;
|
||||
return state.bookings[currentIndex + 1].uid;
|
||||
},
|
||||
|
||||
getPreviousBookingId: () => {
|
||||
getPreviousBookingUid: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return null;
|
||||
if (!state.selectedBookingUid) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
if (currentIndex <= 0) return null;
|
||||
|
||||
return state.bookings[currentIndex - 1].id;
|
||||
return state.bookings[currentIndex - 1].uid;
|
||||
},
|
||||
|
||||
hasNext: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return false;
|
||||
if (!state.selectedBookingUid) return false;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
|
||||
},
|
||||
|
||||
hasPrevious: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return false;
|
||||
if (!state.selectedBookingUid) return false;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.uid === state.selectedBookingUid);
|
||||
return currentIndex > 0;
|
||||
},
|
||||
}));
|
||||
@@ -92,7 +92,7 @@ export function BookingDetailsSheetStoreProvider({
|
||||
bookings: BookingOutput[];
|
||||
}) {
|
||||
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
|
||||
const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId();
|
||||
const [selectedBookingUidFromUrl, setSelectedBookingUidToUrl] = useSelectedBookingUid();
|
||||
|
||||
// Update bookings when they change
|
||||
React.useEffect(() => {
|
||||
@@ -102,22 +102,22 @@ export function BookingDetailsSheetStoreProvider({
|
||||
// Sync Store → URL
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
const storeId = state.selectedBookingId;
|
||||
if (storeId !== selectedBookingIdFromUrl) {
|
||||
setSelectedBookingIdToUrl(storeId);
|
||||
const storeUid = state.selectedBookingUid;
|
||||
if (storeUid !== selectedBookingUidFromUrl) {
|
||||
setSelectedBookingUidToUrl(storeUid);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]);
|
||||
}, [selectedBookingUidFromUrl, setSelectedBookingUidToUrl, store]);
|
||||
|
||||
// Sync URL → Store
|
||||
React.useEffect(() => {
|
||||
const currentStoreId = store.getState().selectedBookingId;
|
||||
if (currentStoreId !== selectedBookingIdFromUrl) {
|
||||
store.getState().setSelectedBookingId(selectedBookingIdFromUrl);
|
||||
const currentStoreUid = store.getState().selectedBookingUid;
|
||||
if (currentStoreUid !== selectedBookingUidFromUrl) {
|
||||
store.getState().setSelectedBookingUid(selectedBookingUidFromUrl);
|
||||
}
|
||||
}, [selectedBookingIdFromUrl, store]);
|
||||
}, [selectedBookingUidFromUrl, store]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreContext.Provider value={store}>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import type { DataTableRow } from "@calcom/features/data-table/lib/separator";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import type { validStatuses } from "./lib/validStatuses";
|
||||
|
||||
export type BookingsGetOutput = RouterOutputs["viewer"]["bookings"]["get"];
|
||||
|
||||
export type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
|
||||
|
||||
export type BookingAttendee = BookingOutput["attendees"][0];
|
||||
|
||||
export type RecurringInfo = {
|
||||
recurringEventId: string | null;
|
||||
count: number;
|
||||
@@ -13,15 +16,13 @@ export type RecurringInfo = {
|
||||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
export type RowData =
|
||||
| {
|
||||
type: "data";
|
||||
booking: BookingOutput;
|
||||
isToday: boolean;
|
||||
recurringInfo?: RecurringInfo;
|
||||
}
|
||||
| {
|
||||
type: "today" | "next";
|
||||
};
|
||||
export type BookingRowData = {
|
||||
type: "data";
|
||||
booking: BookingOutput;
|
||||
isToday: boolean;
|
||||
recurringInfo?: RecurringInfo;
|
||||
};
|
||||
|
||||
export type RowData = DataTableRow<BookingRowData>;
|
||||
|
||||
export type BookingListingStatus = (typeof validStatuses)[number];
|
||||
|
||||
@@ -36,7 +36,6 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { RefundPolicy } from "@calcom/lib/payment/types";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { getIs24hClockFromLocalStorage, isBrowserLocale24h } from "@calcom/lib/timeFormat";
|
||||
import { CURRENT_TIMEZONE } from "@calcom/lib/timezoneConstants";
|
||||
@@ -57,6 +56,7 @@ import CancelBooking from "@calcom/web/components/booking/CancelBooking";
|
||||
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
|
||||
import { timeZone } from "@calcom/web/lib/clock";
|
||||
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import type { PageProps } from "./bookings-single-view.getServerSideProps";
|
||||
|
||||
const stringToBoolean = z
|
||||
@@ -384,6 +384,22 @@ export default function Success(props: PageProps) {
|
||||
const canCancel = !eventType?.disableCancelling;
|
||||
const canReschedule = !eventType?.disableRescheduling;
|
||||
|
||||
const paymentStatusMessage = usePaymentStatus({
|
||||
bookingStatus: bookingInfo.status,
|
||||
startTime: bookingInfo.startTime,
|
||||
eventTypeTeamId: eventType?.teamId,
|
||||
userId: eventType?.owner?.id,
|
||||
payment: props.paymentStatus
|
||||
? {
|
||||
success: props.paymentStatus.success,
|
||||
refunded: props.paymentStatus.refunded,
|
||||
paymentOption: props.paymentStatus.paymentOption,
|
||||
}
|
||||
: { success: false, refunded: false },
|
||||
refundPolicy: eventType?.metadata?.apps?.stripe?.refundPolicy,
|
||||
refundDaysCount: eventType?.metadata?.apps?.stripe?.refundDaysCount,
|
||||
});
|
||||
|
||||
const successPageHeadline = (() => {
|
||||
if (isAwaitingPayment && !isCancelled) {
|
||||
return props.paymentStatus?.paymentOption === "HOLD"
|
||||
@@ -462,7 +478,8 @@ export default function Success(props: PageProps) {
|
||||
<div
|
||||
className={classNames(
|
||||
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
!isBackgroundTransparent && " bg-default dark:bg-cal-muted border-booker border-booker-width",
|
||||
!isBackgroundTransparent &&
|
||||
" bg-default dark:bg-cal-muted border-booker border-booker-width",
|
||||
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
||||
)}
|
||||
role="dialog"
|
||||
@@ -519,47 +536,7 @@ export default function Success(props: PageProps) {
|
||||
</div>
|
||||
{props.paymentStatus &&
|
||||
(bookingInfo.status === BookingStatus.CANCELLED ||
|
||||
bookingInfo.status === BookingStatus.REJECTED) && (
|
||||
<h4>
|
||||
{!props.paymentStatus.success &&
|
||||
!props.paymentStatus.refunded &&
|
||||
t("booking_with_payment_cancelled")}
|
||||
{props.paymentStatus.success &&
|
||||
!props.paymentStatus.refunded &&
|
||||
(() => {
|
||||
const refundPolicy = eventType?.metadata?.apps?.stripe?.refundPolicy;
|
||||
const refundDaysCount = eventType?.metadata?.apps?.stripe?.refundDaysCount;
|
||||
|
||||
// Handle missing team or event type owner (same in processPaymentRefund.ts)
|
||||
if (!eventType?.teamId && !eventType?.owner) {
|
||||
return t("booking_with_payment_cancelled_no_refund");
|
||||
}
|
||||
|
||||
// Handle DAYS policy with expired refund window
|
||||
else if (refundPolicy === RefundPolicy.DAYS && refundDaysCount) {
|
||||
const startTime = new Date(bookingInfo.startTime);
|
||||
const cancelTime = new Date();
|
||||
const daysDiff = Math.floor(
|
||||
(cancelTime.getTime() - startTime.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (daysDiff > refundDaysCount) {
|
||||
return t("booking_with_payment_cancelled_refund_window_expired");
|
||||
}
|
||||
}
|
||||
// Handle NEVER policy
|
||||
else if (refundPolicy === RefundPolicy.NEVER) {
|
||||
return t("booking_with_payment_cancelled_no_refund");
|
||||
}
|
||||
|
||||
// Handle ALWAYS policy
|
||||
else {
|
||||
return t("booking_with_payment_cancelled_already_paid");
|
||||
}
|
||||
})()}
|
||||
{props.paymentStatus.refunded && t("booking_with_payment_cancelled_refunded")}
|
||||
</h4>
|
||||
)}
|
||||
bookingInfo.status === BookingStatus.REJECTED) && <h4>{paymentStatusMessage}</h4>}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
@@ -711,7 +688,7 @@ export default function Success(props: PageProps) {
|
||||
<>
|
||||
<div className="mt-9 font-medium">{t("additional_notes")}</div>
|
||||
<div className="col-span-2 mb-2 mt-9">
|
||||
<p className="whitespace-pre-line wrap-break-word">{bookingInfo.description}</p>
|
||||
<p className="wrap-break-word whitespace-pre-line">{bookingInfo.description}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -738,7 +715,7 @@ export default function Success(props: PageProps) {
|
||||
<div className="col-span-2 mb-2 mt-2">
|
||||
{Object.entries(utmParams).filter(([_, value]) => Boolean(value)).length >
|
||||
0 ? (
|
||||
<ul className="list-disc stack-y-1 p-1 pl-5 sm:w-80">
|
||||
<ul className="stack-y-1 list-disc p-1 pl-5 sm:w-80">
|
||||
{Object.entries(utmParams)
|
||||
.filter(([_, value]) => Boolean(value))
|
||||
.map(([key, value]) => (
|
||||
@@ -892,6 +869,7 @@ export default function Success(props: PageProps) {
|
||||
currentUserEmail={currentUserEmail}
|
||||
isHost={isHost}
|
||||
internalNotePresets={props.internalNotePresets}
|
||||
renderContext="booking-single-view"
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
@@ -1053,7 +1031,7 @@ export default function Success(props: PageProps) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="my-4 stack-y-1 text-center">
|
||||
<div className="stack-y-1 my-4 text-center">
|
||||
<h2 className="font-cal text-lg">{t("submitted_feedback")}</h2>
|
||||
<p className="text-sm">{rateValue < 4 ? t("how_can_we_improve") : t("most_liked")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,37 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableProvider,
|
||||
type SystemFilterSegment,
|
||||
useDataTable,
|
||||
ColumnFilterType,
|
||||
useFilterValue,
|
||||
ZMultiSelectFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ZTextFilterValue,
|
||||
} from "@calcom/features/data-table";
|
||||
import { DataTableProvider, type SystemFilterSegment, ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
||||
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import { viewParser } from "~/bookings/lib/viewParser";
|
||||
import { BookingListContainer } from "../components/BookingListContainer";
|
||||
import type { validStatuses } from "../lib/validStatuses";
|
||||
import { viewParser } from "../lib/viewParser";
|
||||
|
||||
import { BookingsListContainer } from "../components/BookingsListContainer";
|
||||
|
||||
const BookingsCalendarContainer = dynamic(() =>
|
||||
import("../components/BookingsCalendarContainer").then((mod) => ({
|
||||
default: mod.BookingsCalendarContainer,
|
||||
const BookingCalendarContainer = dynamic(() =>
|
||||
import("../components/BookingCalendarContainer").then((mod) => ({
|
||||
default: mod.BookingCalendarContainer,
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -87,134 +72,19 @@ function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsPro
|
||||
const [_view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
// Force view to be "list" if calendar view is disabled
|
||||
const view = bookingsV3Enabled ? _view : "list";
|
||||
const { t } = useLocale();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const tabs: HorizontalTabItemProps[] = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
|
||||
const baseTabConfigs = [
|
||||
{
|
||||
name: "upcoming",
|
||||
path: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
path: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
path: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
path: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
path: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return baseTabConfigs.map((tabConfig) => ({
|
||||
name: tabConfig.name,
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
"data-testid": tabConfig["data-testid"],
|
||||
}));
|
||||
}, [searchParams]);
|
||||
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const userIds = useFilterValue("userId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
||||
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
||||
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
||||
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
|
||||
|
||||
const { limit, offset } = useDataTable();
|
||||
|
||||
// Only apply pagination for list view, calendar view needs all bookings
|
||||
const shouldPaginate = view === "list";
|
||||
const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view
|
||||
const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view
|
||||
|
||||
const query = trpc.viewer.bookings.get.useQuery(
|
||||
{
|
||||
limit: queryLimit,
|
||||
offset: queryOffset,
|
||||
filters: {
|
||||
status,
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
|
||||
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||
const isPending = query.isPending;
|
||||
const totalRowCount = query.data?.totalCount;
|
||||
const hasError = !!query.error;
|
||||
|
||||
const errorView = query.error ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-wrap justify-between lg:hidden">
|
||||
<HorizontalTabs
|
||||
tabs={tabs.map((tab) => ({
|
||||
...tab,
|
||||
name: t(tab.name),
|
||||
}))}
|
||||
<div className={classNames(view === "calendar" && "-mb-8")}>
|
||||
{view === "list" && (
|
||||
<BookingListContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
bookingsV3Enabled={bookingsV3Enabled}
|
||||
/>
|
||||
</div>
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{status === "upcoming" && !isEmpty && (
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
{view === "list" && (
|
||||
<BookingsListContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={query.data}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
enableDetailsSheet={bookingsV3Enabled}
|
||||
ErrorView={errorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
)}
|
||||
{bookingsV3Enabled && view === "calendar" && (
|
||||
<BookingsCalendarContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={query.data}
|
||||
isPending={isPending}
|
||||
ErrorView={errorView}
|
||||
hasError={hasError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
)}
|
||||
{bookingsV3Enabled && view === "calendar" && (
|
||||
<BookingCalendarContainer status={status} permissions={permissions} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"next-seo": "^6.0.0",
|
||||
"next-themes": "^0.2.0",
|
||||
"nodemailer": "^6.7.8",
|
||||
"nuqs": "^2.7.2",
|
||||
"nuqs": "^2.8.2",
|
||||
"openid-client": "6.5.0",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.274.1",
|
||||
|
||||
@@ -2,7 +2,6 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { applySelectFilter } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
@@ -51,123 +50,6 @@ test.describe("Booking Filters", () => {
|
||||
await expect(page.locator('[data-testid="add-filter-item-userId"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("Query params should be preserved when switching between bookings tabs", async ({ page, users }) => {
|
||||
const owner = await users.create(
|
||||
{ name: "Owner User" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await applySelectFilter(page, "userId", "Owner User");
|
||||
|
||||
await expect(page).toHaveURL(/.*userId.*/);
|
||||
const urlWithFilters = page.url();
|
||||
expect(urlWithFilters).toContain("userId");
|
||||
|
||||
const searchParams = new URL(urlWithFilters).searchParams;
|
||||
const activeFilters = searchParams.get("activeFilters");
|
||||
expect(activeFilters).toBeTruthy();
|
||||
|
||||
const pastBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("past-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/past/);
|
||||
await pastBookingsGetResponse;
|
||||
|
||||
const pastUrl = page.url();
|
||||
expect(pastUrl).toContain("/bookings/past");
|
||||
expect(pastUrl).toContain("userId");
|
||||
|
||||
const pastSearchParams = new URL(pastUrl).searchParams;
|
||||
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
|
||||
|
||||
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("cancelled-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/cancelled/);
|
||||
await cancelledBookingsGetResponse;
|
||||
|
||||
const cancelledUrl = page.url();
|
||||
expect(cancelledUrl).toContain("/bookings/cancelled");
|
||||
expect(cancelledUrl).toContain("userId");
|
||||
|
||||
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
|
||||
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
|
||||
});
|
||||
|
||||
test("Query params should NOT be preserved when navigating from a non-bookings page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
});
|
||||
|
||||
await owner.apiLogin();
|
||||
await page.goto(`/event-types?someParam=value`);
|
||||
await expect(page).toHaveURL(/.*someParam=value.*/);
|
||||
|
||||
const upcomingBookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.getByTestId("bookings-test").click();
|
||||
await page.getByTestId("upcoming-test").click();
|
||||
await expect(page).toHaveURL(/\/bookings\/upcoming/);
|
||||
await upcomingBookingsGetResponse;
|
||||
|
||||
const upcomingUrl = page.url();
|
||||
expect(upcomingUrl).toContain("/bookings/upcoming");
|
||||
expect(upcomingUrl).not.toContain("someParam=value");
|
||||
});
|
||||
|
||||
test("Query params should NOT be preserved when navigating from a bookings page to a non-bookings page", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(
|
||||
{ name: "Owner User" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
|
||||
}
|
||||
);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/upcoming`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
|
||||
await applySelectFilter(page, "userId", "Owner User");
|
||||
|
||||
await expect(page).toHaveURL(/.*userId.*/);
|
||||
const urlWithFilters = page.url();
|
||||
expect(urlWithFilters).toContain("userId");
|
||||
|
||||
await page.getByTestId("event_types_page_title-test").click();
|
||||
await expect(page).toHaveURL(/\/event-types/);
|
||||
|
||||
const eventTypeSearchParams = new URL(page.url()).searchParams;
|
||||
expect(eventTypeSearchParams.get("activeFilters")).toBeFalsy();
|
||||
});
|
||||
|
||||
test("Filter segments with removed team members should not cause stuck UI", async ({
|
||||
page,
|
||||
users,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
testEmail,
|
||||
testName,
|
||||
cancelBookingFromBookingsList,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
const freeUserObj = { name: `Free-user-${randomString(3)}` };
|
||||
@@ -105,7 +106,7 @@ test.describe("free user", () => {
|
||||
await page.goto(`/${free.username}`);
|
||||
});
|
||||
|
||||
test("cannot book same slot multiple times", async ({ page, users, emails }) => {
|
||||
test("cannot book same slot multiple times", async ({ page, users }) => {
|
||||
const [user] = users.get();
|
||||
|
||||
const bookerObj = {
|
||||
@@ -124,7 +125,7 @@ test.describe("free user", () => {
|
||||
|
||||
// Make sure we're navigated to the success page
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
const { title: eventTitle } = await user.getFirstEventAsOwner();
|
||||
await user.getFirstEventAsOwner();
|
||||
|
||||
await page.goto(bookingUrl);
|
||||
|
||||
@@ -206,23 +207,12 @@ test.describe("pro user", () => {
|
||||
await pro.apiLogin();
|
||||
|
||||
await page.goto("/bookings/upcoming");
|
||||
// Click the ellipsis menu button to open the dropdown
|
||||
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
|
||||
// Click the cancel option in the dropdown
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking/");
|
||||
await cancelBookingFromBookingsList({
|
||||
page,
|
||||
nth: 0,
|
||||
reason: "Test reason",
|
||||
});
|
||||
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
|
||||
await expect(page.locator(`[data-testid="attendee-email-${testEmail}"]`)).toHaveText(testEmail);
|
||||
await expect(page.locator(`[data-testid="attendee-name-${testName}"]`)).toHaveText(testName);
|
||||
|
||||
await page.goto(`/${pro.username}`);
|
||||
await bookFirstEvent(page);
|
||||
});
|
||||
@@ -241,25 +231,18 @@ test.describe("pro user", () => {
|
||||
await pro.apiLogin();
|
||||
|
||||
await page.goto("/bookings/upcoming");
|
||||
// Click the ellipsis menu button to open the dropdown
|
||||
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
|
||||
// Click the cancel option in the dropdown
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking/");
|
||||
const { bookingUid } = await cancelBookingFromBookingsList({
|
||||
page,
|
||||
nth: 0,
|
||||
reason: "Test reason",
|
||||
});
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
const bookingCancelledId = new URL(page.url()).pathname.split("/booking/")[1];
|
||||
await pro.getFirstEventAsOwner();
|
||||
|
||||
const { slug: eventSlug } = await pro.getFirstEventAsOwner();
|
||||
|
||||
await page.goto(`/reschedule/${bookingCancelledId}`);
|
||||
await page.goto(`/reschedule/${bookingUid}`);
|
||||
|
||||
expect(page.url()).not.toContain("rescheduleUid");
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -280,7 +263,7 @@ test.describe("pro user", () => {
|
||||
await expect(page.locator('[data-testid="empty-screen"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("can book an unconfirmed event multiple times", async ({ page, users }) => {
|
||||
test("can book an unconfirmed event multiple times", async ({ page }) => {
|
||||
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
@@ -297,7 +280,6 @@ test.describe("pro user", () => {
|
||||
|
||||
test("booking an unconfirmed event with the same email brings you to the original request", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -312,7 +294,7 @@ test.describe("pro user", () => {
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can book with multiple guests", async ({ page, users }) => {
|
||||
test("can book with multiple guests", async ({ page }) => {
|
||||
const additionalGuests = ["test@gmail.com", "test2@gmail.com"];
|
||||
|
||||
await page.click('[data-testid="event-type-link"]');
|
||||
@@ -335,7 +317,7 @@ test.describe("pro user", () => {
|
||||
await Promise.all(promises);
|
||||
});
|
||||
|
||||
test("Time slots should be reserved when selected", async ({ context, page, browser }) => {
|
||||
test("Time slots should be reserved when selected", async ({ page, browser }) => {
|
||||
const initialUrl = page.url();
|
||||
await page.locator('[data-testid="event-type-link"]').first().click();
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -417,10 +399,7 @@ test.describe("prefill", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("Persist the field values when going back and coming back to the booking form", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
test("Persist the field values when going back and coming back to the booking form", async ({ page }) => {
|
||||
await page.goto("/pro/30min");
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await page.fill('[name="name"]', "John Doe");
|
||||
@@ -434,7 +413,7 @@ test.describe("prefill", () => {
|
||||
await expect(page.locator('[name="notes"]')).toHaveValue("Test notes");
|
||||
});
|
||||
|
||||
test("logged out", async ({ page, users }) => {
|
||||
test("logged out", async ({ page }) => {
|
||||
await page.goto("/pro/30min");
|
||||
|
||||
await test.step("from query params", async () => {
|
||||
@@ -708,11 +687,11 @@ test("Should throw error when both seatsPerTimeSlot and recurringEvent are set",
|
||||
});
|
||||
|
||||
test.describe("GTM container", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
test.beforeEach(async ({ users }) => {
|
||||
await users.create();
|
||||
});
|
||||
|
||||
test("global GTM should not be loaded on private booking link", async ({ page, users, emails, prisma }) => {
|
||||
test("global GTM should not be loaded on private booking link", async ({ page, users, prisma }) => {
|
||||
const [user] = users.get();
|
||||
const eventType = await user.getFirstEventAsOwner();
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
doOnOrgDomain,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
selectSecondAvailableTimeSlotNextMonth,
|
||||
cancelBookingFromBookingsList,
|
||||
} from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
@@ -57,18 +58,11 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
|
||||
await test.step("Can cancel the recently created booking", async () => {
|
||||
await page.goto("/bookings/upcoming");
|
||||
// Click the ellipsis menu button to open the dropdown
|
||||
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
|
||||
// Click the cancel option in the dropdown
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.waitForURL((url) => {
|
||||
return url.pathname.startsWith("/booking");
|
||||
await cancelBookingFromBookingsList({
|
||||
page,
|
||||
nth: 0,
|
||||
reason: "Test reason",
|
||||
});
|
||||
await page.locator('[data-testid="cancel_reason"]').fill("Test reason");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ export async function expectSegmentCleared(page: Page) {
|
||||
// Check that no segment is selected (button shows default text)
|
||||
const segmentSelect = page.getByTestId("filter-segment-select");
|
||||
const buttonText = await segmentSelect.textContent();
|
||||
expect(buttonText?.trim()).toEqual("Saved filters");
|
||||
expect(["Saved filters", "Saved"]).toContain(buttonText?.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -57,9 +57,7 @@ test.describe("Locale-specific pages must not 404", () => {
|
||||
await user.apiLogin();
|
||||
const response = await page.goto(`/${locale}/bookings/upcoming`);
|
||||
expect(response?.status()).not.toBe(404);
|
||||
await expect(
|
||||
page.locator("text=See upcoming and past events booked through your event type links.")
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "No upcoming bookings" })).toBeVisible();
|
||||
});
|
||||
|
||||
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createHash } from "crypto";
|
||||
import EventEmitter from "events";
|
||||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { createServer } from "http";
|
||||
|
||||
import type { Messages } from "mailhog";
|
||||
import { totp } from "otplib";
|
||||
import { v4 as uuid } from "uuid";
|
||||
@@ -213,7 +212,7 @@ export async function setupManagedEvent({
|
||||
addManagedEventToTeamMates: true,
|
||||
managedEventUnlockedFields: unlockedFields,
|
||||
});
|
||||
|
||||
|
||||
const memberUser = users.get().find((u) => u.name === teamMateName)!;
|
||||
const { team } = await adminUser.getFirstTeamMembership();
|
||||
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
|
||||
@@ -371,7 +370,7 @@ async function createUserWithSeatedEvent(users: Fixtures["users"]) {
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === slug)!;
|
||||
return { user, eventType };
|
||||
}
|
||||
@@ -425,7 +424,9 @@ export function goToUrlWithErrorHandling({ page, url }: { page: Page; url: strin
|
||||
page.on("requestfailed", onRequestFailed);
|
||||
try {
|
||||
await page.goto(url);
|
||||
} catch (e) {}
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
page.off("requestfailed", onRequestFailed);
|
||||
resolve({ success: true, url: page.url() });
|
||||
});
|
||||
@@ -595,3 +596,27 @@ export async function setupOrgMember(users: CreateUsersFixture) {
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
export async function cancelBookingFromBookingsList({
|
||||
page,
|
||||
nth,
|
||||
reason,
|
||||
}: {
|
||||
page: Page;
|
||||
reason: string;
|
||||
nth: number;
|
||||
}) {
|
||||
await page.locator('[data-testid="booking-actions-dropdown"]').nth(nth).click();
|
||||
const bookingUid = await page.locator('[data-testid="cancel"]').getAttribute("data-booking-uid");
|
||||
// Click the cancel option in the dropdown
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.locator('[data-testid="cancel_reason"]').fill(reason);
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await expect(
|
||||
page.locator('[data-testid="toast-success"]').filter({ hasText: "Booking Canceled" })
|
||||
).toBeVisible();
|
||||
|
||||
return {
|
||||
bookingUid,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,12 +252,12 @@ test.describe("authorized user sees correct translations (de)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
|
||||
const locator = page.locator("h2", { hasText: "Es gibt noch keine bevorstehende Buchungen" });
|
||||
await expect(locator).toHaveCount(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Bookings", { exact: true });
|
||||
const locator = page.getByText("No upcoming bookings", { exact: true });
|
||||
await expect(locator).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
@@ -271,12 +271,12 @@ test.describe("authorized user sees correct translations (de)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
const locator = page.getByRole("heading", { name: "Buchungen", exact: true });
|
||||
const locator = page.locator("h2", { hasText: "Es gibt noch keine bevorstehende Buchungen" });
|
||||
await expect(locator).toHaveCount(1);
|
||||
}
|
||||
|
||||
{
|
||||
const locator = page.getByText("Bookings", { exact: true });
|
||||
const locator = page.getByText("No upcoming bookings", { exact: true });
|
||||
await expect(locator).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
@@ -324,7 +324,7 @@ test.describe("authorized user sees correct translations (pt-br)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
const locator = page.getByRole("heading", { name: "Reservas", exact: true });
|
||||
const locator = page.locator("h2", { hasText: "Ainda não tem reservas próximos" });
|
||||
await expect(locator).toHaveCount(1);
|
||||
}
|
||||
|
||||
@@ -343,7 +343,7 @@ test.describe("authorized user sees correct translations (pt-br)", async () => {
|
||||
await page.locator("html[dir=ltr]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
const locator = page.getByRole("heading", { name: "Reservas", exact: true });
|
||||
const locator = page.locator("h2", { hasText: "Ainda não tem reservas próximos" });
|
||||
await expect(locator).toHaveCount(1);
|
||||
}
|
||||
|
||||
@@ -396,7 +396,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
|
||||
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
|
||||
const locator = page.locator("h2", { hasText: "لا توجد عمليات حجز القادم" });
|
||||
await expect(locator).toHaveCount(1);
|
||||
}
|
||||
|
||||
@@ -415,7 +415,7 @@ test.describe("authorized user sees correct translations (ar)", async () => {
|
||||
await page.locator("html[dir=rtl]").waitFor({ state: "attached" });
|
||||
|
||||
{
|
||||
const locator = page.getByRole("heading", { name: "عمليات الحجز", exact: true });
|
||||
const locator = page.locator("h2", { hasText: "لا توجد عمليات حجز القادم" });
|
||||
await expect(locator).toHaveCount(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -25,7 +25,7 @@ test.afterEach(({ users }) => users.deleteAll());
|
||||
test.describe("Reschedule Tests", async () => {
|
||||
test("Should do a booking request reschedule from /bookings", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
});
|
||||
@@ -57,8 +57,8 @@ test.describe("Reschedule Tests", async () => {
|
||||
bookings,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
|
||||
await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: dayjs().subtract(2, "day").toDate(),
|
||||
endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(),
|
||||
@@ -105,7 +105,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
test("Should display former time when rescheduling availability", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
|
||||
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
rescheduled: true,
|
||||
@@ -173,7 +173,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
await user.installStripePersonal({ skip: true });
|
||||
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
rescheduled: true,
|
||||
@@ -196,7 +196,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const payment = await payments.create(booking.id);
|
||||
await payments.create(booking.id);
|
||||
await page.goto(`/reschedule/${booking.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -218,7 +218,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
await user.apiLogin();
|
||||
await user.installStripePersonal({ skip: true });
|
||||
await users.logout();
|
||||
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "paid")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
rescheduled: true,
|
||||
@@ -226,7 +226,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
paid: true,
|
||||
});
|
||||
|
||||
const payment = await payments.create(booking.id);
|
||||
await payments.create(booking.id);
|
||||
await page.goto(`/reschedule/${booking?.uid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -238,7 +238,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
test("Opt in event should be PENDING when rescheduled by USER", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
@@ -259,7 +259,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
test("Opt in event should be ACCEPTED when rescheduled by OWNER", async ({ page, users, bookings }) => {
|
||||
const user = await users.create();
|
||||
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
const booking = await bookings.create(user.id, user.username, eventType.id, {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
@@ -330,20 +330,19 @@ test.describe("Reschedule Tests", async () => {
|
||||
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
browser,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!process.env.DAILY_API_KEY, "DAILY_API_KEY is needed for this test");
|
||||
const user = await users.create();
|
||||
|
||||
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
|
||||
const confirmBooking = async (bookingId: number) => {
|
||||
const confirmBooking = async (bookingUid: string) => {
|
||||
const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser);
|
||||
await authedPage.goto("/bookings/upcoming");
|
||||
await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", {
|
||||
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
|
||||
action: () => authedPage.locator(`[data-booking-uid="${bookingUid}"][data-testid="confirm"]`).click(),
|
||||
});
|
||||
await authedContext.close();
|
||||
};
|
||||
@@ -359,7 +358,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
|
||||
const currentBooking = await prisma.booking.findFirstOrThrow({ where: { uid: bookingUID } });
|
||||
expect(currentBooking).not.toBeUndefined();
|
||||
await confirmBooking(currentBooking.id);
|
||||
await confirmBooking(currentBooking.uid);
|
||||
|
||||
await page.goto(`/reschedule/${currentBooking.uid}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
@@ -372,7 +371,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
});
|
||||
expect(newBooking).not.toBeUndefined();
|
||||
expect(newBooking.status).toBe(BookingStatus.PENDING);
|
||||
await confirmBooking(newBooking.id);
|
||||
await confirmBooking(newBooking.uid);
|
||||
|
||||
const booking = await prisma.booking.findFirstOrThrow({ where: { id: newBooking.id } });
|
||||
expect(booking).not.toBeUndefined();
|
||||
@@ -469,7 +468,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
});
|
||||
const profileUsername = (await orgMember.getFirstProfile()).username;
|
||||
const eventType = orgMember.eventTypes[0];
|
||||
|
||||
|
||||
const orgSlug = org.slug!;
|
||||
const booking = await bookings.create(orgMember.id, orgMember.username, eventType.id);
|
||||
|
||||
@@ -510,7 +509,7 @@ test.describe("Reschedule Tests", async () => {
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
const eventType = orgMember.eventTypes[0];
|
||||
|
||||
|
||||
const orgSlug = org.slug!;
|
||||
const booking = await bookings.create(orgMember.id, orgMember.username, eventType.id);
|
||||
|
||||
|
||||
@@ -4097,6 +4097,8 @@
|
||||
"booking_response": "Booking Response",
|
||||
"list_view": "List view",
|
||||
"calendar_view": "Calendar view",
|
||||
"assignment_reason": "Assignment Reason",
|
||||
"saved": "Saved",
|
||||
"booking_history": "Booking History",
|
||||
"booking_history_description": "View the history of actions performed on this booking",
|
||||
"audit_action": {
|
||||
|
||||
@@ -22,7 +22,9 @@ type GetOptions = {
|
||||
take: number;
|
||||
skip: number;
|
||||
filters: {
|
||||
// Support both singular 'status' (for API v2) and plural 'statuses' (/bookings page)
|
||||
status?: InputByStatus;
|
||||
statuses?: InputByStatus[];
|
||||
teamIds?: number[] | undefined;
|
||||
userIds?: number[] | undefined;
|
||||
eventTypeIds?: number[] | undefined;
|
||||
@@ -36,12 +38,21 @@ type GetOptions = {
|
||||
const getAllUserBookings = async ({ ctx, filters, bookingListingByStatus, take, skip, sort }: GetOptions) => {
|
||||
const { prisma, user, kysely } = ctx;
|
||||
|
||||
// Support both singular 'status' and plural 'statuses' for backward compatibility
|
||||
// Note: filters can be undefined at runtime despite the type definition, e.g., when
|
||||
// API endpoints are called without query parameters (found in managed-user-bookings.e2e-spec.ts).
|
||||
// Use optional chaining to handle this defensively.
|
||||
const statusesFilter = filters?.statuses ?? (filters?.status ? [filters.status] : bookingListingByStatus);
|
||||
|
||||
const { bookings, recurringInfo, totalCount } = await getBookings({
|
||||
user,
|
||||
prisma,
|
||||
kysely,
|
||||
bookingListingByStatus,
|
||||
filters: filters,
|
||||
filters: {
|
||||
...filters,
|
||||
statuses: statusesFilter,
|
||||
},
|
||||
sort,
|
||||
take,
|
||||
skip,
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { Booking } from "@calcom/prisma/client";
|
||||
import { RRTimestampBasis, BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMinimalSelect } from "@calcom/prisma/selects/booking";
|
||||
import {
|
||||
bookingMinimalSelect,
|
||||
bookingAuthorizationCheckSelect,
|
||||
bookingDetailsSelect,
|
||||
} from "@calcom/prisma/selects/booking";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
type ManagedEventReassignmentCreateParams = {
|
||||
@@ -501,6 +505,48 @@ export class BookingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findByUidForAuthorizationCheck({ bookingUid }: { bookingUid: string }) {
|
||||
return await this.prismaClient.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: bookingAuthorizationCheckSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findByUidForDetails({ bookingUid }: { bookingUid: string }) {
|
||||
return await this.prismaClient.booking.findUnique({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
select: bookingDetailsSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findRescheduledToBooking({ bookingUid }: { bookingUid: string }) {
|
||||
return await this.prismaClient.booking.findFirst({
|
||||
where: {
|
||||
fromReschedule: bookingUid,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findPreviousBooking({ fromReschedule }: { fromReschedule: string }) {
|
||||
return await this.prismaClient.booking.findUnique({
|
||||
where: {
|
||||
uid: fromReschedule,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveRecurringBookingsFromDate({
|
||||
recurringEventId,
|
||||
fromDate,
|
||||
@@ -1563,7 +1609,7 @@ export class BookingRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
findByUidIncludeEventTypeAndReferences({ bookingUid }: { bookingUid: string }) {
|
||||
return this.prismaClient.booking.findUniqueOrThrow({
|
||||
where: {
|
||||
@@ -1618,7 +1664,7 @@ export class BookingRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async updateBookingStatus({
|
||||
bookingId,
|
||||
status,
|
||||
@@ -1649,7 +1695,6 @@ export class BookingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cancels a booking as part of the Managed Event reassignment flow.
|
||||
* Callers only pass domain data; repository handles persistence details.
|
||||
@@ -1724,8 +1769,8 @@ export class BookingRepository {
|
||||
location,
|
||||
smsReminderNumber,
|
||||
responses: responses ?? undefined,
|
||||
customInputs: customInputs as unknown as Prisma.InputJsonValue ?? undefined,
|
||||
metadata: metadata as unknown as Prisma.InputJsonValue ?? undefined,
|
||||
customInputs: (customInputs as unknown as Prisma.InputJsonValue) ?? undefined,
|
||||
metadata: (metadata as unknown as Prisma.InputJsonValue) ?? undefined,
|
||||
idempotencyKey,
|
||||
iCalUID,
|
||||
iCalSequence,
|
||||
|
||||
@@ -64,8 +64,8 @@ export class BookingAccessService {
|
||||
const booking = bookingUid
|
||||
? await bookingRepo.findByUidIncludeEventType({ bookingUid })
|
||||
: bookingId
|
||||
? await bookingRepo.findByIdIncludeEventType({ bookingId })
|
||||
: null;
|
||||
? await bookingRepo.findByIdIncludeEventType({ bookingId })
|
||||
: null;
|
||||
|
||||
if (!booking) return false;
|
||||
|
||||
@@ -131,4 +131,62 @@ export class BookingAccessService {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a user has access to view booking details using:
|
||||
* 1. Owner/host check
|
||||
* 2. PBAC permission check for team bookings
|
||||
*/
|
||||
async checkBookingAccessWithPBAC({
|
||||
userId,
|
||||
bookingUid,
|
||||
}: {
|
||||
userId: number;
|
||||
bookingUid: string;
|
||||
}): Promise<boolean> {
|
||||
const bookingRepo = new BookingRepository(this.prismaClient);
|
||||
|
||||
const booking = await bookingRepo.findByUidForAuthorizationCheck({ bookingUid });
|
||||
|
||||
if (!booking) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check 1: User is the owner of the booking
|
||||
const isOwner = booking.userId === userId;
|
||||
if (isOwner) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 2: User is a host (checking eventType.users and eventType.hosts)
|
||||
const attendeeEmails = new Set(booking.attendees?.map((attendee) => attendee.email) || []);
|
||||
|
||||
const isHostViaEventTypeUsers = booking.eventType?.users?.some(
|
||||
(user) => user.id === userId && attendeeEmails.has(user.email)
|
||||
);
|
||||
|
||||
const isHostViaEventTypeHosts = booking.eventType?.hosts?.some(
|
||||
(host) => host.user?.id === userId && attendeeEmails.has(host.user.email)
|
||||
);
|
||||
|
||||
if (isHostViaEventTypeUsers || isHostViaEventTypeHosts) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check 3: PBAC permission check for team bookings
|
||||
if (!booking.eventType?.teamId) {
|
||||
// No team associated with booking and user is not owner/host
|
||||
return false;
|
||||
}
|
||||
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
const hasPermission = await permissionCheckService.checkPermission({
|
||||
userId,
|
||||
teamId: booking.eventType.teamId,
|
||||
permission: "booking.read",
|
||||
fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
|
||||
});
|
||||
|
||||
return hasPermission;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import { BookingRepository } from "../repositories/BookingRepository";
|
||||
import { BookingAccessService } from "./BookingAccessService";
|
||||
|
||||
export class BookingDetailsService {
|
||||
private bookingRepo: BookingRepository;
|
||||
private bookingAccessService: BookingAccessService;
|
||||
|
||||
constructor(prismaClient: PrismaClient) {
|
||||
this.bookingRepo = new BookingRepository(prismaClient);
|
||||
this.bookingAccessService = new BookingAccessService(prismaClient);
|
||||
}
|
||||
|
||||
async getBookingDetails({ userId, bookingUid }: { userId: number; bookingUid: string }) {
|
||||
const hasAccess = await this.bookingAccessService.checkBookingAccessWithPBAC({
|
||||
userId,
|
||||
bookingUid,
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
throw ErrorWithCode.Factory.Forbidden("You do not have permission to view this booking");
|
||||
}
|
||||
|
||||
const booking = await this.bookingRepo.findByUidForDetails({ bookingUid });
|
||||
|
||||
if (!booking) {
|
||||
throw ErrorWithCode.Factory.BookingNotFound("Booking not found");
|
||||
}
|
||||
|
||||
const [rescheduledToBooking, previousBooking] = await Promise.all([
|
||||
// For old rescheduled bookings, find the new booking
|
||||
booking.rescheduled
|
||||
? this.bookingRepo.findRescheduledToBooking({ bookingUid: booking.uid })
|
||||
: Promise.resolve(null),
|
||||
// For new bookings that replaced an old one, fetch the previous booking's schedule
|
||||
booking.fromReschedule
|
||||
? this.bookingRepo.findPreviousBooking({ fromReschedule: booking.fromReschedule })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
rescheduledToBooking,
|
||||
previousBooking,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
|
||||
<div
|
||||
ref={containerNavRef}
|
||||
className={classNames(
|
||||
"bg-default dark:bg-cal-muted sticky top-(--calendar-dates-sticky-offset,0px) z-80 flex-none border-b",
|
||||
"bg-default dark:bg-cal-muted top-(--calendar-dates-sticky-offset,0px) z-80 sticky flex-none border-b",
|
||||
borderColor === "subtle" ? "border-b-subtle" : "border-b-default",
|
||||
showBorder && (borderColor === "subtle" ? "border-r-subtle border-r" : "border-r-default border-r")
|
||||
)}>
|
||||
@@ -101,7 +101,7 @@ export function DateValues({ showBorder, borderColor, days, containerNavRef }: P
|
||||
<span
|
||||
className={classNames(
|
||||
"items-center justify-center p-1",
|
||||
isToday && "bg-brand-default text-brand rounded-md"
|
||||
isToday && "bg-brand-default text-brand ml-1 rounded-md"
|
||||
)}>
|
||||
{day.format("DD")}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
@@ -16,15 +17,15 @@ type EventProps = {
|
||||
};
|
||||
|
||||
const eventClasses = cva(
|
||||
"group flex h-full w-full overflow-hidden rounded-[6px] px-[6px] text-xs leading-5 opacity-80 border-default font-medium",
|
||||
"group flex h-full w-full overflow-hidden rounded-[6px] px-[6px] text-xs leading-5 border-default font-medium",
|
||||
{
|
||||
variants: {
|
||||
status: {
|
||||
ACCEPTED: "bg-subtle hover:bg-emphasis text-emphasis border-[1px] border-default",
|
||||
PENDING: "bg-muted text-emphasis border-[1px] border-dashed border-default",
|
||||
REJECTED: "bg-muted border-[1px] text-subtle line-through",
|
||||
CANCELLED: "bg-muted border-[1px] text-subtle line-through",
|
||||
AWAITING_HOST: "bg-muted text-emphasis border-[1px] border-dashed border-default",
|
||||
PENDING: "bg-subtle text-subtle border-[1px] border-dashed border-default",
|
||||
REJECTED: "bg-subtle border-[1px] border-dashed text-subtle line-through",
|
||||
CANCELLED: "bg-subtle border-[1px] border-dashed text-subtle line-through",
|
||||
AWAITING_HOST: "bg-subtle text-emphasis border-[1px] border-dashed border-default",
|
||||
},
|
||||
disabled: {
|
||||
true: "hover:cursor-default",
|
||||
@@ -42,6 +43,22 @@ const eventClasses = cva(
|
||||
}
|
||||
);
|
||||
|
||||
const STATUS_COLOR_MAP: Record<BookingStatus, string> = {
|
||||
ACCEPTED: "bg-green-500",
|
||||
PENDING: "bg-orange-500",
|
||||
REJECTED: "bg-red-500",
|
||||
CANCELLED: "bg-red-500",
|
||||
AWAITING_HOST: "bg-blue-500",
|
||||
};
|
||||
|
||||
// Maps booking status to color classes for the color bar
|
||||
// Follows the same pattern as BookingDetailsSheet badge variants
|
||||
const getStatusColorClass = (status?: BookingStatus): string | undefined => {
|
||||
if (!status) return undefined;
|
||||
|
||||
return STATUS_COLOR_MAP[status];
|
||||
};
|
||||
|
||||
export function Event({
|
||||
event,
|
||||
currentlySelectedEventId,
|
||||
@@ -53,6 +70,9 @@ export function Event({
|
||||
const selected = currentlySelectedEventId === event.id;
|
||||
const { options } = event;
|
||||
|
||||
// Use custom color if provided, otherwise derive from status
|
||||
const colorClass = !options?.color ? getStatusColorClass(options?.status) : undefined;
|
||||
|
||||
const Component = onEventClick ? "button" : "div";
|
||||
|
||||
const dayOfWeek = dayjs(event.start).day();
|
||||
@@ -61,8 +81,10 @@ export function Event({
|
||||
const tooltipContent = (
|
||||
<div className="flex min-w-[200px] max-w-[300px] flex-col gap-1 py-1">
|
||||
<div className="flex items-start gap-2">
|
||||
{options?.color && (
|
||||
<div className="mt-1 h-3 w-1 shrink-0 rounded-sm" style={{ backgroundColor: options.color }}></div>
|
||||
{(options?.color || colorClass) && (
|
||||
<div
|
||||
className={classNames("mt-1 h-3 w-1 shrink-0 rounded-sm", colorClass)}
|
||||
style={options?.color ? { backgroundColor: options.color } : undefined}></div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold leading-tight">{event.title}</div>
|
||||
@@ -103,10 +125,10 @@ export function Event({
|
||||
style={{
|
||||
transition: "all 100ms ease-out",
|
||||
}}>
|
||||
{options?.color && (
|
||||
{(options?.color || colorClass) && (
|
||||
<div
|
||||
className="-ml-1.5 mr-1.5 h-full w-[3px] shrink-0"
|
||||
style={{ backgroundColor: options.color }}></div>
|
||||
className={classNames("-ml-1.5 mr-1.5 h-full w-[3px] shrink-0", colorClass)}
|
||||
style={options?.color ? { backgroundColor: options.color } : undefined}></div>
|
||||
)}
|
||||
<div className={classNames("flex w-full", displayType !== "single-line" && "flex-col py-1")}>
|
||||
{displayType === "single-line" && (
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface CalendarEvent {
|
||||
allDay?: boolean;
|
||||
color?: string;
|
||||
className?: string;
|
||||
bookingId?: number;
|
||||
bookingUid?: string;
|
||||
"data-test-id"?: string;
|
||||
borderOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -362,7 +362,7 @@ function DataTableBody<TData>({
|
||||
|
||||
return (
|
||||
<TableBody
|
||||
className="border-subtle relative grid border-t"
|
||||
className="border-muted relative grid border-t"
|
||||
data-testid={testId}
|
||||
style={{ height: tableHeight }}>
|
||||
{rowsToRender.map(({ row, virtualItem }) => {
|
||||
@@ -382,7 +382,7 @@ function DataTableBody<TData>({
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}),
|
||||
}}
|
||||
className="hover:bg-subtle border-subtle flex w-full border-b">
|
||||
className="hover:bg-subtle border-muted flex w-full border-b">
|
||||
<SeparatorRowRenderer separator={row.original as SeparatorRow} className={separatorClassName} />
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useDataTable, useFilterableColumns } from "../../hooks";
|
||||
|
||||
export interface AddFilterButtonProps<TData> {
|
||||
table: Table<TData>;
|
||||
variant?: "base" | "sm";
|
||||
variant?: "base" | "minimal";
|
||||
}
|
||||
|
||||
function AddFilterButtonComponent<TData>(
|
||||
@@ -31,7 +31,7 @@ function AddFilterButtonComponent<TData>(
|
||||
(column) => !activeFilters?.some((filter) => filter.f === column.id)
|
||||
);
|
||||
|
||||
if (variant === "sm" && availableColumns.length === 0) {
|
||||
if (variant === "minimal" && availableColumns.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,13 @@ function AddFilterButtonComponent<TData>(
|
||||
ref={ref}
|
||||
color="secondary"
|
||||
data-testid="add-filter-button"
|
||||
StartIcon="sliders-horizontal"
|
||||
StartIcon="list-filter"
|
||||
className="h-full">
|
||||
{t("filter")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
{variant === "sm" && (
|
||||
{variant === "minimal" && (
|
||||
<Tooltip content={t("add_filter")}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button ref={ref} color="secondary" data-testid="add-filter-button" className="h-full">
|
||||
@@ -78,7 +78,7 @@ function AddFilterButtonComponent<TData>(
|
||||
addFilter(column.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="flex items-center justify-between px-4 py-2 rounded-none"
|
||||
className="flex items-center justify-between rounded-none px-4 py-2"
|
||||
data-testid={`add-filter-item-${column.id}`}>
|
||||
<span>{startCase(column.title)}</span>
|
||||
{showHiddenIndicator && <Icon name="eye-off" className="h-4 w-4 opacity-50" />}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { type Table } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import { useDisplayedFilterCount } from "../../hooks";
|
||||
import { ActiveFilters } from "./ActiveFilters";
|
||||
import { AddFilterButton } from "./AddFilterButton";
|
||||
|
||||
@@ -13,18 +12,13 @@ interface FilterBarProps<TData> {
|
||||
}
|
||||
|
||||
export function FilterBar<TData>({ table, columnIdsToHide }: FilterBarProps<TData>) {
|
||||
const { activeFilters } = useDataTable();
|
||||
const displayedFilterCount = useMemo(() => {
|
||||
return (activeFilters ?? []).filter((filter) =>
|
||||
columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true
|
||||
).length;
|
||||
}, [activeFilters, columnIdsToHide]);
|
||||
const displayedFilterCount = useDisplayedFilterCount({ columnIdsToHide });
|
||||
|
||||
return (
|
||||
<>
|
||||
{displayedFilterCount === 0 && <AddFilterButton table={table} />}
|
||||
<ActiveFilters table={table} columnIdsToHide={columnIdsToHide} />
|
||||
{displayedFilterCount > 0 && <AddFilterButton table={table} variant="sm" />}
|
||||
{displayedFilterCount > 0 && <AddFilterButton table={table} variant="minimal" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,11 @@ type SubmenuItem = {
|
||||
enabledForSystemSegment?: boolean;
|
||||
};
|
||||
|
||||
export function FilterSegmentSelect() {
|
||||
interface Props {
|
||||
shortLabel?: boolean;
|
||||
}
|
||||
|
||||
export function FilterSegmentSelect({ shortLabel }: Props = {}) {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
@@ -155,7 +159,7 @@ export function FilterSegmentSelect() {
|
||||
StartIcon="list-filter"
|
||||
EndIcon="chevron-down"
|
||||
data-testid="filter-segment-select">
|
||||
{selectedSegment?.name || t("saved_filters")}
|
||||
{selectedSegment?.name || (shortLabel ? t("saved") : t("saved_filters"))}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
|
||||
@@ -5,3 +5,4 @@ export * from "./useFilterValue";
|
||||
export * from "./useColumnFilters";
|
||||
export * from "./useDataTable";
|
||||
export * from "./useFilterableColumns";
|
||||
export * from "./useDisplayedFilterCount";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useDataTable } from "./useDataTable";
|
||||
|
||||
type UseDisplayedFilterCountProps = {
|
||||
columnIdsToHide?: string[];
|
||||
};
|
||||
|
||||
export const useDisplayedFilterCount = ({ columnIdsToHide }: UseDisplayedFilterCountProps = {}) => {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
return (activeFilters ?? []).filter((filter) =>
|
||||
columnIdsToHide ? !columnIdsToHide.includes(filter.f) : true
|
||||
).length;
|
||||
}, [activeFilters, columnIdsToHide]);
|
||||
};
|
||||
@@ -16,14 +16,6 @@ import { NavigationItem, MobileNavigationItem, MobileNavigationMoreItem } from "
|
||||
|
||||
export const MORE_SEPARATOR_NAME = "more";
|
||||
|
||||
const preserveBookingsQueryParams = ({
|
||||
prevPathname,
|
||||
nextPathname,
|
||||
}: {
|
||||
prevPathname: string | null;
|
||||
nextPathname: string;
|
||||
}) => Boolean(prevPathname?.startsWith("/bookings/")) && nextPathname.startsWith("/bookings/");
|
||||
|
||||
const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemType[] => [
|
||||
{
|
||||
name: "event_types_page_title",
|
||||
@@ -36,38 +28,6 @@ const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemTy
|
||||
icon: "calendar",
|
||||
badge: <UnconfirmedBookingBadge />,
|
||||
isCurrent: ({ pathname }) => pathname?.startsWith("/bookings") ?? false,
|
||||
child: [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
preserveQueryParams: preserveBookingsQueryParams,
|
||||
isCurrent: ({ pathname }) => pathname === "/bookings/upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
preserveQueryParams: preserveBookingsQueryParams,
|
||||
isCurrent: ({ pathname }) => pathname === "/bookings/unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
preserveQueryParams: preserveBookingsQueryParams,
|
||||
isCurrent: ({ pathname }) => pathname === "/bookings/recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
preserveQueryParams: preserveBookingsQueryParams,
|
||||
isCurrent: ({ pathname }) => pathname === "/bookings/past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
preserveQueryParams: preserveBookingsQueryParams,
|
||||
isCurrent: ({ pathname }) => pathname === "/bookings/cancelled",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "availability",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import React, { Fragment, useState, useEffect, useRef } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import React, { Fragment, useState, useEffect } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
|
||||
@@ -33,29 +33,6 @@ const usePersistedExpansionState = (itemName: string) => {
|
||||
return [isExpanded, setPersistedExpansion] as const;
|
||||
};
|
||||
|
||||
const useBuildHref = () => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const prevPathnameRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
prevPathnameRef.current = pathname;
|
||||
}, [pathname]);
|
||||
|
||||
const buildHref = (childItem: NavigationItemType) => {
|
||||
if (
|
||||
childItem.preserveQueryParams &&
|
||||
childItem.preserveQueryParams({ prevPathname: prevPathnameRef.current, nextPathname: childItem.href })
|
||||
) {
|
||||
const params = searchParams?.toString();
|
||||
return params ? `${childItem.href}?${params}` : childItem.href;
|
||||
}
|
||||
return childItem.href;
|
||||
};
|
||||
|
||||
return buildHref;
|
||||
};
|
||||
|
||||
const trackNavigationClick = (itemName: string, parentItemName?: string) => {
|
||||
posthog.capture("navigation_item_clicked", {
|
||||
item_name: itemName,
|
||||
@@ -85,7 +62,6 @@ export type NavigationItemType = {
|
||||
isChild?: boolean;
|
||||
pathname: string | null;
|
||||
}) => boolean;
|
||||
preserveQueryParams?: (context: { prevPathname: string | null; nextPathname: string }) => boolean;
|
||||
};
|
||||
|
||||
const defaultIsCurrent: NavigationItemType["isCurrent"] = ({ isChild, item, pathname }) => {
|
||||
@@ -104,7 +80,6 @@ export const NavigationItem: React.FC<{
|
||||
const current = isCurrent({ isChild: !!isChild, item, pathname });
|
||||
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
|
||||
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
|
||||
const buildHref = useBuildHref();
|
||||
|
||||
const isTablet = useMediaQuery("(max-width: 1024px)");
|
||||
const [isTooltipOpen, setIsTooltipOpen] = useState(false);
|
||||
@@ -126,7 +101,7 @@ export const NavigationItem: React.FC<{
|
||||
open={isTooltipOpen}
|
||||
content={
|
||||
hasChildren ? (
|
||||
<div className="pointer-events-auto flex flex-col stack-y-1 p-1">
|
||||
<div className="stack-y-1 pointer-events-auto flex flex-col p-1">
|
||||
<span className="text-subtle px-2 text-xs font-semibold uppercase tracking-wide">
|
||||
{t(item.name)}
|
||||
</span>
|
||||
@@ -139,7 +114,7 @@ export const NavigationItem: React.FC<{
|
||||
return (
|
||||
<Link
|
||||
key={childItem.name}
|
||||
href={buildHref(childItem)}
|
||||
href={childItem.href}
|
||||
aria-current={childIsCurrent ? "page" : undefined}
|
||||
onClick={() => {
|
||||
setIsTooltipOpen(false);
|
||||
@@ -211,7 +186,7 @@ export const NavigationItem: React.FC<{
|
||||
<Link
|
||||
data-test-id={item.name}
|
||||
onClick={() => trackNavigationClick(item.name)}
|
||||
href={isChild ? buildHref(item) : item.href}
|
||||
href={item.href}
|
||||
aria-label={t(item.name)}
|
||||
target={item.target}
|
||||
className={classNames(
|
||||
@@ -220,8 +195,9 @@ export const NavigationItem: React.FC<{
|
||||
? `aria-[aria-current='page']:bg-transparent!`
|
||||
: `[&[aria-current='page']]:bg-emphasis`,
|
||||
isChild
|
||||
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${props.index === 0 ? "mt-0" : "mt-px"
|
||||
}`
|
||||
? `[&[aria-current='page']]:text-emphasis [&[aria-current='page']]:bg-emphasis hidden h-8 pl-16 lg:flex lg:pl-11 ${
|
||||
props.index === 0 ? "mt-0" : "mt-px"
|
||||
}`
|
||||
: "[&[aria-current='page']]:text-emphasis mt-0.5 text-sm",
|
||||
isLocaleReady
|
||||
? "hover:bg-subtle todesktop:[&[aria-current='page']]:bg-emphasis todesktop:hover:bg-transparent hover:text-emphasis"
|
||||
@@ -232,7 +208,7 @@ export const NavigationItem: React.FC<{
|
||||
<Icon
|
||||
name={item.isLoading ? "rotate-cw" : item.icon}
|
||||
className={classNames(
|
||||
"todesktop:!text-blue-500 mr-2 h-4 w-4 shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2 aria-[aria-current='page']:text-inherit",
|
||||
"todesktop:!text-blue-500 mr-2 h-4 w-4 shrink-0 aria-[aria-current='page']:text-inherit rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2",
|
||||
item.isLoading && "animate-spin"
|
||||
)}
|
||||
aria-hidden="true"
|
||||
@@ -281,7 +257,7 @@ export const MobileNavigationItem: React.FC<{
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
target={item.target}
|
||||
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted relative my-2 min-w-0 flex-1 overflow-hidden rounded-md bg-transparent! p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
|
||||
className="[&[aria-current='page']]:text-emphasis hover:text-default text-muted bg-transparent! relative my-2 min-w-0 flex-1 overflow-hidden rounded-md p-1 text-center text-xs font-medium focus:z-10 sm:text-sm"
|
||||
aria-current={current ? "page" : undefined}>
|
||||
{item.badge && <div className="absolute right-1 top-1">{item.badge}</div>}
|
||||
{item.icon && (
|
||||
@@ -305,7 +281,6 @@ export const MobileNavigationMoreItem: React.FC<{
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
const shouldDisplayNavigationItem = useShouldDisplayNavigationItem(props.item);
|
||||
const [isExpanded, setIsExpanded] = usePersistedExpansionState(item.name);
|
||||
const buildHref = useBuildHref();
|
||||
|
||||
if (!shouldDisplayNavigationItem) return null;
|
||||
|
||||
@@ -320,11 +295,7 @@ export const MobileNavigationMoreItem: React.FC<{
|
||||
className="hover:bg-subtle flex w-full items-center justify-between p-5 text-left transition">
|
||||
<span className="text-default flex items-center font-semibold">
|
||||
{item.icon && (
|
||||
<Icon
|
||||
name={item.icon}
|
||||
className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Icon name={item.icon} className="h-5 w-5 shrink-0 ltr:mr-3 rtl:ml-3" aria-hidden="true" />
|
||||
)}
|
||||
{isLocaleReady ? t(item.name) : <SkeletonText />}
|
||||
</span>
|
||||
@@ -335,7 +306,7 @@ export const MobileNavigationMoreItem: React.FC<{
|
||||
{item.child.map((childItem) => (
|
||||
<li key={childItem.name} className="border-subtle border-t">
|
||||
<Link
|
||||
href={buildHref(childItem)}
|
||||
href={childItem.href}
|
||||
className="hover:bg-cal-muted flex items-center p-4 pl-12 transition">
|
||||
<span className="text-default font-medium">
|
||||
{isLocaleReady ? t(childItem.name) : <SkeletonText />}
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
const getServerSnapshot = () => {
|
||||
// Return undefined during SSR to avoid hydration mismatches
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(false);
|
||||
// Use useSyncExternalStore for SSR-safe media query detection
|
||||
const subscribe = useCallback(
|
||||
(callback: () => void) => {
|
||||
const media = window.matchMedia(query);
|
||||
media.addEventListener("change", callback);
|
||||
return () => media.removeEventListener("change", callback);
|
||||
},
|
||||
[query]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
const getSnapshot = useCallback(() => {
|
||||
return window.matchMedia(query).matches;
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
const matches = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
// Return false if undefined (SSR) to maintain backward compatibility
|
||||
return matches ?? false;
|
||||
};
|
||||
|
||||
export default useMediaQuery;
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Booking_fromReschedule_idx" ON "public"."Booking"("fromReschedule");
|
||||
@@ -894,6 +894,7 @@ model Booking {
|
||||
@@index([uid])
|
||||
@@index([status])
|
||||
@@index([startTime, endTime, status])
|
||||
@@index([fromReschedule])
|
||||
}
|
||||
|
||||
model Tracking {
|
||||
|
||||
@@ -11,3 +11,39 @@ export const bookingMinimalSelect = {
|
||||
attendees: true,
|
||||
metadata: true,
|
||||
} satisfies Prisma.BookingSelect;
|
||||
|
||||
export const bookingAuthorizationCheckSelect = {
|
||||
userId: true,
|
||||
eventType: {
|
||||
select: {
|
||||
teamId: true,
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.BookingSelect;
|
||||
|
||||
export const bookingDetailsSelect = {
|
||||
uid: true,
|
||||
rescheduled: true,
|
||||
fromReschedule: true,
|
||||
} satisfies Prisma.BookingSelect;
|
||||
|
||||
@@ -6,9 +6,10 @@ import { ZConfirmInputSchema } from "./confirm.schema";
|
||||
import { ZEditLocationInputSchema } from "./editLocation.schema";
|
||||
import { ZFindInputSchema } from "./find.schema";
|
||||
import { ZGetInputSchema } from "./get.schema";
|
||||
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
|
||||
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
|
||||
import { ZGetAuditLogsInputSchema } from "./getAuditLogs.schema";
|
||||
import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
|
||||
import { ZGetBookingDetailsInputSchema } from "./getBookingDetails.schema";
|
||||
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
|
||||
import { ZReportBookingInputSchema } from "./reportBooking.schema";
|
||||
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
|
||||
import { bookingsProcedure } from "./util";
|
||||
@@ -70,6 +71,15 @@ export const bookingsRouter = router({
|
||||
});
|
||||
}),
|
||||
|
||||
getBookingDetails: authedProcedure.input(ZGetBookingDetailsInputSchema).query(async ({ input, ctx }) => {
|
||||
const { getBookingDetailsHandler } = await import("./getBookingDetails.handler");
|
||||
|
||||
return getBookingDetailsHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
find: publicProcedure.input(ZFindInputSchema).query(async ({ input, ctx }) => {
|
||||
const { getHandler } = await import("./find.handler");
|
||||
|
||||
|
||||
@@ -35,13 +35,25 @@ type InputByStatus = "upcoming" | "recurring" | "past" | "cancelled" | "unconfir
|
||||
const log = logger.getSubLogger({ prefix: ["bookings.get"] });
|
||||
|
||||
export const getHandler = async ({ ctx, input }: GetOptions) => {
|
||||
// using offset actually because cursor pagination requires a unique column
|
||||
// for orderBy, but we don't use a unique column in our orderBy
|
||||
// Support both offset-based (list) and cursor-based pagination (calendar)
|
||||
// Cursor is just the offset as a string (fake cursor pagination)
|
||||
const take = input.limit;
|
||||
const skip = input.offset;
|
||||
let skip = input.offset;
|
||||
|
||||
// If cursor is provided, parse it to get the offset
|
||||
if (input.cursor) {
|
||||
const parsedCursor = parseInt(input.cursor, 10);
|
||||
if (!isNaN(parsedCursor) && parsedCursor >= 0) {
|
||||
skip = parsedCursor;
|
||||
}
|
||||
}
|
||||
|
||||
const { prisma, user } = ctx;
|
||||
const defaultStatus = "upcoming";
|
||||
const bookingListingByStatus = [input.filters.status || defaultStatus];
|
||||
|
||||
const bookingListingByStatus = input.filters.statuses?.length
|
||||
? input.filters.statuses
|
||||
: [input.filters.status || defaultStatus];
|
||||
|
||||
const { bookings, recurringInfo, totalCount } = await getAllUserBookings({
|
||||
ctx: {
|
||||
@@ -55,10 +67,16 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
|
||||
filters: input.filters,
|
||||
});
|
||||
|
||||
// Generate next cursor for infinite query support
|
||||
const nextOffset = skip + take;
|
||||
const hasMore = nextOffset < totalCount;
|
||||
const nextCursor = hasMore ? nextOffset.toString() : undefined;
|
||||
|
||||
return {
|
||||
bookings,
|
||||
recurringInfo,
|
||||
totalCount,
|
||||
nextCursor,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -478,7 +496,11 @@ export async function getBookings({
|
||||
"Booking.paid",
|
||||
"Booking.fromReschedule",
|
||||
"Booking.rescheduled",
|
||||
"Booking.rescheduledBy",
|
||||
"Booking.cancelledBy",
|
||||
"Booking.isRecorded",
|
||||
"Booking.cancellationReason",
|
||||
"Booking.rejectionReason",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("App_RoutingForms_FormResponse")
|
||||
@@ -508,6 +530,7 @@ export async function getBookings({
|
||||
"EventType.hideOrganizerEmail",
|
||||
"EventType.disableCancelling",
|
||||
"EventType.disableRescheduling",
|
||||
"EventType.teamId",
|
||||
"EventType.parentId",
|
||||
eb
|
||||
.cast<SchedulingType | null>(
|
||||
@@ -563,13 +586,27 @@ export async function getBookings({
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("Payment")
|
||||
.select(["Payment.paymentOption", "Payment.amount", "Payment.currency", "Payment.success"])
|
||||
.select([
|
||||
"Payment.paymentOption",
|
||||
"Payment.amount",
|
||||
"Payment.currency",
|
||||
"Payment.success",
|
||||
"Payment.appId",
|
||||
"Payment.refunded",
|
||||
])
|
||||
.whereRef("Payment.bookingId", "=", "Booking.id")
|
||||
).as("payment"),
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("users")
|
||||
.select(["users.id", "users.name", "users.email"])
|
||||
.select([
|
||||
"users.id",
|
||||
"users.name",
|
||||
"users.email",
|
||||
"users.avatarUrl",
|
||||
"users.username",
|
||||
"users.timeZone",
|
||||
])
|
||||
.whereRef("Booking.userId", "=", "users.id")
|
||||
).as("user"),
|
||||
jsonArrayFrom(
|
||||
@@ -750,7 +787,76 @@ export async function getBookings({
|
||||
};
|
||||
})
|
||||
);
|
||||
return { bookings, recurringInfo, totalCount };
|
||||
|
||||
// Enrich attendees with user data
|
||||
const enrichedBookings = await enrichAttendeesWithUserData(bookings, kysely);
|
||||
|
||||
return { bookings: enrichedBookings, recurringInfo, totalCount };
|
||||
}
|
||||
|
||||
type EnrichedUserData = {
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
username: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enriches booking attendees with user data by performing a left outer join
|
||||
* between attendees and users tables on email addresses.
|
||||
*
|
||||
* @param bookings - Array of bookings with attendees to enrich
|
||||
* @param kysely - Kysely database client instance
|
||||
* @returns Bookings with attendees enriched with user data (name, email, avatarUrl, username)
|
||||
*/
|
||||
async function enrichAttendeesWithUserData<
|
||||
TBooking extends { attendees: ReadonlyArray<{ id: number; email: string }> }
|
||||
>(
|
||||
bookings: TBooking[],
|
||||
kysely: Kysely<DB>
|
||||
): Promise<
|
||||
Array<
|
||||
Omit<TBooking, "attendees"> & {
|
||||
attendees: Array<TBooking["attendees"][number] & { user: EnrichedUserData | null }>;
|
||||
}
|
||||
>
|
||||
> {
|
||||
// Extract all unique attendee emails from bookings
|
||||
const allAttendees = bookings.flatMap((booking) => booking.attendees);
|
||||
const uniqueAttendeeIds = Array.from(new Set(allAttendees.map((attendee) => attendee.id)));
|
||||
|
||||
// Query attendees with left join to users table
|
||||
const enrichedAttendees =
|
||||
uniqueAttendeeIds.length > 0
|
||||
? await kysely
|
||||
.selectFrom("Attendee")
|
||||
.leftJoin("users", "users.email", "Attendee.email")
|
||||
.select(["Attendee.id", "users.name", "Attendee.email", "users.avatarUrl", "users.username"])
|
||||
.where("Attendee.id", "in", uniqueAttendeeIds)
|
||||
.execute()
|
||||
: [];
|
||||
|
||||
// Create a lookup map for O(1) access by attendee ID
|
||||
const attendeeUserDataMap = new Map<number, EnrichedUserData>(
|
||||
enrichedAttendees.map((enriched) => [
|
||||
enriched.id,
|
||||
{
|
||||
name: enriched.name,
|
||||
email: enriched.email,
|
||||
avatarUrl: enriched.avatarUrl,
|
||||
username: enriched.username,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
// Map over bookings and enrich each attendee with user data
|
||||
return bookings.map((booking) => ({
|
||||
...booking,
|
||||
attendees: booking.attendees.map((attendee) => ({
|
||||
...attendee,
|
||||
user: attendeeUserDataMap.get(attendee.id) || null,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async function getEventTypeIdsFromTeamIdsFilter(prisma: PrismaClient, teamIds?: number[]) {
|
||||
|
||||
@@ -6,7 +6,9 @@ export const ZGetInputSchema = z.object({
|
||||
filters: z.object({
|
||||
teamIds: z.number().array().optional(),
|
||||
userIds: z.number().array().optional(),
|
||||
// Support both singular 'status' (for API v2) and plural 'statuses' (/bookings page)
|
||||
status: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]).optional(),
|
||||
statuses: z.enum(["upcoming", "recurring", "past", "cancelled", "unconfirmed"]).array().optional(),
|
||||
eventTypeIds: z.number().array().optional(),
|
||||
attendeeEmail: z.union([z.string(), ZTextFilterValue]).optional(),
|
||||
attendeeName: z.union([z.string(), ZTextFilterValue]).optional(),
|
||||
@@ -20,6 +22,8 @@ export const ZGetInputSchema = z.object({
|
||||
}),
|
||||
limit: z.number().min(1).max(100),
|
||||
offset: z.number().default(0),
|
||||
// Cursor for infinite query support (calendar view)
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TGetInputSchema = z.infer<typeof ZGetInputSchema>;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { BookingDetailsService } from "@calcom/features/bookings/services/BookingDetailsService";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TGetBookingDetailsInputSchema } from "./getBookingDetails.schema";
|
||||
|
||||
type GetBookingDetailsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetBookingDetailsInputSchema;
|
||||
};
|
||||
|
||||
export const getBookingDetailsHandler = async ({ ctx, input }: GetBookingDetailsOptions) => {
|
||||
const bookingDetailsService = new BookingDetailsService(prisma);
|
||||
|
||||
return await bookingDetailsService.getBookingDetails({
|
||||
userId: ctx.user.id,
|
||||
bookingUid: input.uid,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetBookingDetailsInputSchema = z.object({
|
||||
uid: z.string(),
|
||||
});
|
||||
|
||||
export type TGetBookingDetailsInputSchema = z.infer<typeof ZGetBookingDetailsInputSchema>;
|
||||
@@ -170,7 +170,7 @@ export const DropdownItem = (props: DropdownItemProps) => {
|
||||
props.className
|
||||
)}>
|
||||
<>
|
||||
{CustomStartIcon || (StartIcon && <Icon name={StartIcon} className="mr-1 h-4 w-4" />)}
|
||||
{CustomStartIcon || (StartIcon && <Icon name={StartIcon} className="mr-1 h-4 w-4 shrink-0" />)}
|
||||
<div className={classNames("w-full text-left text-sm font-medium leading-none", childrenClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,11 @@ type Props = {
|
||||
onDatesChange?: ((date: Date) => void) | undefined;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
minDate?: Date;
|
||||
minDate?: Date | null;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props) => {
|
||||
const DatePicker = ({ minDate, disabled, date, onDatesChange, className, label }: Props) => {
|
||||
function handleDayClick(newDate: Date) {
|
||||
onDatesChange?.(newDate ?? new Date());
|
||||
}
|
||||
@@ -42,7 +43,7 @@ const DatePicker = ({ minDate, disabled, date, onDatesChange, className }: Props
|
||||
color="secondary"
|
||||
EndIcon="calendar"
|
||||
className={classNames("justify-between text-left font-normal", !date && "text-subtle")}>
|
||||
{date ? <>{format(date, "LLL dd, y")}</> : <span>Pick a date</span>}
|
||||
{label ?? (date ? <>{format(date, "LLL dd, y")}</> : <span>Pick a date</span>)}
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Portal>
|
||||
|
||||
@@ -57,7 +57,7 @@ export const ToggleGroup = ({
|
||||
"0px 2px 3px 0px rgba(0, 0, 0, 0.03), 0px 2px 2px -1px rgba(0, 0, 0, 0.03)",
|
||||
}}
|
||||
className={classNames(
|
||||
`bg-subtle border-subtle rounded-[10px] border p-0.5`,
|
||||
`bg-muted rounded-[10px] p-0.5`,
|
||||
orientation === "horizontal" && "inline-flex gap-0.5 rtl:flex-row-reverse",
|
||||
orientation === "vertical" && "flex w-fit flex-col gap-0.5",
|
||||
props.className,
|
||||
@@ -75,7 +75,7 @@ export const ToggleGroup = ({
|
||||
"aria-checked:bg-default aria-checked:border-subtle rounded-lg border border-transparent p-1.5 text-sm leading-none transition aria-checked:shadow-[0px_2px_3px_0px_rgba(0,0,0,0.03),0px_2px_2px_-1px_rgba(0,0,0,0.03)]",
|
||||
option.disabled
|
||||
? "text-gray-400 hover:cursor-not-allowed"
|
||||
: "text-default [&[aria-checked='false']]:hover:text-emphasis",
|
||||
: "text-default [&[aria-checked='false']]:hover:text-emphasis [&[aria-checked='false']]:hover:bg-subtle cursor-pointer",
|
||||
isFullWidth && "w-full"
|
||||
)}>
|
||||
<div
|
||||
|
||||
@@ -61,48 +61,48 @@ SheetOverlay.displayName = "SheetOverlay";
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitives.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitives.Content>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay>
|
||||
<SheetPrimitives.Content
|
||||
ref={forwardedRef}
|
||||
className={classNames(
|
||||
// base
|
||||
"fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6",
|
||||
// "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ",
|
||||
// border color
|
||||
"border-subtle",
|
||||
// background color
|
||||
"bg-default",
|
||||
// transition
|
||||
"data-[state=closed]:animate-SheetSlideRightAndFade data-[state=open]:animate-SheetSlideLeftAndFade",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</SheetOverlay>
|
||||
</SheetPortal>
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitives.Content> & {
|
||||
hideOverlay?: boolean;
|
||||
}
|
||||
>(({ className, hideOverlay = false, ...props }, forwardedRef) => {
|
||||
const content = (
|
||||
<SheetPrimitives.Content
|
||||
ref={forwardedRef}
|
||||
className={classNames(
|
||||
// base
|
||||
"fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6",
|
||||
// "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ",
|
||||
// border color
|
||||
"border-subtle",
|
||||
// background color
|
||||
"bg-default",
|
||||
// transition
|
||||
"data-[state=closed]:animate-SheetSlideRightAndFade data-[state=open]:animate-SheetSlideLeftAndFade",
|
||||
// Increase z-index when no overlay to ensure it's above other content
|
||||
hideOverlay && "z-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
return <SheetPortal>{hideOverlay ? content : <SheetOverlay>{content}</SheetOverlay>}</SheetPortal>;
|
||||
});
|
||||
|
||||
SheetContent.displayName = "SheetContent";
|
||||
|
||||
const SheetHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<"div"> & { showCloseButton?: boolean; rightContent?: React.ReactNode }
|
||||
>(({ children, className, showCloseButton = true, rightContent, ...props }, ref) => {
|
||||
React.ComponentPropsWithoutRef<"div"> & { showCloseButton?: boolean }
|
||||
>(({ children, className, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className="flex items-start justify-between gap-x-4 pb-2" {...props}>
|
||||
<div className={classNames("mt-1 flex flex-col gap-y-1", className)}>{children}</div>
|
||||
{rightContent !== undefined
|
||||
? rightContent
|
||||
: showCloseButton && (
|
||||
<SheetPrimitives.Close asChild>
|
||||
<Button variant="icon" StartIcon="x" color="minimal" className="aspect-square p-1" />
|
||||
</SheetPrimitives.Close>
|
||||
)}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitives.Close asChild>
|
||||
<Button variant="icon" StartIcon="x" color="minimal" className="aspect-square p-1" />
|
||||
</SheetPrimitives.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ export type ActionType = {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
color?: ButtonBaseProps["color"];
|
||||
bookingId?: number;
|
||||
bookingUid?: string;
|
||||
} & (
|
||||
| { href: string; onClick?: never; actions?: never }
|
||||
| { href?: never; onClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; actions?: never }
|
||||
@@ -61,7 +61,7 @@ export const DropdownActions = ({
|
||||
data-testid={action.id}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
onClick={action.onClick || defaultAction}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
@@ -88,7 +88,7 @@ export const TableActions: FC<Props> = ({ actions }) => {
|
||||
StartIcon={action.icon}
|
||||
{...(action?.actions ? { EndIcon: "chevron-down" } : null)}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-booking-uid={action.bookingUid}
|
||||
color={action.color || "secondary"}>
|
||||
{action.label}
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableE
|
||||
({ className, ...props }, ref) => (
|
||||
<table
|
||||
ref={ref}
|
||||
className={classNames("border-subtle w-full caption-bottom border text-sm", className)}
|
||||
className={classNames("border-muted w-full caption-bottom border text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -39,7 +39,7 @@ const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTML
|
||||
<tr
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"hover:bg-cal-muted data-[state=selected]:bg-subtle border-subtle border-b transition",
|
||||
"hover:bg-cal-muted data-[state=selected]:bg-subtle border-muted border-b transition",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+494
-108
@@ -3,9 +3,10 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { hashPassword } from "@calcom/lib/auth/hashPassword";
|
||||
import { RefundPolicy } from "@calcom/lib/payment/types";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus, AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
import { BookingStatus, AssignmentReasonEnum, PaymentOption } from "@calcom/prisma/enums";
|
||||
|
||||
import { seedAttributes, seedRoutingFormResponses, seedRoutingForms } from "./seed-utils";
|
||||
|
||||
@@ -35,8 +36,18 @@ function getRandomRatingFeedback() {
|
||||
return feedbacks[Math.floor(Math.random() * feedbacks.length)];
|
||||
}
|
||||
|
||||
type BookingInput = {
|
||||
uid: string;
|
||||
title: string;
|
||||
description: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
userId: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const shuffle = (
|
||||
booking: any,
|
||||
booking: BookingInput,
|
||||
year: number,
|
||||
eventTypesToPick: Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
@@ -46,6 +57,7 @@ const shuffle = (
|
||||
length: true;
|
||||
description: true;
|
||||
teamId: true;
|
||||
recurringEvent: true;
|
||||
};
|
||||
}>[],
|
||||
usersIdsToPick?: number[]
|
||||
@@ -60,7 +72,7 @@ const shuffle = (
|
||||
const endTime = dayjs(startTime).add(Math.floor(Math.random() * randomEvent.length), "minute");
|
||||
|
||||
booking.title = randomEvent.title;
|
||||
booking.description = randomEvent.description;
|
||||
booking.description = randomEvent.description || "";
|
||||
booking.startTime = startTime.toISOString();
|
||||
booking.endTime = endTime.toISOString();
|
||||
booking.createdAt = startTime.subtract(1, "day").toISOString();
|
||||
@@ -80,13 +92,11 @@ const shuffle = (
|
||||
if (usersIdsToPick && usersIdsToPick.length > 0) {
|
||||
// Pick a random user from all available users instead of just two
|
||||
booking.userId = usersIdsToPick[Math.floor(Math.random() * usersIdsToPick.length)];
|
||||
} else {
|
||||
} else if (randomEvent.userId) {
|
||||
booking.userId = randomEvent.userId;
|
||||
}
|
||||
|
||||
if (booking.userId === undefined || booking.userId === null) {
|
||||
} else {
|
||||
console.log({ randomEvent, usersIdsToPick });
|
||||
console.log("This should not happen");
|
||||
throw new Error("No valid userId available for booking");
|
||||
}
|
||||
|
||||
booking.rating = Math.floor(Math.random() * 5) + 1; // Generates a random rating from 1 to 5
|
||||
@@ -125,24 +135,40 @@ const shuffle = (
|
||||
return booking;
|
||||
};
|
||||
|
||||
async function createAttendees(bookings: any[]) {
|
||||
for (const booking of bookings) {
|
||||
type BookingWithId = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
async function createAttendees(bookings: BookingWithId[]) {
|
||||
// Batch create all attendees at once
|
||||
const allAttendees = bookings.flatMap((booking) =>
|
||||
Array(Math.floor(Math.random() * 4))
|
||||
.fill(null)
|
||||
.map(() => ({
|
||||
bookingId: booking.id,
|
||||
timeZone: faker.location.timeZone(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
}))
|
||||
);
|
||||
|
||||
if (allAttendees.length > 0) {
|
||||
await prisma.attendee.createMany({
|
||||
data: Array(Math.floor(Math.random() * 4))
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
return {
|
||||
bookingId: booking.id,
|
||||
timeZone: faker.location.timeZone(),
|
||||
email: faker.internet.email(),
|
||||
name: faker.person.fullName(),
|
||||
};
|
||||
}),
|
||||
data: allAttendees,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createPayments(bookings: any[]) {
|
||||
type BookingWithEventType = {
|
||||
id: number;
|
||||
paid: boolean;
|
||||
eventType?: {
|
||||
price?: number;
|
||||
currency?: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function _createPayments(bookings: BookingWithEventType[]) {
|
||||
// Filter for paid bookings
|
||||
const paidBookings = bookings.filter((booking) => booking.paid);
|
||||
|
||||
@@ -178,8 +204,61 @@ async function createPayments(bookings: any[]) {
|
||||
console.log(`Successfully created payments for ${paidBookings.length} bookings`);
|
||||
}
|
||||
|
||||
async function createPaidEventTypeAndBookings(organizationId: number) {
|
||||
console.log("Creating paid event type and bookings...");
|
||||
type PaymentScenario = {
|
||||
status: BookingStatus;
|
||||
paid: boolean;
|
||||
success: boolean;
|
||||
refunded: boolean;
|
||||
paymentOption?: PaymentOption;
|
||||
startTime: dayjs.Dayjs;
|
||||
cancellationReason?: string;
|
||||
scenarioName: string;
|
||||
};
|
||||
|
||||
type BookingWithPaymentData = {
|
||||
id: number;
|
||||
eventType?: {
|
||||
price?: number;
|
||||
currency?: string;
|
||||
};
|
||||
paymentSuccess: boolean;
|
||||
paymentRefunded: boolean;
|
||||
paymentOption: PaymentOption;
|
||||
};
|
||||
|
||||
async function createPaymentsForScenarios(bookings: BookingWithPaymentData[]) {
|
||||
console.log(`Creating payments for ${bookings.length} bookings with diverse scenarios...`);
|
||||
|
||||
// Batch create all payments at once
|
||||
const paymentData = bookings.map((booking) => {
|
||||
const amount = booking.eventType?.price || 5000;
|
||||
const currency = booking.eventType?.currency || "usd";
|
||||
const paymentOption = booking.paymentOption || PaymentOption.ON_BOOKING;
|
||||
|
||||
return {
|
||||
uid: uuidv4(),
|
||||
appId: "stripe",
|
||||
bookingId: booking.id,
|
||||
amount,
|
||||
fee: Math.floor(amount * 0.029) + 30,
|
||||
currency,
|
||||
success: booking.paymentSuccess,
|
||||
refunded: booking.paymentRefunded,
|
||||
paymentOption,
|
||||
data: {},
|
||||
externalId: `ch_${uuidv4().substring(0, 24)}`,
|
||||
};
|
||||
});
|
||||
|
||||
await prisma.payment.createMany({
|
||||
data: paymentData,
|
||||
});
|
||||
|
||||
console.log(`Successfully created payments for ${bookings.length} bookings`);
|
||||
}
|
||||
|
||||
async function createDiversePaymentBookings(organizationId: number) {
|
||||
console.log("\n=== Creating diverse payment event types and bookings ===");
|
||||
|
||||
// Find the insights team
|
||||
const insightsTeam = await prisma.team.findFirst({
|
||||
@@ -190,7 +269,7 @@ async function createPaidEventTypeAndBookings(organizationId: number) {
|
||||
});
|
||||
|
||||
if (!insightsTeam) {
|
||||
console.log("Insights team not found, skipping paid event type creation");
|
||||
console.log("Insights team not found, skipping payment event type creation");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -205,108 +284,401 @@ async function createPaidEventTypeAndBookings(organizationId: number) {
|
||||
});
|
||||
|
||||
if (insightsTeamMembers.length === 0) {
|
||||
console.log("No team members found, skipping paid event type creation");
|
||||
console.log("No team members found, skipping payment event type creation");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if paid event type already exists
|
||||
let paidEventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
slug: "paid-consultation",
|
||||
teamId: insightsTeam.id,
|
||||
// Create multiple event types with different pricing and refund policies
|
||||
const eventTypeConfigs = [
|
||||
{
|
||||
slug: "standard-consultation",
|
||||
title: "Standard Consultation",
|
||||
price: 5000,
|
||||
refundPolicy: RefundPolicy.ALWAYS,
|
||||
description: "1-hour consultation with full refund policy",
|
||||
},
|
||||
});
|
||||
{
|
||||
slug: "premium-consultation",
|
||||
title: "Premium Consultation",
|
||||
price: 10000,
|
||||
refundPolicy: RefundPolicy.DAYS,
|
||||
refundDaysCount: 7,
|
||||
description: "Premium 1-hour consultation with 7-day refund window",
|
||||
},
|
||||
{
|
||||
slug: "non-refundable-session",
|
||||
title: "Non-Refundable Session",
|
||||
price: 7500,
|
||||
refundPolicy: RefundPolicy.NEVER,
|
||||
description: "1-hour session - non-refundable",
|
||||
},
|
||||
{
|
||||
slug: "consultation-with-hold",
|
||||
title: "Consultation with Payment Hold",
|
||||
price: 5000,
|
||||
refundPolicy: RefundPolicy.ALWAYS,
|
||||
paymentOption: PaymentOption.HOLD,
|
||||
description: "1-hour consultation with payment hold option",
|
||||
},
|
||||
];
|
||||
|
||||
// Create paid event type if it doesn't exist
|
||||
if (!paidEventType) {
|
||||
paidEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Paid Consultation",
|
||||
slug: "paid-consultation",
|
||||
description: "1-hour paid consultation session",
|
||||
length: 60,
|
||||
type EventTypeWithRequiredFields = Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
title: true;
|
||||
slug: true;
|
||||
description: true;
|
||||
length: true;
|
||||
price: true;
|
||||
currency: true;
|
||||
metadata: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
const createdEventTypes: EventTypeWithRequiredFields[] = [];
|
||||
|
||||
for (const config of eventTypeConfigs) {
|
||||
let eventType = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
slug: config.slug,
|
||||
teamId: insightsTeam.id,
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
assignAllTeamMembers: true,
|
||||
price: 5000, // $50.00
|
||||
currency: "usd",
|
||||
metadata: {
|
||||
apps: {
|
||||
stripe: {
|
||||
price: 5000,
|
||||
currency: "usd",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create Host records for all team members
|
||||
await prisma.host.createMany({
|
||||
data: insightsTeamMembers.map((member) => ({
|
||||
userId: member.user.id,
|
||||
eventTypeId: paidEventType!.id,
|
||||
isFixed: false,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
if (!eventType) {
|
||||
type StripeMetadata = {
|
||||
price: number;
|
||||
currency: string;
|
||||
enabled: boolean;
|
||||
refundPolicy: RefundPolicy;
|
||||
refundDaysCount?: number;
|
||||
paymentOption?: PaymentOption;
|
||||
};
|
||||
|
||||
console.log(`Created paid event type: ${paidEventType.title}`);
|
||||
const stripeConfig: StripeMetadata = {
|
||||
price: config.price,
|
||||
currency: "usd",
|
||||
enabled: true,
|
||||
refundPolicy: config.refundPolicy,
|
||||
};
|
||||
|
||||
if (config.refundDaysCount) {
|
||||
stripeConfig.refundDaysCount = config.refundDaysCount;
|
||||
}
|
||||
|
||||
if (config.paymentOption) {
|
||||
stripeConfig.paymentOption = config.paymentOption;
|
||||
}
|
||||
|
||||
const metadata: Prisma.InputJsonValue = {
|
||||
apps: {
|
||||
stripe: stripeConfig,
|
||||
},
|
||||
};
|
||||
|
||||
const createdEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: config.title,
|
||||
slug: config.slug,
|
||||
description: config.description,
|
||||
length: 60,
|
||||
teamId: insightsTeam.id,
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
assignAllTeamMembers: true,
|
||||
price: config.price,
|
||||
currency: "usd",
|
||||
metadata,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
length: true,
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create Host records for all team members
|
||||
await prisma.host.createMany({
|
||||
data: insightsTeamMembers.map((member) => ({
|
||||
userId: member.user.id,
|
||||
eventTypeId: createdEventType.id,
|
||||
isFixed: false,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
|
||||
console.log(`Created event type: ${createdEventType.title}`);
|
||||
eventType = createdEventType;
|
||||
}
|
||||
|
||||
createdEventTypes.push(eventType);
|
||||
}
|
||||
|
||||
// Create 100 paid bookings
|
||||
const baseBooking = {
|
||||
uid: "demoUID",
|
||||
title: "Paid Consultation",
|
||||
description: "1-hour paid consultation session",
|
||||
startTime: dayjs().toISOString(),
|
||||
endTime: dayjs().toISOString(),
|
||||
userId: insightsTeamMembers[0].user.id,
|
||||
// Define payment scenarios with specific distributions
|
||||
const scenarios: {
|
||||
eventTypeIndex: number;
|
||||
count: number;
|
||||
scenario: Partial<PaymentScenario>;
|
||||
}[] = [
|
||||
// Scenario 1: Successful paid bookings (20%)
|
||||
{
|
||||
eventTypeIndex: 0,
|
||||
count: 20,
|
||||
scenario: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
paid: true,
|
||||
success: true,
|
||||
refunded: false,
|
||||
startTime: dayjs().add(7, "days"),
|
||||
scenarioName: "Successful Paid",
|
||||
},
|
||||
},
|
||||
// Scenario 2: Awaiting payment (15%)
|
||||
{
|
||||
eventTypeIndex: 0,
|
||||
count: 15,
|
||||
scenario: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
paid: true,
|
||||
success: false,
|
||||
refunded: false,
|
||||
startTime: dayjs().add(5, "days"),
|
||||
scenarioName: "Awaiting Payment",
|
||||
},
|
||||
},
|
||||
// Scenario 3: Awaiting payment with HOLD option (10%)
|
||||
{
|
||||
eventTypeIndex: 3,
|
||||
count: 10,
|
||||
scenario: {
|
||||
status: BookingStatus.PENDING,
|
||||
paid: true,
|
||||
success: false,
|
||||
refunded: false,
|
||||
paymentOption: PaymentOption.HOLD,
|
||||
startTime: dayjs().add(3, "days"),
|
||||
scenarioName: "Awaiting Payment (HOLD)",
|
||||
},
|
||||
},
|
||||
// Scenario 4: Cancelled with refund completed (10%)
|
||||
{
|
||||
eventTypeIndex: 0,
|
||||
count: 10,
|
||||
scenario: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: true,
|
||||
success: true,
|
||||
refunded: true,
|
||||
startTime: dayjs().subtract(2, "days"),
|
||||
cancellationReason: "Customer requested refund",
|
||||
scenarioName: "Cancelled - Refunded",
|
||||
},
|
||||
},
|
||||
// Scenario 5: Cancelled with ALWAYS policy (10%)
|
||||
{
|
||||
eventTypeIndex: 0,
|
||||
count: 10,
|
||||
scenario: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: true,
|
||||
success: true,
|
||||
refunded: false,
|
||||
startTime: dayjs().subtract(1, "days"),
|
||||
cancellationReason: "Change of plans",
|
||||
scenarioName: "Cancelled - ALWAYS policy (no refund yet)",
|
||||
},
|
||||
},
|
||||
// Scenario 6: Cancelled with NEVER policy (10%)
|
||||
{
|
||||
eventTypeIndex: 2,
|
||||
count: 10,
|
||||
scenario: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: true,
|
||||
success: true,
|
||||
refunded: false,
|
||||
startTime: dayjs().subtract(3, "days"),
|
||||
cancellationReason: "Emergency cancellation",
|
||||
scenarioName: "Cancelled - NEVER policy",
|
||||
},
|
||||
},
|
||||
// Scenario 7: Cancelled DAYS policy - within window (10%)
|
||||
{
|
||||
eventTypeIndex: 1,
|
||||
count: 10,
|
||||
scenario: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: true,
|
||||
success: true,
|
||||
refunded: false,
|
||||
startTime: dayjs().add(5, "days"),
|
||||
cancellationReason: "Need to reschedule",
|
||||
scenarioName: "Cancelled - DAYS policy (within window)",
|
||||
},
|
||||
},
|
||||
// Scenario 8: Cancelled DAYS policy - expired (10%)
|
||||
{
|
||||
eventTypeIndex: 1,
|
||||
count: 10,
|
||||
scenario: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: true,
|
||||
success: true,
|
||||
refunded: false,
|
||||
startTime: dayjs().subtract(10, "days"),
|
||||
cancellationReason: "Late cancellation",
|
||||
scenarioName: "Cancelled - DAYS policy (expired window)",
|
||||
},
|
||||
},
|
||||
// Scenario 9: Cancelled without success (5%)
|
||||
{
|
||||
eventTypeIndex: 0,
|
||||
count: 5,
|
||||
scenario: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
paid: true,
|
||||
success: false,
|
||||
refunded: false,
|
||||
startTime: dayjs().add(2, "days"),
|
||||
cancellationReason: "Payment failed, cancelled",
|
||||
scenarioName: "Cancelled - No payment success",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Create bookings for each scenario
|
||||
type ScenarioBooking = {
|
||||
uid: string;
|
||||
title: string;
|
||||
description: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
createdAt: string;
|
||||
status: BookingStatus;
|
||||
eventTypeId: number;
|
||||
userId: number;
|
||||
paid: boolean;
|
||||
rescheduled: boolean;
|
||||
cancellationReason: string | null;
|
||||
paymentSuccess: boolean;
|
||||
paymentRefunded: boolean;
|
||||
paymentOption: PaymentOption;
|
||||
scenarioName: string;
|
||||
};
|
||||
|
||||
console.log(`Creating 100 paid bookings for event type: ${paidEventType.title}`);
|
||||
const allScenarioBookings: ScenarioBooking[] = [];
|
||||
|
||||
for (const { eventTypeIndex, count, scenario } of scenarios) {
|
||||
const eventType = createdEventTypes[eventTypeIndex];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const startTime = scenario.startTime!;
|
||||
const endTime = dayjs(startTime).add(60, "minutes");
|
||||
|
||||
allScenarioBookings.push({
|
||||
uid: uuidv4(),
|
||||
title: eventType.title,
|
||||
description: eventType.description || "",
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
createdAt: startTime.subtract(1, "day").toISOString(),
|
||||
status: scenario.status!,
|
||||
eventTypeId: eventType.id,
|
||||
userId: insightsTeamMembers[Math.floor(Math.random() * insightsTeamMembers.length)].user.id,
|
||||
paid: scenario.paid!,
|
||||
rescheduled: false,
|
||||
cancellationReason: scenario.cancellationReason || null,
|
||||
// Store payment details temporarily for payment creation
|
||||
paymentSuccess: scenario.success!,
|
||||
paymentRefunded: scenario.refunded!,
|
||||
paymentOption: scenario.paymentOption || PaymentOption.ON_BOOKING,
|
||||
scenarioName: scenario.scenarioName!,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Prepared ${count} bookings for scenario: ${scenario.scenarioName}`);
|
||||
}
|
||||
|
||||
console.log(`Creating ${allScenarioBookings.length} bookings across all scenarios...`);
|
||||
|
||||
// Insert all bookings
|
||||
await prisma.booking.createMany({
|
||||
data: [
|
||||
...new Array(100).fill(0).map(() => {
|
||||
const booking = shuffle(
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y"),
|
||||
[paidEventType!],
|
||||
insightsTeamMembers.map((m) => m.user.id)
|
||||
);
|
||||
// Override status for paid bookings - they should always be accepted
|
||||
booking.status = "ACCEPTED";
|
||||
booking.paid = true;
|
||||
booking.rescheduled = false;
|
||||
return booking;
|
||||
}),
|
||||
],
|
||||
data: allScenarioBookings.map((booking) => {
|
||||
// Remove temporary payment fields before inserting
|
||||
const {
|
||||
paymentSuccess: _ps,
|
||||
paymentRefunded: _pr,
|
||||
paymentOption: _po,
|
||||
scenarioName: _sn,
|
||||
...bookingData
|
||||
} = booking;
|
||||
return bookingData;
|
||||
}),
|
||||
});
|
||||
|
||||
// Get the paid bookings we just created
|
||||
const paidBookings = await prisma.booking.findMany({
|
||||
// Get created bookings with event types
|
||||
// Use a more specific query to get only the bookings we just created
|
||||
const eventTypeIds = createdEventTypes.map((et) => et.id);
|
||||
const createdBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
eventTypeId: paidEventType.id,
|
||||
paid: true,
|
||||
eventTypeId: { in: eventTypeIds },
|
||||
paid: true, // Only get paid bookings since we created them with paid: true
|
||||
},
|
||||
include: {
|
||||
eventType: {
|
||||
select: {
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
id: "desc", // Order by ID is faster than createdAt
|
||||
},
|
||||
take: allScenarioBookings.length,
|
||||
});
|
||||
|
||||
// Create attendees for paid bookings
|
||||
await createAttendees(paidBookings);
|
||||
// Match created bookings with scenario data to restore payment fields
|
||||
const bookingsForPayments: BookingWithPaymentData[] = createdBookings
|
||||
.slice(0, allScenarioBookings.length)
|
||||
.map((booking, index) => {
|
||||
const scenarioBooking = allScenarioBookings[allScenarioBookings.length - 1 - index]; // Reverse order since we sorted desc
|
||||
return {
|
||||
id: booking.id,
|
||||
eventType: booking.eventType || undefined,
|
||||
paymentSuccess: scenarioBooking.paymentSuccess,
|
||||
paymentRefunded: scenarioBooking.paymentRefunded,
|
||||
paymentOption: scenarioBooking.paymentOption,
|
||||
};
|
||||
});
|
||||
|
||||
// Create payments for paid bookings
|
||||
await createPayments(paidBookings);
|
||||
// Create attendees for all bookings
|
||||
await createAttendees(createdBookings);
|
||||
|
||||
console.log(`Successfully created ${paidBookings.length} paid bookings with payments`);
|
||||
// Create payments with appropriate states for each scenario
|
||||
await createPaymentsForScenarios(bookingsForPayments);
|
||||
|
||||
console.log(`Successfully created ${allScenarioBookings.length} bookings with diverse payment scenarios`);
|
||||
console.log("\nScenario distribution:");
|
||||
scenarios.forEach(({ count, scenario }) => {
|
||||
console.log(` - ${scenario.scenarioName}: ${count} bookings`);
|
||||
});
|
||||
console.log("=== Diverse payment bookings creation completed ===\n");
|
||||
}
|
||||
|
||||
async function seedBookingAssignments() {
|
||||
@@ -518,7 +890,7 @@ async function main() {
|
||||
|
||||
// After creating or fetching teamEvents and insightsTeamMembers
|
||||
// Create Host records for all team members for each event with assignAllTeamMembers true
|
||||
const assignAllTeamMembersEvents = teamEvents.filter((event) => (event as any).assignAllTeamMembers);
|
||||
const assignAllTeamMembersEvents = teamEvents.filter((event) => event.assignAllTeamMembers);
|
||||
if (assignAllTeamMembersEvents.length > 0 && insightsTeamMembers.length > 0) {
|
||||
const hostRecords = assignAllTeamMembersEvents.flatMap((event) =>
|
||||
insightsTeamMembers.map((member) => ({
|
||||
@@ -550,9 +922,11 @@ async function main() {
|
||||
};
|
||||
|
||||
// Create past bookings -2y, -1y, -0y
|
||||
// NOTE: Reduced from 10000 to 1000 to avoid transaction timeout
|
||||
// Increase these numbers back to 10000 if you need more data for performance testing
|
||||
await prisma.booking.createMany({
|
||||
data: [
|
||||
...new Array(10000).fill(0).map(() =>
|
||||
...new Array(1000).fill(0).map(() =>
|
||||
shuffle(
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y") - 2,
|
||||
@@ -565,7 +939,7 @@ async function main() {
|
||||
|
||||
await prisma.booking.createMany({
|
||||
data: [
|
||||
...new Array(10000).fill(0).map(() =>
|
||||
...new Array(1000).fill(0).map(() =>
|
||||
shuffle(
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y") - 1,
|
||||
@@ -578,7 +952,7 @@ async function main() {
|
||||
|
||||
await prisma.booking.createMany({
|
||||
data: [
|
||||
...new Array(10000).fill(0).map(() =>
|
||||
...new Array(1000).fill(0).map(() =>
|
||||
shuffle(
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y"),
|
||||
@@ -600,10 +974,15 @@ async function main() {
|
||||
if (!attributes) {
|
||||
throw new Error("Attributes not found");
|
||||
}
|
||||
if (!javascriptEventId || !salesEventId) {
|
||||
throw new Error("Required event types not found");
|
||||
}
|
||||
const seededForm = await seedRoutingForms(
|
||||
insightsTeam.id,
|
||||
owner?.user.id ?? insightsTeamMembers[0].user.id,
|
||||
attributes
|
||||
attributes,
|
||||
javascriptEventId,
|
||||
salesEventId
|
||||
);
|
||||
|
||||
if (seededForm) {
|
||||
@@ -612,8 +991,8 @@ async function main() {
|
||||
|
||||
await seedBookingAssignments();
|
||||
|
||||
// Create paid event type and bookings separately
|
||||
await createPaidEventTypeAndBookings(organization.id);
|
||||
// Create diverse payment scenarios for testing
|
||||
await createDiversePaymentBookings(organization.id);
|
||||
}
|
||||
|
||||
async function runMain() {
|
||||
@@ -652,18 +1031,15 @@ async function createPerformanceData() {
|
||||
});
|
||||
|
||||
if (createExtraMembers) {
|
||||
const insightsRandomUsers: Prisma.UserCreateManyArgs["data"] = [];
|
||||
const insightsRandomUsers: { email: string; name: string; username: string }[] = [];
|
||||
const numInsightsUsers = 50; // Change this value to adjust the number of insights users to create
|
||||
const passwordHash = await hashPassword("insightsuser");
|
||||
|
||||
for (let i = 0; i < numInsightsUsers; i++) {
|
||||
const timestamp = Date.now();
|
||||
const email = `insightsuser${timestamp}@example.com`;
|
||||
const insightsUser = {
|
||||
email,
|
||||
password: {
|
||||
create: {
|
||||
hash: await hashPassword("insightsuser"),
|
||||
},
|
||||
},
|
||||
name: `Insights User ${timestamp}`,
|
||||
username: `insights-user-${timestamp}`,
|
||||
completedOnboarding: true,
|
||||
@@ -674,6 +1050,7 @@ async function createPerformanceData() {
|
||||
await prisma.user.createMany({
|
||||
data: insightsRandomUsers,
|
||||
});
|
||||
|
||||
// Lets find the ids of the users we just created
|
||||
extraMembersIds = await prisma.user.findMany({
|
||||
where: {
|
||||
@@ -685,6 +1062,14 @@ async function createPerformanceData() {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create passwords for all users
|
||||
await prisma.userPassword.createMany({
|
||||
data: extraMembersIds.map((user) => ({
|
||||
userId: user.id,
|
||||
hash: passwordHash,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (createExtraMembers) {
|
||||
@@ -761,6 +1146,7 @@ async function createPerformanceData() {
|
||||
startTime: dayjs().toISOString(),
|
||||
endTime: dayjs().toISOString(),
|
||||
eventTypeId: singleEventsCreated[0].id,
|
||||
userId: extraMembersIds[0].id,
|
||||
};
|
||||
|
||||
await prisma.booking.createMany({
|
||||
|
||||
@@ -4515,7 +4515,7 @@ __metadata:
|
||||
node-html-parser: ^6.1.10
|
||||
node-mocks-http: ^1.11.0
|
||||
nodemailer: ^6.7.8
|
||||
nuqs: ^2.7.2
|
||||
nuqs: ^2.8.2
|
||||
openid-client: 6.5.0
|
||||
otplib: ^12.0.1
|
||||
postcss: ^8.4.18
|
||||
@@ -38515,9 +38515,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nuqs@npm:^2.7.2":
|
||||
version: 2.7.2
|
||||
resolution: "nuqs@npm:2.7.2"
|
||||
"nuqs@npm:^2.8.2":
|
||||
version: 2.8.2
|
||||
resolution: "nuqs@npm:2.8.2"
|
||||
dependencies:
|
||||
"@standard-schema/spec": 1.0.0
|
||||
peerDependencies:
|
||||
@@ -38525,8 +38525,8 @@ __metadata:
|
||||
"@tanstack/react-router": ^1
|
||||
next: ">=14.2.0"
|
||||
react: ">=18.2.0 || ^19.0.0-0"
|
||||
react-router: ^6 || ^7
|
||||
react-router-dom: ^6 || ^7
|
||||
react-router: ^5 || ^6 || ^7
|
||||
react-router-dom: ^5 || ^6 || ^7
|
||||
peerDependenciesMeta:
|
||||
"@remix-run/react":
|
||||
optional: true
|
||||
@@ -38538,7 +38538,7 @@ __metadata:
|
||||
optional: true
|
||||
react-router-dom:
|
||||
optional: true
|
||||
checksum: b6735af6841da60029bd882787536db8db3855a9fd25b1d55e37faf033da45fae8b256ba03d6b972f44dbfb9dc205e2b93ef2f339bc9ac842e6508de6e29d016
|
||||
checksum: 22fc5c7e7145742ce328856dfd7da4f0a03f001890311de491f568b1e402fa8d84ea0875df21f98102090be18882d91a5ab3953a5864c27d07e9097f0a4dafa9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user