feat: track no-shows in bookings (#15307)
* added dropdown for no-shows in past bookings * added layout * nit * todo comment: figure out how to close dropdown * more todo comments * Adds TRPC route among other WIP * functional update * fixes type err * fix type err * fix: styles * strikethrough in email * NITs * removed stale comment * add tests * err comment --------- Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: sean-brydon <sean@cal.com> Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
This commit is contained in:
co-authored by
Peer Richelsen
Peer Richelsen
sean-brydon
sean-brydon
parent
765d601ff9
commit
3a4ebadbf3
@@ -1,5 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
|
||||
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import classNames from "@calcom/lib/classNames";
|
||||
import { formatTime } from "@calcom/lib/date-fns";
|
||||
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
@@ -35,6 +37,14 @@ import {
|
||||
TableActions,
|
||||
TextAreaField,
|
||||
Tooltip,
|
||||
Dropdown,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuItem,
|
||||
DropdownItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
@@ -87,7 +97,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isPast = new Date(booking.endTime) < new Date();
|
||||
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;
|
||||
@@ -220,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
|
||||
}
|
||||
|
||||
if (isPast && isPending && !isConfirmed) {
|
||||
if (isBookingInPast && isPending && !isConfirmed) {
|
||||
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
|
||||
}
|
||||
|
||||
@@ -278,9 +288,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
|
||||
const showCheckRecordingButton =
|
||||
isPast &&
|
||||
isBookingInPast &&
|
||||
isConfirmed &&
|
||||
!booking.isRecorded &&
|
||||
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
@@ -298,7 +308,14 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
@@ -436,7 +453,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
|
||||
<Link href={bookingLink}>
|
||||
{/* Time and Badges for mobile */}
|
||||
<div className="w-full pb-2 pt-4 sm:hidden">
|
||||
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
)}
|
||||
{booking.attendees.length !== 0 && (
|
||||
<DisplayAttendees
|
||||
attendees={booking.attendees}
|
||||
attendees={attendeeList}
|
||||
user={booking.user}
|
||||
currentEmail={userEmail}
|
||||
bookingUid={booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
@@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{(showViewRecordingsButton || showCheckRecordingButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
@@ -654,13 +673,215 @@ const FirstAttendee = ({
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
const Attendee = ({ email, name }: AttendeeProps) => {
|
||||
type NoShowProps = {
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
};
|
||||
|
||||
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
|
||||
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
|
||||
const { t } = useLocale();
|
||||
|
||||
const [noShow, setNoShow] = useState(noShowAttendee);
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
|
||||
const noShowMutation = trpc.viewer.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(
|
||||
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
|
||||
x: name || email,
|
||||
}),
|
||||
"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 (
|
||||
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
|
||||
{name || email}
|
||||
</a>
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="guest"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500">
|
||||
{noShow ? (
|
||||
<s>
|
||||
{name || email} <Icon name="eye-off" className="inline h-4" />
|
||||
</s>
|
||||
) : (
|
||||
<>{name || email}</>
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<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();
|
||||
copyToClipboard(email);
|
||||
setOpenDropdown(false);
|
||||
showToast(t("email_copied"), "success");
|
||||
}}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
{noShow ? (
|
||||
<DropdownItem
|
||||
data-testid="unmark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye">
|
||||
{t("unmark_as_no_show")}
|
||||
</DropdownItem>
|
||||
) : (
|
||||
<DropdownItem
|
||||
data-testid="mark-no-show"
|
||||
onClick={(e) => {
|
||||
setOpenDropdown(false);
|
||||
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
|
||||
e.preventDefault();
|
||||
}}
|
||||
StartIcon="eye-off">
|
||||
{t("mark_as_no_show")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
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.public.noShow.useMutation({
|
||||
onSuccess: async () => {
|
||||
showToast(t("no_show_updated"), "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 (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
|
||||
{t("plus_more", { count: attendees.length - 1 })}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -668,17 +889,23 @@ 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 (
|
||||
<div className="text-emphasis text-sm">
|
||||
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
|
||||
{attendees.length > 1 ? <span>, </span> : <span> {t("and")} </span>}
|
||||
<Attendee {...attendees[0]} />
|
||||
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
{attendees.length > 1 && (
|
||||
<>
|
||||
<div className="text-emphasis inline-block text-sm"> {t("and")} </div>
|
||||
@@ -686,13 +913,13 @@ const DisplayAttendees = ({
|
||||
<Tooltip
|
||||
content={attendees.slice(1).map((attendee) => (
|
||||
<p key={attendee.email}>
|
||||
<Attendee {...attendee} />
|
||||
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</p>
|
||||
))}>
|
||||
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
|
||||
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Attendee {...attendees[1]} />
|
||||
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -45,10 +45,10 @@ function VerifyEmailPage() {
|
||||
className="underline"
|
||||
loading={mutation.isPending}
|
||||
onClick={() => {
|
||||
showToast("Send email", "success");
|
||||
showToast(t("send_email"), "success");
|
||||
mutation.mutate();
|
||||
}}>
|
||||
Resend Email
|
||||
{t("resend_email")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -62,6 +62,77 @@ test.describe("Bookings", () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
test.describe("Past bookings", () => {
|
||||
test("Mark first guest as no-show", async ({ page, users, bookings }) => {
|
||||
const firstUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
bookingsFixture: bookings,
|
||||
// Create a booking 3 days ago
|
||||
relativeDate: -3,
|
||||
organizer: firstUser,
|
||||
organizerEventType: firstUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "First", email: "first@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
|
||||
await firstUser.apiLogin();
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
|
||||
await firstGuest.click();
|
||||
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeHidden();
|
||||
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeVisible();
|
||||
await titleAndAttendees.locator('[data-testid="mark-no-show"]').click();
|
||||
await firstGuest.click();
|
||||
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
});
|
||||
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
|
||||
const firstUser = await users.create();
|
||||
const secondUser = await users.create();
|
||||
|
||||
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
|
||||
title: "Booking as organizer",
|
||||
bookingsFixture: bookings,
|
||||
// Create a booking 4 days ago
|
||||
relativeDate: -4,
|
||||
organizer: firstUser,
|
||||
organizerEventType: firstUser.eventTypes[0],
|
||||
attendees: [
|
||||
{ name: "First", email: "first@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
|
||||
await firstUser.apiLogin();
|
||||
await page.goto(`/bookings/past`);
|
||||
const pastBookings = page.locator('[data-testid="past-bookings"]');
|
||||
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]');
|
||||
await moreGuests.click();
|
||||
const firstGuestInMore = page.getByRole("menuitemcheckbox").nth(0);
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: false });
|
||||
await firstGuestInMore.click();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
const updateNoShow = firstPastBooking.locator('[data-testid="update-no-show"]');
|
||||
await updateNoShow.click();
|
||||
await moreGuests.click();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createBooking({
|
||||
@@ -71,6 +142,7 @@ async function createBooking({
|
||||
attendees,
|
||||
/**
|
||||
* Relative date from today
|
||||
* -1 means yesterday
|
||||
* 0 means today
|
||||
* 1 means tomorrow
|
||||
*/
|
||||
|
||||
@@ -2337,6 +2337,9 @@
|
||||
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
|
||||
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
|
||||
"account_already_linked": "Account is already linked",
|
||||
"send_email": "Send email",
|
||||
"mark_as_no_show": "Mark as no-show",
|
||||
"unmark_as_no_show": "Unmark no-show",
|
||||
"account_unlinked_success": "Account unlinked successfully",
|
||||
"account_unlinked_error": "There was an error unlinking the account",
|
||||
"travel_schedule": "Travel Schedule",
|
||||
@@ -2460,5 +2463,10 @@
|
||||
"unable_to_subscribe_to_the_platform": "An error occurred while trying to subscribe to the platform plan, please try again later",
|
||||
"updating_oauth_client_error": "An error occurred while updating the OAuth client, please try again later",
|
||||
"creating_oauth_client_error": "An error occurred while creating the OAuth client, please try again later",
|
||||
"mark_as_no_show_title": "Mark as no show",
|
||||
"x_marked_as_no_show": "{{x}} marked as no-show",
|
||||
"x_unmarked_as_no_show": "{{x}} unmarked as no-show",
|
||||
"no_show_updated": "No-show status updated for attendees",
|
||||
"email_copied": "Email copied",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ describe("Handler: requestReschedule", () => {
|
||||
locale: "hi",
|
||||
// Booker's timezone when the fresh booking happened earlier
|
||||
timeZone: "Asia/Kolkata",
|
||||
noShow: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
@@ -214,6 +215,7 @@ describe("Handler: requestReschedule", () => {
|
||||
locale: "hi",
|
||||
// Booker's timezone when the fresh booking happened earlier
|
||||
timeZone: "Asia/Kolkata",
|
||||
noShow: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Attendee" ADD COLUMN "noShow" BOOLEAN DEFAULT false;
|
||||
@@ -512,6 +512,7 @@ model Attendee {
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
bookingId Int?
|
||||
bookingSeat BookingSeat?
|
||||
noShow Boolean? @default(false)
|
||||
|
||||
@@index([email])
|
||||
@@index([bookingId])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import type { TNoShowInputSchema } from "./noShow.schema";
|
||||
@@ -7,15 +8,57 @@ type NoShowOptions = {
|
||||
};
|
||||
|
||||
export const noShowHandler = async ({ input }: NoShowOptions) => {
|
||||
const { bookingUid } = input;
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
data: {
|
||||
noShowHost: true,
|
||||
},
|
||||
});
|
||||
const { bookingUid, attendees } = input;
|
||||
|
||||
try {
|
||||
const attendeeEmails = attendees?.map((attendee) => attendee.email) || [];
|
||||
if (attendees && attendeeEmails.length > 0) {
|
||||
const allAttendees = await prisma.attendee.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
booking: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
email: {
|
||||
in: attendeeEmails,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const updatePromises = attendees.map((attendee) => {
|
||||
const attendeeToUpdate = allAttendees.find((a) => a.email === attendee.email);
|
||||
|
||||
if (attendeeToUpdate) {
|
||||
return prisma.attendee.update({
|
||||
where: { id: attendeeToUpdate.id },
|
||||
data: { noShow: attendee.noShow },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(updatePromises);
|
||||
} else {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: bookingUid,
|
||||
},
|
||||
data: {
|
||||
noShowHost: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default noShowHandler;
|
||||
|
||||
@@ -2,6 +2,14 @@ import { z } from "zod";
|
||||
|
||||
export const ZNoShowInputSchema = z.object({
|
||||
bookingUid: z.string(),
|
||||
attendees: z
|
||||
.array(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
noShow: z.boolean(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TNoShowInputSchema = z.infer<typeof ZNoShowInputSchema>;
|
||||
|
||||
@@ -52,7 +52,7 @@ DropdownMenuContent.displayName = "DropdownMenuContent";
|
||||
|
||||
type DropdownMenuLabelProps = ComponentProps<(typeof DropdownMenuPrimitive)["Label"]>;
|
||||
export const DropdownMenuLabel = (props: DropdownMenuLabelProps) => (
|
||||
<DropdownMenuPrimitive.Label {...props} className="text-subtle px-3 py-2" />
|
||||
<DropdownMenuPrimitive.Label {...props} className={classNames("text-subtle px-3 py-2", props.className)} />
|
||||
);
|
||||
|
||||
type DropdownMenuItemProps = ComponentProps<(typeof DropdownMenuPrimitive)["CheckboxItem"]>;
|
||||
@@ -71,12 +71,34 @@ export const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
|
||||
type DropdownMenuCheckboxItemProps = ComponentProps<(typeof DropdownMenuPrimitive)["CheckboxItem"]>;
|
||||
export const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(
|
||||
({ children, ...props }, forwardedRef) => {
|
||||
({ children, checked, onCheckedChange, ...props }, forwardedRef) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem {...props} ref={forwardedRef} className="">
|
||||
{children}
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Icon name="circle-check" />
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
{...props}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
ref={forwardedRef}
|
||||
className="hover:text-emphasis text-default hover:bg-subtle flex flex-1 items-center space-x-2 px-3 py-2 hover:outline-none hover:ring-0 disabled:cursor-not-allowed">
|
||||
<div className="w-full">{children}</div>
|
||||
{!checked && (
|
||||
<input
|
||||
aria-disabled={true}
|
||||
aria-label={typeof children === "string" ? `Not active ${children}` : undefined}
|
||||
aria-readonly
|
||||
checked={false}
|
||||
type="checkbox"
|
||||
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default ml-auto h-4 w-4 rounded hover:cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<DropdownMenuPrimitive.ItemIndicator asChild>
|
||||
<input
|
||||
aria-disabled={true}
|
||||
aria-readonly
|
||||
aria-label={typeof children === "string" ? `Active ${children}` : undefined}
|
||||
checked={true}
|
||||
type="checkbox"
|
||||
className="text-emphasis dark:text-muted focus:ring-emphasis border-default bg-default h-4 w-4 rounded hover:cursor-pointer"
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
@@ -113,6 +135,7 @@ type DropdownItemProps = {
|
||||
} & ButtonOrLinkProps;
|
||||
|
||||
type ButtonOrLinkProps = ComponentProps<"button"> & ComponentProps<"a">;
|
||||
|
||||
export function ButtonOrLink({ href, ...props }: ButtonOrLinkProps) {
|
||||
const isLink = typeof href !== "undefined";
|
||||
const ButtonOrLink = isLink ? "a" : "button";
|
||||
|
||||
Reference in New Issue
Block a user