"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}
{
e.preventDefault();
e.stopPropagation();
copyToClipboard(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}
);
}