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:
Syed Ali Shahbaz
2024-06-04 15:18:11 +00:00
committed by GitHub
co-authored by Peer Richelsen Peer Richelsen sean-brydon sean-brydon
parent 765d601ff9
commit 3a4ebadbf3
10 changed files with 419 additions and 33 deletions
+243 -16
View File
@@ -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>,&nbsp;</span> : <span>&nbsp;{t("and")}&nbsp;</span>}
<Attendee {...attendees[0]} />
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
{attendees.length > 1 && (
<>
<div className="text-emphasis inline-block text-sm">&nbsp;{t("and")}&nbsp;</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} />
)}
</>
)}
+2 -2
View File
@@ -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>
}
/>
+72
View File
@@ -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,
}),
],
},
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Attendee" ADD COLUMN "noShow" BOOLEAN DEFAULT false;
+1
View File
@@ -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";