* portal date preset selection with a high zindex * increase height of data-table * skeleton fixes
468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
"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 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 {
|
|
DataTable,
|
|
useFetchMoreOnBottomReached,
|
|
Badge,
|
|
Avatar,
|
|
Icon,
|
|
HoverCard,
|
|
HoverCardContent,
|
|
HoverCardTrigger,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
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 (
|
|
<div className={classNames("group relative max-w-[200px]", className)}>
|
|
<div
|
|
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
|
|
ref={(el) => {
|
|
// 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}
|
|
</div>
|
|
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 <div className="min-w-[200px]" />;
|
|
|
|
return (
|
|
<div className="flex min-w-[200px] flex-wrap gap-1">
|
|
{attendees.map((attendee) => (
|
|
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
|
<Badge variant="gray" className="whitespace-nowrap">
|
|
{attendee.email}
|
|
</Badge>
|
|
</CellWithOverflowX>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
|
|
const cellId = useId();
|
|
if (value.length === 0) return <div className="h-6 w-[200px]" />;
|
|
|
|
return (
|
|
<CellWithOverflowX className="flex w-[200px] gap-1">
|
|
{value.length > 2 ? (
|
|
<>
|
|
{value.slice(0, 2).map((v: string, i: number) => (
|
|
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
|
{v}
|
|
</Badge>
|
|
))}
|
|
<HoverCard>
|
|
<HoverCardTrigger>
|
|
<Badge variant="gray">+{value.length - 2}</Badge>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent side="bottom" align="start" className="w-fit">
|
|
<div className="flex flex-col gap-1">
|
|
{value.slice(2).map((v: string, i: number) => (
|
|
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
|
{v}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
</>
|
|
) : (
|
|
value.map((v: string, i: number) => (
|
|
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
|
{v}
|
|
</Badge>
|
|
))
|
|
)}
|
|
</CellWithOverflowX>
|
|
);
|
|
}
|
|
|
|
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 <Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>;
|
|
}
|
|
|
|
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 <div className="w-[250px]" />;
|
|
}
|
|
|
|
return (
|
|
<HoverCard>
|
|
<HoverCardTrigger asChild>
|
|
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
|
|
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
|
|
<Link href={`/booking/${booking.uid}`}>
|
|
<Badge variant="gray">{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
|
|
</Link>
|
|
</div>
|
|
</HoverCardTrigger>
|
|
<HoverCardContent>
|
|
<div className="flex flex-col">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
|
|
<div>
|
|
<p className="text-sm font-medium">{booking.user.name}</p>
|
|
<p className="group/booking_status_email text-subtle flex items-center text-xs">
|
|
<span className="truncate">{booking.user.email}</span>
|
|
<button
|
|
className="invisible ml-2 group-hover/booking_status_email:visible"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
copyToClipboard(booking.user?.email ?? "");
|
|
}}>
|
|
<Icon name="copy" />
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
|
|
<span>Status:</span>
|
|
<BookingStatusBadge booking={booking} />
|
|
</div>
|
|
</div>
|
|
</HoverCardContent>
|
|
</HoverCard>
|
|
);
|
|
}
|
|
|
|
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
|
|
|
|
export function RoutingFormResponsesTable({
|
|
children,
|
|
}: {
|
|
children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode);
|
|
}) {
|
|
const { t } = useLocale();
|
|
const { filter } = useFilterContext();
|
|
const tableContainerRef = useRef<HTMLDivElement>(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: 10,
|
|
},
|
|
{
|
|
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 header = headers?.find((h) => h.id === fieldId);
|
|
|
|
if (header?.options) {
|
|
if (Array.isArray(field.value)) {
|
|
// Map the IDs to their corresponding labels for array values
|
|
const labels = field.value.map((id) => {
|
|
const option = header.options?.find((opt) => opt.id === id);
|
|
return option?.label ?? id;
|
|
});
|
|
row[fieldId] = labels;
|
|
} else {
|
|
// Handle single value case
|
|
const option = header.options?.find((opt) => opt.id === field.value);
|
|
row[fieldId] = option?.label ?? field.value;
|
|
}
|
|
} else {
|
|
row[fieldId] = field.value;
|
|
}
|
|
});
|
|
|
|
return row;
|
|
});
|
|
}, [flatData, headers, isHeadersLoading]);
|
|
|
|
const columnHelper = createColumnHelper<RoutingFormTableRow>();
|
|
|
|
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 <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
|
|
},
|
|
}),
|
|
|
|
...(headers?.map((header) => {
|
|
return columnHelper.accessor(header.id, {
|
|
id: header.id,
|
|
header: header.label,
|
|
size: 200,
|
|
cell: (info) => {
|
|
let value = info.getValue();
|
|
value = Array.isArray(value) ? value : [value];
|
|
return (
|
|
<div className="max-w-[200px]">
|
|
<ResponseValueCell value={value} rowId={info.row.original.id} />
|
|
</div>
|
|
);
|
|
},
|
|
});
|
|
}) ?? []),
|
|
columnHelper.accessor("routedToBooking", {
|
|
id: "bookingStatus",
|
|
header: t("routing_form_insights_booking_status"),
|
|
size: 250,
|
|
cell: (info) => (
|
|
<div className="max-w-[250px]">
|
|
<BookingStatusBadge booking={info.getValue()} />
|
|
</div>
|
|
),
|
|
}),
|
|
columnHelper.accessor("routedToBooking", {
|
|
id: "bookingAt",
|
|
header: t("routing_form_insights_booking_at"),
|
|
size: 250,
|
|
cell: (info) => (
|
|
<div className="max-w-[250px]">
|
|
<BookingAtCell
|
|
booking={info.getValue()}
|
|
rowId={info.row.original.id}
|
|
copyToClipboard={copyToClipboard}
|
|
t={t}
|
|
/>
|
|
</div>
|
|
),
|
|
}),
|
|
columnHelper.accessor("createdAt", {
|
|
id: "submittedAt",
|
|
header: t("routing_form_insights_submitted_at"),
|
|
size: 250,
|
|
cell: (info) => (
|
|
<div className="whitespace-nowrap">
|
|
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
|
|
</div>
|
|
),
|
|
}),
|
|
],
|
|
[headers, t, copyToClipboard]
|
|
);
|
|
|
|
const table = useReactTable<RoutingFormTableRow>({
|
|
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 (
|
|
<div
|
|
className="grid h-[85dvh]"
|
|
style={{ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'" }}>
|
|
<div
|
|
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
|
|
style={{ gridArea: "body" }}>
|
|
<Table>
|
|
<TableHeader className="bg-subtle sticky top-0 z-10">
|
|
<TableRow>
|
|
{[...Array(4)].map((_, index) => (
|
|
<TableHead key={`skeleton-header-${index}`}>
|
|
<div className="bg-subtle h-4 w-[200px] animate-pulse rounded-md" />
|
|
</TableHead>
|
|
))}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{[...Array(10)].map((_, rowIndex) => (
|
|
<TableRow key={`skeleton-row-${rowIndex}`}>
|
|
{[...Array(4)].map((_, colIndex) => (
|
|
<TableCell key={`skeleton-cell-${rowIndex}-${colIndex}`}>
|
|
<div
|
|
className={classNames(
|
|
"bg-subtle h-6 animate-pulse rounded-md",
|
|
colIndex === 0
|
|
? "w-[200px]"
|
|
: colIndex === 2
|
|
? "w-[250px]"
|
|
: colIndex === 3
|
|
? "w-[250px]"
|
|
: "w-[200px]"
|
|
)}
|
|
/>
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex-1">
|
|
<DataTable
|
|
table={table}
|
|
tableContainerRef={tableContainerRef}
|
|
onScroll={(e) => {
|
|
if (hasNextPage) {
|
|
fetchMoreOnBottomReached(e.target as HTMLDivElement);
|
|
}
|
|
}}
|
|
isPending={isFetching && !data}>
|
|
{typeof children === "function" ? children(table) : children}
|
|
</DataTable>
|
|
</div>
|
|
);
|
|
}
|