fix: revert bookings redesign (#25172)
* Revert "refactor: extract logic as bookingDetailsSheetStore (#25129)" This reverts commit7a0b8a9eba. * Revert "feat: bookings page redesign v3 with calendar view (#24664)" This reverts commit89230474a5.
This commit is contained in:
@@ -1,62 +0,0 @@
|
||||
"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 (
|
||||
<div className="hidden sm:block">
|
||||
<ToggleGroup
|
||||
value={view}
|
||||
onValueChange={(value) => {
|
||||
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: <Icon name="menu" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "calendar",
|
||||
label: "",
|
||||
tooltip: t("calendar_view"),
|
||||
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ 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";
|
||||
@@ -14,8 +16,6 @@ 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,12 +54,18 @@ const Page = async ({ params }: PageProps) => {
|
||||
canReadOthersBookings = teamIdsWithPermission.length > 0;
|
||||
}
|
||||
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"booking-calendar-view"
|
||||
);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} CTA={<ViewToggleButton />}>
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList
|
||||
status={parsed.data.status}
|
||||
userId={session?.user?.id}
|
||||
permissions={{ canReadOthersBookings }}
|
||||
isCalendarViewEnabled={isCalendarViewEnabled}
|
||||
/>
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
|
||||
+1
-1
@@ -499,7 +499,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
<BookingActionsDropdown booking={booking} />
|
||||
<BookingActionsDropdown booking={booking} context="booking-list-item" />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -1,83 +1,36 @@
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonAvatar, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
function SkeletonLoader() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
{/* Table rows with separator at the beginning */}
|
||||
<div className="divide-subtle divide-y">
|
||||
{/* Month separator skeleton */}
|
||||
<div className="bg-muted rounded-t py-2">
|
||||
<SkeletonItem isHeader={true} />
|
||||
</div>
|
||||
<div className="bg-muted">
|
||||
<SkeletonText className="ml-2 mt-3 h-4 w-28 rounded" />
|
||||
</div>
|
||||
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
<SkeletonItem />
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkeletonLoader;
|
||||
|
||||
function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) {
|
||||
function SkeletonItem() {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"grid grid-cols-[132px_130px_185px_150px_140px_280px] gap-6 px-2",
|
||||
isHeader ? "py-2" : "py-2.5"
|
||||
)}>
|
||||
{/* Date column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-20")} />
|
||||
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
|
||||
<div className="flex-grow truncate text-sm">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<SkeletonText className="h-5 w-16" />
|
||||
<SkeletonText className="h-4 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-28")} />
|
||||
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
<SkeletonText className="h-6 w-16" />
|
||||
<SkeletonText className="h-6 w-32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event column - 200px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-16" : "w-full")} />
|
||||
</div>
|
||||
|
||||
{/* Who column - 160px, Avatar group */}
|
||||
<div className="flex items-center -space-x-1">
|
||||
{isHeader && <SkeletonText className="h-4 w-20 rounded" />}
|
||||
{!isHeader && (
|
||||
<>
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
<SkeletonAvatar className="h-6 w-6 rounded-full" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Team column - 140px */}
|
||||
<div className="flex items-center">
|
||||
<SkeletonText className="h-4 w-20 rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* Actions column - 280px */}
|
||||
<div className="mr-2 flex items-center justify-end gap-2">
|
||||
{isHeader && <SkeletonText className="h-4 w-16 rounded-md" />}
|
||||
{!isHeader && (
|
||||
<>
|
||||
<SkeletonText className="h-4 w-16 rounded-md" />
|
||||
<SkeletonText className="h-4 w-20 rounded-md" />
|
||||
<SkeletonText className="h-4 w-8 rounded-md" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +31,6 @@ 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 {
|
||||
@@ -40,29 +39,16 @@ 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,
|
||||
size = "base",
|
||||
className,
|
||||
usePortal = true,
|
||||
}: BookingActionsDropdownProps) {
|
||||
export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -178,13 +164,6 @@ export function BookingActionsDropdown({
|
||||
|
||||
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;
|
||||
@@ -232,10 +211,6 @@ export function BookingActionsDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate showPendingPayment based on payment logic
|
||||
const hasPayment = booking.payment.length > 0;
|
||||
const showPendingPayment = hasPayment;
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
booking,
|
||||
isUpcoming,
|
||||
@@ -253,7 +228,7 @@ export function BookingActionsDropdown({
|
||||
isDisabledCancelling,
|
||||
isDisabledRescheduling,
|
||||
isCalVideoLocation,
|
||||
showPendingPayment,
|
||||
showPendingPayment: false, // This will be calculated below
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
@@ -263,20 +238,6 @@ export function BookingActionsDropdown({
|
||||
|
||||
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) => ({
|
||||
@@ -539,26 +500,22 @@ export function BookingActionsDropdown({
|
||||
|
||||
// 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);
|
||||
|
||||
// Check report and cancel actions
|
||||
// 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
|
||||
const isReportAvailable = !reportActionWithHandler.disabled;
|
||||
const isCancelAvailable = !cancelEventAction.disabled;
|
||||
|
||||
return (
|
||||
hasAvailablePendingAction ||
|
||||
hasAvailableEditAction ||
|
||||
hasAvailableAfterAction ||
|
||||
isReportAvailable ||
|
||||
isCancelAvailable
|
||||
);
|
||||
return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable;
|
||||
};
|
||||
|
||||
// Don't render dropdown if no actions are available
|
||||
@@ -566,150 +523,94 @@ export function BookingActionsDropdown({
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
// Conditional portal wrapper to avoid portal when inside Sheet/Dialog
|
||||
const ConditionalPortal = usePortal
|
||||
? DropdownMenuPortal
|
||||
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
const menuContent = (
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<Dropdown modal={false}>
|
||||
<Dropdown modal={context === "booking-details-sheet" ? false : undefined}>
|
||||
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
size={size}
|
||||
StartIcon="ellipsis"
|
||||
className={className}
|
||||
// Prevent click from bubbling to parent row/container click handlers
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Button type="button" color="secondary" variant="icon" size={size} StartIcon="ellipsis" />
|
||||
</DropdownMenuTrigger>
|
||||
<ConditionalPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="rounded-lg">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon="external-link"
|
||||
href={bookingLink}
|
||||
target="_blank"
|
||||
data-testid="view-booking"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
{t("view")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{pendingActions.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("booking_response")}</DropdownMenuLabel>
|
||||
{pendingActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
action.onClick?.(e);
|
||||
}}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
reportActionWithHandler.onClick?.();
|
||||
}}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelEventAction.onClick?.(e);
|
||||
}}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</ConditionalPortal>
|
||||
{context === "booking-details-sheet" ? (
|
||||
menuContent
|
||||
) : (
|
||||
<DropdownMenuPortal>{menuContent}</DropdownMenuPortal>
|
||||
)}
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -137,18 +137,18 @@ describe("Booking Actions", () => {
|
||||
|
||||
expect(actions).toHaveLength(2);
|
||||
expect(actions[0]).toEqual({
|
||||
id: "reject",
|
||||
label: "reject",
|
||||
icon: "ban",
|
||||
disabled: false,
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
id: "confirm",
|
||||
bookingId: 1,
|
||||
label: "confirm",
|
||||
icon: "check",
|
||||
disabled: false,
|
||||
});
|
||||
expect(actions[1]).toEqual({
|
||||
id: "reject",
|
||||
label: "reject",
|
||||
icon: "ban",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return reject action only for non-pending booking", () => {
|
||||
@@ -167,8 +167,8 @@ describe("Booking Actions", () => {
|
||||
});
|
||||
const actions = getPendingActions(context);
|
||||
|
||||
expect(actions[0].label).toBe("confirm_all");
|
||||
expect(actions[1].label).toBe("reject_all");
|
||||
expect(actions[0].label).toBe("reject_all");
|
||||
expect(actions[1].label).toBe("confirm_all");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,14 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
|
||||
context;
|
||||
|
||||
const actions: ActionType[] = [];
|
||||
const actions: ActionType[] = [
|
||||
{
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
icon: "ban",
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
},
|
||||
];
|
||||
|
||||
// For bookings with payment, only confirm if the booking is paid for
|
||||
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
|
||||
@@ -52,13 +59,6 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: "reject",
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
|
||||
icon: "ban",
|
||||
disabled: false, // This would be controlled by mutation state in the component
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow data-testid="reschedule-dialog">
|
||||
<DialogContent enableOverflow>
|
||||
<div className="flex flex-row space-x-3">
|
||||
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
|
||||
<Icon name="clock" className="m-auto h-6 w-6" />
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
// Initialize with actual media query value on client
|
||||
if (typeof window !== "undefined") {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
@@ -16,8 +10,8 @@ const useMediaQuery = (query: string) => {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import type { VisibilityState } from "@tanstack/react-table";
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
|
||||
import type { RowData } from "../types";
|
||||
|
||||
interface BuildFilterColumnsParams {
|
||||
t: (key: string) => string;
|
||||
permissions: {
|
||||
canReadOthersBookings: boolean;
|
||||
};
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of filter columns that should be hidden in the UI
|
||||
* These columns are used for filtering but not displayed
|
||||
*/
|
||||
const FILTER_COLUMN_IDS = [
|
||||
"eventTypeId",
|
||||
"teamId",
|
||||
"userId",
|
||||
"attendeeName",
|
||||
"attendeeEmail",
|
||||
"dateRange",
|
||||
"bookingUid",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Gets the column visibility state for filter columns
|
||||
* All filter columns are hidden by default as they only provide filtering functionality
|
||||
*/
|
||||
export function getFilterColumnVisibility(): VisibilityState {
|
||||
return Object.fromEntries(FILTER_COLUMN_IDS.map((id) => [id, false]));
|
||||
}
|
||||
|
||||
export function buildFilterColumns({ t, permissions, status }: BuildFilterColumnsParams) {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.id : null), {
|
||||
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 ?? null : null), {
|
||||
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 ?? null : null), {
|
||||
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 : null), {
|
||||
id: "bookingUid",
|
||||
header: t("booking_uid"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
allowedOperators: ["equals"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { JoinMeetingButton } from "../components/JoinMeetingButton";
|
||||
import type { RowData } from "../types";
|
||||
|
||||
interface PendingActionHandlers {
|
||||
onAccept: (bookingId: number, recurringEventId?: string | null) => void;
|
||||
onReject: (bookingId: number, recurringEventId?: string | null) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
interface BuildListDisplayColumnsParams {
|
||||
t: (key: string) => string;
|
||||
user?: {
|
||||
id?: number;
|
||||
email?: string;
|
||||
timeZone?: string;
|
||||
timeFormat?: number | null;
|
||||
};
|
||||
pendingActionHandlers: PendingActionHandlers;
|
||||
}
|
||||
|
||||
// Maximum number of attendees to display before showing "..." button
|
||||
const MAX_DISPLAYED_ATTENDEES = 3;
|
||||
|
||||
type AttendeeProps = {
|
||||
name?: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
};
|
||||
|
||||
// Component for individual attendee avatar with dropdown
|
||||
const IndividualAttendee = ({
|
||||
attendee,
|
||||
bookingUid,
|
||||
isBookingInPast,
|
||||
}: {
|
||||
attendee: AttendeeProps;
|
||||
bookingUid: string;
|
||||
isBookingInPast: boolean;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { copyToClipboard, isCopied } = 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");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="focus:outline-none" data-testid="guest">
|
||||
<Avatar
|
||||
size="sm"
|
||||
imageSrc={getPlaceholderAvatar(null, attendee.name)}
|
||||
alt={attendee.name || attendee.email}
|
||||
title={attendee.name || attendee.email}
|
||||
className="border-subtle cursor-pointer hover:opacity-80"
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-emphasis px-3 py-2 text-sm font-medium">
|
||||
{attendee.name || attendee.email}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{!isSmsCalEmail(attendee.email) && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon="mail"
|
||||
href={`mailto:${attendee.email}`}
|
||||
onClick={() => {
|
||||
setOpenDropdown(false);
|
||||
}}>
|
||||
<a href={`mailto:${attendee.email}`}>{t("email")}</a>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={(e) => {
|
||||
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")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{isBookingInPast && (
|
||||
<DropdownMenuItem className="focus:outline-none">
|
||||
<DropdownItem
|
||||
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
|
||||
onClick={(e) => {
|
||||
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")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="ellipsis"
|
||||
className="px-1.5"
|
||||
data-testid="more-guests"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}></Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent className="w-80" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("attendees")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{attendees.map((attendee) => (
|
||||
<div key={attendee.id} className="hover:bg-muted flex items-center justify-between px-2 py-2">
|
||||
<div className="flex-1 truncate text-sm">
|
||||
<div className="font-medium">{attendee.name || attendee.email}</div>
|
||||
{attendee.name && <div className="text-subtle text-xs">{attendee.email}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isSmsCalEmail(attendee.email) && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="mail"
|
||||
className="px-1.5"
|
||||
tooltip={t("email")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEmail(attendee.email);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon="clipboard"
|
||||
className="px-1.5"
|
||||
tooltip={t("copy")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(attendee);
|
||||
}}
|
||||
/>
|
||||
{isBookingInPast && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="minimal"
|
||||
StartIcon={attendee.noShow ? "eye" : "eye-off"}
|
||||
className="px-1.5"
|
||||
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
|
||||
tooltip={attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleToggleNoShow(attendee);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isBookingInPast && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex justify-end p-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
data-testid="update-no-show"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleMarkAllAsNoShow();
|
||||
}}>
|
||||
{t("mark_as_no_show_title")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="flex items-center">
|
||||
{displayedAttendees.map((attendee, idx) => (
|
||||
<div key={attendee.id} className={idx > 0 ? "-ml-1" : ""}>
|
||||
<IndividualAttendee attendee={attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
|
||||
</div>
|
||||
))}
|
||||
{hasMoreThanMax && (
|
||||
<AllAttendeesDropdown
|
||||
attendees={attendees}
|
||||
bookingUid={bookingUid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: "date",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("date")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
return (
|
||||
<div className="text-default text-sm font-medium">
|
||||
{dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "time",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("time")}</span>,
|
||||
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 (
|
||||
<div className="text-default text-sm font-medium">
|
||||
{startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
|
||||
{endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "event",
|
||||
minSize: 200,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("event")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
return (
|
||||
<div className="text-emphasis flex-1 truncate text-sm font-medium" data-testid="title">
|
||||
{row.booking.title}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "who",
|
||||
size: 160,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("who")}</span>,
|
||||
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 (
|
||||
<AttendeeCell
|
||||
attendees={attendees}
|
||||
bookingUid={row.booking.uid}
|
||||
isBookingInPast={isBookingInPast}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "team",
|
||||
size: 140,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: () => <span className="text-subtle text-sm font-medium">{t("team")}</span>,
|
||||
cell: (props) => {
|
||||
const row = props.row.original;
|
||||
if (isSeparatorRow(row)) return null;
|
||||
|
||||
if (row.booking.eventType.team) {
|
||||
return (
|
||||
<Badge variant="gray" size="sm">
|
||||
{row.booking.eventType.team.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{shouldShowReject && (
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon="ban"
|
||||
disabled={pendingActionHandlers.isLoading}
|
||||
data-testid="reject"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
|
||||
pendingActionHandlers.onReject(booking.id, recurringEventId);
|
||||
}}>
|
||||
{showAllLabel ? t("reject_all") : t("reject")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowAccept && (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
StartIcon="check"
|
||||
disabled={pendingActionHandlers.isLoading}
|
||||
data-testid="confirm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
|
||||
pendingActionHandlers.onAccept(booking.id, recurringEventId);
|
||||
}}>
|
||||
{showAllLabel ? t("confirm_all") : t("confirm")}
|
||||
</Button>
|
||||
)}
|
||||
{shouldShowJoinButton && (
|
||||
<JoinMeetingButton
|
||||
size="sm"
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
)}
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: row.type === "data" ? row.recurringInfo : undefined,
|
||||
loggedInUser: {
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
},
|
||||
isToday: row.type === "data" ? row.isToday : false,
|
||||
}}
|
||||
className="px-1.5"
|
||||
size="sm"
|
||||
/>
|
||||
</BookingActionsStoreProvider>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -29,91 +29,65 @@ import {
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
|
||||
import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import {
|
||||
useBookingDetailsSheetStore,
|
||||
useBookingDetailsSheetStoreApi,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import { buildBookingLink } from "../lib/buildBookingLink";
|
||||
import type { BookingOutput } from "../types";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
|
||||
|
||||
interface BookingDetailsSheetProps {
|
||||
booking: BookingOutput | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userTimeZone?: string;
|
||||
userTimeFormat?: number;
|
||||
userId?: number;
|
||||
userEmail?: string;
|
||||
onPrevious?: () => void;
|
||||
hasPrevious?: boolean;
|
||||
onNext?: () => void;
|
||||
hasNext?: boolean;
|
||||
}
|
||||
|
||||
export function BookingDetailsSheet({
|
||||
userTimeZone,
|
||||
userTimeFormat,
|
||||
userId,
|
||||
userEmail,
|
||||
}: BookingDetailsSheetProps) {
|
||||
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
|
||||
interface BookingDetailsSheetInnerProps extends Omit<BookingDetailsSheetProps, "booking"> {
|
||||
booking: BookingOutput;
|
||||
}
|
||||
|
||||
// Return null if no booking is selected (sheet is closed)
|
||||
if (!booking) return null;
|
||||
export function BookingDetailsSheet(props: BookingDetailsSheetProps) {
|
||||
if (!props.booking) return null;
|
||||
|
||||
return (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingDetailsSheetInner
|
||||
booking={booking}
|
||||
userTimeZone={userTimeZone}
|
||||
userTimeFormat={userTimeFormat}
|
||||
userId={userId}
|
||||
userEmail={userEmail}
|
||||
/>
|
||||
<BookingDetailsSheetInner {...props} booking={props.booking} />
|
||||
</BookingActionsStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
interface BookingDetailsSheetInnerProps {
|
||||
booking: BookingOutput;
|
||||
userTimeZone?: string;
|
||||
userTimeFormat?: number;
|
||||
userId?: number;
|
||||
userEmail?: string;
|
||||
}
|
||||
|
||||
function BookingDetailsSheetInner({
|
||||
booking,
|
||||
isOpen,
|
||||
onClose,
|
||||
userTimeZone,
|
||||
userTimeFormat,
|
||||
userId,
|
||||
userEmail,
|
||||
onPrevious,
|
||||
hasPrevious = false,
|
||||
onNext,
|
||||
hasNext = false,
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Get navigation state directly from the store
|
||||
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
|
||||
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedBookingId(null);
|
||||
};
|
||||
|
||||
const storeApi = useBookingDetailsSheetStoreApi();
|
||||
|
||||
const handleNext = () => {
|
||||
const nextId = storeApi.getState().getNextBookingId();
|
||||
if (nextId !== null) {
|
||||
setSelectedBookingId(nextId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
const prevId = storeApi.getState().getPreviousBookingId();
|
||||
if (prevId !== null) {
|
||||
setSelectedBookingId(prevId);
|
||||
}
|
||||
};
|
||||
|
||||
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":
|
||||
@@ -134,6 +108,14 @@ 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
|
||||
? {
|
||||
@@ -149,7 +131,7 @@ function BookingDetailsSheetInner({
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleClose}>
|
||||
<Sheet open={isOpen} onOpenChange={onClose}>
|
||||
<SheetContent className="overflow-y-auto">
|
||||
<SheetHeader
|
||||
showCloseButton={false}
|
||||
@@ -162,7 +144,7 @@ function BookingDetailsSheetInner({
|
||||
disabled={!hasPrevious}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
onPrevious?.();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
@@ -172,7 +154,7 @@ function BookingDetailsSheetInner({
|
||||
disabled={!hasNext}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
onNext?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -201,8 +183,6 @@ function BookingDetailsSheetInner({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RescheduleRequestMessage booking={booking} />
|
||||
|
||||
<WhoSection booking={booking} />
|
||||
|
||||
<WhereSection booking={booking} meta={bookingMetadata} />
|
||||
@@ -223,34 +203,49 @@ function BookingDetailsSheetInner({
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
|
||||
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
|
||||
<Button color="secondary" StartIcon="x" onClick={handleClose}>
|
||||
{t("close")}
|
||||
<div className="flex w-full flex-row items-center justify-end gap-2 px-4 pb-4 pt-4">
|
||||
{isLocationURL && locationToDisplay && (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider.label })
|
||||
: t("join_meeting")}
|
||||
</Button>
|
||||
<div className="border-subtle h-3 w-px border-r" />
|
||||
</>
|
||||
)}
|
||||
<Button color="secondary" size="sm" EndIcon="external-link" href={bookingLink} target="_blank">
|
||||
{t("view")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
usePortal={false}
|
||||
/>
|
||||
</div>
|
||||
<BookingActionsDropdown
|
||||
context="booking-details-sheet"
|
||||
size="sm"
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
userTimeZone,
|
||||
userTimeFormat: userTimeFormat ?? null,
|
||||
userEmail,
|
||||
},
|
||||
isToday: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
@@ -258,20 +253,6 @@ function BookingDetailsSheetInner({
|
||||
);
|
||||
}
|
||||
|
||||
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
|
||||
{t("reschedule_request_sent")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
|
||||
@@ -1,60 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo, 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, BookingOutput } from "../types";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
import { BookingsCalendarView } from "./BookingsCalendarView";
|
||||
|
||||
type BookingsCalendarProps = {
|
||||
type BookingsCalendarViewProps = {
|
||||
status: BookingListingStatus;
|
||||
table: ReactTable<RowData>;
|
||||
isPending?: boolean;
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
setCurrentWeekStart: (
|
||||
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
|
||||
) => Promise<URLSearchParams>;
|
||||
bookings: BookingOutput[];
|
||||
};
|
||||
|
||||
const COLUMN_IDS_TO_HIDE = ["dateRange"];
|
||||
|
||||
export function BookingsCalendar({
|
||||
table,
|
||||
isPending = false,
|
||||
currentWeekStart,
|
||||
setCurrentWeekStart,
|
||||
bookings,
|
||||
}: BookingsCalendarProps) {
|
||||
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();
|
||||
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<typeof booking> => 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: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
startDate: newStartDate.toISOString(),
|
||||
endDate: newEndDate.toISOString(),
|
||||
preset: CUSTOM_PRESET.value,
|
||||
},
|
||||
});
|
||||
},
|
||||
[updateFilter, setCurrentWeekStart]
|
||||
[dateRange, updateFilter, setCurrentWeekStart]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -74,7 +103,6 @@ export function BookingsCalendar({
|
||||
bookings={bookings}
|
||||
currentWeekStart={currentWeekStart}
|
||||
onWeekStartChange={handleWeekStartChange}
|
||||
isPending={isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { 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 { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
|
||||
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 [currentWeekStart, setCurrentWeekStart] = useQueryState(
|
||||
"weekStart",
|
||||
weekStartParser.withDefault(dayjs().startOf("week"))
|
||||
);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return buildFilterColumns({ t, permissions, status });
|
||||
}, [t, permissions, status]);
|
||||
|
||||
const getFacetedUniqueValues = useFacetedUniqueValues();
|
||||
|
||||
const table = useReactTable<RowData>({
|
||||
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<RowData, { type: "data" }> => 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]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<BookingsCalendar
|
||||
status={status}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
currentWeekStart={currentWeekStart}
|
||||
setCurrentWeekStart={setCurrentWeekStart}
|
||||
bookings={bookings}
|
||||
/>
|
||||
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -12,23 +12,19 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
type BookingsCalendarViewProps = {
|
||||
bookings: BookingOutput[];
|
||||
currentWeekStart: dayjs.Dayjs;
|
||||
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
export function BookingsCalendarView({
|
||||
bookings,
|
||||
currentWeekStart,
|
||||
onWeekStartChange,
|
||||
isPending = false,
|
||||
}: BookingsCalendarViewProps) {
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
const { t } = useLocale();
|
||||
const { timezone } = useTimePreferences();
|
||||
const { resolvedTheme, forcedTheme } = useGetTheme();
|
||||
@@ -48,10 +44,8 @@ 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<CalendarEvent[]>(() => {
|
||||
@@ -84,7 +78,6 @@ export function BookingsCalendarView({
|
||||
options: {
|
||||
status: booking.status,
|
||||
...(eventTypeColor && { color: eventTypeColor }),
|
||||
bookingId: booking.id,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -116,7 +109,6 @@ export function BookingsCalendarView({
|
||||
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold">{weekRange}</h2>
|
||||
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
|
||||
@@ -149,12 +141,7 @@ export function BookingsCalendarView({
|
||||
showBackgroundPattern={false}
|
||||
showBorder={false}
|
||||
borderColor="subtle"
|
||||
onEventClick={(event) => {
|
||||
const bookingId = event.options?.bookingId;
|
||||
if (bookingId) {
|
||||
setSelectedBookingId(bookingId);
|
||||
}
|
||||
}}
|
||||
onEventClick={(_event) => {}}
|
||||
hideHeader
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import type { Row, Table as ReactTable } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
import type { Table as ReactTable } from "@tanstack/react-table";
|
||||
|
||||
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";
|
||||
|
||||
import SkeletonLoader from "@components/booking/SkeletonLoader";
|
||||
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { RowData, BookingListingStatus } from "../types";
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
@@ -30,16 +27,6 @@ type BookingsListViewProps = {
|
||||
|
||||
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
|
||||
const { t } = useLocale();
|
||||
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: Row<RowData>) => {
|
||||
if (!isSeparatorRow(row.original)) {
|
||||
setSelectedBookingId(row.original.booking.id);
|
||||
}
|
||||
},
|
||||
[setSelectedBookingId]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataTableWrapper
|
||||
@@ -47,22 +34,11 @@ export function BookingsList({ status, table, isPending, totalRowCount }: Bookin
|
||||
table={table}
|
||||
testId={`${status}-bookings`}
|
||||
bodyTestId="bookings"
|
||||
rowTestId={(row) => {
|
||||
if (isSeparatorRow(row.original)) return undefined;
|
||||
return "booking-item";
|
||||
}}
|
||||
rowDataAttributes={(row) => {
|
||||
if (isSeparatorRow(row.original)) return undefined;
|
||||
return {
|
||||
"data-today": String(row.original.isToday),
|
||||
};
|
||||
}}
|
||||
headerClassName="hidden"
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
variant="default"
|
||||
variant="compact"
|
||||
paginationMode="standard"
|
||||
onRowMouseclick={handleRowClick}
|
||||
hideSeparatorsOnSort={true}
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"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 { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
|
||||
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();
|
||||
|
||||
// Filter out separator rows and extract bookings
|
||||
const bookings = useMemo(() => {
|
||||
return data
|
||||
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
|
||||
.map((row) => row.booking);
|
||||
}, [data]);
|
||||
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
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 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<RowData>({
|
||||
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 (
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings}>
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={handleRejectionDialogChange}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={confirmMutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={handleConfirmRejection}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<BookingsList status={status} table={table} isPending={isPending} totalRowCount={totalRowCount} />
|
||||
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
/>
|
||||
</BookingDetailsSheetStoreProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"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 (
|
||||
<Button
|
||||
color={color}
|
||||
size={size}
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={classNames("flex items-center gap-2", className)}
|
||||
onClick={handleClick}>
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 flex-shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +1,43 @@
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
import type { BookingOutput } from "../types";
|
||||
import type { RowData } from "../types";
|
||||
|
||||
function isDataRow(row: RowData): row is Extract<RowData, { type: "data" }> {
|
||||
return row.type === "data";
|
||||
}
|
||||
|
||||
export function useBookingCursor({
|
||||
bookings,
|
||||
selectedBookingId,
|
||||
setSelectedBookingId,
|
||||
}: {
|
||||
bookings: BookingOutput[];
|
||||
bookings: RowData[];
|
||||
selectedBookingId: number | null;
|
||||
setSelectedBookingId: (bookingId: number | null) => void;
|
||||
}) {
|
||||
const bookingRows = useMemo(() => bookings.filter(isDataRow), [bookings]);
|
||||
|
||||
const currentIndex = useMemo(
|
||||
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
|
||||
[bookings, selectedBookingId]
|
||||
() => bookingRows.findIndex((row) => selectedBookingId && row.booking.id === selectedBookingId),
|
||||
[bookingRows, selectedBookingId]
|
||||
);
|
||||
|
||||
const onPrevious = useCallback(() => {
|
||||
if (currentIndex >= 1) {
|
||||
setSelectedBookingId(bookings[currentIndex - 1].id);
|
||||
setSelectedBookingId(bookingRows[currentIndex - 1].booking.id);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
}, [bookingRows, currentIndex, setSelectedBookingId]);
|
||||
|
||||
const onNext = useCallback(() => {
|
||||
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
|
||||
setSelectedBookingId(bookings[currentIndex + 1].id);
|
||||
if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) {
|
||||
setSelectedBookingId(bookingRows[currentIndex + 1].booking.id);
|
||||
}
|
||||
}, [bookings, currentIndex, setSelectedBookingId]);
|
||||
}, [bookingRows, currentIndex, setSelectedBookingId]);
|
||||
|
||||
return {
|
||||
onPrevious,
|
||||
onNext,
|
||||
hasPrevious: currentIndex > 0,
|
||||
hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0,
|
||||
hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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";
|
||||
@@ -1,144 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { createStore, useStore } from "zustand";
|
||||
|
||||
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
|
||||
import type { BookingOutput } from "../types";
|
||||
|
||||
interface BookingDetailsSheetStore {
|
||||
// State
|
||||
selectedBookingId: number | null;
|
||||
bookings: BookingOutput[];
|
||||
|
||||
// Actions
|
||||
setSelectedBookingId: (id: number | null) => void;
|
||||
setBookings: (bookings: BookingOutput[]) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
// Computed getters (used via selectors)
|
||||
getSelectedBooking: () => BookingOutput | null;
|
||||
getNextBookingId: () => number | null;
|
||||
getPreviousBookingId: () => number | null;
|
||||
hasNext: () => boolean;
|
||||
hasPrevious: () => boolean;
|
||||
}
|
||||
|
||||
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
|
||||
|
||||
const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => {
|
||||
return createStore<BookingDetailsSheetStore>((set, get) => ({
|
||||
// Initial state
|
||||
selectedBookingId: null,
|
||||
bookings: initialBookings,
|
||||
|
||||
// Actions
|
||||
setSelectedBookingId: (id) => set({ selectedBookingId: id }),
|
||||
setBookings: (bookings) => set({ bookings }),
|
||||
clearSelection: () => set({ selectedBookingId: null }),
|
||||
|
||||
// Computed getters
|
||||
getSelectedBooking: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return null;
|
||||
return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null;
|
||||
},
|
||||
|
||||
getNextBookingId: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
|
||||
|
||||
return state.bookings[currentIndex + 1].id;
|
||||
},
|
||||
|
||||
getPreviousBookingId: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return null;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
if (currentIndex <= 0) return null;
|
||||
|
||||
return state.bookings[currentIndex - 1].id;
|
||||
},
|
||||
|
||||
hasNext: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return false;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
|
||||
},
|
||||
|
||||
hasPrevious: () => {
|
||||
const state = get();
|
||||
if (!state.selectedBookingId) return false;
|
||||
|
||||
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
|
||||
return currentIndex > 0;
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetStoreType | null>(null);
|
||||
|
||||
export function BookingDetailsSheetStoreProvider({
|
||||
children,
|
||||
bookings,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
bookings: BookingOutput[];
|
||||
}) {
|
||||
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
|
||||
const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId();
|
||||
|
||||
// Update bookings when they change
|
||||
React.useEffect(() => {
|
||||
store.getState().setBookings(bookings);
|
||||
}, [bookings, store]);
|
||||
|
||||
// Sync Store → URL
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
const storeId = state.selectedBookingId;
|
||||
if (storeId !== selectedBookingIdFromUrl) {
|
||||
setSelectedBookingIdToUrl(storeId);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]);
|
||||
|
||||
// Sync URL → Store
|
||||
React.useEffect(() => {
|
||||
const currentStoreId = store.getState().selectedBookingId;
|
||||
if (currentStoreId !== selectedBookingIdFromUrl) {
|
||||
store.getState().setSelectedBookingId(selectedBookingIdFromUrl);
|
||||
}
|
||||
}, [selectedBookingIdFromUrl, store]);
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreContext.Provider value={store}>
|
||||
{children}
|
||||
</BookingDetailsSheetStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBookingDetailsSheetStore<T>(selector: (state: BookingDetailsSheetStore) => T): T {
|
||||
const store = React.useContext(BookingDetailsSheetStoreContext);
|
||||
if (!store) {
|
||||
throw new Error("useBookingDetailsSheetStore must be used within BookingDetailsSheetStoreProvider");
|
||||
}
|
||||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
// For direct store access (needed for subscribe and getState)
|
||||
export function useBookingDetailsSheetStoreApi() {
|
||||
const store = React.useContext(BookingDetailsSheetStoreContext);
|
||||
if (!store) {
|
||||
throw new Error("useBookingDetailsSheetStoreApi must be used within BookingDetailsSheetStoreProvider");
|
||||
}
|
||||
return store;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { SeparatorRow } from "@calcom/features/data-table/lib/separator";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import type { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
@@ -19,6 +18,8 @@ export type RowData =
|
||||
isToday: boolean;
|
||||
recurringInfo?: RecurringInfo;
|
||||
}
|
||||
| SeparatorRow;
|
||||
| {
|
||||
type: "today" | "next";
|
||||
};
|
||||
|
||||
export type BookingListingStatus = (typeof validStatuses)[number];
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { createParser, useQueryState } from "nuqs";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTableFilters,
|
||||
DataTableSegment,
|
||||
type SystemFilterSegment,
|
||||
useDataTable,
|
||||
ColumnFilterType,
|
||||
@@ -25,26 +27,24 @@ 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,12 +86,27 @@ export default function Bookings(props: BookingsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
const [view] = useQueryState("view", viewParser.withDefault("list"));
|
||||
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"));
|
||||
// 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() || "";
|
||||
@@ -141,47 +156,161 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
|
||||
const { limit, offset } = useDataTable();
|
||||
|
||||
// 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 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,
|
||||
},
|
||||
{
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<RowData>();
|
||||
|
||||
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 (
|
||||
<BookingListItem
|
||||
key={booking.id}
|
||||
isToday={isToday}
|
||||
loggedInUser={{
|
||||
userId: user?.id,
|
||||
userTimeZone: user?.timeZone,
|
||||
userTimeFormat: user?.timeFormat,
|
||||
userEmail: user?.email,
|
||||
}}
|
||||
listingStatus={status}
|
||||
recurringInfo={recurringInfo}
|
||||
// uncomment this line to enable BookingDetailsSheet
|
||||
// onClick={() => setSelectedBookingId(booking.id)}
|
||||
{...booking}
|
||||
/>
|
||||
);
|
||||
} else if (props.row.original.type === "today") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("today")}
|
||||
</p>
|
||||
);
|
||||
} else if (props.row.original.type === "next") {
|
||||
return (
|
||||
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
|
||||
{t("next")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [user, status, t, permissions.canReadOthersBookings]);
|
||||
|
||||
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
|
||||
|
||||
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 flatData = useMemo<RowData[]>(() => {
|
||||
const shownBookings: Record<string, BookingOutput[]> = {};
|
||||
const filterBookings = (booking: BookingOutput) => {
|
||||
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
||||
@@ -196,86 +325,95 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
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;
|
||||
};
|
||||
|
||||
const todayBookings: RowData[] = [];
|
||||
const currentMonthBookings: RowData[] = [];
|
||||
const monthBuckets: Record<string, RowData[]> = {}; // 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 = {
|
||||
return (
|
||||
query.data?.bookings.filter(filterBookings).map((booking) => ({
|
||||
type: "data",
|
||||
booking,
|
||||
isToday: bookingDateStr === today,
|
||||
recurringInfo: query.data?.recurringInfo.find(
|
||||
(info) => info.recurringEventId === booking.recurringEventId
|
||||
),
|
||||
};
|
||||
|
||||
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 };
|
||||
isToday: false,
|
||||
})) || []
|
||||
);
|
||||
}, [query.data, status, user?.timeZone]);
|
||||
|
||||
const flatData = useMemo<RowData[]>(() => {
|
||||
return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()];
|
||||
}, [groupedBookings]);
|
||||
|
||||
const bookingsToday = useMemo<RowData[]>(() => {
|
||||
return groupedBookings.today;
|
||||
}, [groupedBookings]);
|
||||
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]);
|
||||
|
||||
const finalData = useMemo<RowData[]>(() => {
|
||||
if (status !== "upcoming") {
|
||||
return flatData;
|
||||
}
|
||||
|
||||
const merged: RowData[] = [];
|
||||
|
||||
// Add Today section
|
||||
if (groupedBookings.today.length > 0) {
|
||||
merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today);
|
||||
if (bookingsToday.length > 0) {
|
||||
merged.push({ type: "today" as const }, ...bookingsToday);
|
||||
}
|
||||
|
||||
// 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);
|
||||
if (flatData.length > 0) {
|
||||
merged.push({ type: "next" as const }, ...flatData);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}, [groupedBookings, status, t, flatData]);
|
||||
}, [bookingsToday, flatData, status]);
|
||||
|
||||
const selectedBooking = useMemo(() => {
|
||||
if (!selectedBookingId) return null;
|
||||
const dataRow = finalData.find(
|
||||
(row): row is Extract<RowData, { type: "data" }> =>
|
||||
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<RowData>({
|
||||
data: finalData,
|
||||
columns,
|
||||
initialState: {
|
||||
columnVisibility: {
|
||||
eventTypeId: false,
|
||||
teamId: false,
|
||||
userId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
dateRange: false,
|
||||
bookingUid: false,
|
||||
},
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues,
|
||||
});
|
||||
|
||||
const isPending = query.isPending;
|
||||
const totalRowCount = query.data?.totalCount;
|
||||
@@ -293,32 +431,55 @@ function BookingsContent({ status, permissions }: BookingsProps) {
|
||||
<main className="w-full">
|
||||
<div className="flex w-full flex-col">
|
||||
{query.status === "error" ? (
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
<>
|
||||
<div className="grid w-full items-center gap-2 pb-4">
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex w-full flex-wrap justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.FilterBar table={table} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<DataTableSegment.SaveButton />
|
||||
<DataTableSegment.Select />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!!bookingsToday.length && status === "upcoming" && (
|
||||
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
{view === "list" ? (
|
||||
<BookingsListContainer
|
||||
<BookingsList
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={finalData}
|
||||
table={table}
|
||||
isPending={isPending}
|
||||
totalRowCount={totalRowCount}
|
||||
/>
|
||||
) : (
|
||||
<BookingsCalendarContainer
|
||||
status={status}
|
||||
permissions={permissions}
|
||||
data={finalData}
|
||||
isPending={isPending}
|
||||
/>
|
||||
<BookingsCalendar status={status} table={table} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<BookingDetailsSheet
|
||||
booking={selectedBooking}
|
||||
isOpen={!!selectedBooking}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ 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", () => {
|
||||
@@ -60,14 +58,16 @@ 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 }) => {
|
||||
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
|
||||
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,14 +112,15 @@ 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();
|
||||
|
||||
// Wait for the dropdown to close before clicking again
|
||||
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -141,7 +142,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",
|
||||
@@ -157,33 +158,23 @@ test.describe("Bookings", () => {
|
||||
{ name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" },
|
||||
],
|
||||
});
|
||||
await bookingWhereFirstUserIsOrganizerFixture.self();
|
||||
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);
|
||||
|
||||
// Click the ellipsis button to open the all attendees dropdown
|
||||
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
|
||||
const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]');
|
||||
await moreGuests.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
|
||||
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();
|
||||
const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]');
|
||||
|
||||
// The 3rd attendee should now have the unmark button visible
|
||||
await expect(unmarkNoShowButtons.first()).toBeVisible();
|
||||
await expect(firstGuestInMore).toBeChecked({ checked: true });
|
||||
});
|
||||
test("Team admin/owner can mark first attendee as no-show", async ({
|
||||
page,
|
||||
@@ -208,16 +199,18 @@ test.describe("Bookings", () => {
|
||||
});
|
||||
const booking = await bookingFixture.self();
|
||||
await adminUser.apiLogin();
|
||||
const { webhookReceiver } = await webhooks.createTeamReceiver();
|
||||
const { webhookReceiver, teamId } = 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();
|
||||
// Wait for the dropdown to close before clicking again
|
||||
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
|
||||
await firstGuest.click();
|
||||
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
|
||||
await webhookReceiver.waitForRequestCount(1);
|
||||
const [request] = webhookReceiver.requestList;
|
||||
const body = request.body;
|
||||
@@ -239,7 +232,7 @@ test.describe("Bookings", () => {
|
||||
webhookReceiver.close();
|
||||
});
|
||||
|
||||
test("Can choose date range presets", async ({ page, users }) => {
|
||||
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
|
||||
const firstUser = await users.create();
|
||||
await firstUser.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
@@ -381,15 +374,22 @@ test.describe("Bookings", () => {
|
||||
|
||||
//verify with the booking titles
|
||||
const firstUpcomingBooking = bookingListItems.nth(0);
|
||||
await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
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(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible();
|
||||
await expect(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
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({
|
||||
|
||||
@@ -57,7 +57,9 @@ 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.getByRole("heading", { name: "Bookings" })).toBeVisible();
|
||||
await expect(
|
||||
page.locator("text=See upcoming and past events booked through your event type links.")
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
|
||||
|
||||
@@ -41,9 +41,7 @@ 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('[data-testid="reschedule-dialog"]')).toBeHidden();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.locator('[id="modal-title"]')).toBeHidden();
|
||||
|
||||
const updatedBooking = await booking.self();
|
||||
|
||||
@@ -60,7 +58,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(),
|
||||
@@ -132,9 +130,8 @@ test.describe("Reschedule Tests", async () => {
|
||||
await user.apiLogin();
|
||||
await page.goto("/bookings/cancelled");
|
||||
|
||||
await page.locator('[data-testid="booking-item"]').nth(0).click();
|
||||
|
||||
await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible();
|
||||
const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1);
|
||||
await expect(requestRescheduleSentElement).toBeVisible();
|
||||
await booking.delete();
|
||||
});
|
||||
|
||||
@@ -199,7 +196,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);
|
||||
@@ -229,7 +226,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);
|
||||
@@ -333,6 +330,7 @@ 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
|
||||
@@ -341,13 +339,11 @@ 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: async () => {
|
||||
await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click();
|
||||
},
|
||||
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
|
||||
});
|
||||
await authedContext.close();
|
||||
};
|
||||
|
||||
@@ -221,7 +221,6 @@
|
||||
"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}}",
|
||||
@@ -1712,10 +1711,7 @@
|
||||
"error_updating_password": "Error updating password",
|
||||
"two_factor_auth": "Two factor authentication",
|
||||
"recurring_event_tab_description": "Set up a repeating schedule",
|
||||
"today": "Today",
|
||||
"this_month": "This month",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"today": "today",
|
||||
"appearance": "Appearance",
|
||||
"my_account": "My account",
|
||||
"general": "General",
|
||||
@@ -3891,7 +3887,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",
|
||||
@@ -3981,7 +3977,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",
|
||||
"recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences",
|
||||
"view": "View",
|
||||
"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.",
|
||||
@@ -4010,9 +4006,5 @@
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface CalendarEvent {
|
||||
allDay?: boolean;
|
||||
color?: string;
|
||||
className?: string;
|
||||
bookingId?: number;
|
||||
"data-test-id"?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,9 +38,7 @@ export type DataTablePropsFromWrapper<TData> = {
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
headerClassName?: string;
|
||||
rowClassName?: string | ((row: Row<TData>) => string);
|
||||
rowTestId?: string | ((row: Row<TData>) => string | undefined);
|
||||
rowDataAttributes?: (row: Row<TData>) => Record<string, string> | undefined;
|
||||
rowClassName?: string;
|
||||
paginationMode?: "infinite" | "standard";
|
||||
hasWrapperContext?: boolean;
|
||||
hideSeparatorsOnSort?: boolean;
|
||||
@@ -70,8 +68,6 @@ export function DataTable<TData>({
|
||||
containerClassName,
|
||||
headerClassName,
|
||||
rowClassName,
|
||||
rowTestId,
|
||||
rowDataAttributes,
|
||||
paginationMode = "infinite",
|
||||
hasWrapperContext = false,
|
||||
hideSeparatorsOnSort = true,
|
||||
@@ -207,8 +203,6 @@ export function DataTable<TData>({
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
paginationMode={paginationMode}
|
||||
rowClassName={rowClassName}
|
||||
rowTestId={rowTestId}
|
||||
rowDataAttributes={rowDataAttributes}
|
||||
hideSeparatorsOnSort={hideSeparatorsOnSort}
|
||||
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
|
||||
separatorClassName={separatorClassName}
|
||||
@@ -225,8 +219,6 @@ export function DataTable<TData>({
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
paginationMode={paginationMode}
|
||||
rowClassName={rowClassName}
|
||||
rowTestId={rowTestId}
|
||||
rowDataAttributes={rowDataAttributes}
|
||||
hideSeparatorsOnSort={hideSeparatorsOnSort}
|
||||
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
|
||||
separatorClassName={separatorClassName}
|
||||
@@ -252,8 +244,6 @@ 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 &&
|
||||
@@ -269,9 +259,7 @@ type DataTableBodyProps<TData> = {
|
||||
isPending?: boolean;
|
||||
onRowMouseclick?: (row: Row<TData>) => void;
|
||||
paginationMode?: "infinite" | "standard";
|
||||
rowClassName?: string | ((row: Row<TData>) => string);
|
||||
rowTestId?: string | ((row: Row<TData>) => string | undefined);
|
||||
rowDataAttributes?: (row: Row<TData>) => Record<string, string> | undefined;
|
||||
rowClassName?: string;
|
||||
hideSeparatorsOnSort?: boolean;
|
||||
hideSeparatorsOnFilter?: boolean;
|
||||
separatorClassName?: string;
|
||||
@@ -306,8 +294,6 @@ function DataTableBody<TData>({
|
||||
onRowMouseclick,
|
||||
paginationMode,
|
||||
rowClassName,
|
||||
rowTestId,
|
||||
rowDataAttributes,
|
||||
hideSeparatorsOnSort = true,
|
||||
hideSeparatorsOnFilter = false,
|
||||
separatorClassName,
|
||||
@@ -388,16 +374,10 @@ function DataTableBody<TData>({
|
||||
);
|
||||
}
|
||||
|
||||
const computedRowTestId = typeof rowTestId === "function" ? rowTestId(row) : rowTestId;
|
||||
const computedRowClassName = typeof rowClassName === "function" ? rowClassName(row) : rowClassName;
|
||||
const computedDataAttributes = rowDataAttributes?.(row);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
ref={virtualItem ? (node) => 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={(e) => {
|
||||
@@ -416,7 +396,7 @@ function DataTableBody<TData>({
|
||||
width: "100%",
|
||||
}),
|
||||
}}
|
||||
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", computedRowClassName)}>
|
||||
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const column = cell.column;
|
||||
return (
|
||||
@@ -455,7 +435,7 @@ const TableHeadLabel = <TData,>({ header }: { header: Header<TData, unknown> })
|
||||
if (!canSort && !canHide) {
|
||||
if (typeof header.column.columnDef.header === "string") {
|
||||
return (
|
||||
<div className="truncate" title={header.column.columnDef.header}>
|
||||
<div className="truncate px-2 py-1" title={header.column.columnDef.header}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,8 +22,6 @@ type BaseDataTableWrapperProps<TData> = Omit<
|
||||
LoaderView?: React.ReactNode;
|
||||
tableContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
onRowMouseclick?: (row: Row<TData>) => void;
|
||||
rowTestId?: string | ((row: Row<TData>) => string | undefined);
|
||||
rowDataAttributes?: (row: Row<TData>) => Record<string, string> | undefined;
|
||||
};
|
||||
|
||||
type InfinitePaginationProps<TData> = BaseDataTableWrapperProps<TData> & {
|
||||
@@ -60,8 +58,6 @@ export function DataTableWrapper<TData>({
|
||||
containerClassName,
|
||||
headerClassName,
|
||||
rowClassName,
|
||||
rowTestId,
|
||||
rowDataAttributes,
|
||||
children,
|
||||
tableContainerRef: externalRef,
|
||||
paginationMode,
|
||||
@@ -137,8 +133,6 @@ export function DataTableWrapper<TData>({
|
||||
containerClassName={containerClassName}
|
||||
headerClassName={headerClassName}
|
||||
rowClassName={rowClassName}
|
||||
rowTestId={rowTestId}
|
||||
rowDataAttributes={rowDataAttributes}
|
||||
paginationMode={paginationMode}
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
hasWrapperContext={true}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const useMediaQuery = (query: string) => {
|
||||
const [matches, setMatches] = useState(() => {
|
||||
// Initialize with actual media query value on client
|
||||
if (typeof window !== "undefined") {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query);
|
||||
@@ -15,8 +9,8 @@ const useMediaQuery = (query: string) => {
|
||||
setMatches(media.matches);
|
||||
}
|
||||
const listener = () => setMatches(media.matches);
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
window.addEventListener("resize", listener);
|
||||
return () => window.removeEventListener("resize", listener);
|
||||
}, [matches, query]);
|
||||
|
||||
return matches;
|
||||
|
||||
@@ -13,7 +13,6 @@ export type AvatarGroupProps = {
|
||||
className?: string;
|
||||
truncateAfter?: number;
|
||||
hideTruncatedAvatarsCount?: boolean;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
@@ -47,10 +46,8 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
|
||||
<li
|
||||
className={classNames(
|
||||
"bg-inverted relative -mr-1 inline-flex justify-center overflow-hidden rounded-full",
|
||||
props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16",
|
||||
props.onClick && "cursor-pointer hover:opacity-80"
|
||||
)}
|
||||
onClick={props.onClick}>
|
||||
props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16"
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
" text-inverted m-auto flex h-full w-full items-center justify-center text-center",
|
||||
|
||||
@@ -70,7 +70,7 @@ const SheetContent = React.forwardRef<
|
||||
ref={forwardedRef}
|
||||
className={classNames(
|
||||
// base
|
||||
"fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6",
|
||||
"fixed inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:right-2 sm:max-w-lg sm:p-6",
|
||||
// "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ",
|
||||
// border color
|
||||
"border-subtle",
|
||||
|
||||
Reference in New Issue
Block a user