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 && (
)}
{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")}
);
};
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 (
);
};
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;