mirror of
https://github.com/hanzoai/calendar.git
synced 2026-10-30 14:46:43 +00:00
* refactor: extract bookings list and calendar views with nuqs state management - Extract list-related code into BookingsListView component - Create empty BookingsCalendarView component for future implementation - Add nuqs query param state management for view toggle (defaults to list) - Update bookings-listing-view to conditionally render views - No visible changes to users (list view remains default) Co-Authored-By: [email protected] <[email protected]> * refactor: move data fetching logic to parent component - Keep useFilterValue calls, trpc query, columns, flatData, bookingsToday, finalData, and table setup in parent component - BookingsListView now receives data as props instead of fetching it - This allows both list and calendar views to share the same data source Co-Authored-By: [email protected] <[email protected]> * fix: add customView column back to render booking items The customView column was inadvertently removed during refactoring. This column is crucial as it renders the actual BookingListItem components, the "today" header, and the "next" header for the bookings list. Co-Authored-By: [email protected] <[email protected]> * refactor: rename files for better clarity - Renamed bookings-listing-view.tsx → bookings-view.tsx (parent view) - Renamed bookings-list-view.tsx → BookingsList.tsx (list component) - Renamed bookings-calendar-view.tsx → BookingsCalendar.tsx (calendar component) - Moved list and calendar components from views/ to components/ directory - Updated all imports to reflect new structure This creates a clearer hierarchy where -view is the orchestrator and components are the renderers. Co-Authored-By: [email protected] <[email protected]> * clean up implementation * clean up types * revert unnecessary changes * Update packages/features/data-table/GUIDE.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
|
import { useSearchParams, usePathname } from "next/navigation";
|
|
import { createParser, useQueryState } from "nuqs";
|
|
import { useMemo } from "react";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import {
|
|
DataTableProvider,
|
|
type SystemFilterSegment,
|
|
useDataTable,
|
|
ColumnFilterType,
|
|
useFilterValue,
|
|
ZMultiSelectFilterValue,
|
|
ZDateRangeFilterValue,
|
|
ZTextFilterValue,
|
|
} from "@calcom/features/data-table";
|
|
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
|
import { Alert } from "@calcom/ui/components/alert";
|
|
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
|
|
import { HorizontalTabs } from "@calcom/ui/components/navigation";
|
|
import type { VerticalTabItemProps } 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 { BookingsCalendar } from "../components/BookingsCalendar";
|
|
import { BookingsList } from "../components/BookingsList";
|
|
import type { RowData, BookingOutput } from "../types";
|
|
|
|
type BookingsProps = {
|
|
status: (typeof validStatuses)[number];
|
|
userId?: number;
|
|
permissions: {
|
|
canReadOthersBookings: boolean;
|
|
};
|
|
};
|
|
|
|
function useSystemSegments(userId?: number) {
|
|
const { t } = useLocale();
|
|
|
|
const systemSegments: SystemFilterSegment[] = useMemo(() => {
|
|
if (!userId) return [];
|
|
|
|
return [
|
|
{
|
|
id: "my_bookings",
|
|
name: t("my_bookings"),
|
|
type: "system",
|
|
activeFilters: [
|
|
{
|
|
f: "userId",
|
|
v: {
|
|
type: ColumnFilterType.MULTI_SELECT,
|
|
data: [userId],
|
|
},
|
|
},
|
|
],
|
|
perPage: 10,
|
|
},
|
|
];
|
|
}, [userId, t]);
|
|
|
|
return systemSegments;
|
|
}
|
|
|
|
export default function Bookings(props: BookingsProps) {
|
|
const pathname = usePathname();
|
|
const systemSegments = useSystemSegments(props.userId);
|
|
return (
|
|
<DataTableProvider
|
|
useSegments={useSegments}
|
|
systemSegments={systemSegments}
|
|
tableIdentifier={pathname || undefined}>
|
|
<BookingsContent {...props} />
|
|
</DataTableProvider>
|
|
);
|
|
}
|
|
|
|
const viewParser = createParser({
|
|
parse: (value: string) => {
|
|
if (value === "calendar") return "calendar";
|
|
return "list";
|
|
},
|
|
serialize: (value: "list" | "calendar") => value,
|
|
});
|
|
|
|
function BookingsContent({ status, permissions }: BookingsProps) {
|
|
const [view] = useQueryState("view", viewParser.withDefault("list"));
|
|
const { t } = useLocale();
|
|
const user = useMeQuery().data;
|
|
const searchParams = useSearchParams();
|
|
|
|
// Generate dynamic tabs that preserve query parameters
|
|
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
|
|
const queryString = searchParams?.toString() || "";
|
|
|
|
const baseTabConfigs = [
|
|
{
|
|
name: "upcoming",
|
|
path: "/bookings/upcoming",
|
|
"data-testid": "upcoming",
|
|
},
|
|
{
|
|
name: "unconfirmed",
|
|
path: "/bookings/unconfirmed",
|
|
"data-testid": "unconfirmed",
|
|
},
|
|
{
|
|
name: "recurring",
|
|
path: "/bookings/recurring",
|
|
"data-testid": "recurring",
|
|
},
|
|
{
|
|
name: "past",
|
|
path: "/bookings/past",
|
|
"data-testid": "past",
|
|
},
|
|
{
|
|
name: "cancelled",
|
|
path: "/bookings/cancelled",
|
|
"data-testid": "cancelled",
|
|
},
|
|
];
|
|
|
|
return baseTabConfigs.map((tabConfig) => ({
|
|
name: tabConfig.name,
|
|
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
|
"data-testid": tabConfig["data-testid"],
|
|
}));
|
|
}, [searchParams]);
|
|
|
|
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
|
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
|
const userIds = useFilterValue("userId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
|
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
|
|
const attendeeName = useFilterValue("attendeeName", ZTextFilterValue);
|
|
const attendeeEmail = useFilterValue("attendeeEmail", ZTextFilterValue);
|
|
const bookingUid = useFilterValue("bookingUid", ZTextFilterValue)?.data?.operand as string | undefined;
|
|
|
|
const { limit, offset } = useDataTable();
|
|
|
|
const query = trpc.viewer.bookings.get.useQuery({
|
|
limit,
|
|
offset,
|
|
filters: {
|
|
status,
|
|
eventTypeIds,
|
|
teamIds,
|
|
userIds,
|
|
attendeeName,
|
|
attendeeEmail,
|
|
bookingUid,
|
|
afterStartDate: dateRange?.startDate
|
|
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
|
: undefined,
|
|
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
|
},
|
|
});
|
|
|
|
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}
|
|
{...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 flatData = useMemo<RowData[]>(() => {
|
|
const shownBookings: Record<string, BookingOutput[]> = {};
|
|
const filterBookings = (booking: BookingOutput) => {
|
|
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
|
|
if (!booking.recurringEventId) {
|
|
return true;
|
|
}
|
|
if (
|
|
shownBookings[booking.recurringEventId] !== undefined &&
|
|
shownBookings[booking.recurringEventId].length > 0
|
|
) {
|
|
shownBookings[booking.recurringEventId].push(booking);
|
|
return false;
|
|
}
|
|
shownBookings[booking.recurringEventId] = [booking];
|
|
} else if (status === "upcoming") {
|
|
return (
|
|
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
|
|
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
|
|
);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return (
|
|
query.data?.bookings.filter(filterBookings).map((booking) => ({
|
|
type: "data",
|
|
booking,
|
|
recurringInfo: query.data?.recurringInfo.find(
|
|
(info) => info.recurringEventId === booking.recurringEventId
|
|
),
|
|
isToday: false,
|
|
})) || []
|
|
);
|
|
}, [query.data, status, user?.timeZone]);
|
|
|
|
const bookingsToday = useMemo<RowData[]>(() => {
|
|
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[] = [];
|
|
if (bookingsToday.length > 0) {
|
|
merged.push({ type: "today" as const }, ...bookingsToday);
|
|
}
|
|
if (flatData.length > 0) {
|
|
merged.push({ type: "next" as const }, ...flatData);
|
|
}
|
|
return merged;
|
|
}, [bookingsToday, flatData, status]);
|
|
|
|
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;
|
|
|
|
return (
|
|
<div className="flex flex-col">
|
|
<div className="flex flex-row flex-wrap justify-between">
|
|
<HorizontalTabs
|
|
tabs={tabs.map((tab) => ({
|
|
...tab,
|
|
name: t(tab.name),
|
|
}))}
|
|
/>
|
|
</div>
|
|
<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} />
|
|
)}
|
|
{query.status !== "error" && (
|
|
<>
|
|
{!!bookingsToday.length && status === "upcoming" && (
|
|
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
|
|
)}
|
|
{view === "list" ? (
|
|
<BookingsList
|
|
status={status}
|
|
table={table}
|
|
isPending={isPending}
|
|
totalRowCount={totalRowCount}
|
|
/>
|
|
) : (
|
|
<BookingsCalendar status={status} table={table} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|