diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index 5f483e458d..519220c3e1 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -29,54 +29,88 @@ 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 type { BookingOutput } from "../types"; import { JoinMeetingButton } from "./JoinMeetingButton"; type BookingMetaData = z.infer; 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; } -interface BookingDetailsSheetInnerProps extends Omit { - booking: BookingOutput; -} +export function BookingDetailsSheet({ + userTimeZone, + userTimeFormat, + userId, + userEmail, +}: BookingDetailsSheetProps) { + const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking()); -export function BookingDetailsSheet(props: BookingDetailsSheetProps) { - if (!props.booking) return null; + // Return null if no booking is selected (sheet is closed) + if (!booking) return null; return ( - + ); +} + +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); @@ -115,7 +149,7 @@ function BookingDetailsSheetInner({ : []; return ( - + { e.preventDefault(); - onPrevious?.(); + handlePrevious(); }} /> diff --git a/apps/web/modules/bookings/components/BookingsCalendar.tsx b/apps/web/modules/bookings/components/BookingsCalendar.tsx index db0f637e27..fd6f60b8eb 100644 --- a/apps/web/modules/bookings/components/BookingsCalendar.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendar.tsx @@ -19,7 +19,6 @@ type BookingsCalendarProps = { status: BookingListingStatus; table: ReactTable; isPending?: boolean; - onOpenDetails: (bookingId: number) => void; currentWeekStart: dayjs.Dayjs; setCurrentWeekStart: ( value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null @@ -32,7 +31,6 @@ const COLUMN_IDS_TO_HIDE = ["dateRange"]; export function BookingsCalendar({ table, isPending = false, - onOpenDetails, currentWeekStart, setCurrentWeekStart, bookings, @@ -77,7 +75,6 @@ export function BookingsCalendar({ currentWeekStart={currentWeekStart} onWeekStartChange={handleWeekStartChange} isPending={isPending} - onOpenDetails={onOpenDetails} /> ); diff --git a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx index 8d71b5dbc0..40a0a63d1d 100644 --- a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx @@ -2,7 +2,7 @@ import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table"; import { createParser, useQueryState } from "nuqs"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -11,8 +11,7 @@ import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns"; -import { useBookingCursor } from "../hooks/useBookingCursor"; -import { useSelectedBookingId } from "../hooks/useSelectedBookingId"; +import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore"; import type { RowData, BookingListingStatus } from "../types"; import { BookingDetailsSheet } from "./BookingDetailsSheet"; import { BookingsCalendar } from "./BookingsCalendar"; @@ -43,19 +42,11 @@ export function BookingsCalendarContainer({ const { t } = useLocale(); const user = useMeQuery().data; - const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId(); const [currentWeekStart, setCurrentWeekStart] = useQueryState( "weekStart", weekStartParser.withDefault(dayjs().startOf("week")) ); - const onOpenDetails = useCallback( - (bookingId: number) => { - setSelectedBookingId(bookingId); - }, - [setSelectedBookingId] - ); - const columns = useMemo(() => { return buildFilterColumns({ t, permissions, status }); }, [t, permissions, status]); @@ -90,42 +81,23 @@ export function BookingsCalendarContainer({ }); }, [data, currentWeekStart]); - const selectedBooking = useMemo(() => { - if (!selectedBookingId) return null; - return bookings.find((booking) => booking.id === selectedBookingId) ?? null; - }, [selectedBookingId, bookings]); - - const bookingNavigation = useBookingCursor({ - bookings, - selectedBookingId, - setSelectedBookingId, - }); - return ( - <> + setSelectedBookingId(null)} userTimeZone={user?.timeZone} userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} userId={user?.id} userEmail={user?.email} - onPrevious={bookingNavigation.onPrevious} - hasPrevious={bookingNavigation.hasPrevious} - onNext={bookingNavigation.onNext} - hasNext={bookingNavigation.hasNext} /> - + ); } diff --git a/apps/web/modules/bookings/components/BookingsCalendarView.tsx b/apps/web/modules/bookings/components/BookingsCalendarView.tsx index c32b386992..0a91023521 100644 --- a/apps/web/modules/bookings/components/BookingsCalendarView.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendarView.tsx @@ -12,6 +12,7 @@ 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 = { @@ -19,7 +20,6 @@ type BookingsCalendarViewProps = { currentWeekStart: dayjs.Dayjs; onWeekStartChange: (weekStart: dayjs.Dayjs) => void; isPending?: boolean; - onOpenDetails: (bookingId: number) => void; }; export function BookingsCalendarView({ @@ -27,8 +27,8 @@ export function BookingsCalendarView({ currentWeekStart, onWeekStartChange, isPending = false, - onOpenDetails, }: BookingsCalendarViewProps) { + const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId); const { t } = useLocale(); const { timezone } = useTimePreferences(); const { resolvedTheme, forcedTheme } = useGetTheme(); @@ -152,7 +152,7 @@ export function BookingsCalendarView({ onEventClick={(event) => { const bookingId = event.options?.bookingId; if (bookingId) { - onOpenDetails(bookingId); + setSelectedBookingId(bookingId); } }} hideHeader diff --git a/apps/web/modules/bookings/components/BookingsList.tsx b/apps/web/modules/bookings/components/BookingsList.tsx index 24944f77ad..69577f0811 100644 --- a/apps/web/modules/bookings/components/BookingsList.tsx +++ b/apps/web/modules/bookings/components/BookingsList.tsx @@ -10,6 +10,7 @@ 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 = { @@ -25,25 +26,19 @@ type BookingsListViewProps = { table: ReactTable; isPending: boolean; totalRowCount?: number; - onOpenDetails: (bookingId: number) => void; }; -export function BookingsList({ - status, - table, - isPending, - totalRowCount, - onOpenDetails, -}: BookingsListViewProps) { +export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) { const { t } = useLocale(); + const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId); const handleRowClick = useCallback( (row: Row) => { if (!isSeparatorRow(row.original)) { - onOpenDetails(row.original.booking.id); + setSelectedBookingId(row.original.booking.id); } }, - [onOpenDetails] + [setSelectedBookingId] ); return ( diff --git a/apps/web/modules/bookings/components/BookingsListContainer.tsx b/apps/web/modules/bookings/components/BookingsListContainer.tsx index 6b0f1fca29..60153d02c9 100644 --- a/apps/web/modules/bookings/components/BookingsListContainer.tsx +++ b/apps/web/modules/bookings/components/BookingsListContainer.tsx @@ -16,8 +16,7 @@ import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues" import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns"; import { buildListDisplayColumns } from "../columns/listColumns"; -import { useBookingCursor } from "../hooks/useBookingCursor"; -import { useSelectedBookingId } from "../hooks/useSelectedBookingId"; +import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore"; import type { RowData, BookingListingStatus } from "../types"; import { BookingDetailsSheet } from "./BookingDetailsSheet"; import { BookingsList } from "./BookingsList"; @@ -43,8 +42,6 @@ export function BookingsListContainer({ const user = useMeQuery().data; const utils = trpc.useUtils(); - const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId(); - // Filter out separator rows and extract bookings const bookings = useMemo(() => { return data @@ -52,17 +49,6 @@ export function BookingsListContainer({ .map((row) => row.booking); }, [data]); - const selectedBooking = useMemo(() => { - if (!selectedBookingId) return null; - return bookings.find((booking) => booking.id === selectedBookingId) ?? null; - }, [selectedBookingId, bookings]); - - const bookingNavigation = useBookingCursor({ - bookings, - selectedBookingId, - setSelectedBookingId, - }); - const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); const [rejectionReason, setRejectionReason] = useState(""); const [pendingRejection, setPendingRejection] = useState<{ @@ -117,13 +103,6 @@ export function BookingsListContainer({ }); }, [pendingRejection, rejectionReason, confirmMutation]); - const onOpenDetails = useCallback( - (bookingId: number) => { - setSelectedBookingId(bookingId); - }, - [setSelectedBookingId] - ); - const columns = useMemo(() => { const filterCols = buildFilterColumns({ t, permissions, status }); const listCols = buildListDisplayColumns({ @@ -163,7 +142,7 @@ export function BookingsListContainer({ }, []); return ( - <> +
@@ -192,27 +171,14 @@ export function BookingsListContainer({
- + setSelectedBookingId(null)} userTimeZone={user?.timeZone} userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat} userId={user?.id} userEmail={user?.email} - onPrevious={bookingNavigation.onPrevious} - hasPrevious={bookingNavigation.hasPrevious} - onNext={bookingNavigation.onNext} - hasNext={bookingNavigation.hasNext} /> - +
); } diff --git a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx new file mode 100644 index 0000000000..531c83ae1a --- /dev/null +++ b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx @@ -0,0 +1,144 @@ +"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; + +const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => { + return createStore((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(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 ( + + {children} + + ); +} + +export function useBookingDetailsSheetStore(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; +}