diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx new file mode 100644 index 0000000000..4f6874fe9c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useQueryState } from "nuqs"; +import { useEffect } from "react"; + +import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import useMediaQuery from "@calcom/lib/hooks/useMediaQuery"; +import { ToggleGroup } from "@calcom/ui/components/form"; +import { Icon } from "@calcom/ui/components/icon"; + +import { viewParser, type BookingView } from "~/bookings/lib/viewParser"; + +export function ViewToggleButton() { + const { t } = useLocale(); + const [view, setView] = useQueryState( + "view", + viewParser.withDefault("list").withOptions({ clearOnDefault: true }) + ); + const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); + const isMobile = !useMediaQuery("(min-width: 640px)"); + + useEffect(() => { + // Force list view on mobile + if (isMobile && view !== "list") { + setView("list"); + } + }, [isMobile, view, setView]); + + return ( +
+ { + if (!value) return; + const newView = value as BookingView; + + // When switching from calendar to list view, remove the dateRange filter + if (view === "calendar" && newView === "list") { + setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []); + } + + setView(newView); + }} + options={[ + { + value: "list", + label: "", + tooltip: t("list_view"), + iconLeft: , + }, + { + value: "calendar", + label: "", + tooltip: t("calendar_view"), + iconLeft: , + }, + ]} + /> +
+ ); +} diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index f749bfe150..9e27631fbf 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -6,9 +6,7 @@ import { redirect } from "next/navigation"; import { z } from "zod"; import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; -import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; -import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -16,6 +14,8 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx"; import { validStatuses } from "~/bookings/lib/validStatuses"; import BookingsList from "~/bookings/views/bookings-view"; +import { ViewToggleButton } from "./ViewToggleButton"; + const querySchema = z.object({ status: z.enum(validStatuses), }); @@ -54,18 +54,12 @@ const Page = async ({ params }: PageProps) => { canReadOthersBookings = teamIdsWithPermission.length > 0; } - const featuresRepository = new FeaturesRepository(prisma); - const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( - "booking-calendar-view" - ); - return ( - + }> ); diff --git a/apps/web/components/booking/SkeletonLoader.tsx b/apps/web/components/booking/SkeletonLoader.tsx index 419a0ecf03..fd60bafaef 100644 --- a/apps/web/components/booking/SkeletonLoader.tsx +++ b/apps/web/components/booking/SkeletonLoader.tsx @@ -1,36 +1,83 @@ import React from "react"; -import { SkeletonText } from "@calcom/ui/components/skeleton"; +import classNames from "@calcom/ui/classNames"; +import { SkeletonAvatar, SkeletonText } from "@calcom/ui/components/skeleton"; function SkeletonLoader() { return ( - +
+ {/* Table rows with separator at the beginning */} +
+ {/* Month separator skeleton */} +
+ +
+
+ +
+ + + + + + +
+
); } export default SkeletonLoader; -function SkeletonItem() { +function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) { return ( -
  • -
    -
    -
    - - -
    -
    +
    + {/* Date column - 140px */} +
    +
    -
    -
    - - -
    + + {/* Time column - 140px */} +
    +
    -
  • + + {/* Event column - 200px */} +
    + +
    + + {/* Who column - 160px, Avatar group */} +
    + {isHeader && } + {!isHeader && ( + <> + + + + + )} +
    + + {/* Team column - 140px */} +
    + +
    + + {/* Actions column - 280px */} +
    + {isHeader && } + {!isHeader && ( + <> + + + + + )} +
    + ); } diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/_BookingListItem.tsx similarity index 99% rename from apps/web/components/booking/BookingListItem.tsx rename to apps/web/components/booking/_BookingListItem.tsx index c869c1d3ba..3feb3a4b29 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/_BookingListItem.tsx @@ -499,7 +499,7 @@ function BookingListItem(booking: BookingItemProps) {
    {shouldShowPendingActions(actionContext) && } - + {shouldShowRecurringCancelAction(actionContext) && } {shouldShowIndividualReportButton(actionContext) && (
    diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 13c04b8915..66cc5b5ea7 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -15,9 +15,9 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuPortal, DropdownMenuSeparator, DropdownMenuTrigger, - DropdownMenuPortal, } from "@calcom/ui/components/dropdown"; import { TextAreaField } from "@calcom/ui/components/form"; import type { ActionType } from "@calcom/ui/components/table"; @@ -31,6 +31,7 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog"; import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; +import { buildBookingLink } from "../../../modules/bookings/lib/buildBookingLink"; import type { BookingItemProps } from "../types"; import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider"; import { @@ -39,16 +40,29 @@ import { getAfterEventActions, getReportAction, shouldShowEditActions, + shouldShowPendingActions, + getPendingActions, type BookingActionContext, } from "./bookingActions"; interface BookingActionsDropdownProps { booking: BookingItemProps; - context: "booking-list-item" | "booking-details-sheet"; size?: "xs" | "sm" | "base" | "lg"; + className?: string; + /** + * Whether to use a portal for the dropdown menu. + * Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context. + * @default true + */ + usePortal?: boolean; } -export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) { +export function BookingActionsDropdown({ + booking, + size = "base", + className, + usePortal = true, +}: BookingActionsDropdownProps) { const { t } = useLocale(); const utils = trpc.useUtils(); @@ -164,6 +178,13 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team; + // Build booking confirmation link + const bookingLink = buildBookingLink({ + bookingUid: booking.uid, + allRemainingBookings: isRecurring, + email: booking.attendees?.[0]?.email, + }); + const userEmail = booking.loggedInUser.userEmail; const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail); const isAttendee = !!userSeat; @@ -211,6 +232,10 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book } }; + // Calculate showPendingPayment based on payment logic + const hasPayment = booking.payment.length > 0; + const showPendingPayment = hasPayment; + const actionContext: BookingActionContext = { booking, isUpcoming, @@ -228,7 +253,7 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book isDisabledCancelling, isDisabledRescheduling, isCalVideoLocation, - showPendingPayment: false, // This will be calculated below + showPendingPayment, isAttendee, cardCharged, attendeeList, @@ -238,6 +263,20 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book const cancelEventAction = getCancelEventAction(actionContext); + // Get pending actions (accept/reject) + const shouldShowPending = shouldShowPendingActions(actionContext); + const basePendingActions = shouldShowPending ? getPendingActions(actionContext) : []; + const pendingActions: ActionType[] = basePendingActions.map((action) => ({ + ...action, + disabled: mutation.isPending, + onClick: + action.id === "confirm" + ? () => bookingConfirm(true) + : action.id === "reject" + ? () => setRejectionDialogIsOpen(true) + : undefined, + })) as ActionType[]; + const shouldShowEdit = shouldShowEditActions(actionContext); const baseEditEventActions = getEditEventActions(actionContext); const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({ @@ -500,22 +539,26 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book // Check if there are any available actions across all action groups const hasAnyAvailableActions = () => { + // Check if any pending action is available + const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled); + // Check if any edit action is available const hasAvailableEditAction = editEventActions.some((action) => !action.disabled); // Check if any after event action is available const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled); - // For booking-list-item context, only check edit and after event actions - if (context === "booking-list-item") { - return hasAvailableEditAction || hasAvailableAfterAction; - } - - // For booking-details-sheet context, also check report and cancel actions + // Check report and cancel actions const isReportAvailable = !reportActionWithHandler.disabled; const isCancelAvailable = !cancelEventAction.disabled; - return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable; + return ( + hasAvailablePendingAction || + hasAvailableEditAction || + hasAvailableAfterAction || + isReportAvailable || + isCancelAvailable + ); }; // Don't render dropdown if no actions are available @@ -523,94 +566,150 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book return dialogs; } - const menuContent = ( - - {t("edit_event")} - {editEventActions.map((action) => ( - - - {action.label} - - - ))} - - {t("after_event")} - {afterEventActions.map((action) => ( - - - {action.label} - - - ))} - <> - - - - {reportActionWithHandler.label} - - - - - - - {cancelEventAction.label} - - - - ); + // Conditional portal wrapper to avoid portal when inside Sheet/Dialog + const ConditionalPortal = usePortal + ? DropdownMenuPortal + : ({ children }: { children: React.ReactNode }) => <>{children}; return ( <> {dialogs} - + - + + + e.stopPropagation()}> + + {attendee.name || attendee.email} + + + {!isSmsCalEmail(attendee.email) && ( + + { + setOpenDropdown(false); + }}> + {t("email")} + + + )} + + + { + e.preventDefault(); + const smsCalEmail = isSmsCalEmail(attendee.email); + copyToClipboard(smsCalEmail ? attendee.email : attendee.phoneNumber ?? ""); + setOpenDropdown(false); + showToast(smsCalEmail ? t("email_copied") : t("phone_number_copied"), "success"); + }}> + {!isCopied ? t("copy") : t("copied")} + + + + {isBookingInPast && ( + + { + e.preventDefault(); + setOpenDropdown(false); + noShowMutation.mutate({ + bookingUid, + attendees: [{ noShow: !attendee.noShow, email: attendee.email }], + }); + }} + StartIcon={attendee.noShow ? "eye" : "eye-off"}> + {attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")} + + + )} + + + + ); +}; + +// Component for the "..." dropdown that shows all attendees with action buttons +const AllAttendeesDropdown = ({ + attendees, + bookingUid, + isBookingInPast, +}: { + attendees: AttendeeProps[]; + bookingUid: string; + isBookingInPast: boolean; +}) => { + const { t } = useLocale(); + const utils = trpc.useUtils(); + const [openDropdown, setOpenDropdown] = useState(false); + const { copyToClipboard } = useCopy(); + + const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({ + onSuccess: async (data) => { + showToast(data.message, "success"); + await utils.viewer.bookings.invalidate(); + }, + onError: (err) => { + showToast(err.message, "error"); + }, + }); + + const handleEmail = (email: string) => { + window.location.href = `mailto:${email}`; + setOpenDropdown(false); + }; + + const handleCopy = (attendee: AttendeeProps) => { + const isSmsAttendee = isSmsCalEmail(attendee.email); + copyToClipboard(isSmsAttendee ? attendee.email : attendee.phoneNumber ?? ""); + showToast(isSmsAttendee ? t("email_copied") : t("phone_number_copied"), "success"); + }; + + const handleToggleNoShow = (attendee: AttendeeProps) => { + noShowMutation.mutate({ + bookingUid, + attendees: [{ noShow: !attendee.noShow, email: attendee.email }], + }); + }; + + const handleMarkAllAsNoShow = () => { + const allAttendees = attendees.map((attendee) => ({ + noShow: true, + email: attendee.email, + })); + noShowMutation.mutate({ + bookingUid, + attendees: allAttendees, + }); + setOpenDropdown(false); + }; + + return ( + + + + + + e.stopPropagation()}> + {t("attendees")} + +
    + {attendees.map((attendee) => ( +
    +
    +
    {attendee.name || attendee.email}
    + {attendee.name &&
    {attendee.email}
    } +
    +
    + {!isSmsCalEmail(attendee.email) && ( +
    +
    + ))} +
    + {isBookingInPast && ( + <> + +
    + +
    + + )} +
    +
    +
    + ); +}; + +// Component that renders the attendees with appropriate dropdown +const AttendeeCell = ({ + attendees, + bookingUid, + isBookingInPast, +}: { + attendees: AttendeeProps[]; + bookingUid: string; + isBookingInPast: boolean; +}) => { + // Show "..." button if there are more attendees than the max display count + const hasMoreThanMax = attendees.length > MAX_DISPLAYED_ATTENDEES; + const displayedAttendees = attendees.slice(0, MAX_DISPLAYED_ATTENDEES); + + return ( +
    + {displayedAttendees.map((attendee, idx) => ( +
    0 ? "-ml-1" : ""}> + +
    + ))} + {hasMoreThanMax && ( + + )} +
    + ); +}; + +export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) { + const columnHelper = createColumnHelper(); + + return [ + columnHelper.display({ + id: "date", + size: 140, + enableColumnFilter: true, + enableSorting: false, + enableHiding: false, + header: () => {t("date")}, + cell: (props) => { + const row = props.row.original; + if (isSeparatorRow(row)) return null; + + return ( +
    + {dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")} +
    + ); + }, + }), + columnHelper.display({ + id: "time", + size: 140, + enableColumnFilter: true, + enableSorting: false, + enableHiding: false, + header: () => {t("time")}, + cell: (props) => { + const row = props.row.original; + if (isSeparatorRow(row)) return null; + + const startTime = dayjs(row.booking.startTime).tz(user?.timeZone); + const endTime = dayjs(row.booking.endTime).tz(user?.timeZone); + return ( +
    + {startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "} + {endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} +
    + ); + }, + }), + columnHelper.display({ + id: "event", + minSize: 200, + enableColumnFilter: true, + enableSorting: false, + enableHiding: false, + header: () => {t("event")}, + cell: (props) => { + const row = props.row.original; + if (isSeparatorRow(row)) return null; + + return ( +
    + {row.booking.title} +
    + ); + }, + }), + columnHelper.display({ + id: "who", + size: 160, + enableColumnFilter: true, + enableSorting: false, + enableHiding: false, + header: () => {t("who")}, + cell: (props) => { + const row = props.row.original; + if (isSeparatorRow(row)) return null; + + const isBookingInPast = new Date(row.booking.endTime) < new Date(); + + const attendees = row.booking.attendees.map((attendee) => ({ + name: attendee.name, + email: attendee.email, + id: attendee.id, + noShow: attendee.noShow || false, + phoneNumber: attendee.phoneNumber, + })); + + return ( + + ); + }, + }), + columnHelper.display({ + id: "team", + size: 140, + enableColumnFilter: true, + enableSorting: false, + enableHiding: false, + header: () => {t("team")}, + cell: (props) => { + const row = props.row.original; + if (isSeparatorRow(row)) return null; + + if (row.booking.eventType.team) { + return ( + + {row.booking.eventType.team.name} + + ); + } + return null; + }, + }), + columnHelper.display({ + id: "actions", + size: 280, + enableColumnFilter: true, + enableSorting: false, + enableHiding: false, + header: () => null, + cell: (props) => { + const row = props.row.original; + if (isSeparatorRow(row)) return null; + + const booking = row.booking; + const isPending = booking.status === "PENDING"; + const isUpcoming = new Date(booking.endTime) >= new Date(); + const isCancelled = booking.status === "CANCELLED"; + const isRejected = booking.status === "REJECTED"; + const isAccepted = booking.status === "ACCEPTED"; + + // Determine if we should show pending actions + const shouldShowPendingActions = isPending && isUpcoming && !isCancelled; + + // Determine which buttons to show based on payment status + const hasPayment = Array.isArray(booking.payment) && booking.payment.length > 0; + const isPaid = booking.paid; + const shouldShowAccept = shouldShowPendingActions && (!hasPayment || isPaid); + const shouldShowReject = shouldShowPendingActions; + + // Show join meeting button only for upcoming accepted/confirmed bookings + const shouldShowJoinButton = isAccepted && isUpcoming && !isCancelled && !isRejected; + + // Determine if this is a recurring booking for the label + const isRecurring = booking.recurringEventId !== null; + const isTabRecurring = row.type === "data" && row.recurringInfo !== undefined; + const isTabUnconfirmed = booking.status === "PENDING"; + const showAllLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring; + + return ( +
    + {shouldShowReject && ( + + )} + {shouldShowAccept && ( + + )} + {shouldShowJoinButton && ( + + )} + + + +
    + ); + }, + }), + ]; +} diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index 4b7e347228..5f483e458d 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -29,8 +29,8 @@ import { import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown"; import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider"; import type { BookingListingStatus } from "../../../components/booking/types"; -import { buildBookingLink } from "../lib/buildBookingLink"; import type { BookingOutput } from "../types"; +import { JoinMeetingButton } from "./JoinMeetingButton"; type BookingMetaData = z.infer; @@ -80,14 +80,6 @@ function BookingDetailsSheetInner({ const startTime = dayjs(booking.startTime).tz(userTimeZone); const endTime = dayjs(booking.endTime).tz(userTimeZone); - // Build booking confirmation link - const isRecurring = booking.recurringEventId !== null; - const bookingLink = buildBookingLink({ - bookingUid: booking.uid, - allRemainingBookings: isRecurring, - email: booking.attendees?.[0]?.email, - }); - const getStatusBadge = () => { switch (booking.status) { case "ACCEPTED": @@ -108,14 +100,6 @@ function BookingDetailsSheetInner({ const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null); const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null; - // Get conference link info for Join button - const { locationToDisplay, provider, isLocationURL } = useBookingLocation({ - location: booking.location, - videoCallUrl: bookingMetadata?.videoCallUrl, - t, - bookingStatus: booking.status, - }); - const recurringInfo = booking.recurringEventId && booking.eventType?.recurringEvent ? { @@ -183,6 +167,8 @@ function BookingDetailsSheetInner({

    + + @@ -203,49 +189,34 @@ function BookingDetailsSheetInner({ -
    - {isLocationURL && locationToDisplay && ( - <> - -
    - - )} - - + +
    + + + +
    @@ -253,6 +224,20 @@ function BookingDetailsSheetInner({ ); } +function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) { + const { t } = useLocale(); + + if (booking.status !== "CANCELLED" || !booking.rescheduled) { + return null; + } + + return ( + + {t("reschedule_request_sent")} + + ); +} + function WhoSection({ booking }: { booking: BookingOutput }) { const { t } = useLocale(); return ( diff --git a/apps/web/modules/bookings/components/BookingsCalendar.tsx b/apps/web/modules/bookings/components/BookingsCalendar.tsx index 8c7c68bb57..db0f637e27 100644 --- a/apps/web/modules/bookings/components/BookingsCalendar.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendar.tsx @@ -1,89 +1,62 @@ "use client"; import type { Table as ReactTable } from "@tanstack/react-table"; -import { createParser, useQueryState } from "nuqs"; -import { useMemo, useCallback } from "react"; +import { useCallback } from "react"; import dayjs from "@calcom/dayjs"; import { DataTableFilters, DataTableSegment, useDataTable, - useFilterValue, - ZDateRangeFilterValue, ColumnFilterType, } from "@calcom/features/data-table"; import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange"; -import type { RowData, BookingListingStatus } from "../types"; +import type { RowData, BookingListingStatus, BookingOutput } from "../types"; import { BookingsCalendarView } from "./BookingsCalendarView"; -type BookingsCalendarViewProps = { +type BookingsCalendarProps = { status: BookingListingStatus; table: ReactTable; + isPending?: boolean; + onOpenDetails: (bookingId: number) => void; + currentWeekStart: dayjs.Dayjs; + setCurrentWeekStart: ( + value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null + ) => Promise; + bookings: BookingOutput[]; }; const COLUMN_IDS_TO_HIDE = ["dateRange"]; -const weekStartParser = createParser({ - parse: (value: string) => { - const parsed = dayjs(value); - return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week"); - }, - serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"), -}); - -export function BookingsCalendar({ table }: BookingsCalendarViewProps) { - const { rows } = table.getRowModel(); +export function BookingsCalendar({ + table, + isPending = false, + onOpenDetails, + currentWeekStart, + setCurrentWeekStart, + bookings, +}: BookingsCalendarProps) { const { updateFilter } = useDataTable(); - const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data; - - const [currentWeekStart, setCurrentWeekStart] = useQueryState( - "weekStart", - weekStartParser.withDefault(dayjs().startOf("week")) - ); - - const bookings = useMemo(() => { - return rows - .filter((row) => row.original.type === "data") - .map((row) => (row.original.type === "data" ? row.original.booking : null)) - .filter((booking): booking is NonNullable => booking !== null); - }, [rows]); const handleWeekStartChange = useCallback( (newWeekStart: dayjs.Dayjs) => { setCurrentWeekStart(newWeekStart); + // Always set the date range to match the current week exactly const startDate = newWeekStart.toDate(); const endDate = newWeekStart.add(6, "day").toDate(); - if (!dateRange) { - return; - } - - const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null; - const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null; - - const needsStartUpdate = !rangeStart || startDate < rangeStart; - const needsEndUpdate = !rangeEnd || endDate > rangeEnd; - - if (!needsStartUpdate && !needsEndUpdate) { - return; - } - - const newStartDate = needsStartUpdate ? startDate : rangeStart; - const newEndDate = needsEndUpdate ? endDate : rangeEnd; - updateFilter("dateRange", { type: ColumnFilterType.DATE_RANGE, data: { - startDate: newStartDate.toISOString(), - endDate: newEndDate.toISOString(), + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), preset: CUSTOM_PRESET.value, }, }); }, - [dateRange, updateFilter, setCurrentWeekStart] + [updateFilter, setCurrentWeekStart] ); return ( @@ -103,6 +76,8 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) { bookings={bookings} currentWeekStart={currentWeekStart} onWeekStartChange={handleWeekStartChange} + isPending={isPending} + onOpenDetails={onOpenDetails} /> ); diff --git a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx new file mode 100644 index 0000000000..8d71b5dbc0 --- /dev/null +++ b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table"; +import { createParser, useQueryState } from "nuqs"; +import { useCallback, useMemo } from "react"; + +import dayjs from "@calcom/dayjs"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; + +import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; + +import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns"; +import { useBookingCursor } from "../hooks/useBookingCursor"; +import { useSelectedBookingId } from "../hooks/useSelectedBookingId"; +import type { RowData, BookingListingStatus } from "../types"; +import { BookingDetailsSheet } from "./BookingDetailsSheet"; +import { BookingsCalendar } from "./BookingsCalendar"; + +const weekStartParser = createParser({ + parse: (value: string) => { + const parsed = dayjs(value); + return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week"); + }, + serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"), +}); + +interface BookingsCalendarContainerProps { + status: BookingListingStatus; + permissions: { + canReadOthersBookings: boolean; + }; + data: RowData[]; + isPending?: boolean; +} + +export function BookingsCalendarContainer({ + status, + permissions, + data, + isPending = false, +}: BookingsCalendarContainerProps) { + const { t } = useLocale(); + const user = useMeQuery().data; + + const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId(); + const [currentWeekStart, setCurrentWeekStart] = useQueryState( + "weekStart", + weekStartParser.withDefault(dayjs().startOf("week")) + ); + + const onOpenDetails = useCallback( + (bookingId: number) => { + setSelectedBookingId(bookingId); + }, + [setSelectedBookingId] + ); + + const columns = useMemo(() => { + return buildFilterColumns({ t, permissions, status }); + }, [t, permissions, status]); + + const getFacetedUniqueValues = useFacetedUniqueValues(); + + const table = useReactTable({ + data, + columns, + initialState: { + columnVisibility: getFilterColumnVisibility(), + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedUniqueValues, + }); + + // Extract bookings from table data and filter by current week + const bookings = useMemo(() => { + const weekStart = currentWeekStart; + const weekEnd = currentWeekStart.add(6, "day"); + + return data + .filter((row): row is Extract => row.type === "data") + .map((row) => row.booking) + .filter((booking) => { + const bookingStart = dayjs(booking.startTime); + return ( + (bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) && + bookingStart.isBefore(weekEnd.endOf("day")) + ); + }); + }, [data, currentWeekStart]); + + const selectedBooking = useMemo(() => { + if (!selectedBookingId) return null; + return bookings.find((booking) => booking.id === selectedBookingId) ?? null; + }, [selectedBookingId, bookings]); + + const bookingNavigation = useBookingCursor({ + bookings, + selectedBookingId, + setSelectedBookingId, + }); + + return ( + <> + + + setSelectedBookingId(null)} + userTimeZone={user?.timeZone} + userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} + userId={user?.id} + userEmail={user?.email} + onPrevious={bookingNavigation.onPrevious} + hasPrevious={bookingNavigation.hasPrevious} + onNext={bookingNavigation.onNext} + hasNext={bookingNavigation.hasNext} + /> + + ); +} diff --git a/apps/web/modules/bookings/components/BookingsCalendarView.tsx b/apps/web/modules/bookings/components/BookingsCalendarView.tsx index 6a24a62f72..c32b386992 100644 --- a/apps/web/modules/bookings/components/BookingsCalendarView.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendarView.tsx @@ -18,12 +18,16 @@ type BookingsCalendarViewProps = { bookings: BookingOutput[]; currentWeekStart: dayjs.Dayjs; onWeekStartChange: (weekStart: dayjs.Dayjs) => void; + isPending?: boolean; + onOpenDetails: (bookingId: number) => void; }; export function BookingsCalendarView({ bookings, currentWeekStart, onWeekStartChange, + isPending = false, + onOpenDetails, }: BookingsCalendarViewProps) { const { t } = useLocale(); const { timezone } = useTimePreferences(); @@ -44,8 +48,10 @@ export function BookingsCalendarView({ const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]); const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]); + // Intentionally only runs on mount to trigger the initial currentWeekStart useEffect(() => { onWeekStartChange(currentWeekStart); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const events = useMemo(() => { @@ -78,6 +84,7 @@ export function BookingsCalendarView({ options: { status: booking.status, ...(eventTypeColor && { color: eventTypeColor }), + bookingId: booking.id, }, }; }); @@ -109,6 +116,7 @@ export function BookingsCalendarView({

    {weekRange}

    + {isPending && }
    diff --git a/apps/web/modules/bookings/components/BookingsList.tsx b/apps/web/modules/bookings/components/BookingsList.tsx index ae4ad90067..24944f77ad 100644 --- a/apps/web/modules/bookings/components/BookingsList.tsx +++ b/apps/web/modules/bookings/components/BookingsList.tsx @@ -1,8 +1,10 @@ "use client"; -import type { Table as ReactTable } from "@tanstack/react-table"; +import type { Row, Table as ReactTable } from "@tanstack/react-table"; +import { useCallback } from "react"; import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table"; +import { isSeparatorRow } from "@calcom/features/data-table/lib/separator"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { EmptyScreen } from "@calcom/ui/components/empty-screen"; @@ -23,22 +25,49 @@ type BookingsListViewProps = { table: ReactTable; isPending: boolean; totalRowCount?: number; + onOpenDetails: (bookingId: number) => void; }; -export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) { +export function BookingsList({ + status, + table, + isPending, + totalRowCount, + onOpenDetails, +}: BookingsListViewProps) { const { t } = useLocale(); + const handleRowClick = useCallback( + (row: Row) => { + if (!isSeparatorRow(row.original)) { + onOpenDetails(row.original.booking.id); + } + }, + [onOpenDetails] + ); + return ( { + if (isSeparatorRow(row.original)) return undefined; + return "booking-item"; + }} + rowDataAttributes={(row) => { + if (isSeparatorRow(row.original)) return undefined; + return { + "data-today": String(row.original.isToday), + }; + }} isPending={isPending} totalRowCount={totalRowCount} - variant="compact" + variant="default" paginationMode="standard" + onRowMouseclick={handleRowClick} + hideSeparatorsOnSort={true} ToolbarLeft={ <> diff --git a/apps/web/modules/bookings/components/BookingsListContainer.tsx b/apps/web/modules/bookings/components/BookingsListContainer.tsx new file mode 100644 index 0000000000..6b0f1fca29 --- /dev/null +++ b/apps/web/modules/bookings/components/BookingsListContainer.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table"; +import { useCallback, useMemo, useState } from "react"; + +import { Dialog } from "@calcom/features/components/controlled-dialog"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc/react"; +import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; +import { Button } from "@calcom/ui/components/button"; +import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog"; +import { TextAreaField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; + +import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns"; +import { buildListDisplayColumns } from "../columns/listColumns"; +import { useBookingCursor } from "../hooks/useBookingCursor"; +import { useSelectedBookingId } from "../hooks/useSelectedBookingId"; +import type { RowData, BookingListingStatus } from "../types"; +import { BookingDetailsSheet } from "./BookingDetailsSheet"; +import { BookingsList } from "./BookingsList"; + +interface BookingsListContainerProps { + status: BookingListingStatus; + permissions: { + canReadOthersBookings: boolean; + }; + data: RowData[]; + isPending: boolean; + totalRowCount?: number; +} + +export function BookingsListContainer({ + status, + permissions, + data, + isPending, + totalRowCount, +}: BookingsListContainerProps) { + const { t } = useLocale(); + const user = useMeQuery().data; + const utils = trpc.useUtils(); + + const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId(); + + // Filter out separator rows and extract bookings + const bookings = useMemo(() => { + return data + .filter((row): row is Extract => row.type === "data") + .map((row) => row.booking); + }, [data]); + + const selectedBooking = useMemo(() => { + if (!selectedBookingId) return null; + return bookings.find((booking) => booking.id === selectedBookingId) ?? null; + }, [selectedBookingId, bookings]); + + const bookingNavigation = useBookingCursor({ + bookings, + selectedBookingId, + setSelectedBookingId, + }); + + const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); + const [rejectionReason, setRejectionReason] = useState(""); + const [pendingRejection, setPendingRejection] = useState<{ + bookingId: number; + recurringEventId?: string | null; + } | null>(null); + + const confirmMutation = trpc.viewer.bookings.confirm.useMutation({ + onSuccess: (data) => { + if (data?.status === "REJECTED") { + setRejectionDialogIsOpen(false); + setRejectionReason(""); + setPendingRejection(null); + showToast(t("booking_rejection_success"), "success"); + } else { + showToast(t("booking_confirmation_success"), "success"); + } + utils.viewer.bookings.invalidate(); + utils.viewer.me.bookingUnconfirmedCount.invalidate(); + }, + onError: () => { + showToast(t("booking_confirmation_failed"), "error"); + utils.viewer.bookings.invalidate(); + }, + }); + + const handleAccept = useCallback( + (bookingId: number, recurringEventId?: string | null) => { + confirmMutation.mutate({ + bookingId, + confirmed: true, + reason: "", + ...(recurringEventId && { recurringEventId }), + }); + }, + [confirmMutation] + ); + + const handleReject = useCallback((bookingId: number, recurringEventId?: string | null) => { + setPendingRejection({ bookingId, recurringEventId }); + setRejectionDialogIsOpen(true); + }, []); + + const handleConfirmRejection = useCallback(() => { + if (!pendingRejection) return; + + confirmMutation.mutate({ + bookingId: pendingRejection.bookingId, + confirmed: false, + reason: rejectionReason, + ...(pendingRejection.recurringEventId && { recurringEventId: pendingRejection.recurringEventId }), + }); + }, [pendingRejection, rejectionReason, confirmMutation]); + + const onOpenDetails = useCallback( + (bookingId: number) => { + setSelectedBookingId(bookingId); + }, + [setSelectedBookingId] + ); + + const columns = useMemo(() => { + const filterCols = buildFilterColumns({ t, permissions, status }); + const listCols = buildListDisplayColumns({ + t, + user, + pendingActionHandlers: { + onAccept: handleAccept, + onReject: handleReject, + isLoading: confirmMutation.isPending, + }, + }); + return [...filterCols, ...listCols]; + }, [t, permissions, status, user, handleAccept, handleReject, confirmMutation.isPending]); + + const getFacetedUniqueValues = useFacetedUniqueValues(); + + const table = useReactTable({ + data, + columns, + initialState: { + columnVisibility: getFilterColumnVisibility(), + columnPinning: { + right: ["actions"], + }, + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedUniqueValues, + }); + + const handleRejectionDialogChange = useCallback((open: boolean) => { + setRejectionDialogIsOpen(open); + if (!open) { + setRejectionReason(""); + setPendingRejection(null); + } + }, []); + + return ( + <> + + +
    + + {t("rejection_reason")} + (Optional) + + } + value={rejectionReason} + onChange={(e) => setRejectionReason(e.target.value)} + /> +
    + + + + + +
    +
    + + + + setSelectedBookingId(null)} + userTimeZone={user?.timeZone} + userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} + userId={user?.id} + userEmail={user?.email} + onPrevious={bookingNavigation.onPrevious} + hasPrevious={bookingNavigation.hasPrevious} + onNext={bookingNavigation.onNext} + hasNext={bookingNavigation.hasNext} + /> + + ); +} diff --git a/apps/web/modules/bookings/components/JoinMeetingButton.tsx b/apps/web/modules/bookings/components/JoinMeetingButton.tsx new file mode 100644 index 0000000000..8ff4d4ed29 --- /dev/null +++ b/apps/web/modules/bookings/components/JoinMeetingButton.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { BookingStatus } from "@calcom/prisma/enums"; +import classNames from "@calcom/ui/classNames"; +import { Button } from "@calcom/ui/components/button"; + +import { useJoinableLocation } from "./useJoinableLocation"; + +interface JoinMeetingButtonProps { + location: string | null; + metadata?: unknown; + bookingStatus: BookingStatus; + size?: "sm" | "base" | "lg"; + color?: "primary" | "secondary" | "minimal" | "destructive"; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +export function JoinMeetingButton({ + location, + metadata, + bookingStatus, + size = "base", + color = "secondary", + className, + onClick, +}: JoinMeetingButtonProps) { + const { t } = useLocale(); + const { isJoinable, locationToDisplay, provider } = useJoinableLocation({ + location, + metadata, + bookingStatus, + t, + }); + + if (!isJoinable || !locationToDisplay) { + return null; + } + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + }; + + return ( + + ); +} diff --git a/apps/web/modules/bookings/components/useJoinableLocation.tsx b/apps/web/modules/bookings/components/useJoinableLocation.tsx new file mode 100644 index 0000000000..82993c2bec --- /dev/null +++ b/apps/web/modules/bookings/components/useJoinableLocation.tsx @@ -0,0 +1,46 @@ +import type { TFunction } from "i18next"; +import { useMemo } from "react"; + +import { useBookingLocation } from "@calcom/features/bookings/hooks"; +import type { BookingStatus } from "@calcom/prisma/enums"; +import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; + +interface UseJoinableLocationParams { + location: string | null; + metadata?: unknown; + bookingStatus: BookingStatus; + t: TFunction; +} + +/** + * Custom hook to determine if a booking location is joinable (i.e., has a clickable URL). + * This hook is used to show/hide the join meeting button and related UI elements. + * + * @returns An object containing: + * - `isJoinable`: Whether the location is joinable (has a valid URL) + * - `locationToDisplay`: The location URL or text to display + * - `provider`: Provider information (label, iconUrl, etc.) + * - `isLocationURL`: Whether the location is a URL + */ +export function useJoinableLocation({ location, metadata, bookingStatus, t }: UseJoinableLocationParams) { + const bookingMetadata = useMemo(() => { + const parsedMetadata = bookingMetadataSchema.safeParse(metadata ?? null); + return parsedMetadata.success ? parsedMetadata.data : null; + }, [metadata]); + + const { locationToDisplay, provider, isLocationURL } = useBookingLocation({ + location, + videoCallUrl: bookingMetadata?.videoCallUrl, + t, + bookingStatus, + }); + + const isJoinable = isLocationURL && !!locationToDisplay; + + return { + isJoinable, + locationToDisplay, + provider, + isLocationURL, + }; +} diff --git a/apps/web/modules/bookings/hooks/useBookingCursor.ts b/apps/web/modules/bookings/hooks/useBookingCursor.ts index a271898c8b..96dbfa80bd 100644 --- a/apps/web/modules/bookings/hooks/useBookingCursor.ts +++ b/apps/web/modules/bookings/hooks/useBookingCursor.ts @@ -1,43 +1,37 @@ import { useMemo, useCallback } from "react"; -import type { RowData } from "../types"; - -function isDataRow(row: RowData): row is Extract { - return row.type === "data"; -} +import type { BookingOutput } from "../types"; export function useBookingCursor({ bookings, selectedBookingId, setSelectedBookingId, }: { - bookings: RowData[]; + bookings: BookingOutput[]; selectedBookingId: number | null; setSelectedBookingId: (bookingId: number | null) => void; }) { - const bookingRows = useMemo(() => bookings.filter(isDataRow), [bookings]); - const currentIndex = useMemo( - () => bookingRows.findIndex((row) => selectedBookingId && row.booking.id === selectedBookingId), - [bookingRows, selectedBookingId] + () => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId), + [bookings, selectedBookingId] ); const onPrevious = useCallback(() => { if (currentIndex >= 1) { - setSelectedBookingId(bookingRows[currentIndex - 1].booking.id); + setSelectedBookingId(bookings[currentIndex - 1].id); } - }, [bookingRows, currentIndex, setSelectedBookingId]); + }, [bookings, currentIndex, setSelectedBookingId]); const onNext = useCallback(() => { - if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) { - setSelectedBookingId(bookingRows[currentIndex + 1].booking.id); + if (currentIndex >= 0 && currentIndex < bookings.length - 1) { + setSelectedBookingId(bookings[currentIndex + 1].id); } - }, [bookingRows, currentIndex, setSelectedBookingId]); + }, [bookings, currentIndex, setSelectedBookingId]); return { onPrevious, onNext, hasPrevious: currentIndex > 0, - hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0, + hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0, }; } diff --git a/apps/web/modules/bookings/hooks/useSelectedBookingId.ts b/apps/web/modules/bookings/hooks/useSelectedBookingId.ts new file mode 100644 index 0000000000..45e2379b41 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useSelectedBookingId.ts @@ -0,0 +1,10 @@ +import { useQueryState } from "nuqs"; + +export function useSelectedBookingId() { + return useQueryState("selectedId", { + defaultValue: null, + parse: (value) => (value ? parseInt(value, 10) : null), + serialize: (value) => (value ? String(value) : ""), + clearOnDefault: true, + }); +} diff --git a/apps/web/modules/bookings/lib/viewParser.ts b/apps/web/modules/bookings/lib/viewParser.ts new file mode 100644 index 0000000000..b6b2569f0e --- /dev/null +++ b/apps/web/modules/bookings/lib/viewParser.ts @@ -0,0 +1,11 @@ +import { createParser } from "nuqs"; + +export const viewParser = createParser({ + parse: (value: string) => { + if (value === "calendar") return "calendar"; + return "list"; + }, + serialize: (value: "list" | "calendar") => value, +}); + +export type BookingView = "list" | "calendar"; diff --git a/apps/web/modules/bookings/types.ts b/apps/web/modules/bookings/types.ts index 68bcaa935a..4aefb9ef0f 100644 --- a/apps/web/modules/bookings/types.ts +++ b/apps/web/modules/bookings/types.ts @@ -1,3 +1,4 @@ +import type { SeparatorRow } from "@calcom/features/data-table/lib/separator"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { validStatuses } from "~/bookings/lib/validStatuses"; @@ -18,8 +19,6 @@ export type RowData = isToday: boolean; recurringInfo?: RecurringInfo; } - | { - type: "today" | "next"; - }; + | SeparatorRow; export type BookingListingStatus = (typeof validStatuses)[number]; diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 48678a0f79..6eff0f978a 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -1,15 +1,13 @@ "use client"; -import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table"; +import dynamic from "next/dynamic"; import { useSearchParams, usePathname } from "next/navigation"; -import { createParser, useQueryState } from "nuqs"; +import { useQueryState } from "nuqs"; import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; import { DataTableProvider, - DataTableFilters, - DataTableSegment, type SystemFilterSegment, useDataTable, ColumnFilterType, @@ -27,24 +25,26 @@ import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation"; import { HorizontalTabs } from "@calcom/ui/components/navigation"; import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton"; -import BookingListItem from "@components/booking/BookingListItem"; - -import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; import type { validStatuses } from "~/bookings/lib/validStatuses"; +import { viewParser } from "~/bookings/lib/viewParser"; -import { BookingDetailsSheet } from "../components/BookingDetailsSheet"; -import { BookingsCalendar } from "../components/BookingsCalendar"; -import { BookingsList } from "../components/BookingsList"; -import { useBookingCursor } from "../hooks/useBookingCursor"; import type { RowData, BookingOutput } from "../types"; +const BookingsListContainer = dynamic(() => + import("../components/BookingsListContainer").then((mod) => ({ default: mod.BookingsListContainer })) +); +const BookingsCalendarContainer = dynamic(() => + import("../components/BookingsCalendarContainer").then((mod) => ({ + default: mod.BookingsCalendarContainer, + })) +); + type BookingsProps = { status: (typeof validStatuses)[number]; userId?: number; permissions: { canReadOthersBookings: boolean; }; - isCalendarViewEnabled: boolean; }; function useSystemSegments(userId?: number) { @@ -86,27 +86,12 @@ export default function Bookings(props: BookingsProps) { ); } -const viewParser = createParser({ - parse: (value: string) => { - if (value === "calendar") return "calendar"; - return "list"; - }, - serialize: (value: "list" | "calendar") => value, -}); - -function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) { - const [_view] = useQueryState("view", viewParser.withDefault("list")); +function BookingsContent({ status, permissions }: BookingsProps) { + const [view] = useQueryState("view", viewParser.withDefault("list")); // Force view to be "list" if calendar view is disabled - const view = isCalendarViewEnabled ? _view : "list"; const { t } = useLocale(); const user = useMeQuery().data; const searchParams = useSearchParams(); - const [selectedBookingId, setSelectedBookingId] = useQueryState("selectedId", { - defaultValue: null, - parse: (value) => (value ? parseInt(value, 10) : null), - serialize: (value) => (value ? String(value) : ""), - clearOnDefault: true, - }); const tabs: HorizontalTabItemProps[] = useMemo(() => { const queryString = searchParams?.toString() || ""; @@ -156,161 +141,47 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking const { limit, offset } = useDataTable(); - const query = trpc.viewer.bookings.get.useQuery({ - limit, - offset, - filters: { - status, - eventTypeIds, - teamIds, - userIds, - attendeeName, - attendeeEmail, - bookingUid, - afterStartDate: dateRange?.startDate - ? dayjs(dateRange?.startDate).startOf("day").toISOString() - : undefined, - beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined, + // Only apply pagination for list view, calendar view needs all bookings + const shouldPaginate = view === "list"; + const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view + const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view + + const query = trpc.viewer.bookings.get.useQuery( + { + limit: queryLimit, + offset: queryOffset, + filters: { + status, + eventTypeIds, + teamIds, + userIds, + attendeeName, + attendeeEmail, + bookingUid, + afterStartDate: dateRange?.startDate + ? dayjs(dateRange?.startDate).startOf("day").toISOString() + : undefined, + beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined, + }, }, - }); - - const columns = useMemo(() => { - const columnHelper = createColumnHelper(); - - return [ - columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, { - id: "eventTypeId", - header: t("event_type"), - enableColumnFilter: true, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.MULTI_SELECT, - }, - }, - }), - columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, { - id: "teamId", - header: t("team"), - enableColumnFilter: true, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.MULTI_SELECT, - }, - }, - }), - columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, { - id: "userId", - header: t("member"), - enableColumnFilter: permissions.canReadOthersBookings, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.MULTI_SELECT, - }, - }, - }), - columnHelper.accessor((row) => row, { - id: "attendeeName", - header: t("attendee_name"), - enableColumnFilter: true, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.TEXT, - }, - }, - }), - columnHelper.accessor((row) => row, { - id: "attendeeEmail", - header: t("attendee_email_variable"), - enableColumnFilter: true, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.TEXT, - }, - }, - }), - columnHelper.accessor((row) => row, { - id: "dateRange", - header: t("date_range"), - enableColumnFilter: true, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.DATE_RANGE, - dateRangeOptions: { - range: status === "past" ? "past" : "custom", - }, - }, - }, - }), - columnHelper.accessor((row) => row.type === "data" && row.booking.uid, { - id: "bookingUid", - header: t("booking_uid"), - enableColumnFilter: true, - enableSorting: false, - cell: () => null, - meta: { - filter: { - type: ColumnFilterType.TEXT, - textOptions: { - allowedOperators: ["equals"], - }, - }, - }, - }), - columnHelper.display({ - id: "customView", - cell: (props) => { - if (props.row.original.type === "data") { - const { booking, recurringInfo, isToday } = props.row.original; - return ( - setSelectedBookingId(booking.id)} - {...booking} - /> - ); - } else if (props.row.original.type === "today") { - return ( -

    - {t("today")} -

    - ); - } else if (props.row.original.type === "next") { - return ( -

    - {t("next")} -

    - ); - } - }, - }), - ]; - }, [user, status, t, permissions.canReadOthersBookings]); + { + staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh + gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time + } + ); const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]); - const flatData = useMemo(() => { + const groupedBookings = useMemo(() => { + if (!query.data?.bookings) { + return { today: [], currentMonth: [], monthBuckets: {} }; + } + + const now = dayjs().tz(user?.timeZone); + const today = now.format("YYYY-MM-DD"); + const currentMonthStart = now.startOf("month"); + const currentMonthEnd = now.endOf("month"); + const shownBookings: Record = {}; const filterBookings = (booking: BookingOutput) => { if (status === "recurring" || status == "unconfirmed" || status === "cancelled") { @@ -325,95 +196,86 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking return false; } shownBookings[booking.recurringEventId] = [booking]; - } else if (status === "upcoming") { - return ( - dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !== - dayjs().tz(user?.timeZone).format("YYYY-MM-DD") - ); } return true; }; - return ( - query.data?.bookings.filter(filterBookings).map((booking) => ({ + const todayBookings: RowData[] = []; + const currentMonthBookings: RowData[] = []; + const monthBuckets: Record = {}; // Key format: "YYYY-MM" + + query.data.bookings.filter(filterBookings).forEach((booking) => { + const bookingDate = dayjs(booking.startTime).tz(user?.timeZone); + const bookingDateStr = bookingDate.format("YYYY-MM-DD"); + const monthKey = bookingDate.format("YYYY-MM"); + + const rowData: RowData = { type: "data", booking, + isToday: bookingDateStr === today, recurringInfo: query.data?.recurringInfo.find( (info) => info.recurringEventId === booking.recurringEventId ), - isToday: false, - })) || [] - ); + }; + + if (bookingDateStr === today) { + todayBookings.push(rowData); + } else if (bookingDate.isAfter(currentMonthStart) && bookingDate.isBefore(currentMonthEnd)) { + currentMonthBookings.push(rowData); + } else if (bookingDate.isAfter(currentMonthEnd)) { + if (!monthBuckets[monthKey]) { + monthBuckets[monthKey] = []; + } + monthBuckets[monthKey].push(rowData); + } else if (bookingDate.isBefore(currentMonthStart)) { + // Handle bookings from months before the current month + if (!monthBuckets[monthKey]) { + monthBuckets[monthKey] = []; + } + monthBuckets[monthKey].push(rowData); + } + }); + + return { today: todayBookings, currentMonth: currentMonthBookings, monthBuckets }; }, [query.data, status, user?.timeZone]); + const flatData = useMemo(() => { + return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()]; + }, [groupedBookings]); + const bookingsToday = useMemo(() => { - return ( - query.data?.bookings - .filter( - (booking: BookingOutput) => - dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") === - dayjs().tz(user?.timeZone).format("YYYY-MM-DD") - ) - .map((booking) => ({ - type: "data" as const, - booking, - recurringInfo: query.data?.recurringInfo.find( - (info) => info.recurringEventId === booking.recurringEventId - ), - isToday: true, - })) ?? [] - ); - }, [query.data, user?.timeZone]); + return groupedBookings.today; + }, [groupedBookings]); const finalData = useMemo(() => { if (status !== "upcoming") { return flatData; } + const merged: RowData[] = []; - if (bookingsToday.length > 0) { - merged.push({ type: "today" as const }, ...bookingsToday); + + // Add Today section + if (groupedBookings.today.length > 0) { + merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today); } - if (flatData.length > 0) { - merged.push({ type: "next" as const }, ...flatData); + + // Add Current Month section (rest of this month, excluding today) + if (groupedBookings.currentMonth.length > 0) { + merged.push({ type: "separator", label: t("this_month") }, ...groupedBookings.currentMonth); } + + // Add individual month sections + const sortedMonthKeys = Object.keys(groupedBookings.monthBuckets).sort(); + sortedMonthKeys.forEach((monthKey) => { + const bookings = groupedBookings.monthBuckets[monthKey]; + if (bookings.length > 0) { + const monthLabel = dayjs(monthKey, "YYYY-MM").format("MMMM YYYY"); + merged.push({ type: "separator", label: monthLabel }, ...bookings); + } + }); + return merged; - }, [bookingsToday, flatData, status]); - - const selectedBooking = useMemo(() => { - if (!selectedBookingId) return null; - const dataRow = finalData.find( - (row): row is Extract => - row.type === "data" && row.booking.id === selectedBookingId - ); - return dataRow?.booking ?? null; - }, [selectedBookingId, finalData]); - - const bookingNavigation = useBookingCursor({ - bookings: finalData, - selectedBookingId, - setSelectedBookingId, - }); - - const getFacetedUniqueValues = useFacetedUniqueValues(); - - const table = useReactTable({ - data: finalData, - columns, - initialState: { - columnVisibility: { - eventTypeId: false, - teamId: false, - userId: false, - attendeeName: false, - attendeeEmail: false, - dateRange: false, - bookingUid: false, - }, - }, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - getFacetedUniqueValues, - }); + }, [groupedBookings, status, t, flatData]); const isPending = query.isPending; const totalRowCount = query.data?.totalCount; @@ -431,55 +293,32 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
    {query.status === "error" ? ( - <> -
    -
    -
    -
    - -
    -
    - - - -
    -
    -
    -
    - - + ) : ( <> {!!bookingsToday.length && status === "upcoming" && ( )} {view === "list" ? ( - ) : ( - + )} )}
    - setSelectedBookingId(null)} - userTimeZone={user?.timeZone} - userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} - userId={user?.id} - userEmail={user?.email} - onPrevious={bookingNavigation.onPrevious} - hasPrevious={bookingNavigation.hasPrevious} - onNext={bookingNavigation.onNext} - hasNext={bookingNavigation.hasNext} - />
    ); } diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index cf23c6c111..42d61134fc 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -10,6 +10,8 @@ import type { Fixtures } from "./lib/fixtures"; import { test } from "./lib/fixtures"; import { setupManagedEvent } from "./lib/testUtils"; +test.describe.configure({ mode: "parallel" }); + test.afterEach(({ users }) => users.deleteAll()); test.describe("Bookings", () => { @@ -58,16 +60,14 @@ test.describe("Bookings", () => { const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0); const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1); await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`) ).toBeVisible(); await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`) ).toBeVisible(); }); - test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => { + test("Cannot choose date range presets", async ({ page, users }) => { const firstUser = await users.create(); await firstUser.apiLogin(); const bookingsGetResponse = page.waitForResponse((response) => @@ -91,7 +91,7 @@ test.describe("Bookings", () => { test.describe("Past bookings", () => { test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => { const firstUser = await users.create(); - const secondUser = await users.create(); + const _secondUser = await users.create(); const bookingWhereFirstUserIsOrganizerFixture = await createBooking({ title: "Booking as organizer", @@ -112,15 +112,14 @@ test.describe("Bookings", () => { 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(page.locator('[data-testid="unmark-no-show"]')).toBeHidden(); await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible(); await page.locator('[data-testid="mark-no-show"]').click(); - await firstGuest.click(); - await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible(); - await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden(); + + // Wait for the dropdown to close before clicking again + await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible(); await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; const body = request.body; @@ -142,7 +141,7 @@ test.describe("Bookings", () => { }); test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => { const firstUser = await users.create(); - const secondUser = await users.create(); + const _secondUser = await users.create(); const bookingWhereFirstUserIsOrganizerFixture = await createBooking({ title: "Booking as organizer", @@ -158,23 +157,33 @@ test.describe("Bookings", () => { { name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" }, ], }); - const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self(); + 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"]'); + + // Click the ellipsis button to open the all attendees dropdown 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(); + + // Find all the mark-no-show buttons in the dropdown (one for each attendee) + const noShowButtons = page.locator('[data-testid="mark-no-show"]'); + + // Verify the 3rd attendee (index 2) has mark-no-show button (not already marked) + const thirdAttendeeNoShowButton = noShowButtons.nth(2); + await expect(thirdAttendeeNoShowButton).toBeVisible(); + + // Click the 3rd attendee's mark-no-show button + await thirdAttendeeNoShowButton.click(); + + // Open dropdown again to verify the 3rd attendee now shows unmark-no-show await moreGuests.click(); - await expect(firstGuestInMore).toBeChecked({ checked: true }); + const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]'); + + // The 3rd attendee should now have the unmark button visible + await expect(unmarkNoShowButtons.first()).toBeVisible(); }); test("Team admin/owner can mark first attendee as no-show", async ({ page, @@ -199,18 +208,16 @@ test.describe("Bookings", () => { }); const booking = await bookingFixture.self(); await adminUser.apiLogin(); - const { webhookReceiver, teamId } = await webhooks.createTeamReceiver(); + const { webhookReceiver } = await webhooks.createTeamReceiver(); 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(page.locator('[data-testid="mark-no-show"]')).toBeVisible(); await page.locator('[data-testid="mark-no-show"]').click(); - await firstGuest.click(); - await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible(); - await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden(); + // Wait for the dropdown to close before clicking again + await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible(); await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; const body = request.body; @@ -232,7 +239,7 @@ test.describe("Bookings", () => { webhookReceiver.close(); }); - test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => { + test("Can choose date range presets", async ({ page, users }) => { const firstUser = await users.create(); await firstUser.apiLogin(); const bookingsGetResponse = page.waitForResponse((response) => @@ -374,22 +381,15 @@ test.describe("Bookings", () => { //verify with the booking titles const firstUpcomingBooking = bookingListItems.nth(0); - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`) - ).toBeVisible(); + await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible(); const secondUpcomingBooking = bookingListItems.nth(1); await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion secondUpcomingBooking.locator(`text=${thirdUserAttendeeIndividualBooking!.title}`) ).toBeVisible(); const thirdUpcomingBooking = bookingListItems.nth(2); - await expect( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`) - ).toBeVisible(); + await expect(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible(); }); test("Does not show booking from another user from collective event type when a member is filtered", async ({ @@ -414,7 +414,7 @@ test.describe("Bookings", () => { const { team } = await owner.getFirstTeamMembership(); const eventType = await owner.getFirstTeamEvent(team.id); - const { id: eventTypeId, title: teamEventTitle, slug: teamEventSlug } = eventType; + const { id: eventTypeId, title: _teamEventTitle, slug: _teamEventSlug } = eventType; // remove myself from host of this event type await prisma.host.delete({ diff --git a/apps/web/playwright/i18n-routing.e2e.ts b/apps/web/playwright/i18n-routing.e2e.ts index 7944d5f8be..92463ba123 100644 --- a/apps/web/playwright/i18n-routing.e2e.ts +++ b/apps/web/playwright/i18n-routing.e2e.ts @@ -57,9 +57,7 @@ test.describe("Locale-specific pages must not 404", () => { await user.apiLogin(); const response = await page.goto(`/${locale}/bookings/upcoming`); expect(response?.status()).not.toBe(404); - await expect( - page.locator("text=See upcoming and past events booked through your event type links.") - ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Bookings" })).toBeVisible(); }); test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => { diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 2dc45dba99..87a55bd1f2 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -41,7 +41,9 @@ test.describe("Reschedule Tests", async () => { await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it"); await page.locator('button[data-testid="send_request"]').click(); - await expect(page.locator('[id="modal-title"]')).toBeHidden(); + await expect(page.locator('[data-testid="reschedule-dialog"]')).toBeHidden(); + + await page.waitForTimeout(2000); const updatedBooking = await booking.self(); @@ -58,7 +60,7 @@ test.describe("Reschedule Tests", async () => { }) => { const user = await users.create(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { + const _booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, { status: BookingStatus.ACCEPTED, startTime: dayjs().subtract(2, "day").toDate(), endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(), @@ -130,8 +132,9 @@ test.describe("Reschedule Tests", async () => { await user.apiLogin(); await page.goto("/bookings/cancelled"); - const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1); - await expect(requestRescheduleSentElement).toBeVisible(); + await page.locator('[data-testid="booking-item"]').nth(0).click(); + + await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible(); await booking.delete(); }); @@ -196,7 +199,7 @@ test.describe("Reschedule Tests", async () => { }, }, }); - const payment = await payments.create(booking.id); + const _payment = await payments.create(booking.id); await page.goto(`/reschedule/${booking.uid}`); await selectFirstAvailableTimeSlotNextMonth(page); @@ -226,7 +229,7 @@ test.describe("Reschedule Tests", async () => { paid: true, }); - const payment = await payments.create(booking.id); + const _payment = await payments.create(booking.id); await page.goto(`/reschedule/${booking?.uid}`); await selectFirstAvailableTimeSlotNextMonth(page); @@ -330,7 +333,6 @@ test.describe("Reschedule Tests", async () => { test("Should load Valid Cal video url after rescheduling Opt in events", async ({ page, users, - bookings, browser, }) => { // eslint-disable-next-line playwright/no-skipped-test @@ -339,11 +341,13 @@ test.describe("Reschedule Tests", async () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!; - const confirmBooking = async (bookingId: number) => { + const confirmBooking = async (_bookingId: number) => { const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser); await authedPage.goto("/bookings/upcoming"); await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", { - action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(), + action: async () => { + await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click(); + }, }); await authedContext.close(); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index f7d93cbbbc..323d1796f5 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -221,6 +221,7 @@ "manage_this_team": "Manage this team", "team_info": "Team Info", "join_meeting": "Join Meeting", + "join_platform": "Join {{platform}}", "request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.", "you_have_been_invited": "You have been invited to join the team {{teamName}}", "user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}", @@ -1711,7 +1712,10 @@ "error_updating_password": "Error updating password", "two_factor_auth": "Two factor authentication", "recurring_event_tab_description": "Set up a repeating schedule", - "today": "today", + "today": "Today", + "this_month": "This month", + "time": "Time", + "event": "Event", "appearance": "Appearance", "my_account": "My account", "general": "General", @@ -3885,7 +3889,7 @@ "history": "History", "no_audit_history": "No audit history available", "remove_value_from_blocklist": "Remove {{value}} from blocklist?", - "remove_value_from_blocklist_description":"The action is irreversible", + "remove_value_from_blocklist_description": "The action is irreversible", "delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.", "created_by": "Created By", "blocked_by": "Blocked By", @@ -3975,7 +3979,7 @@ "team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team", "repeats_num_times": "Repeats {{count}} times", "view_booking_details": "View Booking Details", - "view": "View", + "recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences", "team_bio": "Team Bio", "team_bio_placeholder": "Tell us about your team...", "onboarding_invite_subtitle": "Connect your Google workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Team.", @@ -4004,5 +4008,9 @@ "onboarding_browser_view_ask_question_description": "Ask a question", "onboarding_browser_view_default_bio": "Add your bio here...", "book_now": "Book now", + "view": "View", + "booking_response": "Booking Response", + "list_view": "List view", + "calendar_view": "Calendar view", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts index 5803d0a46d..b8d68914d1 100644 --- a/packages/features/calendars/weeklyview/types/events.ts +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -13,6 +13,7 @@ export interface CalendarEvent { allDay?: boolean; color?: string; className?: string; + bookingId?: number; "data-test-id"?: string; }; } diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 5c7214288b..587cda4a4e 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -38,7 +38,9 @@ export type DataTablePropsFromWrapper = { className?: string; containerClassName?: string; headerClassName?: string; - rowClassName?: string; + rowClassName?: string | ((row: Row) => string); + rowTestId?: string | ((row: Row) => string | undefined); + rowDataAttributes?: (row: Row) => Record | undefined; paginationMode?: "infinite" | "standard"; hasWrapperContext?: boolean; hideSeparatorsOnSort?: boolean; @@ -68,6 +70,8 @@ export function DataTable({ containerClassName, headerClassName, rowClassName, + rowTestId, + rowDataAttributes, paginationMode = "infinite", hasWrapperContext = false, hideSeparatorsOnSort = true, @@ -203,6 +207,8 @@ export function DataTable({ onRowMouseclick={onRowMouseclick} paginationMode={paginationMode} rowClassName={rowClassName} + rowTestId={rowTestId} + rowDataAttributes={rowDataAttributes} hideSeparatorsOnSort={hideSeparatorsOnSort} hideSeparatorsOnFilter={hideSeparatorsOnFilter} separatorClassName={separatorClassName} @@ -219,6 +225,8 @@ export function DataTable({ onRowMouseclick={onRowMouseclick} paginationMode={paginationMode} rowClassName={rowClassName} + rowTestId={rowTestId} + rowDataAttributes={rowDataAttributes} hideSeparatorsOnSort={hideSeparatorsOnSort} hideSeparatorsOnFilter={hideSeparatorsOnFilter} separatorClassName={separatorClassName} @@ -244,6 +252,8 @@ const MemoizedTableBody = memo( prev.onRowMouseclick === next.onRowMouseclick && prev.paginationMode === next.paginationMode && prev.rowClassName === next.rowClassName && + prev.rowTestId === next.rowTestId && + prev.rowDataAttributes === next.rowDataAttributes && prev.hideSeparatorsOnSort === next.hideSeparatorsOnSort && prev.hideSeparatorsOnFilter === next.hideSeparatorsOnFilter && prev.separatorClassName === next.separatorClassName && @@ -259,7 +269,9 @@ type DataTableBodyProps = { isPending?: boolean; onRowMouseclick?: (row: Row) => void; paginationMode?: "infinite" | "standard"; - rowClassName?: string; + rowClassName?: string | ((row: Row) => string); + rowTestId?: string | ((row: Row) => string | undefined); + rowDataAttributes?: (row: Row) => Record | undefined; hideSeparatorsOnSort?: boolean; hideSeparatorsOnFilter?: boolean; separatorClassName?: string; @@ -294,6 +306,8 @@ function DataTableBody({ onRowMouseclick, paginationMode, rowClassName, + rowTestId, + rowDataAttributes, hideSeparatorsOnSort = true, hideSeparatorsOnFilter = false, separatorClassName, @@ -374,10 +388,16 @@ function DataTableBody({ ); } + const computedRowTestId = typeof rowTestId === "function" ? rowTestId(row) : rowTestId; + const computedRowClassName = typeof rowClassName === "function" ? rowClassName(row) : rowClassName; + const computedDataAttributes = rowDataAttributes?.(row); + return ( filteredRowVirtualizer.measureElement(node) : undefined} key={row.id} + data-testid={computedRowTestId} + {...computedDataAttributes} data-index={virtualItem?.index} // needed for dynamic row height measurement data-state={row.getIsSelected() && "selected"} onClick={() => onRowMouseclick && onRowMouseclick(row)} @@ -389,7 +409,7 @@ function DataTableBody({ width: "100%", }), }} - className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}> + className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", computedRowClassName)}> {row.getVisibleCells().map((cell) => { const column = cell.column; return ( @@ -428,7 +448,7 @@ const TableHeadLabel = ({ header }: { header: Header }) if (!canSort && !canHide) { if (typeof header.column.columnDef.header === "string") { return ( -
    +
    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
    ); diff --git a/packages/features/data-table/components/DataTableWrapper.tsx b/packages/features/data-table/components/DataTableWrapper.tsx index 46337cd147..aef5aa5143 100644 --- a/packages/features/data-table/components/DataTableWrapper.tsx +++ b/packages/features/data-table/components/DataTableWrapper.tsx @@ -22,6 +22,8 @@ type BaseDataTableWrapperProps = Omit< LoaderView?: React.ReactNode; tableContainerRef?: React.RefObject; onRowMouseclick?: (row: Row) => void; + rowTestId?: string | ((row: Row) => string | undefined); + rowDataAttributes?: (row: Row) => Record | undefined; }; type InfinitePaginationProps = BaseDataTableWrapperProps & { @@ -58,6 +60,8 @@ export function DataTableWrapper({ containerClassName, headerClassName, rowClassName, + rowTestId, + rowDataAttributes, children, tableContainerRef: externalRef, paginationMode, @@ -133,6 +137,8 @@ export function DataTableWrapper({ containerClassName={containerClassName} headerClassName={headerClassName} rowClassName={rowClassName} + rowTestId={rowTestId} + rowDataAttributes={rowDataAttributes} paginationMode={paginationMode} onRowMouseclick={onRowMouseclick} hasWrapperContext={true} diff --git a/packages/lib/hooks/useMediaQuery.ts b/packages/lib/hooks/useMediaQuery.ts index 9d06c75005..badad29d30 100644 --- a/packages/lib/hooks/useMediaQuery.ts +++ b/packages/lib/hooks/useMediaQuery.ts @@ -1,7 +1,13 @@ import { useState, useEffect } from "react"; const useMediaQuery = (query: string) => { - const [matches, setMatches] = useState(false); + const [matches, setMatches] = useState(() => { + // Initialize with actual media query value on client + if (typeof window !== "undefined") { + return window.matchMedia(query).matches; + } + return false; + }); useEffect(() => { const media = window.matchMedia(query); @@ -9,8 +15,8 @@ const useMediaQuery = (query: string) => { setMatches(media.matches); } const listener = () => setMatches(media.matches); - window.addEventListener("resize", listener); - return () => window.removeEventListener("resize", listener); + media.addEventListener("change", listener); + return () => media.removeEventListener("change", listener); }, [matches, query]); return matches; diff --git a/packages/ui/components/avatar/AvatarGroup.tsx b/packages/ui/components/avatar/AvatarGroup.tsx index ae8c37f748..6d331717e2 100644 --- a/packages/ui/components/avatar/AvatarGroup.tsx +++ b/packages/ui/components/avatar/AvatarGroup.tsx @@ -13,6 +13,7 @@ export type AvatarGroupProps = { className?: string; truncateAfter?: number; hideTruncatedAvatarsCount?: boolean; + onClick?: (e: React.MouseEvent) => void; }; export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) { @@ -46,8 +47,10 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
  • + props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16", + props.onClick && "cursor-pointer hover:opacity-80" + )} + onClick={props.onClick}>