"use client"; import { keepPreviousData } from "@tanstack/react-query"; import { useReactTable, getCoreRowModel, getFilteredRowModel, getSortedRowModel, createColumnHelper, } from "@tanstack/react-table"; // eslint-disable-next-line no-restricted-imports import startCase from "lodash/startCase"; import Link from "next/link"; import { useMemo, useId } from "react"; import { z } from "zod"; import dayjs from "@calcom/dayjs"; import type { ExternalFilter } from "@calcom/features/data-table"; import { DataTableWrapper, DataTableFilters, DataTableProvider, DataTableSkeleton, useColumnFilters, multiSelectFilter, textFilter, dataTableFilter, 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 { RoutingFormResponsesDownload } from "../filters/Download"; import { RoutingFormFilterList } from "../filters/RoutingFormFilterList"; import { TeamAndSelfList } from "../filters/TeamAndSelfList"; import { UserListInTeam } from "../filters/UserListInTeam"; import { ZResponseMultipleValues, ZResponseSingleValue, ZResponseTextValue, ZResponseNumericValue, } from "../lib/types"; import { RoutingKPICards } from "./RoutingKPICards"; type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number]; const statusOrder: Record = { [BookingStatus.ACCEPTED]: 1, [BookingStatus.PENDING]: 2, [BookingStatus.AWAITING_HOST]: 3, [BookingStatus.CANCELLED]: 4, [BookingStatus.REJECTED]: 5, }; 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: RoutingFormTableRow["bookingAttendees"] | undefined; rowId: number; }) { const cellId = useId(); if (!attendees || attendees.length === 0) return
; return (
{attendees.map((attendee) => ( {attendee.name} ))}
); } function ResponseValueCell({ optionMap, values, rowId, }: { optionMap: Record; values: string[]; rowId: number; }) { const cellId = useId(); if (values.length === 0) return
; return ( {values.length > 2 ? ( <> {values.slice(0, 2).map((id: string, i: number) => ( {optionMap[id] ?? id} ))} +{values.length - 2}
{values.slice(2).map((id: string, i: number) => ( {optionMap[id] ?? id} ))}
) : ( values.map((id: string, i: number) => ( {optionMap[id] ?? id} )) )}
); } function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) { let badgeVariant: BadgeProps["variant"] = "success"; if (!bookingStatus) return null; switch (bookingStatus) { case BookingStatus.REJECTED: case BookingStatus.AWAITING_HOST: case BookingStatus.PENDING: case BookingStatus.CANCELLED: badgeVariant = "warning"; break; } return {bookingStatusToText(bookingStatus)}; } function BookingAtCell({ row, rowId, copyToClipboard, t, }: { row: RoutingFormTableRow; rowId: number; copyToClipboard: (text: string) => void; t: (key: string) => string; }) { const cellId = useId(); if (!row.bookingUserId || !row.bookingCreatedAt) { return
; } return (
{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}

{row.bookingUserName}

{row.bookingUserEmail}

Status:
); } export type RoutingFormTableType = ReturnType>; export function RoutingFormResponsesTable() { return ( ); } export function RoutingFormResponsesTableContent() { const { t } = useLocale(); const { filter } = useFilterContext(); const { copyToClipboard } = useCopy(); const { dateRange, selectedTeamId, isAll, initialConfig, selectedRoutingFormId, selectedMemberUserId, selectedUserId, } = filter; const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll); const [startDate, endDate] = dateRange; const columnFilters = useColumnFilters(); const teamId = selectedTeamId ?? undefined; const userId = selectedUserId ?? undefined; const memberUserId = selectedMemberUserId ?? undefined; const routingFormId = selectedRoutingFormId ?? undefined; const { data: headers, isLoading: isHeadersLoading, isSuccess: isHeadersSuccess, } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery( { userId, teamId, isAll: isAll ?? false, routingFormId, }, { enabled: initialConfigIsReady, } ); const { removeDisplayedExternalFilter, sorting, setSorting } = useDataTable(); const { data, fetchNextPage, isFetching, hasNextPage, isLoading } = trpc.viewer.insights.routingFormResponses.useInfiniteQuery( { teamId, startDate: startDate.toISOString(), endDate: endDate.toISOString(), userId, memberUserId, isAll: isAll ?? false, routingFormId, columnFilters, sorting, limit: 30, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, placeholderData: keepPreviousData, trpc: { context: { skipBatch: true }, }, enabled: initialConfigIsReady, } ); const processedData = useMemo(() => { if (!isHeadersSuccess) return []; return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[]; }, [data, isHeadersSuccess]); const columnHelper = createColumnHelper(); const columns = useMemo(() => { if (!isHeadersSuccess) { return []; } return [ columnHelper.accessor("bookingAttendees", { id: "bookingAttendees", header: t("routing_form_insights_booked_by"), size: 200, enableColumnFilter: false, enableSorting: false, cell: (info) => { 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"; const optionMap = fieldHeader.options?.reduce((acc, option) => { if (option.id) { acc[option.id] = option.label; } return acc; }, {} as Record) ?? {}; return columnHelper.accessor(`response.${fieldHeader.id}`, { id: fieldHeader.id, header: startCase(fieldHeader.label), enableSorting: false, cell: (info) => { const values = info.getValue(); if (isMultiSelect || isSingleSelect) { const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values); return ( result.success && ( ) ); } else if (isText || isNumber) { const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values); return ( result.success && (
{result.data.value}
) ); } else { return null; } }, meta: { filter: { type: filterType }, }, filterFn: (row, id, filterValue) => { const cellValue = row.original.response[id].value; return dataTableFilter(cellValue, filterValue); }, }); }) ?? []), columnHelper.accessor("bookingStatusOrder", { id: "bookingStatusOrder", header: t("routing_form_insights_booking_status"), sortDescFirst: false, cell: (info) => (
), meta: { filter: { type: "multi_select", icon: "circle" }, }, filterFn: (row, id, filterValue) => { return multiSelectFilter(row.original.bookingStatusOrder, filterValue); }, sortingFn: (rowA, rowB) => { const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null const statusB = rowB.original.bookingStatusOrder ?? 6; return statusA - statusB; }, }), columnHelper.accessor("bookingCreatedAt", { id: "bookingCreatedAt", header: t("routing_form_insights_booking_at"), enableColumnFilter: false, cell: (info) => (
), sortingFn: (rowA, rowB) => { const dateA = rowA.original.bookingCreatedAt; const dateB = rowB.original.bookingCreatedAt; if (!dateA && !dateB) return 0; if (!dateA) return -1; if (!dateB) return 1; if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0; return dateA.getTime() - dateB.getTime(); }, }), columnHelper.accessor("bookingAssignmentReason", { id: "bookingAssignmentReason", header: t("routing_form_insights_assignment_reason"), enableSorting: false, meta: { filter: { type: "text" }, }, cell: (info) => { const assignmentReason = info.getValue(); return
{assignmentReason}
; }, filterFn: (row, id, filterValue) => { const reason = row.original.bookingAssignmentReason; return textFilter(reason, filterValue); }, }), columnHelper.accessor("createdAt", { id: "createdAt", header: t("routing_form_insights_submitted_at"), enableColumnFilter: false, cell: (info) => (
{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}
), }), ]; }, [isHeadersSuccess, headers, t, copyToClipboard]); const table = useReactTable({ data: processedData, columns, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), defaultColumn: { size: 150, }, state: { sorting, columnFilters, }, onSortingChange: setSorting, 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 === "bookingStatusOrder") { return new Map( Object.keys(statusOrder).map((status) => [ { value: statusOrder[status as BookingStatus], label: bookingStatusToText(status as BookingStatus), }, 1, ]) ); } return new Map(); }, }); const externalFilters = useMemo( () => [ { key: "memberUserId", titleKey: "people", component: () => ( removeDisplayedExternalFilter("memberUserId")} /> ), }, ], [removeDisplayedExternalFilter] ); if (isHeadersLoading || ((isFetching || isLoading) && !data)) { return ; } return (
} ToolbarRight={ <> }>
); }