06bb994145
* Pass already fetched booking * Fetch the assignment reason * Update tests * Label as first assignment reason * Pass single booking object * Remove unused repository method * Only pass bookingUid to handler * Rename booking `assignmentReason` to `assignmentReasonSortedByCreatedAt` * fix: use renamed assignmentReasonSortedByCreatedAt property Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Remove take statement * fix: address Udit's review comments - Restore BookingAccessService for access control checks - Rename repository method from findByUidForWrongAssignmentReport to findByUidIncludeUserAndEventTypeTeamAndAttendeesAndAssignmentReason Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * test: update tests to use BookingAccessService mock Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use latest assignment reason in BookingDetailsSheet for consistency Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1181 lines
41 KiB
TypeScript
1181 lines
41 KiB
TypeScript
import Link from "next/link";
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
|
|
|
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
|
|
import type { getEventLocationValue } from "@calcom/app-store/locations";
|
|
import { getSuccessPageLocationMessage, guessEventLocationType } from "@calcom/app-store/locations";
|
|
import dayjs from "@calcom/dayjs";
|
|
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
|
import "@calcom/dayjs/locales";
|
|
import { formatTime } from "@calcom/lib/dayjs";
|
|
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { useGetTheme } from "@calcom/lib/hooks/useTheme";
|
|
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
|
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
|
import type { AssignmentReason } from "@calcom/prisma/client";
|
|
import { BookingStatus } from "@calcom/prisma/enums";
|
|
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
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 {
|
|
Dropdown,
|
|
DropdownItem,
|
|
DropdownMenuCheckboxItem,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuPortal,
|
|
} from "@calcom/ui/components/dropdown";
|
|
import { Icon } from "@calcom/ui/components/icon";
|
|
import { MeetingTimeInTimezones } from "@calcom/ui/components/popover";
|
|
import { showToast } from "@calcom/ui/components/toast";
|
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
|
|
|
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
|
|
|
import { WrongAssignmentDialog } from "../dialog/WrongAssignmentDialog";
|
|
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
|
|
import { useBookingDetailsSheetStore } from "../../modules/bookings/store/bookingDetailsSheetStore";
|
|
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 {
|
|
shouldShowPendingActions,
|
|
shouldShowRecurringCancelAction,
|
|
shouldShowIndividualReportButton,
|
|
type BookingActionContext,
|
|
getReportAction,
|
|
isActionDisabled,
|
|
} from "./actions/bookingActions";
|
|
import type { BookingItemProps } from "./types";
|
|
|
|
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
|
|
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
|
|
type TeamEventBooking = Omit<ParsedBooking, "eventType"> & {
|
|
eventType: TeamEvent;
|
|
};
|
|
type ReroutableBooking = Ensure<TeamEventBooking, "routedFromRoutingFormReponse">;
|
|
|
|
function buildParsedBooking(booking: BookingItemProps) {
|
|
// The way we fetch bookings there could be eventType object even without an eventType, but id confirms its existence
|
|
const bookingEventType = booking.eventType.id
|
|
? (booking.eventType as Ensure<
|
|
typeof booking.eventType,
|
|
// It would only ensure that the props are present, if they are optional in the original type. So, it is safe to assert here.
|
|
"id" | "length" | "title" | "slug" | "schedulingType" | "team"
|
|
>)
|
|
: null;
|
|
|
|
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
|
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
|
|
|
return {
|
|
...booking,
|
|
eventType: bookingEventType,
|
|
metadata: bookingMetadata,
|
|
};
|
|
}
|
|
|
|
const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooking => {
|
|
// We support only team bookings for now for rerouting
|
|
// Though `routedFromRoutingFormReponse` could be there for a non-team booking, we don't want to support it for now.
|
|
// Let's not support re-routing for a booking without an event-type for now.
|
|
// Such a booking has its event-type deleted and there might not be something to reroute to.
|
|
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
|
};
|
|
|
|
const ConditionalLink = ({
|
|
children,
|
|
onClick,
|
|
bookingLink,
|
|
className,
|
|
}: {
|
|
children: React.ReactNode;
|
|
onClick?: () => void;
|
|
bookingLink: string;
|
|
className?: string;
|
|
}) => {
|
|
const { t } = useLocale();
|
|
|
|
if (onClick) {
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" || e.key === " ") {
|
|
e.preventDefault();
|
|
onClick();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={onClick}
|
|
onKeyDown={handleKeyDown}
|
|
className={className}
|
|
aria-label={t("view_booking_details")}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<Link href={bookingLink} className={className}>
|
|
{children}
|
|
</Link>
|
|
);
|
|
};
|
|
|
|
function BookingListItem(booking: BookingItemProps) {
|
|
const parsedBooking = buildParsedBooking(booking);
|
|
const itemRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
|
|
const { onClick } = booking;
|
|
const {
|
|
t,
|
|
i18n: { language },
|
|
} = useLocale();
|
|
|
|
// Get selected booking UID from store
|
|
// The provider should always be available when BookingListItem is rendered (bookingsV3Enabled is true)
|
|
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
|
|
const isSelected = !!selectedBookingUid && selectedBookingUid === booking.uid;
|
|
|
|
// Scroll into view when this booking becomes selected
|
|
useEffect(() => {
|
|
if (isSelected && itemRef.current) {
|
|
itemRef.current.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "nearest",
|
|
});
|
|
}
|
|
}, [isSelected]);
|
|
|
|
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);
|
|
const isBookingInPast = new Date(booking.endTime) < new Date();
|
|
const isCancelled = booking.status === BookingStatus.CANCELLED;
|
|
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
|
|
const isRejected = booking.status === BookingStatus.REJECTED;
|
|
const isPending = booking.status === BookingStatus.PENDING;
|
|
const isRescheduled = booking.fromReschedule !== null;
|
|
const isRecurring = booking.recurringEventId !== null;
|
|
|
|
const isTabRecurring = booking.listingStatus === "recurring";
|
|
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
|
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
|
|
|
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
|
|
|
const isAttendee = !!userSeat;
|
|
|
|
const paymentAppData = getPaymentAppData(booking.eventType);
|
|
|
|
const location = booking.location as ReturnType<typeof getEventLocationValue>;
|
|
const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl;
|
|
|
|
const { resolvedTheme, forcedTheme } = useGetTheme();
|
|
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
|
|
const eventTypeColor =
|
|
booking.eventType.eventTypeColor &&
|
|
booking.eventType.eventTypeColor[hasDarkTheme ? "darkEventTypeColor" : "lightEventTypeColor"];
|
|
|
|
const locationToDisplay = getSuccessPageLocationMessage(
|
|
locationVideoCallUrl ? locationVideoCallUrl : location,
|
|
t,
|
|
booking.status
|
|
);
|
|
const provider = guessEventLocationType(location);
|
|
|
|
const isDisabledCancelling = booking.eventType.disableCancelling;
|
|
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
|
const cardCharged = booking?.payment[0]?.success;
|
|
|
|
const getSeatReferenceUid = () => {
|
|
return userSeat?.referenceUid;
|
|
};
|
|
|
|
const actionContext: BookingActionContext = {
|
|
booking,
|
|
isUpcoming,
|
|
isOngoing,
|
|
isBookingInPast,
|
|
isCancelled,
|
|
isConfirmed,
|
|
isRejected,
|
|
isPending,
|
|
isRescheduled,
|
|
isRecurring,
|
|
isTabRecurring,
|
|
isTabUnconfirmed,
|
|
isBookingFromRoutingForm,
|
|
isDisabledCancelling,
|
|
isDisabledRescheduling,
|
|
isCalVideoLocation:
|
|
!booking.location ||
|
|
booking.location === "integrations:daily" ||
|
|
(typeof booking.location === "string" && booking.location.trim() === ""),
|
|
showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid,
|
|
isAttendee,
|
|
cardCharged,
|
|
attendeeList,
|
|
getSeatReferenceUid,
|
|
t,
|
|
} as BookingActionContext;
|
|
|
|
const RequestSentMessage = () => {
|
|
return (
|
|
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
|
{t("reschedule_request_sent")}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
const bookingYear = dayjs(booking.startTime).year();
|
|
const currentYear = dayjs().year();
|
|
const isDifferentYear = bookingYear !== currentYear;
|
|
|
|
const startTime = dayjs(booking.startTime)
|
|
.tz(userTimeZone)
|
|
.locale(language)
|
|
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
|
|
|
|
// Getting accepted recurring dates to show
|
|
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
|
|
.concat(booking.recurringInfo?.bookings[BookingStatus.CANCELLED])
|
|
.concat(booking.recurringInfo?.bookings[BookingStatus.PENDING])
|
|
.sort((date1: Date, date2: Date) => date1.getTime() - date2.getTime());
|
|
|
|
const bookingLink = buildBookingLink({
|
|
bookingUid: booking.uid,
|
|
allRemainingBookings: isTabRecurring,
|
|
email: booking.attendees?.[0]?.email,
|
|
});
|
|
|
|
const title = booking.title;
|
|
|
|
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
|
|
|
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
|
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
|
|
const isOpenWrongAssignmentDialog = useBookingActionsStoreContext(
|
|
(state) => state.isOpenWrongAssignmentDialog
|
|
);
|
|
const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext(
|
|
(state) => state.setIsOpenWrongAssignmentDialog
|
|
);
|
|
const setIsOpenRoutingTraceSheet = useBookingActionsStoreContext(
|
|
(state) => state.setIsOpenRoutingTraceSheet
|
|
);
|
|
const reportAction = getReportAction(actionContext);
|
|
const reportActionWithHandler = {
|
|
...reportAction,
|
|
onClick: () => setIsOpenReportDialog(true),
|
|
};
|
|
|
|
return (
|
|
<div
|
|
ref={itemRef}
|
|
data-testid="booking-item"
|
|
data-today={String(booking.isToday)}
|
|
data-booking-list-item="true"
|
|
data-booking-uid={booking.uid}
|
|
className={classNames(
|
|
"group relative w-full transition-all duration-100 ease-out",
|
|
"hover:bg-cal-muted",
|
|
isSelected &&
|
|
"bg-cal-muted before:bg-brand-default rounded-r-md before:absolute before:left-0 before:top-0 before:h-full before:w-1"
|
|
)}>
|
|
<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={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 text-emphasis sm:max-w-56 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 text-default sm:max-w-32 md:max-w-52 xl:max-w-80 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}
|
|
hideOrganizerEmail={booking.eventType?.hideOrganizerEmail}
|
|
organizerEmail={booking.user?.email}
|
|
eventTypeHosts={booking.eventType?.hosts}
|
|
/>
|
|
)}
|
|
{!isPending && (
|
|
<div className="sm:hidden">
|
|
{(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 && (
|
|
<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>
|
|
)}
|
|
{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}
|
|
onAssignmentReasonClick={
|
|
booking.assignmentReasonSortedByCreatedAt.length > 0 ? () => setIsOpenRoutingTraceSheet(true) : undefined
|
|
}
|
|
/>
|
|
{isBookingFromRoutingForm && (
|
|
<WrongAssignmentDialog
|
|
isOpenDialog={isOpenWrongAssignmentDialog}
|
|
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
|
|
booking={booking}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const BookingItemBadges = ({
|
|
booking,
|
|
isPending,
|
|
isRejected,
|
|
recurringDates,
|
|
userTimeFormat,
|
|
userTimeZone,
|
|
isRescheduled,
|
|
onAssignmentReasonClick,
|
|
}: {
|
|
booking: BookingItemProps;
|
|
isPending: boolean;
|
|
isRejected: boolean;
|
|
recurringDates: Date[] | undefined;
|
|
userTimeFormat: number | null | undefined;
|
|
userTimeZone: string | undefined;
|
|
isRescheduled: boolean;
|
|
onAssignmentReasonClick?: () => void;
|
|
}) => {
|
|
const { t } = useLocale();
|
|
|
|
return (
|
|
<div className="hidden h-9 flex-row items-center pb-4 pl-6 sm:flex">
|
|
{isPending && (
|
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
|
{t("unconfirmed")}
|
|
</Badge>
|
|
)}
|
|
{isRescheduled && (
|
|
<Tooltip content={`${t("rescheduled_by")} ${booking.rescheduler}`}>
|
|
<Badge variant="orange" className="ltr:mr-2 rtl:ml-2">
|
|
{t("rescheduled")}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{isRejected && !isRescheduled && booking.assignmentReasonSortedByCreatedAt.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}
|
|
</Badge>
|
|
)}
|
|
{booking?.assignmentReasonSortedByCreatedAt.length > 0 && (
|
|
<AssignmentReasonTooltip
|
|
assignmentReason={booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1]}
|
|
onClick={onAssignmentReasonClick}
|
|
/>
|
|
)}
|
|
{booking.report && (
|
|
<Tooltip
|
|
content={
|
|
<div className="text-xs">
|
|
{(() => {
|
|
const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`;
|
|
const reasonText = t(reasonKey);
|
|
return booking.report.description
|
|
? `${reasonText}: ${booking.report.description}`
|
|
: reasonText;
|
|
})()}
|
|
</div>
|
|
}>
|
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="red">
|
|
{t("reported")}
|
|
</Badge>
|
|
</Tooltip>
|
|
)}
|
|
{booking.paid && !booking.payment[0] ? (
|
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
|
|
{t("error_collecting_card")}
|
|
</Badge>
|
|
) : booking.paid ? (
|
|
<Badge className="ltr:mr-2 rtl:ml-2" variant="green" data-testid="paid_badge">
|
|
{booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")}
|
|
</Badge>
|
|
) : null}
|
|
{recurringDates !== undefined && (
|
|
<div className="text-muted -mt-1 text-sm">
|
|
<RecurringBookingsTooltip
|
|
userTimeFormat={userTimeFormat}
|
|
userTimeZone={userTimeZone}
|
|
booking={booking}
|
|
recurringDates={recurringDates}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface RecurringBookingsTooltipProps {
|
|
booking: BookingItemProps;
|
|
recurringDates: Date[];
|
|
userTimeZone: string | undefined;
|
|
userTimeFormat: number | null | undefined;
|
|
}
|
|
|
|
const RecurringBookingsTooltip = ({
|
|
booking,
|
|
recurringDates,
|
|
userTimeZone,
|
|
userTimeFormat,
|
|
}: RecurringBookingsTooltipProps) => {
|
|
const {
|
|
t,
|
|
i18n: { language },
|
|
} = useLocale();
|
|
const now = new Date();
|
|
const recurringCount = recurringDates.filter((recurringDate) => {
|
|
return (
|
|
recurringDate >= now &&
|
|
!booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
|
.map((date) => date.toString())
|
|
.includes(recurringDate.toString())
|
|
);
|
|
}).length;
|
|
|
|
return (
|
|
(booking.recurringInfo &&
|
|
booking.eventType?.recurringEvent?.freq != null &&
|
|
(booking.listingStatus === "recurring" ||
|
|
booking.listingStatus === "unconfirmed" ||
|
|
booking.listingStatus === "cancelled") && (
|
|
<div className="underline decoration-gray-400 decoration-dashed underline-offset-2">
|
|
<div className="flex">
|
|
<Tooltip
|
|
content={recurringDates.map((aDate, key) => {
|
|
const pastOrCancelled =
|
|
aDate < now ||
|
|
booking.recurringInfo?.bookings[BookingStatus.CANCELLED]
|
|
.map((date) => date.toString())
|
|
.includes(aDate.toString());
|
|
return (
|
|
<p key={key} className={classNames(pastOrCancelled && "line-through")}>
|
|
{formatTime(aDate, userTimeFormat, userTimeZone)}
|
|
{" - "}
|
|
{dayjs(aDate).locale(language).format("D MMMM YYYY")}
|
|
</p>
|
|
);
|
|
})}>
|
|
<div className="text-default">
|
|
<Icon
|
|
name="refresh-ccw"
|
|
strokeWidth="3"
|
|
className="text-muted float-left mr-1 mt-1.5 inline-block h-3 w-3"
|
|
/>
|
|
<p className="mt-1 pl-5 text-xs">
|
|
{booking.status === BookingStatus.ACCEPTED
|
|
? `${t("event_remaining_other", {
|
|
count: recurringCount,
|
|
})}`
|
|
: getEveryFreqFor({
|
|
t,
|
|
recurringEvent: booking.eventType.recurringEvent,
|
|
recurringCount: booking.recurringInfo.count,
|
|
})}
|
|
</p>
|
|
</div>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
)) ||
|
|
null
|
|
);
|
|
};
|
|
|
|
interface UserProps {
|
|
id: number;
|
|
name: string | null;
|
|
email: string;
|
|
}
|
|
|
|
const FirstAttendee = ({
|
|
user,
|
|
currentEmail,
|
|
hideOrganizerEmail,
|
|
}: {
|
|
user: UserProps;
|
|
currentEmail: string | null | undefined;
|
|
hideOrganizerEmail?: boolean;
|
|
}) => {
|
|
const { t } = useLocale();
|
|
if (user.email === currentEmail) {
|
|
return <div className="inline-block">{t("you")}</div>;
|
|
}
|
|
if (hideOrganizerEmail) {
|
|
return <span className="inline-block">{user.name || ""}</span>;
|
|
}
|
|
return (
|
|
<a
|
|
key={user.email}
|
|
className="hover:text-blue-500"
|
|
href={`mailto:${user.email}`}
|
|
onClick={(e) => e.stopPropagation()}>
|
|
{user.name || user.email}
|
|
</a>
|
|
);
|
|
};
|
|
|
|
type NoShowProps = {
|
|
bookingUid: string;
|
|
isBookingInPast: boolean;
|
|
};
|
|
|
|
const Attendee = (
|
|
attendeeProps: BookingAttendee &
|
|
NoShowProps & {
|
|
hideOrganizerEmail?: boolean;
|
|
organizerEmail?: string | null;
|
|
eventTypeHosts?: Array<{ user: { email: string } | null }> | null;
|
|
}
|
|
) => {
|
|
const {
|
|
email,
|
|
name,
|
|
bookingUid,
|
|
isBookingInPast,
|
|
noShow,
|
|
phoneNumber,
|
|
user,
|
|
hideOrganizerEmail,
|
|
organizerEmail,
|
|
eventTypeHosts,
|
|
} = attendeeProps;
|
|
const { t } = useLocale();
|
|
|
|
const utils = trpc.useUtils();
|
|
const [openDropdown, setOpenDropdown] = useState(false);
|
|
const { copyToClipboard, isCopied } = useCopy();
|
|
|
|
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
|
onSuccess: async (data) => {
|
|
showToast(data.message, "success");
|
|
await utils.viewer.bookings.invalidate();
|
|
},
|
|
onError: (err) => {
|
|
showToast(err.message, "error");
|
|
},
|
|
});
|
|
|
|
const displayName = user?.name || name || user?.email || email;
|
|
|
|
const isTeamMemberOrHost =
|
|
email === organizerEmail || eventTypeHosts?.some((host) => host.user?.email === email);
|
|
const shouldHideEmail = hideOrganizerEmail && isTeamMemberOrHost;
|
|
|
|
return (
|
|
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
data-testid="guest"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="radix-state-open:text-blue-500 transition hover:text-blue-500">
|
|
{noShow ? (
|
|
<>
|
|
{displayName} <Icon name="eye-off" className="inline h-4" />
|
|
</>
|
|
) : (
|
|
<>{displayName}</>
|
|
)}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuPortal>
|
|
<DropdownMenuContent>
|
|
{!isSmsCalEmail(email) && !shouldHideEmail && (
|
|
<DropdownMenuItem className="focus:outline-none">
|
|
<DropdownItem
|
|
StartIcon="mail"
|
|
href={`mailto:${email}`}
|
|
onClick={(e) => {
|
|
setOpenDropdown(false);
|
|
e.stopPropagation();
|
|
}}>
|
|
<a href={`mailto:${email}`}>{t("email")}</a>
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
)}
|
|
|
|
<DropdownMenuItem className="focus:outline-none">
|
|
<DropdownItem
|
|
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
const isEmailCopied = isSmsCalEmail(email);
|
|
copyToClipboard(isEmailCopied ? email : (phoneNumber ?? ""));
|
|
setOpenDropdown(false);
|
|
showToast(isEmailCopied ? t("email_copied") : t("phone_number_copied"), "success");
|
|
}}>
|
|
{!isCopied ? t("copy") : t("copied")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
|
|
{isBookingInPast && (
|
|
<DropdownMenuItem className="focus:outline-none">
|
|
<DropdownItem
|
|
data-testid={noShow ? "unmark-no-show" : "mark-no-show"}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
setOpenDropdown(false);
|
|
noShowMutation.mutate({ bookingUid, attendees: [{ noShow: !noShow, email }] });
|
|
}}
|
|
StartIcon={noShow ? "eye" : "eye-off"}>
|
|
{noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
|
</DropdownItem>
|
|
</DropdownMenuItem>
|
|
)}
|
|
</DropdownMenuContent>
|
|
</DropdownMenuPortal>
|
|
</Dropdown>
|
|
);
|
|
};
|
|
|
|
type GroupedAttendeeProps = {
|
|
attendees: BookingAttendee[];
|
|
bookingUid: string;
|
|
};
|
|
|
|
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
|
const { bookingUid, attendees } = groupedAttendeeProps;
|
|
const { t } = useLocale();
|
|
const utils = trpc.useUtils();
|
|
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
|
onSuccess: async (data) => {
|
|
showToast(t(data.message), "success");
|
|
await utils.viewer.bookings.invalidate();
|
|
},
|
|
onError: (err) => {
|
|
showToast(err.message, "error");
|
|
},
|
|
});
|
|
|
|
type FormValues = {
|
|
attendees: Array<{ id: number; email: string; noShow: boolean }>;
|
|
};
|
|
|
|
const { control, handleSubmit } = useForm<FormValues>({
|
|
defaultValues: {
|
|
attendees: attendees.map((a) => ({ id: a.id, email: a.email, noShow: a.noShow || false })),
|
|
},
|
|
mode: "onBlur",
|
|
});
|
|
|
|
const { fields } = useFieldArray({
|
|
control,
|
|
name: "attendees",
|
|
});
|
|
|
|
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);
|
|
};
|
|
|
|
const [openDropdown, setOpenDropdown] = useState(false);
|
|
|
|
return (
|
|
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
data-testid="more-guests"
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
|
|
{t("plus_more", { count: attendees.length - 1 })}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<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) => {
|
|
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
|
|
data-testid="update-no-show"
|
|
color="secondary"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
handleSubmit(onSubmit)();
|
|
}}>
|
|
{t("mark_as_no_show_title")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DropdownMenuContent>
|
|
</Dropdown>
|
|
);
|
|
};
|
|
|
|
const GroupedGuests = ({ guests }: { guests: BookingAttendee[] }) => {
|
|
const [openDropdown, setOpenDropdown] = useState(false);
|
|
const { t } = useLocale();
|
|
const { copyToClipboard, isCopied } = useCopy();
|
|
const [selectedEmail, setSelectedEmail] = useState("");
|
|
|
|
return (
|
|
<Dropdown
|
|
open={openDropdown}
|
|
onOpenChange={(value) => {
|
|
setOpenDropdown(value);
|
|
setSelectedEmail("");
|
|
}}>
|
|
<DropdownMenuTrigger asChild>
|
|
<button
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="radix-state-open:text-blue-500 transition hover:text-blue-500 focus:outline-none">
|
|
{t("plus_more", { count: guests.length - 1 })}
|
|
</button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="min-w-[300px]">
|
|
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
|
|
{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 gap-2 p-2">
|
|
<Link href={`mailto:${selectedEmail}`}>
|
|
<Button
|
|
color="secondary"
|
|
disabled={selectedEmail.length === 0}
|
|
onClick={(e) => {
|
|
setOpenDropdown(false);
|
|
e.stopPropagation();
|
|
}}>
|
|
{t("email")}
|
|
</Button>
|
|
</Link>
|
|
<Button
|
|
color="secondary"
|
|
disabled={selectedEmail.length === 0}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
copyToClipboard(selectedEmail);
|
|
showToast(t("email_copied"), "success");
|
|
}}>
|
|
{!isCopied ? t("copy") : t("copied")}
|
|
</Button>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</Dropdown>
|
|
);
|
|
};
|
|
|
|
const DisplayAttendees = ({
|
|
attendees,
|
|
user,
|
|
currentEmail,
|
|
bookingUid,
|
|
isBookingInPast,
|
|
hideOrganizerEmail,
|
|
organizerEmail,
|
|
eventTypeHosts,
|
|
}: {
|
|
attendees: BookingAttendee[];
|
|
user: UserProps | null;
|
|
currentEmail?: string | null;
|
|
bookingUid: string;
|
|
isBookingInPast: boolean;
|
|
hideOrganizerEmail?: boolean;
|
|
organizerEmail?: string | null;
|
|
eventTypeHosts?: Array<{ user: { email: string } | null }> | null;
|
|
}) => {
|
|
const { t } = useLocale();
|
|
attendees.sort((a, b) => a.id - b.id);
|
|
|
|
return (
|
|
<div className="text-emphasis text-sm" onClick={(e) => e.stopPropagation()}>
|
|
{user && (
|
|
<FirstAttendee user={user} currentEmail={currentEmail} hideOrganizerEmail={hideOrganizerEmail} />
|
|
)}
|
|
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
|
<Attendee
|
|
{...attendees[0]}
|
|
bookingUid={bookingUid}
|
|
isBookingInPast={isBookingInPast}
|
|
hideOrganizerEmail={hideOrganizerEmail}
|
|
organizerEmail={organizerEmail}
|
|
eventTypeHosts={eventTypeHosts}
|
|
/>
|
|
{attendees.length > 1 && (
|
|
<>
|
|
<div className="text-emphasis inline-block text-sm"> {t("and")} </div>
|
|
{attendees.length > 2 ? (
|
|
<Tooltip
|
|
content={attendees.slice(1).map((attendee) => (
|
|
<p key={attendee.email}>
|
|
<Attendee
|
|
{...attendee}
|
|
bookingUid={bookingUid}
|
|
isBookingInPast={isBookingInPast}
|
|
hideOrganizerEmail={hideOrganizerEmail}
|
|
organizerEmail={organizerEmail}
|
|
eventTypeHosts={eventTypeHosts}
|
|
/>
|
|
</p>
|
|
))}>
|
|
{isBookingInPast ? (
|
|
<GroupedAttendees attendees={attendees} bookingUid={bookingUid} />
|
|
) : (
|
|
<GroupedGuests guests={attendees} />
|
|
)}
|
|
</Tooltip>
|
|
) : (
|
|
<Attendee
|
|
{...attendees[1]}
|
|
bookingUid={bookingUid}
|
|
isBookingInPast={isBookingInPast}
|
|
hideOrganizerEmail={hideOrganizerEmail}
|
|
organizerEmail={organizerEmail}
|
|
eventTypeHosts={eventTypeHosts}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AssignmentReasonTooltip = ({
|
|
assignmentReason,
|
|
onClick,
|
|
}: {
|
|
assignmentReason: AssignmentReason;
|
|
onClick?: () => void;
|
|
}) => {
|
|
const { t } = useLocale();
|
|
const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum);
|
|
return (
|
|
<Tooltip content={<p>{assignmentReason.reasonString}</p>}>
|
|
<Badge
|
|
className={classNames("ltr:mr-2 rtl:ml-2", onClick && "cursor-pointer hover:opacity-80")}
|
|
variant="gray"
|
|
onClick={onClick}>
|
|
{t(badgeTitle)}
|
|
</Badge>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking
|
|
const BookingListItemWithProvider = (props: BookingItemProps) => {
|
|
return (
|
|
<BookingActionsStoreProvider>
|
|
<BookingListItem {...props} />
|
|
</BookingActionsStoreProvider>
|
|
);
|
|
};
|
|
|
|
export default BookingListItemWithProvider;
|