diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 248d4e2be1..2a8c220d57 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -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 ( <> - + {/* Time and Badges for mobile */}
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) { )} {booking.attendees.length !== 0 && ( )} {isCancelled && booking.rescheduled && ( @@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) { {isRejected &&
{t("rejected")}
} ) : null} - {isPast && isPending && !isConfirmed ? : null} + {isBookingInPast && isPending && !isConfirmed ? : null} {(showViewRecordingsButton || showCheckRecordingButton) && ( )} @@ -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 ( - e.stopPropagation()}> - {name || email} - + + + + + + + { + setOpenDropdown(false); + e.stopPropagation(); + }}> + {t("email")} + + + + { + e.preventDefault(); + copyToClipboard(email); + setOpenDropdown(false); + showToast(t("email_copied"), "success"); + }}> + {!isCopied ? t("copy") : t("copied")} + + + {isBookingInPast && ( + + {noShow ? ( + { + setOpenDropdown(false); + toggleNoShow({ attendee: { noShow: false, email }, bookingUid }); + e.preventDefault(); + }} + StartIcon="eye"> + {t("unmark_as_no_show")} + + ) : ( + { + setOpenDropdown(false); + toggleNoShow({ attendee: { noShow: true, email }, bookingUid }); + e.preventDefault(); + }} + 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.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 ( + + + + + + + {t("mark_as_no_show_title")} + +
+ {fields.slice(1).map((field, index) => ( + ( + { + e.preventDefault(); + onChange(!value); + }}> + {field.email} + + )} + /> + ))} + +
+ +
+ +
+
); }; @@ -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 (
{user && } {attendees.length > 1 ? ,  :  {t("and")} } - + {attendees.length > 1 && ( <>
 {t("and")} 
@@ -686,13 +913,13 @@ const DisplayAttendees = ({ (

- +

))}> -
{t("plus_more", { count: attendees.length - 1 })}
+ {isBookingInPast && }
) : ( - + )} )} diff --git a/apps/web/pages/auth/verify-email.tsx b/apps/web/pages/auth/verify-email.tsx index 84fa9ce6d5..370dc41e3f 100644 --- a/apps/web/pages/auth/verify-email.tsx +++ b/apps/web/pages/auth/verify-email.tsx @@ -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")} } /> diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index de5ccb55a3..818d51e329 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -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 */ diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index a211a80cfe..47f38c1470 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/apps/web/test/handlers/requestReschedule.test.ts b/apps/web/test/handlers/requestReschedule.test.ts index afcb66a8e3..22f1e90852 100644 --- a/apps/web/test/handlers/requestReschedule.test.ts +++ b/apps/web/test/handlers/requestReschedule.test.ts @@ -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, }), ], }, diff --git a/packages/prisma/migrations/20240531082824_add_meeting_attendee_no_show_column/migration.sql b/packages/prisma/migrations/20240531082824_add_meeting_attendee_no_show_column/migration.sql new file mode 100644 index 0000000000..e0e4613f0c --- /dev/null +++ b/packages/prisma/migrations/20240531082824_add_meeting_attendee_no_show_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Attendee" ADD COLUMN "noShow" BOOLEAN DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0f710ae213..f8175099cd 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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]) diff --git a/packages/trpc/server/routers/publicViewer/noShow.handler.ts b/packages/trpc/server/routers/publicViewer/noShow.handler.ts index d6585b3433..d854ed9726 100644 --- a/packages/trpc/server/routers/publicViewer/noShow.handler.ts +++ b/packages/trpc/server/routers/publicViewer/noShow.handler.ts @@ -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; diff --git a/packages/trpc/server/routers/publicViewer/noShow.schema.ts b/packages/trpc/server/routers/publicViewer/noShow.schema.ts index 56e59d0f1f..ff1b16374a 100644 --- a/packages/trpc/server/routers/publicViewer/noShow.schema.ts +++ b/packages/trpc/server/routers/publicViewer/noShow.schema.ts @@ -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; diff --git a/packages/ui/components/form/dropdown/Dropdown.tsx b/packages/ui/components/form/dropdown/Dropdown.tsx index cd09315c33..1d07da40ae 100644 --- a/packages/ui/components/form/dropdown/Dropdown.tsx +++ b/packages/ui/components/form/dropdown/Dropdown.tsx @@ -52,7 +52,7 @@ DropdownMenuContent.displayName = "DropdownMenuContent"; type DropdownMenuLabelProps = ComponentProps<(typeof DropdownMenuPrimitive)["Label"]>; export const DropdownMenuLabel = (props: DropdownMenuLabelProps) => ( - + ); 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( - ({ children, ...props }, forwardedRef) => { + ({ children, checked, onCheckedChange, ...props }, forwardedRef) => { return ( - - {children} - - + +
{children}
+ {!checked && ( + + )} + +
); @@ -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";