import type { AssignmentReason } from "@prisma/client"; import Link from "next/link"; import { useState } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; 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 ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog"; import classNames from "@calcom/lib/classNames"; import { formatTime } from "@calcom/lib/date-fns"; import { getPaymentAppData } from "@calcom/lib/getPaymentAppData"; 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 { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import type { Ensure } from "@calcom/types/utils"; import type { ActionType } from "@calcom/ui"; import { Badge, Button, Dialog, DialogClose, DialogContent, DialogFooter, Dropdown, DropdownItem, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuPortal, Icon, MeetingTimeInTimezones, showToast, TableActions, TextAreaField, Tooltip, } from "@calcom/ui"; import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap"; import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"]; type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number]; type BookingItemProps = BookingItem & { listingStatus: BookingListingStatus; recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined; loggedInUser: { userId: number | undefined; userTimeZone: string | undefined; userTimeFormat: number | null | undefined; userEmail: string | undefined; }; isToday: boolean; }; type ParsedBooking = ReturnType; type TeamEvent = Ensure, "team">; type TeamEventBooking = Omit & { eventType: TeamEvent; }; type ReroutableBooking = Ensure; 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 bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? 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; }; function BookingListItem(booking: BookingItemProps) { const parsedBooking = buildParsedBooking(booking); const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser; const { t, i18n: { language }, } = useLocale(); const utils = trpc.useUtils(); const [rejectionReason, setRejectionReason] = useState(""); const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false); const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState(false); const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState(false); const cardCharged = booking?.payment[0]?.success; 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(); }, onError: () => { showToast(t("booking_confirmation_failed"), "error"); utils.viewer.bookings.invalidate(); }, }); 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 isRecurring = booking.recurringEventId !== null; const isTabRecurring = booking.listingStatus === "recurring"; const isTabUnconfirmed = booking.listingStatus === "unconfirmed"; const isBookingFromRoutingForm = isBookingReroutable(parsedBooking); const paymentAppData = getPaymentAppData(booking.eventType); const location = booking.location as ReturnType; 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 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 = () => { if (!booking.seatsReferences[0]) { return undefined; } return booking.seatsReferences[0].referenceUid; }; const pendingActions: ActionType[] = [ { id: "reject", label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"), onClick: () => { setRejectionDialogIsOpen(true); }, icon: "ban", disabled: mutation.isPending, }, // For bookings with payment, only confirm if the booking is paid for ...((isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid) ? [ { id: "confirm", bookingId: booking.id, label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"), onClick: () => { bookingConfirm(true); }, icon: "check" as const, disabled: mutation.isPending, }, ] : []), ]; const editBookingActions: ActionType[] = [ ...(isBookingInPast && !booking.eventType.allowReschedulingPastBookings ? [] : [ { id: "reschedule", icon: "clock" as const, label: t("reschedule_booking"), href: `/reschedule/${booking.uid}${ booking.seatsReferences.length ? `?seatReferenceUid=${getSeatReferenceUid()}` : "" }`, }, { id: "reschedule_request", icon: "send" as const, iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", label: t("send_reschedule_request"), onClick: () => { setIsOpenRescheduleDialog(true); }, }, ]), ...(isBookingFromRoutingForm ? [ { id: "reroute", label: t("reroute"), onClick: () => { setRerouteDialogIsOpen(true); }, icon: "waypoints" as const, }, ] : []), { id: "change_location", label: t("edit_location"), onClick: () => { setIsOpenLocationDialog(true); }, icon: "map-pin" as const, }, { id: "add_members", label: t("additional_guests"), onClick: () => { setIsOpenAddGuestsDialog(true); }, icon: "user-plus" as const, }, ]; if (booking.eventType.schedulingType === SchedulingType.ROUND_ROBIN) { editBookingActions.push({ id: "reassign ", label: t("reassign"), onClick: () => { setIsOpenReassignDialog(true); }, icon: "users" as const, }); } if (isBookingInPast || isOngoing) { editBookingActions.push({ id: "no_show", label: t("mark_as_no_show"), onClick: () => { setIsNoShowDialogOpen(true); }, icon: "eye-off" as const, }); } let bookedActions: ActionType[] = [ { id: "cancel", label: isTabRecurring && isRecurring ? t("cancel_all_remaining") : t("cancel_event"), /* When cancelling we need to let the UI and the API know if the intention is to cancel all remaining bookings or just that booking instance. */ href: `/booking/${booking.uid}?cancel=true${ isTabRecurring && isRecurring ? "&allRemainingBookings=true" : "" }${booking.seatsReferences.length ? `&seatReferenceUid=${getSeatReferenceUid()}` : ""} `, icon: "x" as const, }, { id: "edit_booking", label: t("edit"), actions: editBookingActions, }, ]; const chargeCardActions: ActionType[] = [ { id: "charge_card", label: cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee"), disabled: cardCharged, onClick: () => { setChargeCardDialogIsOpen(true); }, icon: "credit-card" as const, }, ]; if (isTabRecurring && isRecurring) { bookedActions = bookedActions.filter((action) => action.id !== "edit_booking"); } if (isBookingInPast && isPending && !isConfirmed) { bookedActions = bookedActions.filter((action) => action.id !== "cancel"); } const RequestSentMessage = () => { return ( {t("reschedule_request_sent")} ); }; const startTime = dayjs(booking.startTime) .tz(userTimeZone) .locale(language) .format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY"); const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false); const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false); const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false); const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { showToast(t("location_updated"), "success"); setIsOpenLocationDialog(false); utils.viewer.bookings.invalidate(); }, onError: (e) => { const errorMessages: Record = { UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"), BAD_REQUEST: e.message, }; const message = errorMessages[e.data?.code as string] || t("location_update_failed"); showToast(message, "error"); }, }); const saveLocation = async ({ newLocation, credentialId, }: { newLocation: string; /** * It could be set for conferencing locations that support team level installations. */ credentialId: number | null; }) => { try { await setLocationMutation.mutateAsync({ bookingId: booking.id, newLocation, credentialId, }); } catch { // Errors are shown through the mutation onError handler } }; // 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 buildBookingLink = () => { const urlSearchParams = new URLSearchParams({ allRemainingBookings: isTabRecurring.toString(), }); if (booking.attendees?.[0]?.email) urlSearchParams.set("email", booking.attendees[0].email); return `/booking/${booking.uid}?${urlSearchParams.toString()}`; }; const bookingLink = buildBookingLink(); const title = booking.title; const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed); const showCheckRecordingButton = isBookingInPast && isConfirmed && !booking.isRecorded && (!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === ""); const showRecordingActions: ActionType[] = [ { id: "view_recordings", label: showCheckRecordingButton ? t("check_for_recordings") : t("view_recordings"), onClick: () => { setViewRecordingsDialogIsOpen(true); }, color: showCheckRecordingButton ? "secondary" : "primary", disabled: mutation.isPending, }, ]; const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid; const attendeeList = booking.attendees.map((attendee) => { return { name: attendee.name, email: attendee.email, id: attendee.id, noShow: attendee.noShow || false, phoneNumber: attendee.phoneNumber, }; }); return ( <> {isOpenReassignDialog && ( )} {booking.paid && booking.payment[0] && ( )} {(showViewRecordingsButton || showCheckRecordingButton) && ( )} {isNoShowDialogOpen && ( )}
{t("rejection_reason")} (Optional) } value={rejectionReason} onChange={(e) => setRejectionReason(e.target.value)} />
{eventTypeColor && (
)}
{startTime}
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "} {formatTime(booking.endTime, userTimeFormat, userTimeZone)}
{!isPending && ( )}
{/* Time and Badges for mobile */}
{startTime}
{formatTime(booking.startTime, userTimeFormat, userTimeZone)} -{" "} {formatTime(booking.endTime, userTimeFormat, userTimeZone)}
{isPending && ( {t("unconfirmed")} )} {booking.eventType?.team && ( {booking.eventType.team.name} )} {showPendingPayment && ( {t("pending_payment")} )} {recurringDates !== undefined && (
)}
{title} {showPendingPayment && ( {t("pending_payment")} )}
{booking.description && (
"{booking.description}"
)} {booking.attendees.length !== 0 && ( )} {isCancelled && booking.rescheduled && (
)}
{isUpcoming && !isCancelled ? ( <> {isPending && } {isConfirmed && } {isRejected &&
{t("rejected")}
} ) : null} {isBookingInPast && isPending && !isConfirmed ? : null} {isBookingInPast && isConfirmed ? : null} {(showViewRecordingsButton || showCheckRecordingButton) && ( )} {isCancelled && booking.rescheduled && (
)} {booking.status === "ACCEPTED" && booking.paid && booking.payment[0]?.paymentOption === "HOLD" && (
)}
{isBookingFromRoutingForm && ( )} ); } const BookingItemBadges = ({ booking, isPending, recurringDates, userTimeFormat, userTimeZone, }: { booking: BookingItemProps; isPending: boolean; recurringDates: Date[] | undefined; userTimeFormat: number | null | undefined; userTimeZone: string | undefined; }) => { const { t } = useLocale(); return (
{isPending && ( {t("unconfirmed")} )} {booking.eventType?.team && ( {booking.eventType.team.name} )} {booking?.assignmentReason.length > 0 && ( )} {booking.paid && !booking.payment[0] ? ( {t("error_collecting_card")} ) : booking.paid ? ( {booking.payment[0].paymentOption === "HOLD" ? t("card_held") : t("paid")} ) : null} {recurringDates !== undefined && (
)}
); }; 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 && (booking.listingStatus === "recurring" || booking.listingStatus === "unconfirmed" || booking.listingStatus === "cancelled") && (
{ const pastOrCancelled = aDate < now || booking.recurringInfo?.bookings[BookingStatus.CANCELLED] .map((date) => date.toString()) .includes(aDate.toString()); return (

{formatTime(aDate, userTimeFormat, userTimeZone)} {" - "} {dayjs(aDate).locale(language).format("D MMMM YYYY")}

); })}>

{booking.status === BookingStatus.ACCEPTED ? `${t("event_remaining_other", { count: recurringCount, })}` : getEveryFreqFor({ t, recurringEvent: booking.eventType.recurringEvent, recurringCount: booking.recurringInfo.count, })}

)) || null ); }; interface UserProps { id: number; name: string | null; email: string; } const FirstAttendee = ({ user, currentEmail, }: { user: UserProps; currentEmail: string | null | undefined; }) => { const { t } = useLocale(); return user.email === currentEmail ? (
{t("you")}
) : ( e.stopPropagation()}> {user.name || user.email} ); }; 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: noShowAttendee, phoneNumber } = attendeeProps; const { t } = useLocale(); const [noShow, setNoShow] = useState(noShowAttendee); const [openDropdown, setOpenDropdown] = useState(false); const { copyToClipboard, isCopied } = useCopy(); const noShowMutation = trpc.viewer.markNoShow.useMutation({ onSuccess: async (data) => { showToast(data.message, "success"); }, onError: (err) => { showToast(err.message, "error"); }, }); function toggleNoShow({ attendee, bookingUid, }: { attendee: { email: string; noShow: boolean }; bookingUid: string; }) { noShowMutation.mutate({ bookingUid, attendees: [attendee] }); setNoShow(!noShow); } return ( {!isSmsCalEmail(email) && ( { setOpenDropdown(false); e.stopPropagation(); }}> {t("email")} )} { 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")} {isBookingInPast && ( {noShow ? ( { e.preventDefault(); setOpenDropdown(false); toggleNoShow({ attendee: { noShow: false, email }, bookingUid }); }} StartIcon="eye"> {t("unmark_as_no_show")} ) : ( { e.preventDefault(); setOpenDropdown(false); toggleNoShow({ attendee: { noShow: true, email }, bookingUid }); }} StartIcon="eye-off"> {t("mark_as_no_show")} )} )} ); }; type GroupedAttendeeProps = { attendees: AttendeeProps[]; 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 { t } = useLocale(); const noShowMutation = trpc.viewer.markNoShow.useMutation({ onSuccess: async (data) => { showToast(t(data.message), "success"); }, onError: (err) => { showToast(err.message, "error"); }, }); const { control, handleSubmit } = useForm<{ attendees: AttendeeProps[]; }>({ defaultValues: { attendees, }, mode: "onBlur", }); const { fields } = useFieldArray({ control, name: "attendees", }); const onSubmit = (data: { attendees: AttendeeProps[] }) => { const filteredData = data.attendees.slice(1); noShowMutation.mutate({ bookingUid, attendees: filteredData }); setOpenDropdown(false); }; const [openDropdown, setOpenDropdown] = useState(false); return ( {t("mark_as_no_show_title")}
{fields.slice(1).map((field, index) => ( ( { e.preventDefault(); onChange(!value); }}> {field.email} )} /> ))}
); }; const NoShowAttendeesDialog = ({ attendees, isOpen, setIsOpen, bookingUid, }: { attendees: AttendeeProps[]; isOpen: boolean; setIsOpen: (value: boolean) => void; bookingUid: string; }) => { const { t } = useLocale(); const [noShowAttendees, setNoShowAttendees] = useState( attendees.map((attendee) => ({ id: attendee.id, email: attendee.email, name: attendee.name, noShow: attendee.noShow || false, })) ); const noShowMutation = trpc.viewer.markNoShow.useMutation({ onSuccess: async (data) => { const newValue = data.attendees[0]; setNoShowAttendees((old) => old.map((attendee) => attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee ) ); showToast(t(data.message), "success"); }, onError: (err) => { showToast(err.message, "error"); }, }); return ( setIsOpen(false)}> {noShowAttendees.map((attendee) => (
{ e.preventDefault(); noShowMutation.mutate({ bookingUid, attendees: [{ email: attendee.email, noShow: !attendee.noShow }], }); }}>
{attendee.name} {attendee.email && ({attendee.email})}
))} {t("done")}
); }; const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => { const [openDropdown, setOpenDropdown] = useState(false); const { t } = useLocale(); const { copyToClipboard, isCopied } = useCopy(); const [selectedEmail, setSelectedEmail] = useState(""); return ( { setOpenDropdown(value); setSelectedEmail(""); }}> {t("guests")} {guests.slice(1).map((guest) => ( { e.preventDefault(); setSelectedEmail(guest.email); }}> {guest.email} ))}
); }; const DisplayAttendees = ({ attendees, user, currentEmail, bookingUid, isBookingInPast, }: { attendees: AttendeeProps[]; user: UserProps | null; currentEmail?: string | null; bookingUid: string; isBookingInPast: boolean; }) => { const { t } = useLocale(); attendees.sort((a, b) => a.id - b.id); return (
{user && } {attendees.length > 1 ? :  {t("and")} } {attendees.length > 1 && ( <>
 {t("and")} 
{attendees.length > 2 ? ( (

))}> {isBookingInPast ? ( ) : ( )}
) : ( )} )}
); }; const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: AssignmentReason }) => { const { t } = useLocale(); const badgeTitle = assignmentReasonBadgeTitleMap(assignmentReason.reasonEnum); return ( {assignmentReason.reasonString}

}> {t(badgeTitle)}
); }; export default BookingListItem;