+
diff --git a/packages/features/insights/components/FailedBookingsByField.tsx b/packages/features/insights/components/FailedBookingsByField.tsx
new file mode 100644
index 0000000000..7a6b285f0c
--- /dev/null
+++ b/packages/features/insights/components/FailedBookingsByField.tsx
@@ -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
;
+}
+
+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 (
+
+
+
+
{formName}
+
+
+
value && setSelectedField(value)}
+ />
+ {selectedFieldData && (
+
+ value.toString()}
+ className="mt-2"
+ />
+
+ )}
+
+
+
+ );
+}
+
+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 (
+
+
+
Failed Bookings By Field
+
+
+
+ {Object.entries(data).map(([formName, fields]) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx
new file mode 100644
index 0000000000..a326fd309c
--- /dev/null
+++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx
@@ -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 (
+
+
{
+ // Only add shadow if the scroll width is greater than 200px
+ if (!el) return;
+ const nextElement = el.nextElementSibling;
+ if (!nextElement) return;
+
+ if (el.scrollWidth > 200) {
+ nextElement.classList.remove("hidden");
+ } else {
+ nextElement.classList.add("hidden");
+ }
+ }}>
+ {children}
+
+
+
+ );
+}
+
+// Upper case the first letter of each word and replace underscores with spaces
+function bookingStatusToText(status: BookingStatus) {
+ return status
+ .toLowerCase()
+ .replace(/_/g, " ")
+ .replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+function BookedByCell({
+ attendees,
+ rowId,
+}: {
+ attendees: { email: string; timeZone: string }[] | undefined;
+ rowId: number;
+}) {
+ const cellId = useId();
+ if (!attendees || attendees.length === 0) return null;
+
+ return (
+
+ {attendees.map((attendee) => (
+
+
+ {attendee.email}
+
+
+ ))}
+
+ );
+}
+
+function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
+ const cellId = useId();
+ if (value.length === 0) return ;
+
+ return (
+
+ {value.length > 2 ? (
+ <>
+ {value.slice(0, 2).map((v: string, i: number) => (
+
+ {v}
+
+ ))}
+
+
+{value.length - 2}
+
+
+ {value.slice(2).map((v: string, i: number) => (
+
+ {v}
+
+ ))}
+
+
+
+ >
+ ) : (
+ value.map((v: string, i: number) => (
+
+ {v}
+
+ ))
+ )}
+
+ );
+}
+
+function 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 (
+
+
+ {t("no_booking")}
+
+
+ );
+ }
+
+ 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 (
+
+
+
+
{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}
+
+
+
+
+
+
+
{booking.user.name}
+
+ {booking.user.email}
+
+
+
+
+
+ Status:
+ {bookingStatusToText(booking.status)}
+
+
+
+
+ );
+}
+
+export function RoutingFormResponsesTable() {
+ const { t } = useLocale();
+ const { filter } = useFilterContext();
+ const tableContainerRef = useRef(null);
+ const { copyToClipboard, isCopied } = useCopy();
+
+ const {
+ dateRange,
+ selectedTeamId,
+ isAll,
+ initialConfig,
+ selectedRoutingFormId,
+ selectedMemberUserId,
+ selectedBookingStatus,
+ selectedRoutingFormFilter,
+ } = filter;
+ const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
+ const [startDate, endDate] = dateRange;
+
+ const { data: headers } = 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();
+
+ 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 ;
+ },
+ }),
+ ...(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 (
+
+
+
+ );
+ },
+ });
+ }) ?? []),
+
+ columnHelper.accessor("routedToBooking", {
+ id: "bookingStatus",
+ header: t("routing_form_insights_booking_status"),
+ size: 250,
+ cell: (info) => (
+
+
+
+ ),
+ }),
+ ],
+ [headers, t, copyToClipboard]
+ );
+
+ const table = useReactTable({
+ data: processedData,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ defaultColumn: {
+ size: 200,
+ },
+ });
+
+ const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
+ tableContainerRef,
+ fetchNextPage,
+ isFetching,
+ totalFetched,
+ totalDBRowCount
+ );
+
+ return (
+
+ {
+ if (hasNextPage) {
+ fetchMoreOnBottomReached(e.target as HTMLDivElement);
+ }
+ }}
+ isPending={isFetching && !data}
+ />
+
+ );
+}
diff --git a/packages/features/insights/components/RoutingKPICards.tsx b/packages/features/insights/components/RoutingKPICards.tsx
new file mode 100644
index 0000000000..a4e866dc74
--- /dev/null
+++ b/packages/features/insights/components/RoutingKPICards.tsx
@@ -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 ;
+ }
+
+ if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
+
+ return (
+ <>
+
+ {categories.map((item) => (
+
+ {item.title}
+
+ {/* @ts-expect-error - theyre actually dynamic fields that we know the index of - but TS doesnt know that */}
+ {valueFormatter(data[item.index])}
+
+
+ ))}
+
+ >
+ );
+};
+
+const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
+ const { categories } = props;
+ return (
+
+ {categories.map((item) => (
+
+
+
+ ))}
+
+ );
+};
diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts
index 1448932927..86acb20258 100644
--- a/packages/features/insights/components/index.ts
+++ b/packages/features/insights/components/index.ts
@@ -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";
diff --git a/packages/features/insights/context/FiltersProvider.tsx b/packages/features/insights/context/FiltersProvider.tsx
index ec557a9730..7920175660 100644
--- a/packages/features/insights/context/FiltersProvider.tsx
+++ b/packages/features/insights/context/FiltersProvider.tsx
@@ -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({
@@ -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();
diff --git a/packages/features/insights/context/provider.ts b/packages/features/insights/context/provider.ts
index 41134bd859..bf31124e87 100644
--- a/packages/features/insights/context/provider.ts
+++ b/packages/features/insights/context/provider.ts
@@ -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;
diff --git a/packages/features/insights/filters/BookingStatusFilter.tsx b/packages/features/insights/filters/BookingStatusFilter.tsx
new file mode 100644
index 0000000000..dafdd92752
--- /dev/null
+++ b/packages/features/insights/filters/BookingStatusFilter.tsx
@@ -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 (
+ {
+ setConfigFilters({ selectedBookingStatus: value as BookingStatus | "NO_BOOKING" });
+ }}
+ buttonIcon={}
+ placeholder={t("search")}
+ />
+ );
+};
diff --git a/packages/features/insights/filters/Download/Download.tsx b/packages/features/insights/filters/Download/Download.tsx
new file mode 100644
index 0000000000..58fb42e2a2
--- /dev/null
+++ b/packages/features/insights/filters/Download/Download.tsx
@@ -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 (
+
+
+
+
+
+ handleDownloadClick(data)}>{t("as_csv")}
+
+
+ );
+};
+
+export { Download };
diff --git a/packages/features/insights/filters/Download/RoutingDownload.tsx b/packages/features/insights/filters/Download/RoutingDownload.tsx
new file mode 100644
index 0000000000..b4980c76a3
--- /dev/null
+++ b/packages/features/insights/filters/Download/RoutingDownload.tsx
@@ -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[], filename);
+ }
+ } catch (error) {
+ showToast(t("error_downloading_data"), "error");
+ } finally {
+ setIsDownloading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t("as_csv")}
+
+
+ );
+};
+
+export { RoutingDownload };
diff --git a/packages/features/insights/filters/Download/index.tsx b/packages/features/insights/filters/Download/index.tsx
index 3530ca097b..c7d2759b3e 100644
--- a/packages/features/insights/filters/Download/index.tsx
+++ b/packages/features/insights/filters/Download/index.tsx
@@ -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 (
-
-
-
-
-
- handleDownloadClick(data)}>{t("as_csv")}
-
-
- );
-};
-
-export { Download };
+export { Download } from "./Download";
+export { RoutingDownload } from "./RoutingDownload";
diff --git a/packages/features/insights/filters/EventTypeList.tsx b/packages/features/insights/filters/EventTypeList.tsx
index b4e8bf76fa..5d6355632e 100644
--- a/packages/features/insights/filters/EventTypeList.tsx
+++ b/packages/features/insights/filters/EventTypeList.tsx
@@ -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 (
-
-
- {filterOptions?.map((eventType) => (
-
- {
- 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}
- />
-
- ))}
- {filterOptions?.length === 0 && (
- {t("no_options_available")}
- )}
-
-
+ setConfigFilters({ selectedEventTypeId: value ? Number(value) : null })}
+ buttonIcon={}
+ emptyText={t("no_options_available")}
+ />
);
});
diff --git a/packages/features/insights/filters/FilterType.tsx b/packages/features/insights/filters/FilterType.tsx
index bc1c87984a..206add99f4 100644
--- a/packages/features/insights/filters/FilterType.tsx
+++ b/packages/features/insights/filters/FilterType.tsx
@@ -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 (
@@ -51,26 +104,29 @@ export const FilterType = () => {
- {t("add_filter")}
+ {selectedFilter?.length ? `${selectedFilter.length} ${t("filters")}` : t("add_filter")}
-
+
{filterOptions?.map((option) => (
{
- // 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">
{t(option.label)}
- {filterValue?.value === option.value && }
+ {selectedFilter?.includes(option.value) && }
diff --git a/packages/features/insights/filters/RoutingFormFieldFilter.tsx b/packages/features/insights/filters/RoutingFormFieldFilter.tsx
new file mode 100644
index 0000000000..b7c4707500
--- /dev/null
+++ b/packages/features/insights/filters/RoutingFormFieldFilter.tsx
@@ -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 (
+ <>
+ {
+ console.log("value", value);
+ if (!value) {
+ setConfigFilters({
+ selectedRoutingFormFilter: null,
+ });
+ } else {
+ setConfigFilters({
+ selectedRoutingFormFilter: {
+ fieldId,
+ optionId: value as string,
+ },
+ });
+ }
+ }}
+ buttonIcon={}
+ placeholder={t("search")}
+ />
+ >
+ );
+};
diff --git a/packages/features/insights/filters/RoutingFormFilterList.tsx b/packages/features/insights/filters/RoutingFormFilterList.tsx
new file mode 100644
index 0000000000..44a8cd25f1
--- /dev/null
+++ b/packages/features/insights/filters/RoutingFormFilterList.tsx
@@ -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 (
+ setConfigFilters({ selectedRoutingFormId: value as string })}
+ buttonIcon={}
+ />
+ );
+});
+
+RoutingFormFilterList.displayName = "RoutingFormFilterList";
diff --git a/packages/features/insights/filters/TeamAndSelfList.tsx b/packages/features/insights/filters/TeamAndSelfList.tsx
index 698f62c17c..ce302d2c12 100644
--- a/packages/features/insights/filters/TeamAndSelfList.tsx
+++ b/packages/features/insights/filters/TeamAndSelfList.tsx
@@ -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 = () => {
)}
- {data?.map((team) => (
- {
- 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 (
+ {
+ 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={
+
}
- }}
- icon={
-
- }
- />
- ))}
+ />
+ );
+ })}
({
+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: ,
});
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 (
-
-
- setSearchText(e.target.value)} placeholder={t("search")} />
- {filteredUserListOptions?.map((member) => (
- {
- if (e.target.checked) {
- setConfigFilters({
- selectedMemberUserId: member.value,
- });
- } else if (!e.target.checked) {
- setConfigFilters({
- selectedMemberUserId: undefined,
- });
- }
- }}
- icon={}
- />
- ))}
- {filteredUserListOptions?.length === 0 && (
- {t("no_options_available")}
- )}
-
-
+ setConfigFilters({ selectedMemberUserId: Number(value) })}
+ buttonIcon={}
+ placeholder={t("search")}
+ testId="people-filter"
+ />
);
};
diff --git a/packages/features/insights/filters/index.tsx b/packages/features/insights/filters/index.tsx
index 0cb29db884..62b0d8ed40 100644
--- a/packages/features/insights/filters/index.tsx
+++ b/packages/features/insights/filters/index.tsx
@@ -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 (
-
+
-
+ {showRoutingFilters ? (
+ <>
+
+
+ {routingFormFieldIds.map((fieldId) => {
+ if (fieldId) return ;
+ })}
+ >
+ ) : null}
+
+
@@ -73,7 +96,7 @@ export const Filters = () => {
*/}
-
+ {showRoutingFilters ? : }
diff --git a/packages/features/insights/hooks/useDefaultRoutingForm.ts b/packages/features/insights/hooks/useDefaultRoutingForm.ts
new file mode 100644
index 0000000000..8cf93c624a
--- /dev/null
+++ b/packages/features/insights/hooks/useDefaultRoutingForm.ts
@@ -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 };
+}
diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts
new file mode 100644
index 0000000000..c6089edbc0
--- /dev/null
+++ b/packages/features/insights/server/routing-events.ts
@@ -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>);
+
+ // 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>);
+
+ 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;
+ };
+
+ type FormFieldsMap = Record>;
+
+ const formFieldsMap = forms.reduce((acc, form) => {
+ const fields = routingFormFieldsSchema.parse(form.fields);
+ acc[form.id] =
+ fields?.reduce((fieldMap: Record, 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) ?? {},
+ };
+ 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 = {
+ "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 };
diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts
index 84df945667..206d97d5f2 100644
--- a/packages/features/insights/server/trpc-router.ts
+++ b/packages/features/insights/server/trpc-router.ts
@@ -6,12 +6,14 @@ import dayjs from "@calcom/dayjs";
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
import { randomString } from "@calcom/lib/random";
import type { readonlyPrisma } from "@calcom/prisma";
+import { BookingStatus } from "@calcom/prisma/enums";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { EventsInsights } from "./events";
+import { RoutingEventsInsights } from "./routing-events";
const UserBelongsToTeamInput = z.object({
teamId: z.coerce.number().optional().nullable(),
@@ -33,6 +35,8 @@ interface BuildBaseWhereConditionType {
ctx: BuildBaseWhereConditionCtxType;
}
+const bookingStatusSchema = z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).optional();
+
const buildBaseWhereCondition = async ({
teamId,
eventTypeId,
@@ -251,158 +255,141 @@ export interface IResultTeamList {
isOrg?: boolean;
}
+const BATCH_SIZE = 1000; // Adjust based on your needs
+
export const insightsRouter = router({
- eventsByStatus: userBelongsToTeamProcedure
- .input(
- z.object({
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
- userId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
- .query(async ({ ctx, input }) => {
- const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input;
- if (userId && userId !== ctx.user.id) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
+ eventsByStatus: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
+ const { teamId, startDate, endDate, eventTypeId, memberUserId, userId, isAll } = input;
+ if (userId && userId !== ctx.user.id) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
- const r = await buildBaseWhereCondition({
- teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
- ctx: {
- userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
- userOrganizationId: ctx.user.organizationId,
- insightsDb: ctx.insightsDb,
- },
- });
+ const r = await buildBaseWhereCondition({
+ teamId,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
+ ctx: {
+ userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
+ userOrganizationId: ctx.user.organizationId,
+ insightsDb: ctx.insightsDb,
+ },
+ });
- const { whereCondition: whereConditional } = r;
+ const { whereCondition: whereConditional } = r;
- const baseWhereCondition = {
- ...whereConditional,
- createdAt: {
- gte: dayjs(startDate).startOf("day").toDate(),
- lte: dayjs(endDate).endOf("day").toDate(),
- },
- };
+ const baseWhereCondition = {
+ ...whereConditional,
+ createdAt: {
+ gte: dayjs(startDate).startOf("day").toDate(),
+ lte: dayjs(endDate).endOf("day").toDate(),
+ },
+ };
- const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day");
+ const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day");
- const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day");
- const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day");
+ const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day");
+ const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day");
- const lastPeriodBaseCondition = {
- ...whereConditional,
- createdAt: {
- gte: lastPeriodStartDate.toDate(),
- lte: lastPeriodEndDate.toDate(),
- },
- };
+ const lastPeriodBaseCondition = {
+ ...whereConditional,
+ createdAt: {
+ gte: lastPeriodStartDate.toDate(),
+ lte: lastPeriodEndDate.toDate(),
+ },
+ };
- const [
- countGroupedByStatus,
- totalRatingsAggregate,
- totalCSAT,
- lastPeriodCountGroupedByStatus,
- lastPeriodTotalRatingsAggregate,
- lastPeriodTotalCSAT,
- ] = await Promise.all([
- EventsInsights.countGroupedByStatus(baseWhereCondition),
- EventsInsights.getAverageRating(baseWhereCondition),
- EventsInsights.getTotalCSAT(baseWhereCondition),
- EventsInsights.countGroupedByStatus(lastPeriodBaseCondition),
- EventsInsights.getAverageRating(lastPeriodBaseCondition),
- EventsInsights.getTotalCSAT(lastPeriodBaseCondition),
- ]);
+ const [
+ countGroupedByStatus,
+ totalRatingsAggregate,
+ totalCSAT,
+ lastPeriodCountGroupedByStatus,
+ lastPeriodTotalRatingsAggregate,
+ lastPeriodTotalCSAT,
+ ] = await Promise.all([
+ EventsInsights.countGroupedByStatus(baseWhereCondition),
+ EventsInsights.getAverageRating(baseWhereCondition),
+ EventsInsights.getTotalCSAT(baseWhereCondition),
+ EventsInsights.countGroupedByStatus(lastPeriodBaseCondition),
+ EventsInsights.getAverageRating(lastPeriodBaseCondition),
+ EventsInsights.getTotalCSAT(lastPeriodBaseCondition),
+ ]);
- const baseBookingsCount = countGroupedByStatus["_all"];
- const totalCompleted = countGroupedByStatus["completed"];
- const totalRescheduled = countGroupedByStatus["rescheduled"];
- const totalCancelled = countGroupedByStatus["cancelled"];
- const totalNoShow = countGroupedByStatus["noShowHost"];
+ const baseBookingsCount = countGroupedByStatus["_all"];
+ const totalCompleted = countGroupedByStatus["completed"];
+ const totalRescheduled = countGroupedByStatus["rescheduled"];
+ const totalCancelled = countGroupedByStatus["cancelled"];
+ const totalNoShow = countGroupedByStatus["noShowHost"];
- const averageRating = totalRatingsAggregate._avg.rating
- ? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1))
- : 0;
+ const averageRating = totalRatingsAggregate._avg.rating
+ ? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1))
+ : 0;
- const lastPeriodBaseBookingsCount = lastPeriodCountGroupedByStatus["_all"];
- const lastPeriodTotalRescheduled = lastPeriodCountGroupedByStatus["rescheduled"];
- const lastPeriodTotalCancelled = lastPeriodCountGroupedByStatus["cancelled"];
- const lastPeriodTotalNoShow = lastPeriodCountGroupedByStatus["noShowHost"];
+ const lastPeriodBaseBookingsCount = lastPeriodCountGroupedByStatus["_all"];
+ const lastPeriodTotalRescheduled = lastPeriodCountGroupedByStatus["rescheduled"];
+ const lastPeriodTotalCancelled = lastPeriodCountGroupedByStatus["cancelled"];
+ const lastPeriodTotalNoShow = lastPeriodCountGroupedByStatus["noShowHost"];
- const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating
- ? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1))
- : 0;
+ const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating
+ ? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1))
+ : 0;
- const result = {
- empty: false,
- created: {
- count: baseBookingsCount,
- deltaPrevious: EventsInsights.getPercentage(baseBookingsCount, lastPeriodBaseBookingsCount),
- },
- completed: {
- count: totalCompleted,
- deltaPrevious: EventsInsights.getPercentage(
- baseBookingsCount - totalCancelled - totalRescheduled,
- lastPeriodBaseBookingsCount - lastPeriodTotalCancelled - lastPeriodTotalRescheduled
- ),
- },
- rescheduled: {
- count: totalRescheduled,
- deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled),
- },
- cancelled: {
- count: totalCancelled,
- deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled),
- },
- no_show: {
- count: totalNoShow,
- deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow),
- },
- rating: {
- count: averageRating,
- deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating),
- },
- csat: {
- count: totalCSAT,
- deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT),
- },
- previousRange: {
- startDate: lastPeriodStartDate.format("YYYY-MM-DD"),
- endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
- },
- };
- if (
- result.created.count === 0 &&
- result.completed.count === 0 &&
- result.rescheduled.count === 0 &&
- result.cancelled.count === 0 &&
- result.no_show.count === 0 &&
- result.rating.count === 0
- ) {
- return emptyResponseEventsByStatus;
- }
+ const result = {
+ empty: false,
+ created: {
+ count: baseBookingsCount,
+ deltaPrevious: EventsInsights.getPercentage(baseBookingsCount, lastPeriodBaseBookingsCount),
+ },
+ completed: {
+ count: totalCompleted,
+ deltaPrevious: EventsInsights.getPercentage(
+ baseBookingsCount - totalCancelled - totalRescheduled,
+ lastPeriodBaseBookingsCount - lastPeriodTotalCancelled - lastPeriodTotalRescheduled
+ ),
+ },
+ rescheduled: {
+ count: totalRescheduled,
+ deltaPrevious: EventsInsights.getPercentage(totalRescheduled, lastPeriodTotalRescheduled),
+ },
+ cancelled: {
+ count: totalCancelled,
+ deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled),
+ },
+ no_show: {
+ count: totalNoShow,
+ deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow),
+ },
+ rating: {
+ count: averageRating,
+ deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating),
+ },
+ csat: {
+ count: totalCSAT,
+ deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT),
+ },
+ previousRange: {
+ startDate: lastPeriodStartDate.format("YYYY-MM-DD"),
+ endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
+ },
+ };
+ if (
+ result.created.count === 0 &&
+ result.completed.count === 0 &&
+ result.rescheduled.count === 0 &&
+ result.cancelled.count === 0 &&
+ result.no_show.count === 0 &&
+ result.rating.count === 0
+ ) {
+ return emptyResponseEventsByStatus;
+ }
- return result;
- }),
+ return result;
+ }),
eventsTimeline: userBelongsToTeamProcedure
.input(
- z.object({
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
+ rawDataInputSchema.extend({
timeView: z.enum(["week", "month", "year", "day"]),
- userId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
})
)
.query(async ({ ctx, input }) => {
@@ -444,10 +431,10 @@ export const insightsRouter = router({
const r = await buildBaseWhereCondition({
teamId,
- eventTypeId,
- memberUserId,
- userId: selfUserId,
- isAll,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: selfUserId ?? undefined,
+ isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
@@ -511,251 +498,217 @@ export const insightsRouter = router({
return result;
}),
- popularEventTypes: userBelongsToTeamProcedure
- .input(
- z.object({
- memberUserId: z.coerce.number().optional(),
- eventTypeId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- userId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
- .query(async ({ ctx, input }) => {
- const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input;
+ popularEventTypes: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
+ const { teamId, startDate, endDate, memberUserId, userId, isAll, eventTypeId } = input;
- const user = ctx.user;
+ const user = ctx.user;
- if (userId && user?.id !== userId) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
- }
+ if (userId && user?.id !== userId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
- if (!teamId && !userId) {
- return [];
- }
+ if (!teamId && !userId) {
+ return [];
+ }
- const r = await buildBaseWhereCondition({
- teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
- ctx: {
- userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
- userOrganizationId: ctx.user.organizationId,
- insightsDb: ctx.insightsDb,
- },
- });
+ const r = await buildBaseWhereCondition({
+ teamId,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
+ ctx: {
+ userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
+ userOrganizationId: ctx.user.organizationId,
+ insightsDb: ctx.insightsDb,
+ },
+ });
- let { whereCondition: bookingWhere } = r;
+ let { whereCondition: bookingWhere } = r;
- bookingWhere = {
- ...bookingWhere,
- createdAt: {
- gte: dayjs(startDate).toISOString(),
- lte: dayjs(endDate).toISOString(),
- },
- };
+ bookingWhere = {
+ ...bookingWhere,
+ createdAt: {
+ gte: dayjs(startDate).toISOString(),
+ lte: dayjs(endDate).toISOString(),
+ },
+ };
- const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatus.groupBy({
- by: ["eventTypeId"],
- where: bookingWhere,
+ const bookingsFromSelected = await ctx.insightsDb.bookingTimeStatus.groupBy({
+ by: ["eventTypeId"],
+ where: bookingWhere,
+ _count: {
+ id: true,
+ },
+ orderBy: {
_count: {
- id: true,
+ id: "desc",
},
- orderBy: {
- _count: {
- id: "desc",
+ },
+ take: 10,
+ });
+
+ const eventTypeIds = bookingsFromSelected
+ .filter((booking) => typeof booking.eventTypeId === "number")
+ .map((booking) => booking.eventTypeId);
+
+ const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
+ id: {
+ in: eventTypeIds as number[],
+ },
+ };
+
+ const eventTypesFrom = await ctx.insightsDb.eventType.findMany({
+ select: {
+ id: true,
+ title: true,
+ teamId: true,
+ userId: true,
+ slug: true,
+ users: {
+ select: {
+ username: true,
},
},
- take: 10,
- });
-
- const eventTypeIds = bookingsFromSelected
- .filter((booking) => typeof booking.eventTypeId === "number")
- .map((booking) => booking.eventTypeId);
-
- const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
- id: {
- in: eventTypeIds as number[],
+ team: {
+ select: {
+ slug: true,
+ },
},
- };
+ },
+ where: eventTypeWhereConditional,
+ });
- const eventTypesFrom = await ctx.insightsDb.eventType.findMany({
+ const eventTypeHashMap: Map<
+ number,
+ Prisma.EventTypeGetPayload<{
select: {
- id: true,
- title: true,
- teamId: true,
- userId: true,
- slug: true,
+ id: true;
+ title: true;
+ teamId: true;
+ userId: true;
+ slug: true;
users: {
select: {
- username: true,
- },
- },
- team: {
- select: {
- slug: true,
- },
- },
- },
- where: eventTypeWhereConditional,
- });
-
- const eventTypeHashMap: Map<
- number,
- Prisma.EventTypeGetPayload<{
- select: {
- id: true;
- title: true;
- teamId: true;
- userId: true;
- slug: true;
- users: {
- select: {
- username: true;
- };
- };
- team: {
- select: {
- slug: true;
- };
+ username: true;
+ };
+ };
+ team: {
+ select: {
+ slug: true;
};
};
- }>
- > = new Map();
- eventTypesFrom.forEach((eventType) => {
- eventTypeHashMap.set(eventType.id, eventType);
- });
-
- const result = bookingsFromSelected.map((booking) => {
- const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
- if (!eventTypeSelected) {
- return {};
- }
-
- let eventSlug = "";
- if (eventTypeSelected.userId) {
- eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
- }
- if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
- eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
- }
- return {
- eventTypeId: booking.eventTypeId,
- eventTypeName: eventSlug,
- count: booking._count.id,
};
- });
+ }>
+ > = new Map();
+ eventTypesFrom.forEach((eventType) => {
+ eventTypeHashMap.set(eventType.id, eventType);
+ });
- return result;
- }),
- averageEventDuration: userBelongsToTeamProcedure
- .input(
- z.object({
- memberUserId: z.coerce.number().optional(),
- eventTypeId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- userId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
- .query(async ({ ctx, input }) => {
- const {
- teamId,
- startDate: startDateString,
- endDate: endDateString,
- memberUserId,
- userId,
- eventTypeId,
- isAll,
- } = input;
-
- if (userId && ctx.user?.id !== userId) {
- throw new TRPCError({ code: "UNAUTHORIZED" });
+ const result = bookingsFromSelected.map((booking) => {
+ const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
+ if (!eventTypeSelected) {
+ return {};
}
- if (!teamId && !userId) {
- return [];
+ let eventSlug = "";
+ if (eventTypeSelected.userId) {
+ eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
}
+ if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
+ eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
+ }
+ return {
+ eventTypeId: booking.eventTypeId,
+ eventTypeName: eventSlug,
+ count: booking._count.id,
+ };
+ });
- const startDate = dayjs.utc(startDateString).startOf("day");
- const endDate = dayjs.utc(endDateString).endOf("day");
+ return result;
+ }),
+ averageEventDuration: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
+ const {
+ teamId,
+ startDate: startDateString,
+ endDate: endDateString,
+ memberUserId,
+ userId,
+ eventTypeId,
+ isAll,
+ } = input;
- const { whereCondition: whereConditional } = await buildBaseWhereCondition({
- teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
- ctx: {
- userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
- userOrganizationId: ctx.user.organizationId,
- insightsDb: ctx.insightsDb,
+ if (userId && ctx.user?.id !== userId) {
+ throw new TRPCError({ code: "UNAUTHORIZED" });
+ }
+
+ if (!teamId && !userId) {
+ return [];
+ }
+
+ const startDate = dayjs.utc(startDateString).startOf("day");
+ const endDate = dayjs.utc(endDateString).endOf("day");
+
+ const { whereCondition: whereConditional } = await buildBaseWhereCondition({
+ teamId,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
+ ctx: {
+ userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
+ userOrganizationId: ctx.user.organizationId,
+ insightsDb: ctx.insightsDb,
+ },
+ });
+
+ const timeView = EventsInsights.getTimeView("week", startDate, endDate);
+ const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate);
+
+ if (!timeLine) {
+ return [];
+ }
+
+ const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
+
+ const allBookings = await ctx.insightsDb.bookingTimeStatus.findMany({
+ select: {
+ eventLength: true,
+ createdAt: true,
+ },
+ where: {
+ ...whereConditional,
+ createdAt: {
+ gte: startDate.toDate(),
+ lte: endDate.toDate(),
},
- });
+ },
+ });
- const timeView = EventsInsights.getTimeView("week", startDate, endDate);
- const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate);
+ const resultMap = new Map();
- if (!timeLine) {
- return [];
+ for (const date of timeLine) {
+ resultMap.set(dayjs(date).startOf(startOfEndOf).format("ll"), { totalDuration: 0, count: 0 });
+ }
+
+ for (const booking of allBookings) {
+ const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
+ if (resultMap.has(periodStart)) {
+ const current = resultMap.get(periodStart)!;
+ current.totalDuration += booking.eventLength || 0;
+ current.count += 1;
}
+ }
- const startOfEndOf = timeView === "year" ? "year" : timeView === "month" ? "month" : "week";
+ const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
+ Date: date,
+ Average: count > 0 ? totalDuration / count : 0,
+ }));
- const allBookings = await ctx.insightsDb.bookingTimeStatus.findMany({
- select: {
- eventLength: true,
- createdAt: true,
- },
- where: {
- ...whereConditional,
- createdAt: {
- gte: startDate.toDate(),
- lte: endDate.toDate(),
- },
- },
- });
-
- const resultMap = new Map();
-
- for (const date of timeLine) {
- resultMap.set(dayjs(date).startOf(startOfEndOf).format("ll"), { totalDuration: 0, count: 0 });
- }
-
- for (const booking of allBookings) {
- const periodStart = dayjs(booking.createdAt).startOf(startOfEndOf).format("ll");
- if (resultMap.has(periodStart)) {
- const current = resultMap.get(periodStart)!;
- current.totalDuration += booking.eventLength || 0;
- current.count += 1;
- }
- }
-
- const result = Array.from(resultMap.entries()).map(([date, { totalDuration, count }]) => ({
- Date: date,
- Average: count > 0 ? totalDuration / count : 0,
- }));
-
- return result;
- }),
+ return result;
+ }),
membersWithMostBookings: userBelongsToTeamProcedure
- .input(
- z.object({
- memberUserId: z.coerce.number().optional(),
- eventTypeId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- userId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
+ .input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
@@ -766,10 +719,10 @@ export const insightsRouter = router({
const r = await buildBaseWhereCondition({
teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
@@ -837,17 +790,7 @@ export const insightsRouter = router({
return result;
}),
membersWithLeastBookings: userBelongsToTeamProcedure
- .input(
- z.object({
- userId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
+ .input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
if (!teamId) {
@@ -857,10 +800,10 @@ export const insightsRouter = router({
const r = await buildBaseWhereCondition({
teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
@@ -1207,148 +1150,126 @@ export const insightsRouter = router({
return eventTypeResult;
}),
- recentRatings: userBelongsToTeamProcedure
- .input(
- z.object({
- userId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
- .query(async ({ ctx, input }) => {
- const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
- if (!teamId) {
- return [];
- }
- const user = ctx.user;
+ recentRatings: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
+ const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
+ if (!teamId) {
+ return [];
+ }
+ const user = ctx.user;
- const r = await buildBaseWhereCondition({
- teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
- ctx: {
- userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
- userOrganizationId: ctx.user.organizationId,
- insightsDb: ctx.insightsDb,
+ const r = await buildBaseWhereCondition({
+ teamId,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
+ ctx: {
+ userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
+ userOrganizationId: ctx.user.organizationId,
+ insightsDb: ctx.insightsDb,
+ },
+ });
+
+ let { whereCondition: bookingWhere } = r;
+
+ bookingWhere = {
+ ...bookingWhere,
+ teamId,
+ eventTypeId,
+ createdAt: {
+ gte: dayjs(startDate).startOf("day").toDate(),
+ lte: dayjs(endDate).endOf("day").toDate(),
+ },
+ ratingFeedback: { not: null },
+ };
+
+ if (isAll && user.isOwnerAdminOfParentTeam) {
+ delete bookingWhere.teamId;
+ const teamsFromOrg = await ctx.insightsDb.team.findMany({
+ where: {
+ parentId: user?.organizationId,
+ },
+ select: {
+ id: true,
},
});
-
- let { whereCondition: bookingWhere } = r;
-
- bookingWhere = {
- ...bookingWhere,
- teamId,
- eventTypeId,
- createdAt: {
- gte: dayjs(startDate).startOf("day").toDate(),
- lte: dayjs(endDate).endOf("day").toDate(),
- },
- ratingFeedback: { not: null },
- };
-
- if (isAll && user.isOwnerAdminOfParentTeam) {
- delete bookingWhere.teamId;
- const teamsFromOrg = await ctx.insightsDb.team.findMany({
- where: {
- parentId: user?.organizationId,
+ const usersFromTeam = await ctx.insightsDb.membership.findMany({
+ where: {
+ teamId: {
+ in: teamsFromOrg.map((t) => t.id),
},
- select: {
- id: true,
- },
- });
- const usersFromTeam = await ctx.insightsDb.membership.findMany({
- where: {
- teamId: {
- in: teamsFromOrg.map((t) => t.id),
- },
- accepted: true,
- },
- select: {
- userId: true,
- },
- });
-
- bookingWhere["OR"] = [
- ...(bookingWhere.OR || []),
- {
- teamId: {
- in: teamsFromOrg.map((t) => t.id),
- },
- isTeamBooking: true,
- },
- {
- userId: {
- in: usersFromTeam.map((u) => u.userId),
- },
- isTeamBooking: false,
- },
- ];
- }
-
- const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.findMany({
- where: bookingWhere,
- orderBy: {
- endTime: "desc",
+ accepted: true,
},
select: {
userId: true,
- rating: true,
- ratingFeedback: true,
},
- take: 10,
});
- const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
- if (!!booking.userId && !userIds.includes(booking.userId)) {
- userIds.push(booking.userId);
- }
- return userIds;
- }, []);
-
- if (userIds.length === 0) {
- return [];
- }
- const usersFromTeam = await ctx.insightsDb.user.findMany({
- where: {
- id: {
- in: userIds,
+ bookingWhere["OR"] = [
+ ...(bookingWhere.OR || []),
+ {
+ teamId: {
+ in: teamsFromOrg.map((t) => t.id),
},
+ isTeamBooking: true,
},
- select: userSelect,
- });
+ {
+ userId: {
+ in: usersFromTeam.map((u) => u.userId),
+ },
+ isTeamBooking: false,
+ },
+ ];
+ }
- const userHashMap = buildHashMapForUsers(usersFromTeam);
+ const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.findMany({
+ where: bookingWhere,
+ orderBy: {
+ endTime: "desc",
+ },
+ select: {
+ userId: true,
+ rating: true,
+ ratingFeedback: true,
+ },
+ take: 10,
+ });
- const result = bookingsFromTeam.map((booking) => ({
- userId: booking.userId,
- // We know with 100% certainty that userHashMap.get(...) will retrieve a user
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- user: userHashMap.get(booking.userId)!,
- emailMd5: md5(user?.email),
- rating: booking.rating,
- feedback: booking.ratingFeedback,
- }));
+ const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
+ if (!!booking.userId && !userIds.includes(booking.userId)) {
+ userIds.push(booking.userId);
+ }
+ return userIds;
+ }, []);
- return result;
- }),
+ if (userIds.length === 0) {
+ return [];
+ }
+ const usersFromTeam = await ctx.insightsDb.user.findMany({
+ where: {
+ id: {
+ in: userIds,
+ },
+ },
+ select: userSelect,
+ });
+
+ const userHashMap = buildHashMapForUsers(usersFromTeam);
+
+ const result = bookingsFromTeam.map((booking) => ({
+ userId: booking.userId,
+ // We know with 100% certainty that userHashMap.get(...) will retrieve a user
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ user: userHashMap.get(booking.userId)!,
+ emailMd5: md5(user?.email),
+ rating: booking.rating,
+ feedback: booking.ratingFeedback,
+ }));
+
+ return result;
+ }),
membersWithMostNoShow: userBelongsToTeamProcedure
- .input(
- z.object({
- userId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
+ .input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input;
if (!teamId) {
@@ -1357,10 +1278,10 @@ export const insightsRouter = router({
const user = ctx.user;
const r = await buildBaseWhereCondition({
teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
@@ -1427,17 +1348,7 @@ export const insightsRouter = router({
return result;
}),
membersWithHighestRatings: userBelongsToTeamProcedure
- .input(
- z.object({
- userId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
+ .input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input;
if (!teamId) {
@@ -1447,10 +1358,10 @@ export const insightsRouter = router({
const r = await buildBaseWhereCondition({
teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
@@ -1516,17 +1427,7 @@ export const insightsRouter = router({
return result;
}),
membersWithLowestRatings: userBelongsToTeamProcedure
- .input(
- z.object({
- userId: z.coerce.number().optional(),
- memberUserId: z.coerce.number().optional(),
- teamId: z.coerce.number().nullish(),
- startDate: z.string(),
- endDate: z.string(),
- eventTypeId: z.coerce.number().optional(),
- isAll: z.boolean().optional(),
- })
- )
+ .input(rawDataInputSchema)
.query(async ({ ctx, input }) => {
const { teamId, startDate, endDate, eventTypeId, isAll, memberUserId, userId } = input;
if (!teamId) {
@@ -1536,10 +1437,10 @@ export const insightsRouter = router({
const r = await buildBaseWhereCondition({
teamId,
- eventTypeId,
- memberUserId,
- userId,
- isAll,
+ eventTypeId: eventTypeId ?? undefined,
+ memberUserId: memberUserId ?? undefined,
+ userId: userId ?? undefined,
+ isAll: isAll ?? false,
ctx: {
userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam,
userOrganizationId: ctx.user.organizationId,
@@ -1633,4 +1534,162 @@ export const insightsRouter = router({
}
return { data: "", filename: "" };
}),
+
+ getRoutingFormsForFilters: userBelongsToTeamProcedure
+ .input(z.object({ teamId: z.number().optional(), isAll: z.boolean() }))
+ .query(async ({ ctx, input }) => {
+ const { teamId, isAll } = input;
+ return await RoutingEventsInsights.getRoutingFormsForFilters({
+ teamId,
+ isAll,
+ organizationId: ctx.user.organizationId ?? undefined,
+ });
+ }),
+ routingFormsByStatus: userBelongsToTeamProcedure
+ .input(
+ rawDataInputSchema.extend({
+ routingFormId: z.string().optional(),
+ bookingStatus: bookingStatusSchema,
+ fieldFilter: z
+ .object({
+ fieldId: z.string(),
+ optionId: z.string(),
+ })
+ .optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { startDate, endDate } = input;
+
+ const stats = await RoutingEventsInsights.getRoutingFormStats({
+ teamId: input.teamId ?? null,
+ startDate,
+ endDate,
+ isAll: input.isAll ?? false,
+ organizationId: ctx.user.organizationId ?? null,
+ routingFormId: input.routingFormId ?? null,
+ userId: input.userId ?? null,
+ bookingStatus: input.bookingStatus ?? null,
+ fieldFilter: input.fieldFilter ?? null,
+ });
+
+ return stats;
+ }),
+ routingFormResponses: userBelongsToTeamProcedure
+ .input(
+ rawDataInputSchema.extend({
+ routingFormId: z.string().optional(),
+ cursor: z.number().optional(),
+ limit: z.number().optional(),
+ bookingStatus: bookingStatusSchema,
+ fieldFilter: z
+ .object({
+ fieldId: z.string(),
+ optionId: z.string(),
+ })
+ .optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { startDate, endDate } = input;
+ return await RoutingEventsInsights.getRoutingFormPaginatedResponses({
+ teamId: input.teamId ?? null,
+ startDate,
+ endDate,
+ isAll: input.isAll ?? false,
+ organizationId: ctx.user.organizationId ?? null,
+ routingFormId: input.routingFormId ?? null,
+ cursor: input.cursor,
+ userId: input.userId ?? null,
+ limit: input.limit,
+ bookingStatus: input.bookingStatus ?? null,
+ fieldFilter: input.fieldFilter ?? null,
+ });
+ }),
+ getRoutingFormFieldOptions: userBelongsToTeamProcedure
+ .input(
+ z.object({ teamId: z.number().optional(), isAll: z.boolean(), routingFormId: z.string().optional() })
+ )
+ .query(async ({ input, ctx }) => {
+ const options = await RoutingEventsInsights.getRoutingFormFieldOptions({
+ ...input,
+ organizationId: ctx.user.organizationId ?? null,
+ });
+ return options;
+ }),
+ failedBookingsByField: userBelongsToTeamProcedure
+ .input(
+ z.object({ teamId: z.number().optional(), isAll: z.boolean(), routingFormId: z.string().optional() })
+ )
+ .query(async ({ ctx, input }) => {
+ return await RoutingEventsInsights.getFailedBookingsByRoutingFormGroup({
+ ...input,
+ organizationId: ctx.user.organizationId ?? null,
+ });
+ }),
+ routingFormResponsesHeaders: userBelongsToTeamProcedure
+ .input(
+ z.object({
+ teamId: z.number().optional(),
+ isAll: z.boolean(),
+ routingFormId: z.string().optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ return await RoutingEventsInsights.getRoutingFormHeaders({
+ teamId: input.teamId ?? null,
+ isAll: input.isAll,
+ organizationId: ctx.user.organizationId ?? null,
+ routingFormId: input.routingFormId ?? null,
+ });
+ }),
+ rawRoutingData: userBelongsToTeamProcedure
+ .input(
+ rawDataInputSchema.extend({
+ routingFormId: z.string().optional(),
+ bookingStatus: bookingStatusSchema,
+ fieldFilter: z
+ .object({
+ fieldId: z.string(),
+ optionId: z.string(),
+ })
+ .optional(),
+ cursor: z.number().optional(),
+ })
+ )
+ .query(async ({ ctx, input }) => {
+ const { teamId, startDate, endDate, userId, isAll, routingFormId, bookingStatus, fieldFilter, cursor } =
+ input;
+
+ if (!teamId && !userId) {
+ return { data: [], hasMore: false, nextCursor: null };
+ }
+
+ try {
+ const csvData = await RoutingEventsInsights.getRawData({
+ teamId,
+ startDate,
+ endDate,
+ userId,
+ isAll: isAll ?? false,
+ organizationId: ctx.user.organizationId,
+ routingFormId,
+ bookingStatus,
+ fieldFilter,
+ take: BATCH_SIZE,
+ skip: cursor || 0,
+ });
+
+ const hasMore = csvData.length === BATCH_SIZE;
+ const nextCursor = hasMore ? (cursor || 0) + BATCH_SIZE : null;
+
+ return {
+ data: csvData,
+ hasMore,
+ nextCursor,
+ };
+ } catch (e) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
+ }
+ }),
});
diff --git a/packages/features/shell/navigation/Navigation.tsx b/packages/features/shell/navigation/Navigation.tsx
index 17d8412401..34bd2c1795 100644
--- a/packages/features/shell/navigation/Navigation.tsx
+++ b/packages/features/shell/navigation/Navigation.tsx
@@ -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,
+ },
+ ],
},
];
diff --git a/packages/features/tips/UpgradeTip.tsx b/packages/features/tips/UpgradeTip.tsx
index ede58754b5..e9d4fa5ae3 100644
--- a/packages/features/tips/UpgradeTip.tsx
+++ b/packages/features/tips/UpgradeTip.tsx
@@ -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}>;
diff --git a/packages/lib/csvUtils.ts b/packages/lib/csvUtils.ts
index 7d906d4432..e15e9e3b8b 100644
--- a/packages/lib/csvUtils.ts
+++ b/packages/lib/csvUtils.ts
@@ -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[], 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 => {
+ 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
diff --git a/packages/prisma/seed-utils.ts b/packages/prisma/seed-utils.ts
index e12efc4ca1..0a04f154f1 100644
--- a/packages/prisma/seed-utils.ts
+++ b/packages/prisma/seed-utils.ts
@@ -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`);
diff --git a/packages/ui/components/filter-select/index.tsx b/packages/ui/components/filter-select/index.tsx
new file mode 100644
index 0000000000..5a9be461e1
--- /dev/null
+++ b/packages/ui/components/filter-select/index.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {emptyText}
+
+ {options.map((option, index) => {
+ const isSelected = selectedValue === option.value;
+ return (
+ {
+ onChange(isSelected ? null : option.value);
+ }}
+ data-testId={`${testId}-group-option-${index}`}>
+
+
+
+ {option.icon}
+ {option.label}
+
+ );
+ })}
+
+ {selectedValue && (
+ <>
+
+
+ onChange(null)} className="justify-center text-center">
+ Clear filter
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/ui/index.ts b/packages/ui/index.ts
index 6deb373607..8c7c2ece4a 100644
--- a/packages/ui/index.ts
+++ b/packages/ui/index.ts
@@ -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,
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 47a5b73e38..98ec1b714a 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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",