"use client"; import { keepPreviousData } from "@tanstack/react-query"; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, createColumnHelper, } from "@tanstack/react-table"; import Link from "next/link"; import { useRef, useMemo, useId } from "react"; import dayjs from "@calcom/dayjs"; import { DataTable, useFetchMoreOnBottomReached } from "@calcom/features/data-table"; import classNames from "@calcom/lib/classNames"; import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { BookingStatus } from "@calcom/prisma/enums"; import { trpc, type RouterOutputs } from "@calcom/trpc"; import { Badge, Avatar, Icon, HoverCard, HoverCardContent, HoverCardTrigger, Table, TableHeader, TableBody, TableCell, TableHead, TableRow, } from "@calcom/ui"; import type { BadgeProps } from "@calcom/ui/components/badge/Badge"; import { useFilterContext } from "../context/provider"; type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number]; type RoutingFormTableRow = { id: number; formName: string; createdAt: Date; routedToBooking: RoutingFormResponse["routedToBooking"]; [key: string]: any; }; function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) { return (
{ // Only add shadow if the scroll width is greater than 200px if (!el) return; const nextElement = el.nextElementSibling; if (!nextElement) return; if (el.scrollWidth > 200) { nextElement.classList.remove("hidden"); } else { nextElement.classList.add("hidden"); } }}> {children}
); } // Upper case the first letter of each word and replace underscores with spaces function bookingStatusToText(status: BookingStatus) { return status .toLowerCase() .replace(/_/g, " ") .replace(/\b\w/g, (c) => c.toUpperCase()); } function BookedByCell({ attendees, rowId, }: { attendees: { email: string; timeZone: string }[] | undefined; rowId: number; }) { const cellId = useId(); if (!attendees || attendees.length === 0) return
; return (
{attendees.map((attendee) => ( {attendee.email} ))}
); } function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) { const cellId = useId(); if (value.length === 0) return
; return ( {value.length > 2 ? ( <> {value.slice(0, 2).map((v: string, i: number) => ( {v} ))} +{value.length - 2}
{value.slice(2).map((v: string, i: number) => ( {v} ))}
) : ( value.map((v: string, i: number) => ( {v} )) )}
); } function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedToBooking"] }) { let badgeVariant: BadgeProps["variant"] = "success"; if (!booking) return null; switch (booking.status) { case BookingStatus.REJECTED: case BookingStatus.AWAITING_HOST: case BookingStatus.PENDING: case BookingStatus.CANCELLED: badgeVariant = "warning"; break; } return {bookingStatusToText(booking.status)}; } function BookingAtCell({ booking, rowId, copyToClipboard, t, }: { booking: RoutingFormResponse["routedToBooking"]; rowId: number; copyToClipboard: (text: string) => void; t: (key: string) => string; }) { const cellId = useId(); if (!booking || !booking.user) { return
; } return (
{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}

{booking.user.name}

{booking.user.email}

Status:
); } export type RoutingFormTableType = ReturnType>; export function RoutingFormResponsesTable({ children, }: { children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode); }) { const { t } = useLocale(); const { filter } = useFilterContext(); const tableContainerRef = useRef(null); const { copyToClipboard, isCopied } = useCopy(); const { dateRange, selectedTeamId, isAll, initialConfig, selectedRoutingFormId, selectedMemberUserId, selectedBookingStatus, selectedRoutingFormFilter, } = filter; const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll); const [startDate, endDate] = dateRange; const { data: headers, isLoading: isHeadersLoading } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery( { teamId: selectedTeamId ?? undefined, isAll: isAll ?? false, routingFormId: selectedRoutingFormId ?? undefined, }, { enabled: initialConfigIsReady, } ); const { data, fetchNextPage, isFetching, hasNextPage, isLoading } = trpc.viewer.insights.routingFormResponses.useInfiniteQuery( { teamId: selectedTeamId, startDate: startDate.toISOString(), endDate: endDate.toISOString(), userId: selectedMemberUserId ?? undefined, isAll: isAll ?? false, routingFormId: selectedRoutingFormId ?? undefined, bookingStatus: selectedBookingStatus ?? undefined, fieldFilter: selectedRoutingFormFilter ?? undefined, limit: 30, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, placeholderData: keepPreviousData, trpc: { context: { skipBatch: true }, }, enabled: initialConfigIsReady, } ); const flatData = useMemo(() => data?.pages?.flatMap((page) => page.data) ?? [], [data]); const totalDBRowCount = data?.pages?.[0]?.total ?? 0; const totalFetched = flatData.length; const mergedHeaders = useMemo(() => { if (!headers) return []; // Group headers by label const headersByLabel = headers.reduce( (acc, header) => { if (!acc[header.label]) { acc[header.label] = { id: header.id, label: header.label, options: [], // Initialize as empty array }; } // Only merge options if they exist if (header.options?.length) { acc[header.label].options = [...acc[header.label].options, ...header.options]; } return acc; }, {} as Record< string, { id: string; label: string; options: { id: string | null; label: string }[]; } > ); return Object.values(headersByLabel); }, [headers]); const processedData = useMemo(() => { if (isHeadersLoading) return []; return flatData.map((response) => { const row: RoutingFormTableRow = { id: response.id, formName: response.form.name, formId: response.form.id, createdAt: response.createdAt, routedToBooking: response.routedToBooking, }; // Group responses by header label // NOTE: this is a HACK to group responses by label and not ID. Some how a client has duplicate headers in a form and we need to merge them // We will revert this code when we have merged these on a DB level. // These fields can also have different types, select,multiselect,text etc... so can provide weird results. const valuesByLabel: Record = {}; Object.entries(response.response).forEach(([fieldId, field]) => { const header = headers?.find((h) => h.id === fieldId); if (!header) return; if (!valuesByLabel[header.label]) { valuesByLabel[header.label] = []; } if (header.options) { if (Array.isArray(field.value)) { const labels = field.value.map((id) => { const option = header.options?.find((opt) => opt?.id?.toLowerCase() === id.toLowerCase()); return option?.label ?? id; }); valuesByLabel[header.label].push(...labels); } else { const option = header.options?.find((opt) => opt.id === field.value); valuesByLabel[header.label].push(option?.label ?? field.value); } } else { valuesByLabel[header.label].push(field.value); } }); // Add merged values to row Object.entries(valuesByLabel).forEach(([label, values]) => { const uniqueValues = Array.from(new Set(values)).filter(Boolean); row[label] = uniqueValues; }); return row; }); }, [flatData, headers, isHeadersLoading]); const columnHelper = createColumnHelper(); const columns = useMemo( () => [ columnHelper.accessor("bookedAttendees", { id: "bookedBy", header: t("routing_form_insights_booked_by"), size: 200, cell: (info) => { const row = info.row.original; return ; }, }), ...(mergedHeaders?.map((header) => { return columnHelper.accessor(header.label, { id: header.label, header: header.label, size: 200, cell: (info) => { let value = info.getValue(); value = Array.isArray(value) ? value : [value]; return (
); }, }); }) ?? []), columnHelper.accessor("routedToBooking", { id: "bookingStatus", header: t("routing_form_insights_booking_status"), size: 250, cell: (info) => (
), }), columnHelper.accessor("routedToBooking", { id: "bookingAt", header: t("routing_form_insights_booking_at"), size: 250, cell: (info) => (
), }), columnHelper.accessor("createdAt", { id: "submittedAt", header: t("routing_form_insights_submitted_at"), size: 250, cell: (info) => (
{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}
), }), ], [headers, t, copyToClipboard] ); const table = useReactTable({ data: processedData, columns, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), defaultColumn: { size: 200, }, }); const fetchMoreOnBottomReached = useFetchMoreOnBottomReached( tableContainerRef, fetchNextPage, isFetching, totalFetched, totalDBRowCount ); if (isHeadersLoading || ((isFetching || isLoading) && !data)) { return (
{[...Array(4)].map((_, index) => (
))} {[...Array(10)].map((_, rowIndex) => ( {[...Array(4)].map((_, colIndex) => (
))} ))}
); } return (
{ if (hasNextPage) { fetchMoreOnBottomReached(e.target as HTMLDivElement); } }} isPending={isFetching && !data}> {typeof children === "function" ? children(table) : children}
); }