feat: insights routing forms (#17583)

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
This commit is contained in:
sean-brydon
2024-11-15 14:54:51 -05:00
committed by GitHub
co-authored by Joe Au-Yeung
parent e85c0e1a85
commit 7ce5886b4f
34 changed files with 2764 additions and 872 deletions
@@ -0,0 +1,42 @@
"use client";
import {
RoutingFormResponsesTable,
RoutingKPICards,
FailedBookingsByField,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<Filters showRoutingFilters />
<div className="mb-4 space-y-4">
<RoutingKPICards />
<RoutingFormResponsesTable />
<FailedBookingsByField />
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+34 -87
View File
@@ -14,107 +14,54 @@ import {
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import InsightsLayout from "./layout";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<Filters />
<InsightsLayout>
<FiltersProvider>
<Filters />
<div className="mb-4 space-y-4">
<BookingKPICards />
<div className="mb-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsLayout({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? null : children}
</UpgradeTip>
</Shell>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import InsightsPage from "~/insights/insights-routing-view";
const Page = () => <InsightsPage />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps };
+13 -9
View File
@@ -209,20 +209,24 @@ test.describe("Insights", async () => {
await page.getByRole("button", { name: "User" }).click();
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
// await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
await page.waitForSelector('[data-testid="people-filter-button"]');
await page.click('[data-testid="people-filter-button"]');
await page
.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
)
.nth(0)
// .locator(
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
// )
.locator('[data-testid="people-filter-group-option-0"]')
// .nth(0)
.click();
await page
.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
)
.nth(1)
// .locator(
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
// )
.locator('[data-testid="people-filter-group-option-1"]')
// .nth(1)
.click();
// press escape button to close the filter
await page.keyboard.press("Escape");
@@ -410,6 +410,8 @@
"back_to_bookings": "Back to bookings",
"free_to_pick_another_event_type": "Feel free to pick another event anytime.",
"cancelled": "Canceled",
"no_booking": "No booking",
"awaiting_host": "Awaiting host",
"cancellation_successful": "Cancellation successful",
"really_cancel_booking": "Really cancel your booking?",
"cannot_cancel_booking": "You cannot cancel this booking",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routing",
"routing_forms_description": "Create forms to direct attendees to the correct destinations",
"routing_forms_send_email_owner": "Send Email to Owner",
"routing_forms_send_email_owner_description": "Sends an email to the owner when the form is submitted",
@@ -2764,6 +2767,17 @@
"booking_start_date": "Booking start date",
"booking_created_date": "Booking created date",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"routing_forms_created": "Routing Forms Created",
"routing_forms_total_responses": "Total Responses",
"routing_forms_total_responses_without_booking": "Total Responses Without Booking",
"routing_forms_total_responses_with_booking": "Total Responses With Booking",
"routing_form_insights": "No Bookings",
"routing_form_insights_form_name_header": "Form Name",
"routing_form_insights_booking_status_header": "Booking",
"routing_form_insights_response_no_booking": "No Booking",
"routing_form_insights_booked_by": "Booked by",
"routing_form_insights_created_at": "Created at",
"routing_form_insights_booking_status": "Booking Status",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
@@ -80,12 +80,13 @@ export const PeopleFilter = () => {
icon={<Avatar alt={`${member?.id} avatar`} imageSrc={member.avatarUrl} size="xs" />}
/>
))}
<div className="text-default text-center" ref={observerRef}>
<div className="text-default text-center" ref={observerRef} data-testid="people-filter">
<Button
color="minimal"
loading={queryMembers.isFetchingNextPage}
disabled={!queryMembers.hasNextPage}
onClick={() => queryMembers.fetchNextPage()}>
onClick={() => queryMembers.fetchNextPage()}
data-testid="people-filter">
{queryMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
@@ -0,0 +1,89 @@
import { BarList } from "@tremor/react";
import { useState } from "react";
import { classNames } from "@calcom/lib";
import { trpc } from "@calcom/trpc";
import { ToggleGroup } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
interface FormCardProps {
formName: string;
fields: Record<string, { optionId: string; count: number; optionLabel: string }[]>;
}
function FormCard({ formName, fields }: FormCardProps) {
const fieldNames = Object.keys(fields);
const [selectedField, setSelectedField] = useState(fieldNames[0]);
const toggleOptions = fieldNames.map((fieldName) => ({
value: fieldName,
label: fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
}));
const selectedFieldData = fields[selectedField]?.map((option) => ({
name: option.optionLabel,
value: option.count,
}));
return (
<div className="border-subtle h-[400px] w-full rounded-md border">
<div className="flex flex-col">
<div className="bg-subtle border-subtle flex h-12 items-center border-b">
<h3 className="text-default px-2 text-left align-middle font-medium">{formName}</h3>
</div>
<div className="p-4">
<ToggleGroup
options={toggleOptions}
value={selectedField}
className="w-fit"
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-full overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
className="mt-2"
/>
</div>
)}
</div>
</div>
</div>
);
}
export function FailedBookingsByField() {
const { filter } = useFilterContext();
const { selectedTeamId, isAll, initialConfig, selectedRoutingFormId } = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
if (!data) return null;
return (
<div className="w-full text-sm">
<div className="flex h-12 items-center">
<h2 className="text-emphasis text-md font-semibold">Failed Bookings By Field</h2>
</div>
<div className="">
<div className={classNames("grid grid-cols-1 gap-6 lg:grid-cols-2")}>
{Object.entries(data).map(([formName, fields]) => (
<FormCard key={formName} formName={formName} fields={fields} />
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,370 @@
"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 } 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 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="absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l from-white to-transparent group-hover:from-gray-100" />
</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 null;
return (
<div className="flex 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 (
<div className="flex w-[200px] flex-wrap gap-1">
{value.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
</Badge>
))}
<div className="group/badge relative">
<Badge variant="gray">+{value.length - 2}</Badge>
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/badge:visible group-hover/badge:translate-y-0 group-hover/badge:opacity-100">
<div className="flex flex-col gap-1">
{value.slice(2).map((v: string, i: number) => (
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-sm text-gray-600">
{v}
</span>
))}
</div>
</div>
</div>
</>
) : (
value.map((v: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
</Badge>
))
)}
</div>
);
}
function BookingStatusCell({
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]">
<Badge variant="error" className="ml-6" key={`${cellId}-no-booking-${rowId}`}>
{t("no_booking")}
</Badge>
</div>
);
}
let badgeVariant: BadgeProps["variant"] = "success";
switch (booking.status) {
case BookingStatus.REJECTED:
case BookingStatus.AWAITING_HOST:
case BookingStatus.PENDING:
case BookingStatus.CANCELLED:
badgeVariant = "warning";
break;
}
return (
<div className="group/booking_status relative 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={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
</Link>
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/booking_status:visible group-hover/booking_status:translate-y-0 group-hover/booking_status:opacity-100 ">
<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>
<Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>
</div>
</div>
</div>
</div>
);
}
export function RoutingFormResponsesTable() {
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 } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const { data, fetchNextPage, isFetching, hasNextPage } =
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(() => {
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]);
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]">
<BookingStatusCell
booking={info.getValue()}
rowId={info.row.original.id}
copyToClipboard={copyToClipboard}
t={t}
/>
</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
);
return (
<div className="flex-1">
<DataTable
table={table}
tableContainerRef={tableContainerRef}
onScroll={(e) => {
if (hasNextPage) {
fetchMoreOnBottomReached(e.target as HTMLDivElement);
}
}}
isPending={isFetching && !data}
/>
</div>
);
}
@@ -0,0 +1,118 @@
import { Grid } from "@tremor/react";
import { Flex, Text, Metric } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib";
import { CardInsights } from "./Card";
export const RoutingKPICards = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const {
dateRange,
selectedEventTypeId,
selectedUserId,
selectedMemberUserId,
isAll,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
routingFormId: selectedRoutingFormId ?? undefined,
userId: selectedMemberUserId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: initialConfigIsReady,
}
);
const categories: {
title: string;
index:
| "created"
| "active"
| "total_responses"
| "total_responses_without_booking"
| "total_responses_with_booking";
}[] = [
{
title: t("routing_forms_created"),
index: "created",
},
{
title: t("routing_forms_total_responses"),
index: "total_responses",
},
{
title: t("routing_forms_total_responses_without_booking"),
index: "total_responses_without_booking",
},
{
title: t("routing_forms_total_responses_with_booking"),
index: "total_responses_with_booking",
},
];
if (isPending) {
return <LoadingKPICards categories={categories} />;
}
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
return (
<>
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<Text className="text-default">{item.title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
{/* @ts-expect-error - theyre actually dynamic fields that we know the index of - but TS doesnt know that */}
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
</Flex>
</CardInsights>
))}
</Grid>
</>
);
};
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
const { categories } = props;
return (
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<div className="animate-pulse">
<div className="h-4 w-24 rounded bg-gray-200" />
<div className="mt-4 flex items-baseline space-x-3">
<div className="h-8 w-16 rounded bg-gray-200" />
</div>
</div>
</CardInsights>
))}
</Grid>
);
};
@@ -1,5 +1,6 @@
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
export { RoutingKPICards } from "./RoutingKPICards";
export { BookingStatusLineChart } from "./BookingStatusLineChart";
export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable";
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
@@ -8,3 +9,5 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
export { RoutingFormResponsesTable } from "./RoutingFormResponsesTable";
export { FailedBookingsByField } from "./FailedBookingsByField";
@@ -4,8 +4,10 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { useDefaultRoutingForm } from "../hooks/useDefaultRoutingForm";
import type { FilterContextType } from "./provider";
import { FilterProvider } from "./provider";
@@ -16,9 +18,14 @@ const querySchema = z.object({
userId: z.coerce.number().nullable(),
memberUserId: z.coerce.number().nullable(),
eventTypeId: z.coerce.number().nullable(),
filter: z.enum(["event-type", "user"]).nullable(),
filter: z
.union([z.enum(["event-type", "user", "routing_forms", "booking_status"]), z.string().regex(/^rf_.*$/)])
.nullable(),
routingFormId: z.string().nullable(),
bookingStatus: z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).nullable(),
});
// TODO(SEAN): We can have a big refactor here and move this all out of context and into query state. This will make it easier to create shareable links
export function FiltersProvider({ children }: { children: React.ReactNode }) {
// searchParams to get initial values from query params
const utils = trpc.useUtils();
@@ -32,6 +39,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
userIdParsed,
eventTypeIdParsed,
filterParsed,
routingFormIdParsed,
bookingStatusParsed,
memberUserIdParsed;
const safe = querySchema.safeParse({
@@ -42,6 +51,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
eventTypeId: searchParams?.get("eventTypeId") ?? null,
filter: searchParams?.get("filter") ?? null,
memberUserId: searchParams?.get("memberUserId") ?? null,
routingFormId: searchParams?.get("routingFormId") ?? null,
bookingStatus: searchParams?.get("bookingStatus") ?? null,
});
if (!safe.success) {
@@ -53,7 +64,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
userIdParsed = safe.data.userId;
eventTypeIdParsed = safe.data.eventTypeId;
filterParsed = safe.data.filter;
routingFormIdParsed = safe.data.routingFormId;
memberUserIdParsed = safe.data.memberUserId;
bookingStatusParsed = safe.data.bookingStatus;
}
const [configFilters, setConfigFilters] = useState<FilterContextType["filter"]>({
@@ -68,7 +81,11 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedTeamId: teamIdParsed || null,
selectedTeamName: null,
selectedEventTypeId: eventTypeIdParsed || null,
selectedFilter: filterParsed ? [filterParsed] : null,
selectedFilter: filterParsed
? [filterParsed as "event-type" | "user" | "routing_forms" | `rf_${string}`]
: null,
selectedRoutingFormId: routingFormIdParsed || null,
selectedBookingStatus: bookingStatusParsed || null,
isAll: false,
initialConfig: {
userId: null,
@@ -77,6 +94,20 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
},
});
// Use the custom hook
const { mostPopularForm } = useDefaultRoutingForm({
teamId: teamIdParsed,
isAll: safe.success ? !!safe.data.teamId : false,
routingFormId: routingFormIdParsed,
onRoutingFormChange: (formId) => {
setConfigFilters((prev) => ({
...prev,
selectedFilter: prev.selectedFilter ? [...prev.selectedFilter, "routing_forms"] : ["routing_forms"],
selectedRoutingFormId: formId,
}));
},
});
const {
dateRange,
selectedTimeView,
@@ -86,6 +117,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedEventTypeId,
selectedFilter,
selectedTeamName,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
isAll,
initialConfig,
} = configFilters;
@@ -103,6 +137,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedFilter,
isAll,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
},
setConfigFilters: (newConfigFilters) => {
setConfigFilters({
@@ -119,6 +156,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
isAll,
dateRange,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
} = newConfigFilters;
const [startTime, endTime] = dateRange || [null, null];
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
@@ -134,6 +173,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
setParamsIfDefined("startTime", startTime?.format("YYYY-MM-DD"));
setParamsIfDefined("endTime", endTime?.format("YYYY-MM-DD"));
setParamsIfDefined("filter", selectedFilter?.[0]);
setParamsIfDefined("routingFormId", selectedRoutingFormId);
setParamsIfDefined("bookingStatus", selectedBookingStatus);
router.push(`${pathname}?${newSearchParams.toString()}`);
},
clearFilters: () => {
@@ -152,6 +193,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
isAll: !!initialConfig?.isAll,
dateRange: [dayjs().subtract(1, "week"), dayjs(), "w"],
initialConfig,
selectedRoutingFormId: mostPopularForm?.id ?? null, // Set the most popular form as default when clearing filters
selectedBookingStatus: null,
selectedRoutingFormFilter: null,
});
const newSearchParams = new URLSearchParams();
@@ -1,3 +1,4 @@
import type { BookingStatus } from "@prisma/client";
import * as React from "react";
import type { Dayjs } from "@calcom/dayjs";
@@ -5,12 +6,18 @@ import type { Dayjs } from "@calcom/dayjs";
interface IFilter {
dateRange: [Dayjs, Dayjs, null | string];
selectedTimeView?: "year" | "week" | "month";
selectedFilter?: Array<"user" | "event-type"> | null;
selectedFilter?: Array<"user" | "event-type" | "routing_forms" | "booking_status" | `rf_${string}`> | null;
selectedTeamId?: number | null;
selectedTeamName?: string | null;
selectedUserId?: number | null;
selectedMemberUserId?: number | null;
selectedEventTypeId?: number | null;
selectedRoutingFormId?: string | null;
selectedBookingStatus?: BookingStatus | "NO_BOOKING" | null;
selectedRoutingFormFilter?: {
fieldId: string;
optionId: string;
} | null;
isAll?: boolean;
initialConfig?: {
teamId?: number | null;
@@ -0,0 +1,49 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type BookingStatusOption = {
value: BookingStatus | "NO_BOOKING";
label: string;
};
export const BookingStatusFilter = () => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedBookingStatus, selectedFilter } = filter;
const bookingStatusOptions = useMemo(() => {
const options: BookingStatusOption[] = Object.values(BookingStatus).map((status) => ({
value: status,
label: t(`${status.toLowerCase()}`).charAt(0).toUpperCase() + t(`${status.toLowerCase()}`).slice(1),
}));
options.push({
value: "NO_BOOKING",
label: t("no_booking"),
});
return options;
}, [t]);
if (!selectedFilter?.includes("booking_status")) {
return null;
}
return (
<FilterSelect
title={t("routing_form_insights_booking_status")}
options={bookingStatusOptions}
selectedValue={selectedBookingStatus}
onChange={(value) => {
setConfigFilters({ selectedBookingStatus: value as BookingStatus | "NO_BOOKING" });
}}
buttonIcon={<Icon name="circle" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
/>
);
};
@@ -0,0 +1,57 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
const Download = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const { data, isPending } = trpc.viewer.insights.rawData.useQuery(
{
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId,
userId: filter.selectedUserId,
eventTypeId: filter.selectedEventTypeId,
memberUserId: filter.selectedMemberUserId,
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: Infinity,
trpc: {
context: { skipBatch: true },
},
}
);
type RawData = RouterOutputs["viewer"]["insights"]["rawData"] | undefined;
const handleDownloadClick = async (data: RawData) => {
if (!data) return;
const { data: csvRaw, filename } = data;
downloadAsCsv(csvRaw, filename);
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
{...(isPending && { loading: isPending })}
className="self-end sm:self-baseline">
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={() => handleDownloadClick(data)}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { Download };
@@ -0,0 +1,87 @@
import { useState } from "react";
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
import { showToast } from "@calcom/ui";
type RawRoutingData = RouterOutputs["viewer"]["insights"]["rawRoutingData"]["data"][number] | undefined;
interface BatchResult {
data: RawRoutingData[];
nextCursor: number | null;
hasMore: boolean;
}
const RoutingDownload = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useContext();
const fetchBatch = async (cursor: number | null = null) => {
const result = await utils.viewer.insights.rawRoutingData.fetch({
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId ?? undefined,
userId: filter.selectedUserId ?? undefined,
routingFormId: filter.selectedRoutingFormId ?? undefined,
bookingStatus: filter.selectedBookingStatus ?? undefined,
fieldFilter: filter.selectedRoutingFormFilter ?? undefined,
isAll: !!filter.isAll,
cursor: cursor ?? undefined,
});
return result;
};
const handleDownloadClick = async () => {
try {
setIsDownloading(true);
let allData: RawRoutingData[] = [];
let hasMore = true;
let cursor: number | null = null;
// Fetch data in batches until there's no more data
while (hasMore) {
const result: BatchResult = await fetchBatch(cursor);
allData = [...allData, ...result.data];
hasMore = result.hasMore;
cursor = result.nextCursor;
}
if (allData.length > 0) {
const filename = `RoutingInsights-${filter.dateRange[0].format(
"YYYY-MM-DD"
)}-${filter.dateRange[1].format("YYYY-MM-DD")}.csv`;
downloadAsCsv(allData as Record<string, unknown>[], filename);
}
} catch (error) {
showToast(t("error_downloading_data"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
className="self-end sm:self-baseline"
loading={isDownloading}>
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={handleDownloadClick}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { RoutingDownload };
@@ -1,58 +1,2 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
const Download = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const { data, isPending } = trpc.viewer.insights.rawData.useQuery(
{
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId,
userId: filter.selectedUserId,
eventTypeId: filter.selectedEventTypeId,
memberUserId: filter.selectedMemberUserId,
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: Infinity,
trpc: {
context: { skipBatch: true },
},
}
);
type RawData = RouterOutputs["viewer"]["insights"]["rawData"] | undefined;
const handleDownloadClick = async (data: RawData) => {
if (!data) return;
const { data: csvRaw, filename } = data;
downloadAsCsv(csvRaw, filename);
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
{...(isPending && { loading: isPending })}
className="self-end sm:self-baseline">
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={() => handleDownloadClick(data)}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { Download };
export { Download } from "./Download";
export { RoutingDownload } from "./RoutingDownload";
@@ -1,18 +1,16 @@
import { memo } from "react";
import { FilterCheckboxFieldsContainer } from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, CheckboxField } from "@calcom/ui";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number];
type Option = { value: string; label: string };
const mapEventTypeToOption = (eventType: EventType): Option => ({
value: eventType.id.toString(),
const mapEventTypeToOption = (eventType: EventType) => ({
value: eventType.id,
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
});
@@ -21,6 +19,7 @@ export const EventTypeList = memo(() => {
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedEventTypeId, selectedUserId, isAll } = filter;
const { selectedFilter } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
teamId: selectedTeamId ?? undefined,
userId: selectedUserId ?? undefined,
@@ -29,52 +28,19 @@ export const EventTypeList = memo(() => {
if (!selectedFilter?.includes("event-type")) return null;
if (!selectedTeamId && !selectedUserId) return null;
const filterOptions = data?.map(mapEventTypeToOption);
const selectedEventType = data?.find((item) => item.id === selectedEventTypeId);
const eventTypeValue = selectedEventType ? mapEventTypeToOption(selectedEventType) : null;
if (!isSuccess || !data || !Array.isArray(data)) return null;
const getPopoverText = () => {
if (eventTypeValue?.label) {
return `${t("event_type")}: ${eventTypeValue?.label}`;
}
return t("event_type");
};
const filterOptions = data.map(mapEventTypeToOption);
return (
<AnimatedPopover text={getPopoverText()}>
<FilterCheckboxFieldsContainer>
{filterOptions?.map((eventType) => (
<div
key={eventType.value}
className="item-center hover:bg-muted flex cursor-pointer px-4 py-2 transition">
<CheckboxField
checked={eventTypeValue?.value === eventType.value}
onChange={(e) => {
if (e.target.checked) {
const selectedEventTypeId = data.find((item) => item.id.toString() === eventType.value)?.id;
!!selectedEventTypeId &&
setConfigFilters({
selectedEventTypeId,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedEventTypeId: null,
});
}
}}
description={eventType.label}
/>
</div>
))}
{filterOptions?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
<FilterSelect
title={t("event_type")}
options={filterOptions}
selectedValue={selectedEventTypeId}
onChange={(value) => setConfigFilters({ selectedEventTypeId: value ? Number(value) : null })}
buttonIcon={<Icon name="calendar" className="mr-2 h-4 w-4" />}
emptyText={t("no_options_available")}
/>
);
});
@@ -1,4 +1,7 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { IconName } from "@calcom/ui";
import {
Dropdown,
@@ -12,38 +15,88 @@ import {
import { useFilterContext } from "../context/provider";
type FilterType = "event-type" | "user" | "routing_forms" | `rf_${string}` | "booking_status";
type Option = {
value: "event-type" | "user";
value: FilterType;
label: string;
StartIcon: IconName;
};
export const FilterType = () => {
export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?: boolean }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedUserId } = filter;
const { selectedFilter, selectedUserId, selectedTeamId, selectedRoutingFormId, isAll, initialConfig } =
filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
let filterOptions: Option[] = [
// Dynamically load filters if showRoutingFilters is set to true
// Query routing form field options when showRoutingFilters is true
const { data: routingFormFieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
label: t("event_type"),
value: "event-type",
StartIcon: "link",
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
label: t("user"),
value: "user",
StartIcon: "user",
},
];
enabled: showRoutingFilters && initialConfigIsReady,
trpc: {
context: {
skipBatch: true,
},
},
}
);
if (selectedUserId) {
// remove user option from filterOptions
filterOptions = filterOptions.filter((option) => option.value !== "user");
}
const filterOptions = useMemo(() => {
let options: Option[] = [
{
label: t("user"),
value: "user",
StartIcon: "users" as IconName,
},
];
const filterValue = selectedFilter
? filterOptions.find((option) => option.value === selectedFilter[0])
: null;
// Add routing forms filter options
if (showRoutingFilters) {
options.push({
label: t("routing_forms"),
value: "routing_forms" as FilterType,
StartIcon: "calendar-check-2" as IconName,
});
options.push({
label: t("booking_status"),
value: "booking_status" as FilterType,
StartIcon: "circle" as IconName,
});
// Add dynamic routing form field options
if (routingFormFieldOptions?.length) {
options = [
...options,
...routingFormFieldOptions.map((option) => ({
label: option.label,
value: `rf_${option.id}` as FilterType,
StartIcon: "layers" as IconName,
})),
];
}
} else {
options.push({
label: t("event_type"),
value: "event-type",
StartIcon: "link",
});
}
if (selectedUserId) {
// remove user option from filterOptions
options = options.filter((option) => option.value !== "user");
}
return options;
}, [t, showRoutingFilters, routingFormFieldOptions, selectedUserId]);
return (
<Dropdown>
@@ -51,26 +104,29 @@ export const FilterType = () => {
<div className="hover:border-emphasis border-default text-default hover:text-emphasis focus:border-subtle mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
<Icon name="plus" className="mr-2 h-4 w-4" />
<Tooltip content={t("add_filter")}>
<div>{t("add_filter")}</div>
<div>{selectedFilter?.length ? `${selectedFilter.length} ${t("filters")}` : t("add_filter")}</div>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-44">
<DropdownMenuContent className="z-50 w-44">
{filterOptions?.map((option) => (
<DropdownMenuItem key={option.label} className="w-44">
<DropdownItem
type="button"
StartIcon={option.StartIcon}
onClick={() => {
// This can multiple values, but for now we only want to have one filter active at a time
setConfigFilters({
selectedFilter: [option.value],
selectedFilter: selectedFilter
? selectedFilter.includes(option.value)
? selectedFilter.filter((f) => f !== option.value)
: [...selectedFilter, option.value]
: [option.value],
});
}}
childrenClassName="w-full">
<div className="flex w-full items-center justify-between">
{t(option.label)}
{filterValue?.value === option.value && <Icon name="check" className="h-4 w-4" />}
{selectedFilter?.includes(option.value) && <Icon name="check" className="h-4 w-4" />}
</div>
</DropdownItem>
</DropdownMenuItem>
@@ -0,0 +1,63 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const RoutingFormFieldFilter = ({ fieldId }: { fieldId: string }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, isAll, selectedRoutingFormId, selectedRoutingFormFilter } = filter;
const { data: fieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: !!selectedTeamId || !!isAll,
}
);
const currentField = useMemo(() => {
return fieldOptions?.find((option) => `${option.id}` === fieldId);
}, [fieldOptions]);
if (!currentField) return null;
const options =
currentField.options?.map((option) => ({
value: option.id ?? "",
label: option.label,
})) ?? [];
return (
<>
<FilterSelect
title={currentField.label}
options={options}
selectedValue={selectedRoutingFormFilter?.optionId}
onChange={(value) => {
console.log("value", value);
if (!value) {
setConfigFilters({
selectedRoutingFormFilter: null,
});
} else {
setConfigFilters({
selectedRoutingFormFilter: {
fieldId,
optionId: value as string,
},
});
}
}}
buttonIcon={<Icon name="layers" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
/>
</>
);
};
@@ -0,0 +1,50 @@
import { memo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type Form = RouterOutputs["viewer"]["insights"]["getRoutingFormsForFilters"][number];
function buildFilterOptions(forms: Form[]) {
return forms.map((form) => ({
value: form.id,
label: form.name,
}));
}
export const RoutingFormFilterList = memo(() => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
if (!selectedFilter?.includes("routing_forms")) return null;
const filterOptions = buildFilterOptions(allForms || []);
return (
<FilterSelect
title={t("routing_forms")}
options={filterOptions}
selectedValue={selectedRoutingFormId}
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
/>
);
});
RoutingFormFilterList.displayName = "RoutingFormFilterList";
@@ -12,10 +12,10 @@ import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const TeamAndSelfList = () => {
export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
@@ -101,41 +101,44 @@ export const TeamAndSelfList = () => {
)}
<Divider />
{data?.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && !isAll}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedTeamId: team.id,
selectedUserId: null,
selectedTeamName: team.name,
isAll: false,
// Setting these to null to reset the filters
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: null,
selectedTeamName: null,
isAll: false,
});
{data?.map((team) => {
if (omitOrg && team.id === currentOrgId) return null;
return (
<FilterCheckboxField
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && !isAll}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedTeamId: team.id,
selectedUserId: null,
selectedTeamName: team.name,
isAll: false,
// Setting these to null to reset the filters
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: null,
selectedTeamName: null,
isAll: true,
});
}
}}
icon={
<Avatar
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
size="xs"
/>
}
}}
icon={
<Avatar
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
size="xs"
/>
}
/>
))}
/>
);
})}
<Divider />
<FilterCheckboxField
@@ -1,89 +1,43 @@
import { useState } from "react";
import {
FilterCheckboxField,
FilterCheckboxFieldsContainer,
} from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, Avatar, FilterSearchField } from "@calcom/ui";
import { Icon, Avatar, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type User = RouterOutputs["viewer"]["insights"]["userList"][number];
type Option = { value: number; label: string; username: string | null; avatarUrl: string | null };
const mapUserToOption = (user: User): Option => ({
const mapUserToOption = (user: User) => ({
value: user.id,
label: user.name ?? user.email, // every user should have at least email
username: user.username,
avatarUrl: user.avatarUrl,
label: user.name ?? user.email,
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
});
export const UserListInTeam = () => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
const [searchText, setSearchText] = useState("");
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
});
if (!selectedFilter?.includes("user")) return null;
if (!selectedTeamId) return null;
if (!selectedFilter?.includes("user") || !selectedTeamId || !isSuccess || data?.length === 0) {
return null;
}
const userListOptions = data?.map(mapUserToOption);
const selectedTeamUser = data?.find((item) => item.id === selectedMemberUserId);
const userValue = selectedTeamUser ? mapUserToOption(selectedTeamUser) : null;
const filteredUserListOptions = userListOptions?.filter((member) => {
if (searchText.trim() === "") return true;
const searchLower = searchText.toLowerCase();
const labelMatch = member.label.toLowerCase().includes(searchLower);
const usernameMatch = member.username?.toLowerCase().includes(searchLower);
return labelMatch || usernameMatch;
});
if (!isSuccess || data?.length === 0) return null;
const getTextForPopover = () => {
if (userValue?.label) {
return `${t("people")}: ${userValue.label}`;
}
return t("people");
};
const userListOptions = data.map(mapUserToOption);
return (
<AnimatedPopover text={getTextForPopover()}>
<FilterCheckboxFieldsContainer>
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
{filteredUserListOptions?.map((member) => (
<FilterCheckboxField
key={member.value}
id={member?.value?.toString()}
label={member?.label ?? member.username ?? "No Name"}
checked={userValue?.value === member?.value}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedMemberUserId: member.value,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedMemberUserId: undefined,
});
}
}}
icon={<Avatar alt={`${member?.value} avatar`} imageSrc={member.avatarUrl} size="xs" />}
/>
))}
{filteredUserListOptions?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
<FilterSelect
title={t("people")}
options={userListOptions}
selectedValue={selectedMemberUserId}
onChange={(value) => setConfigFilters({ selectedMemberUserId: Number(value) })}
buttonIcon={<Icon name="users" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
testId="people-filter"
/>
);
};
+28 -5
View File
@@ -2,10 +2,13 @@ import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Tooltip } from "@calcom/ui";
import { BookingStatusFilter } from "./BookingStatusFilter";
import { DateSelect } from "./DateSelect";
import { Download } from "./Download/index";
import { Download, RoutingDownload } from "./Download";
import { EventTypeList } from "./EventTypeList";
import { FilterType } from "./FilterType";
import { RoutingFormFieldFilter } from "./RoutingFormFieldFilter";
import { RoutingFormFilterList } from "./RoutingFormFilterList";
import { TeamAndSelfList } from "./TeamAndSelfList";
import { UserListInTeam } from "./UsersListInTeam";
@@ -34,17 +37,37 @@ const ClearFilters = () => {
);
};
export const Filters = () => {
export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: boolean }) => {
const { filter } = useFilterContext();
const { selectedFilter } = filter;
// Get all filters that relate to the routing form
const routingFormFieldIds = selectedFilter
? selectedFilter.map((filter) => {
if (filter.startsWith("rf_")) return filter.substring(3);
})
: [];
return (
<div className="ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-nowrap sm:justify-start">
<TeamAndSelfList />
<TeamAndSelfList omitOrg={showRoutingFilters} />
<UserListInTeam />
<EventTypeList />
<FilterType />
{showRoutingFilters ? (
<>
<RoutingFormFilterList />
<BookingStatusFilter />
{routingFormFieldIds.map((fieldId) => {
if (fieldId) return <RoutingFormFieldFilter fieldId={fieldId} />;
})}
</>
) : null}
<FilterType showRoutingFilters={showRoutingFilters} />
<ClearFilters />
</div>
@@ -73,7 +96,7 @@ export const Filters = () => {
</Tooltip>
</ButtonGroup> */}
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
<Download />
{showRoutingFilters ? <RoutingDownload /> : <Download />}
<DateSelect />
</div>
</div>
@@ -0,0 +1,60 @@
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { trpc } from "@calcom/trpc";
export function useDefaultRoutingForm({
teamId,
isAll,
routingFormId,
onRoutingFormChange,
}: {
teamId: number | null | undefined;
isAll: boolean;
routingFormId: string | null | undefined;
onRoutingFormChange: (formId: string) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const isRoutingInsights = pathname?.includes("/insights/routing");
const hasSetDefault = useRef(false);
// Query to get routing forms list
const { data: routingForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: teamId ?? undefined,
isAll,
},
{
enabled: isRoutingInsights,
}
);
// Get the most popular form
const mostPopularForm = routingForms?.length
? [...routingForms].sort((a, b) => (b._count?.responses ?? 0) - (a._count?.responses ?? 0))[0]
: null;
// Effect to set default routing form only once when the page loads
useEffect(() => {
if (isRoutingInsights && !routingFormId && mostPopularForm && !hasSetDefault.current) {
hasSetDefault.current = true;
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
newSearchParams.set("routingFormId", mostPopularForm.id);
router.push(`${pathname}?${newSearchParams.toString()}`);
onRoutingFormChange(mostPopularForm.id);
}
}, [
isRoutingInsights,
routingFormId,
mostPopularForm,
pathname,
searchParams,
router,
onRoutingFormChange,
]);
return { routingForms, mostPopularForm };
}
@@ -0,0 +1,640 @@
import { Prisma } from "@prisma/client";
import {
zodFields as routingFormFieldsSchema,
routingFormResponseInDbSchema,
} from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
type RoutingFormInsightsTeamFilter = {
teamId?: number | null;
isAll: boolean;
organizationId?: number | null;
routingFormId?: string | null;
};
type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
startDate?: string;
endDate?: string;
userId?: number | null;
bookingStatus?: BookingStatus | "NO_BOOKING" | null;
fieldFilter?: {
fieldId: string;
optionId: string;
} | null;
};
class RoutingEventsInsights {
private static async getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
// Get team IDs based on organization if applicable
let teamIds: number[] = [];
if (isAll && organizationId) {
const teamsFromOrg = await prisma.team.findMany({
where: {
parentId: organizationId,
},
select: {
id: true,
},
});
teamIds = [organizationId, ...teamsFromOrg.map((t) => t.id)];
} else if (teamId) {
teamIds = [teamId];
}
// Base where condition for forms
const formsWhereCondition: Prisma.App_RoutingForms_FormWhereInput = {
...(teamIds.length > 0 && {
teamId: {
in: teamIds,
},
}),
...(routingFormId && {
id: routingFormId,
}),
};
return formsWhereCondition;
}
static async getRoutingFormStats({
teamId,
startDate,
endDate,
isAll = false,
organizationId,
routingFormId,
userId,
bookingStatus,
fieldFilter,
}: RoutingFormInsightsFilter) {
// Get team IDs based on organization if applicable
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
// Base where condition for responses
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
form: formsWhereCondition,
};
// Get total forms count
const totalFormsPromise = prisma.app_RoutingForms_Form.count({
where: formsWhereCondition,
});
// Get total responses
const totalResponsesPromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
// Get responses without booking
const responsesWithoutBookingPromise = prisma.app_RoutingForms_FormResponse.count({
where: {
...responsesWhereCondition,
routedToBookingUid: null,
},
});
const [totalForms, totalResponses, responsesWithoutBooking] = await Promise.all([
totalFormsPromise,
totalResponsesPromise,
responsesWithoutBookingPromise,
]);
return {
created: totalForms,
total_responses: totalResponses,
total_responses_without_booking: responsesWithoutBooking,
total_responses_with_booking: totalResponses - responsesWithoutBooking,
};
}
static async getRoutingFormsForFilters({
teamId,
isAll,
organizationId,
}: {
teamId?: number;
isAll: boolean;
organizationId?: number | undefined;
routingFormId?: string | undefined;
}) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, isAll, organizationId });
return await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
_count: {
select: {
responses: true,
},
},
},
});
}
static async getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
fieldFilter,
bookingStatus,
}: RoutingFormInsightsFilter & { cursor?: number; limit?: number }) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
form: formsTeamWhereCondition,
};
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
const responsesPromise = prisma.app_RoutingForms_FormResponse.findMany({
select: {
id: true,
response: true,
form: {
select: {
id: true,
name: true,
},
},
routedToBooking: {
select: {
uid: true,
status: true,
createdAt: true,
attendees: {
select: {
timeZone: true,
email: true,
},
},
user: {
select: { id: true, name: true, email: true, avatarUrl: true },
},
},
},
createdAt: true,
},
where: responsesWhereCondition,
orderBy: {
createdAt: "desc",
},
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
cursor: cursor ? { id: cursor } : undefined,
});
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
// Parse response data
const parsedResponses = responses.map((r) => {
const responseData = routingFormResponseInDbSchema.parse(r.response);
return { ...r, response: responseData };
});
return {
total: totalResponses,
data: parsedResponses,
nextCursor: responses.length > (limit ?? 0) ? responses[responses.length - 1].id : undefined,
};
}
static async getRoutingFormFieldOptions({
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
routingFormId,
organizationId,
});
const routingForms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
fields: true,
},
});
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
return fields;
}
static async getFailedBookingsByRoutingFormGroup({
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
const teamConditions = [];
// @ts-expect-error it doest exist but TS isnt smart enough when its unmber or int filter
if (formsWhereCondition.teamId?.in) {
// @ts-expect-error it doest exist but TS isnt smart enough when its unmber or int filter
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
const whereClause = teamConditions.length
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
: Prisma.sql``;
// If youre at this point wondering what this does. This groups the responses by form and field and counts the number of responses for each option that don't have a booking.
const result = await prisma.$queryRaw<
{
formId: string;
formName: string;
fieldId: string;
fieldLabel: string;
optionId: string;
optionLabel: string;
count: number;
}[]
>`
WITH form_fields AS (
SELECT
f.id as form_id,
f.name as form_name,
field->>'id' as field_id,
field->>'label' as field_label,
opt->>'id' as option_id,
opt->>'label' as option_label
FROM "App_RoutingForms_Form" f,
LATERAL jsonb_array_elements(f.fields) as field
LEFT JOIN LATERAL jsonb_array_elements(field->'options') as opt ON true
WHERE true
${whereClause}
),
response_stats AS (
SELECT
r."formId",
key as field_id,
CASE
WHEN jsonb_typeof(value->'value') = 'array' THEN
v.value_item
ELSE
value->>'value'
END as selected_option,
COUNT(DISTINCT r.id) as response_count
FROM "App_RoutingForms_FormResponse" r
CROSS JOIN jsonb_each(r.response::jsonb) as fields(key, value)
LEFT JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN jsonb_typeof(value->'value') = 'array'
THEN value->'value'
ELSE NULL
END
) as v(value_item) ON true
WHERE r."routedToBookingUid" IS NULL
GROUP BY r."formId", key, selected_option
)
SELECT
ff.form_id as "formId",
ff.form_name as "formName",
ff.field_id as "fieldId",
ff.field_label as "fieldLabel",
ff.option_id as "optionId",
ff.option_label as "optionLabel",
COALESCE(rs.response_count, 0)::integer as count
FROM form_fields ff
LEFT JOIN response_stats rs ON
rs."formId" = ff.form_id AND
rs.field_id = ff.field_id AND
rs.selected_option = ff.option_id
WHERE ff.option_id IS NOT NULL
ORDER BY count DESC`;
// First group by form and field
const groupedByFormAndField = result.reduce((acc, curr) => {
const formKey = curr.formName;
acc[formKey] = acc[formKey] || {};
const labelKey = curr.fieldLabel;
acc[formKey][labelKey] = acc[formKey][labelKey] || [];
acc[formKey][labelKey].push({
optionId: curr.optionId,
count: curr.count,
optionLabel: curr.optionLabel,
});
return acc;
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
// NOTE: totalCount represents the sum of all response counts across all fields and options for a form
// For example, if a form has 2 fields with 2 options each:
// Field1: Option1 (5 responses), Option2 (3 responses)
// Field2: Option1 (2 responses), Option2 (4 responses)
// Then totalCount = 5 + 3 + 2 + 4 = 14 total responses
const sortedEntries = Object.entries(groupedByFormAndField)
.map(([formName, fields]) => ({
formName,
fields,
totalCount: Object.values(fields)
.flat()
.reduce((sum, item) => sum + item.count, 0),
}))
.sort((a, b) => b.totalCount - a.totalCount);
// Convert back to original format
const sortedGroupedByFormAndField = sortedEntries.reduce((acc, { formName, fields }) => {
acc[formName] = fields;
return acc;
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
return sortedGroupedByFormAndField;
}
static async getRoutingFormHeaders({
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
const routingForms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
fields: true,
},
});
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
const headers = fields?.map((f) => {
return {
id: f.id,
label: f.label,
options: f.options,
};
});
return headers;
}
static async getRawData({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
bookingStatus,
fieldFilter,
take,
skip,
}: RoutingFormInsightsFilter & { take?: number; skip?: number }) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
// First get all forms and their fields to build a mapping
const forms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
fields: true,
},
});
// Create a mapping of form ID to fields
type FormFieldOption = {
label: string;
type: string;
options: Record<string, string>;
};
type FormFieldsMap = Record<string, Record<string, FormFieldOption>>;
const formFieldsMap = forms.reduce((acc, form) => {
const fields = routingFormFieldsSchema.parse(form.fields);
acc[form.id] =
fields?.reduce((fieldMap: Record<string, FormFieldOption>, field) => {
fieldMap[field.id] = {
label: field.label,
type: field.type,
options:
field.options?.reduce((optMap, opt) => {
if (opt.id !== null) {
optMap[opt.id] = opt.label;
}
return optMap;
}, {} as Record<string, string>) ?? {},
};
return fieldMap;
}, {}) || {};
return acc;
}, {} as FormFieldsMap);
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
form: formsWhereCondition,
};
const responses = await prisma.app_RoutingForms_FormResponse.findMany({
select: {
id: true,
response: true,
createdAt: true,
form: {
select: {
id: true,
name: true,
},
},
routedToBooking: {
select: {
uid: true,
status: true,
createdAt: true,
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
name: true,
timeZone: true,
},
},
user: {
select: {
name: true,
email: true,
},
},
},
},
},
where: responsesWhereCondition,
orderBy: {
createdAt: "desc",
},
take: take,
skip: skip,
});
// Transform the data into a flat structure suitable for CSV
return responses.map((response) => {
const parsedResponse = routingFormResponseInDbSchema.parse(response.response);
const formFields = formFieldsMap[response.form.id];
const flatResponse: Record<string, unknown> = {
"Response ID": response.id,
"Form Name": response.form.name,
"Submitted At": response.createdAt.toISOString(),
"Has Booking": !!response.routedToBooking,
"Booking Status": response.routedToBooking?.status || "NO_BOOKING",
"Booking Created At": response.routedToBooking?.createdAt?.toISOString() || "",
"Booking Start Time": response.routedToBooking?.startTime?.toISOString() || "",
"Booking End Time": response.routedToBooking?.endTime?.toISOString() || "",
"Attendee Name": response.routedToBooking?.attendees[0]?.name || "",
"Attendee Email": response.routedToBooking?.attendees[0]?.email || "",
"Attendee Timezone": response.routedToBooking?.attendees[0]?.timeZone || "",
"Routed To Name": response.routedToBooking?.user?.name || "",
"Routed To Email": response.routedToBooking?.user?.email || "",
};
// Add form fields as columns with their labels
Object.entries(parsedResponse).forEach(([fieldId, field]) => {
const fieldInfo = formFields[fieldId];
if (fieldInfo) {
const fieldLabel = fieldInfo.label;
if (Array.isArray(field.value)) {
// For multi-select fields, map the IDs to labels
const values = field.value.map((val) => fieldInfo.options[val] || val);
flatResponse[fieldLabel] = values.join(", ");
} else {
// For single-select fields, map the ID to label
flatResponse[fieldLabel] = fieldInfo.options[field.value] || field.value;
}
}
});
return flatResponse;
});
}
}
export { RoutingEventsInsights };
File diff suppressed because it is too large Load Diff
@@ -86,6 +86,19 @@ const navigation: NavigationItemType[] = [
name: "insights",
href: "/insights",
icon: "chart-bar",
isCurrent: ({ pathname: path, item }) => path?.startsWith(item.href) ?? false,
child: [
{
name: "bookings",
href: "/insights",
isCurrent: ({ pathname: path }) => path == "/insights" ?? false,
},
{
name: "routing",
href: "/insights/routing",
isCurrent: ({ pathname: path }) => path?.startsWith("/insights/routing") ?? false,
},
],
},
];
+3 -3
View File
@@ -23,7 +23,7 @@ export function UpgradeTip({
features: Array<{ icon: JSX.Element; title: string; description: string }>;
buttons?: JSX.Element;
/**Chldren renders when the user is in a team */
children: JSX.Element;
children: ReactNode;
isParentLoading?: ReactNode;
plan: "team" | "enterprise";
}) {
@@ -38,9 +38,9 @@ export function UpgradeTip({
const hasUnpublishedTeam = !!data?.[0];
if (plan === "team" && (hasTeamPlan || hasUnpublishedTeam)) return children;
if (plan === "team" && (hasTeamPlan || hasUnpublishedTeam)) return <>{children}</>;
if (plan === "enterprise" && hasEnterprisePlan) return children;
if (plan === "enterprise" && hasEnterprisePlan) return <>{children}</>;
if (isPending) return <>{isParentLoading}</>;
+30 -3
View File
@@ -2,9 +2,12 @@ import type { Table } from "@tanstack/react-table";
import type { UserTableUser } from "@calcom/features/users/components/UserTable/types";
export const downloadAsCsv = (csvRaw: string, filename: string) => {
export const downloadAsCsv = (data: string | Record<string, any>[], filename: string) => {
// If data is an array of objects, convert it to CSV string
const csvString = typeof data === "string" ? data : objectsToCsv(data);
// Create a Blob from the text data
const blob = new Blob([csvRaw], { type: "text/plain" });
const blob = new Blob([csvString], { type: "text/plain" });
// Create an Object URL for the Blob
const url = window.URL.createObjectURL(blob);
@@ -12,7 +15,7 @@ export const downloadAsCsv = (csvRaw: string, filename: string) => {
// Create a download link
const a = document.createElement("a");
a.href = url;
a.download = filename; // Specify the filename
a.download = filename;
// Simulate a click event to trigger the download
a.click();
@@ -21,6 +24,30 @@ export const downloadAsCsv = (csvRaw: string, filename: string) => {
window.URL.revokeObjectURL(url);
};
export const objectsToCsv = (data: Record<string, any>[]): string => {
if (!data.length) return "";
// Get headers from the first object
const headers = Object.keys(data[0]);
// Create CSV rows
const csvRows = [
// Header row
headers.map((header) => sanitizeValue(header)).join(","),
// Data rows
...data.map((row) =>
headers
.map((header) => {
const value = row[header];
return sanitizeValue(value?.toString() ?? "");
})
.join(",")
),
];
return csvRows.join("\n");
};
export const sanitizeValue = (value: string) => {
// handling three cases:
// 1. quotes - we need to double quotes for CSV
+14
View File
@@ -633,6 +633,20 @@ export async function seedRoutingFormResponses(
routedFromRoutingFormReponse: { connect: { id: response.id } },
},
});
// Create some responses without a booking
await prisma.app_RoutingForms_FormResponse.create({
data: {
formId: seededForm.id,
formFillerId: randomUUID(),
response: {
[seededForm.formFieldFilled.id]: {
label: "skills",
value: selectedSkills.map((opt) => opt.id),
},
},
},
});
}
console.log(`Created ${bookings.length} routing form responses`);
@@ -0,0 +1,105 @@
import { classNames } from "@calcom/lib";
import {
Button,
Popover,
PopoverTrigger,
PopoverContent,
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
Badge,
Icon,
} from "@calcom/ui";
export interface FilterOption {
value: string | number;
label: string;
icon?: React.ReactNode;
}
interface FilterSelectProps {
title: string;
options: FilterOption[];
selectedValue?: string | number | null;
onChange: (value: string | number | null) => void;
buttonIcon?: React.ReactNode;
placeholder?: string;
emptyText?: string;
testId?: string;
}
export function FilterSelect({
title,
options,
selectedValue,
onChange,
buttonIcon,
placeholder,
emptyText = "No results",
testId,
}: FilterSelectProps) {
const selectedOption = options.find((option) => option.value === selectedValue);
return (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" className="border-subtle rounded-md" data-testid={`${testId}-button`}>
{buttonIcon}
{title}
{selectedValue && (
<div className="ml-2 hidden space-x-1 md:flex">
<Badge color="gray" className="rounded-sm px-1 font-normal">
{selectedOption?.label}
</Badge>
</div>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={placeholder ?? title} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup data-testId={`${testId}-group`}>
{options.map((option, index) => {
const isSelected = selectedValue === option.value;
return (
<CommandItem
key={option.value}
onSelect={() => {
onChange(isSelected ? null : option.value);
}}
data-testId={`${testId}-group-option-${index}`}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className="h-4 w-4" />
</div>
{option.icon}
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
{selectedValue && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem onSelect={() => onChange(null)} className="justify-center text-center">
Clear filter
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
+1
View File
@@ -15,6 +15,7 @@ export { Button, LinkIconButton, buttonClasses } from "./components/button";
export type { ButtonBaseProps, ButtonProps } from "./components/button";
export { ButtonGroup } from "./components/buttonGroup";
export { EditableHeading } from "./components/editable-heading";
export { FilterSelect } from "./components/filter-select";
export {
Checkbox,
CheckboxField,
+1
View File
@@ -48,6 +48,7 @@
"./toast": "./components/toast/index.ts",
"./tooltip": "./components/tooltip/index.ts",
"./top-banner": "./components/top-banner/index.ts",
"./filter-select": "./components/filter-select/index.ts",
"./unpublished-entity/UnpublishedEntity": "./components/unpublished-entity/index.ts",
"./components/icon": "./components/icon/index.ts",
"./components/icon/SatSymbol": "./components/icon/SatSymbol.tsx",