"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 { z } from "zod"; import dayjs from "@calcom/dayjs"; import type { FilterValue, ExternalFilter } from "@calcom/features/data-table"; import { DataTableProvider, DataTable, DataTableSkeleton, DataTableFilters, useFetchMoreOnBottomReached, useColumnFilters, multiSelectFilter, textFilter, dataTableFilter, convertToTitleCase, useDataTable, } 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 { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes"; import { trpc, type RouterOutputs } from "@calcom/trpc"; import { Badge, Avatar, Icon, HoverCard, HoverCardContent, HoverCardTrigger, type BadgeProps, HoverCardPortal, } from "@calcom/ui"; import { useFilterContext } from "../context/provider"; import { ClearFilters } from "../filters/ClearFilters"; import { DateSelect } from "../filters/DateSelect"; import { RoutingDownload } from "../filters/Download"; import { RoutingFormFilterList } from "../filters/RoutingFormFilterList"; import { TeamAndSelfList } from "../filters/TeamAndSelfList"; import { UserListInTeam } from "../filters/UserListInTeam"; import { RoutingKPICards } from "./RoutingKPICards"; type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number]; type RoutingFormTableRow = { id: number; formName: string; createdAt: Date; routedToBooking: RoutingFormResponse["routedToBooking"]; [key: string]: any; }; type FieldCellValue = { label: string; value: string }; const ZResponseValue = z.object({ label: z.string(), value: z.string(), }); const ZResponseValues = z.array(ZResponseValue); type ResponseValues = FieldCellValue[]; 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({ values, rowId }: { values: ResponseValues; rowId: number }) { const cellId = useId(); if (values.length === 0) return
; return ( {values.length > 2 ? ( <> {values.slice(0, 2).map((value, i: number) => ( {value.label} ))} +{values.length - 2}
{values.slice(2).map((value, i: number) => ( {value.label} ))}
) : ( values.map((value, i: number) => ( {value.label} )) )}
); } 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() { return ( ); } export function RoutingFormResponsesTableContent({ children, }: { children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode); }) { const { t } = useLocale(); const { filter } = useFilterContext(); const tableContainerRef = useRef(null); const { copyToClipboard } = useCopy(); const { dateRange, selectedTeamId, isAll, initialConfig, selectedRoutingFormId, selectedMemberUserId, selectedUserId, selectedBookingStatus, selectedRoutingFormFilter, } = filter; const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll); const [startDate, endDate] = dateRange; const columnFilters = useColumnFilters(); const { data: headers, isLoading: isHeadersLoading } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery( { userId: selectedUserId ?? undefined, 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: selectedUserId ?? undefined, memberUserId: selectedMemberUserId ?? undefined, isAll: isAll ?? false, routingFormId: selectedRoutingFormId ?? undefined, columnFilters, 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 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, }; Object.entries(response.response).forEach(([fieldId, field]) => { const fieldHeader = (headers || []).find((h) => h.id === fieldId); if (fieldHeader?.options) { if (Array.isArray(field.value)) { // Map the IDs to their corresponding labels for array values const labels = field.value .map((id) => { const option = fieldHeader.options?.find((opt) => opt.id === id); if (!option) { return undefined; } return { label: option.label, value: option.id }; }) .filter(Boolean); row[fieldId] = labels; } else { // Handle single value case const option = fieldHeader.options?.find((opt) => opt.id === field.value); if (option) { row[fieldId] = { label: option.label, value: option.id }; } } } else { row[fieldId] = field.value; } }); return row; }); }, [flatData, headers, isHeadersLoading]); const statusOrder: Record = { [BookingStatus.ACCEPTED]: 1, [BookingStatus.PENDING]: 2, [BookingStatus.AWAITING_HOST]: 3, [BookingStatus.CANCELLED]: 4, [BookingStatus.REJECTED]: 5, }; const columnHelper = createColumnHelper(); const columns = useMemo( () => [ columnHelper.accessor("routedToBooking", { id: "bookedBy", header: t("routing_form_insights_booked_by"), size: 200, enableColumnFilter: false, cell: (info) => { const row = info.row.original; return ; }, }), ...((headers || []).map((fieldHeader) => { const isText = [ RoutingFormFieldType.TEXT, RoutingFormFieldType.EMAIL, RoutingFormFieldType.PHONE, RoutingFormFieldType.TEXTAREA, ].includes(fieldHeader.type as RoutingFormFieldType); const isNumber = fieldHeader.type === RoutingFormFieldType.NUMBER; const isSingleSelect = fieldHeader.type === RoutingFormFieldType.SINGLE_SELECT; const isMultiSelect = fieldHeader.type === RoutingFormFieldType.MULTI_SELECT; const filterType = isSingleSelect ? "single_select" : isNumber ? "number" : isText ? "text" : "multi_select"; return columnHelper.accessor(fieldHeader.id, { id: fieldHeader.id, header: convertToTitleCase(fieldHeader.label), size: 200, cell: (info) => { const values = info.getValue(); if (isMultiSelect) { const result = ZResponseValues.safeParse(values); return ( result.success && ); } else if (isSingleSelect) { const result = ZResponseValue.safeParse(values); return ( result.success && ); } else if (typeof values !== "object") { return (
{values}
); } else { return null; } }, meta: { filter: { type: filterType }, }, filterFn: (row, id, filterValue: FilterValue) => { let cellValue: unknown; if (Array.isArray(fieldHeader.options)) { cellValue = Array.isArray(row.original[id]) ? row.original[id].map((item: FieldCellValue) => item.value) : row.original[id].value; } else { cellValue = row.original[id]; } return dataTableFilter(cellValue, filterValue); }, }); }) ?? []), columnHelper.accessor("routedToBooking", { id: "bookingStatus", header: t("routing_form_insights_booking_status"), size: 250, cell: (info) => (
), meta: { filter: { type: "multi_select", icon: "circle" }, }, filterFn: (row, id, filterValue) => { return multiSelectFilter(row.original.routedToBooking?.status, filterValue); }, sortingFn: (rowA, rowB) => { const statusA = rowA.original.routedToBooking?.status; const statusB = rowB.original.routedToBooking?.status; // Default to highest number (5) + 1 for null/undefined values to sort them last const orderA = statusA ? statusOrder[statusA] : 6; const orderB = statusB ? statusOrder[statusB] : 6; return orderA - orderB; }, }), columnHelper.accessor("routedToBooking", { id: "bookingAt", header: t("routing_form_insights_booking_at"), size: 250, enableColumnFilter: false, cell: (info) => (
), }), columnHelper.accessor("routedToBooking", { id: "assignmentReason", header: t("routing_form_insights_assignment_reason"), size: 250, meta: { filter: { type: "text" }, }, cell: (info) => { const assignmentReason = info.getValue()?.assignmentReason; return (
{assignmentReason && assignmentReason.length > 0 ? assignmentReason[0].reasonString : ""}
); }, filterFn: (row, id, filterValue) => { return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue); }, }), columnHelper.accessor("createdAt", { id: "submittedAt", header: t("routing_form_insights_submitted_at"), size: 250, enableColumnFilter: false, 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, }, state: { columnFilters, }, getFacetedUniqueValues: (_, columnId) => () => { if (!headers) { return new Map(); } const fieldHeader = headers.find((h) => h.id === columnId); if (fieldHeader?.options) { return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1])); } else if (columnId === "bookingStatus") { return new Map( Object.keys(BookingStatus).map((status) => [ { value: status, label: bookingStatusToText(status as BookingStatus) }, 1, ]) ); } return new Map(); }, }); const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({ tableContainerRef, hasNextPage, fetchNextPage, isFetching, }); const { removeDisplayedExternalFilter } = useDataTable(); const externalFilters = useMemo( () => [ { key: "memberUserId", titleKey: "people", component: () => ( removeDisplayedExternalFilter("memberUserId")} /> ), }, ], [removeDisplayedExternalFilter] ); if (isHeadersLoading || ((isFetching || isLoading) && !data)) { return ; } return (
{ if (hasNextPage) { fetchMoreOnBottomReached(e.target as HTMLDivElement); } }} enableColumnResizing={true} isPending={isFetching && !data}>
); }