fix: stats on /insights/routing (#18985)

* fix: replace filter implementation on /insights/routing

* remove unused functions

* fix type error

* refactor: extract insights parameters

* remove FiltersProvider
This commit is contained in:
Eunjae Lee
2025-01-31 17:16:53 +01:00
committed by GitHub
parent 195e645f24
commit fdc8e084ea
16 changed files with 357 additions and 327 deletions
@@ -1,35 +1,38 @@
"use client";
import { DataTableProvider } from "@calcom/features/data-table";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
FailedBookingsByField,
RoutedToPerPeriod,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export default function InsightsRoutingFormResponsesPage() {
const { t } = useLocale();
return (
<FiltersProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable />
<DataTableProvider>
<InsightsOrgTeamsProvider>
<div className="mb-4 space-y-4">
<RoutingFormResponsesTable />
<RoutedToPerPeriod />
<RoutedToPerPeriod />
<FailedBookingsByField />
<FailedBookingsByField />
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</InsightsOrgTeamsProvider>
</DataTableProvider>
);
}
@@ -114,6 +114,19 @@ export function DataTable<TData, TValue>({
...rest.style,
}}
data-testid={testId ?? "data-table"}>
{/*
Invalidate left & right properties for <= sm screen size,
because we pin columns only for >= sm screen sizes.
*/}
<style jsx global>{`
@media (max-width: 640px) {
.data-table th,
.data-table td {
left: initial !important;
right: initial !important;
}
}
`}</style>
<div
ref={tableContainerRef}
onScroll={onScroll}
@@ -124,7 +137,7 @@ export function DataTable<TData, TValue>({
)}
style={{ gridArea: "body" }}>
<TableNew
className="grid border-0"
className="data-table grid border-0"
style={{
...columnSizingVars,
...(Boolean(enableColumnResizing) && { width: table.getTotalSize() }),
@@ -135,7 +148,6 @@ export function DataTable<TData, TValue>({
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
{headerGroup.headers.map((header) => {
const { column } = header;
const meta = column.columnDef.meta;
return (
<TableHead
key={header.id}
@@ -84,7 +84,7 @@ export function DataTableWrapper<TData, TValue>({
className={className}
containerClassName={containerClassName}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
{(ToolbarLeft || ToolbarRight) && (
{(ToolbarLeft || ToolbarRight || children) && (
<DataTableToolbar.Root>
<div className="flex w-full flex-col gap-2">
<div className="flex w-full flex-wrap justify-between gap-2">
+16
View File
@@ -111,6 +111,20 @@ export const numberFilter = (cellValue: unknown, filterValue: NumberFilterValue)
return false;
};
export const dateRangeFilter = (cellValue: unknown, filterValue: DateRangeFilterValue) => {
if (!(cellValue instanceof Date)) {
return false;
}
if (!filterValue.data.startDate || !filterValue.data.endDate) {
return true;
}
const cellValueStr = cellValue.toISOString();
return filterValue.data.startDate <= cellValueStr && filterValue.data.endDate >= cellValueStr;
};
export const isNumberFilterValue = (filterValue: unknown): filterValue is NumberFilterValue => {
return ZNumberFilterValue.safeParse(filterValue).success;
};
@@ -128,6 +142,8 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) =>
return textFilter(cellValue, filterValue);
} else if (isNumberFilterValue(filterValue)) {
return numberFilter(cellValue, filterValue);
} else if (isDateRangeFilterValue(filterValue)) {
return dateRangeFilter(cellValue, filterValue);
}
return false;
};
@@ -4,7 +4,7 @@ import { classNames } from "@calcom/lib";
import { trpc } from "@calcom/trpc";
import { ToggleGroup } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { useInsightsParameters } from "../hooks/useInsightsParameters";
import { BarList } from "./tremor/BarList";
interface FormCardProps {
@@ -55,21 +55,13 @@ function FormCard({ formName, fields }: FormCardProps) {
}
export function FailedBookingsByField() {
const { filter } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll, initialConfig, selectedRoutingFormId } = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters();
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery({
userId,
teamId,
isAll,
routingFormId,
});
if (!data) return null;
@@ -2,7 +2,6 @@ import type { TFunction } from "next-i18next";
import { useQueryState } from "nuqs";
import { type ReactNode, useMemo, useRef, useState } from "react";
import type { Dayjs } from "@calcom/dayjs";
import { DataTableSkeleton } from "@calcom/features/data-table";
import classNames from "@calcom/lib/classNames";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
@@ -29,14 +28,15 @@ import {
TableRow,
} from "@calcom/ui/components/table/TableNew";
import { useFilterContext } from "../context/provider";
import { useInsightsParameters } from "../hooks/useInsightsParameters";
interface DownloadButtonProps {
teamId?: number;
userId?: number;
isAll?: boolean;
routingFormId?: string;
dateRange: [Dayjs, Dayjs, string | null];
startDate: string;
endDate: string;
selectedPeriod: string;
searchQuery?: string;
}
@@ -46,7 +46,8 @@ function DownloadButton({
teamId,
isAll,
routingFormId,
dateRange,
startDate,
endDate,
selectedPeriod,
searchQuery,
}: DownloadButtonProps) {
@@ -59,13 +60,13 @@ function DownloadButton({
try {
const result = await utils.viewer.insights.routedToPerPeriodCsv.fetch({
userId: userId ?? undefined,
teamId: teamId ?? undefined,
startDate: dateRange[0]?.toISOString() ?? "",
endDate: dateRange[1]?.toISOString() ?? "",
userId,
teamId,
startDate,
endDate,
period: selectedPeriod as "perDay" | "perWeek" | "perMonth",
isAll: !!isAll,
routingFormId: routingFormId ?? undefined,
isAll,
routingFormId,
searchQuery: searchQuery || undefined,
});
@@ -103,7 +104,8 @@ interface FormCardProps {
userId?: number;
isAll?: boolean;
routingFormId?: string;
dateRange: [Dayjs, Dayjs, string | null];
startDate: string;
endDate: string;
}
function FormCard({
@@ -116,7 +118,8 @@ function FormCard({
userId,
isAll,
routingFormId,
dateRange,
startDate,
endDate,
}: FormCardProps) {
const { t } = useLocale();
@@ -150,7 +153,8 @@ function FormCard({
teamId={teamId}
isAll={isAll}
routingFormId={routingFormId}
dateRange={dateRange}
startDate={startDate}
endDate={endDate}
selectedPeriod={selectedPeriod}
searchQuery={searchQuery}
/>
@@ -219,8 +223,7 @@ const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TF
export function RoutedToPerPeriod() {
const { t } = useLocale();
const { filter } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll, selectedRoutingFormId, dateRange } = filter;
const { userId, teamId, startDate, endDate, isAll, routingFormId } = useInsightsParameters();
const [selectedPeriod, setSelectedPeriod] = useQueryState("selectedPeriod", {
defaultValue: "perWeek",
});
@@ -239,13 +242,13 @@ export function RoutedToPerPeriod() {
const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =
trpc.viewer.insights.routedToPerPeriod.useInfiniteQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
startDate: dateRange[0]?.toISOString() ?? "",
endDate: dateRange[1]?.toISOString() ?? "",
userId,
teamId,
startDate,
endDate,
period: selectedPeriod as "perDay" | "perWeek" | "perMonth",
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
isAll,
routingFormId,
searchQuery: searchQuery || undefined,
limit: 10,
},
@@ -260,7 +263,6 @@ export function RoutedToPerPeriod() {
periodCursor: lastPage.periodStats.nextCursor,
};
},
enabled: !!dateRange[0] && !!dateRange[1],
}
);
@@ -334,11 +336,12 @@ export function RoutedToPerPeriod() {
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
userId={selectedUserId ?? undefined}
teamId={selectedTeamId ?? undefined}
userId={userId}
teamId={teamId}
isAll={isAll}
routingFormId={selectedRoutingFormId ?? undefined}
dateRange={dateRange}>
routingFormId={routingFormId}
startDate={startDate}
endDate={endDate}>
<div className="mt-6">
<DataTableSkeleton columns={5} columnWidths={[200, 120, 120, 120, 120]} />
</div>
@@ -377,11 +380,12 @@ export function RoutedToPerPeriod() {
onPeriodChange={setSelectedPeriod}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
userId={selectedUserId ?? undefined}
teamId={selectedTeamId ?? undefined}
userId={userId}
teamId={teamId}
isAll={isAll}
routingFormId={selectedRoutingFormId ?? undefined}
dateRange={dateRange}>
routingFormId={routingFormId}
startDate={startDate}
endDate={endDate}>
<div className="mt-6">
<div
className="scrollbar-thin border-subtle relaitve relative h-[80dvh] overflow-auto rounded-md border"
@@ -10,26 +10,17 @@ import {
} from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import startCase from "lodash/startCase";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useMemo, useId, useState } from "react";
import { useMemo, useId } from "react";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import {
DataTableWrapper,
DataTableFilters,
DataTableProvider,
DataTableSkeleton,
useColumnFilters,
useFilterValue,
multiSelectFilter,
textFilter,
dataTableFilter,
useDataTable,
ZDateRangeFilterValue,
ZMultiSelectFilterValue,
ZSingleSelectFilterValue,
DateRangeFilter,
ColumnFilterType,
type FilterableColumn,
@@ -52,8 +43,8 @@ import {
} from "@calcom/ui";
import { RoutingFormResponsesDownload } from "../filters/Download";
import type { OrgTeamsType } from "../filters/OrgTeamsFilter";
import { OrgTeamsFilter } from "../filters/OrgTeamsFilter";
import { useInsightsParameters } from "../hooks/useInsightsParameters";
import {
ZResponseMultipleValues,
ZResponseSingleValue,
@@ -254,41 +245,17 @@ function BookingAtCell({
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable() {
return (
<DataTableProvider>
<RoutingFormResponsesTableContent />
</DataTableProvider>
);
}
const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }> = {
id: "createdAt",
title: "createdAt",
type: ColumnFilterType.DATE_RANGE,
};
export function RoutingFormResponsesTableContent() {
export function RoutingFormResponsesTable() {
const { t } = useLocale();
const { copyToClipboard } = useCopy();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const [orgTeamsType, setOrgTeamsType] = useState<OrgTeamsType>(currentOrgId ? "org" : "yours");
const [selectedTeamId, setSelectedTeamId] = useState<number | undefined>();
const columnFilters = useColumnFilters({ exclude: ["bookingUserId", "formId", "createdAt"] });
const isAll = orgTeamsType === "org";
const teamId = orgTeamsType === "team" ? selectedTeamId : undefined;
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as
| number[]
| undefined;
const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined;
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
const startDate = createdAtRange?.startDate ?? dayjs().subtract(1, "week").startOf("day").toISOString();
const endDate = createdAtRange?.endDate ?? dayjs().endOf("day").toISOString();
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
const {
data: headers,
@@ -358,6 +325,10 @@ export function RoutingFormResponsesTableContent() {
filter: { type: ColumnFilterType.SINGLE_SELECT },
},
cell: () => null,
filterFn: (row, id, filterValue) => {
const cellValue = row.original.formId;
return dataTableFilter(cellValue, filterValue);
},
}),
columnHelper.accessor("bookingUserId", {
id: "bookingUserId",
@@ -368,6 +339,10 @@ export function RoutingFormResponsesTableContent() {
filter: { type: ColumnFilterType.MULTI_SELECT },
},
cell: () => null,
filterFn: (row, id, filterValue) => {
const cellValue = row.original.bookingUserId;
return dataTableFilter(cellValue, filterValue);
},
}),
columnHelper.accessor("bookingAttendees", {
id: "bookingAttendees",
@@ -461,7 +436,8 @@ export function RoutingFormResponsesTableContent() {
filter: { type: ColumnFilterType.MULTI_SELECT, icon: "circle" },
},
filterFn: (row, id, filterValue) => {
return multiSelectFilter(row.original.bookingStatusOrder, filterValue);
const cellValue = row.original.bookingStatusOrder;
return dataTableFilter(cellValue, filterValue);
},
sortingFn: (rowA, rowB) => {
const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null
@@ -497,6 +473,7 @@ export function RoutingFormResponsesTableContent() {
columnHelper.accessor("bookingAssignmentReason", {
id: "bookingAssignmentReason",
header: t("routing_form_insights_assignment_reason"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: { type: ColumnFilterType.TEXT },
@@ -507,18 +484,24 @@ export function RoutingFormResponsesTableContent() {
},
filterFn: (row, id, filterValue) => {
const reason = row.original.bookingAssignmentReason;
return textFilter(reason, filterValue);
return dataTableFilter(reason, filterValue);
},
}),
columnHelper.accessor("createdAt", {
id: "createdAt",
header: t("routing_form_insights_submitted_at"),
// exclude from "Filters" component
// because we already have a DateRangeFilter component
enableColumnFilter: false,
cell: (info) => (
<div className="whitespace-nowrap">
<Badge variant="gray">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</Badge>
</div>
),
filterFn: (row, id, filterValue) => {
const createdAt = row.original.createdAt;
return dataTableFilter(createdAt, filterValue);
},
}),
];
}, [isHeadersSuccess, headers, t, copyToClipboard]);
@@ -588,47 +571,32 @@ export function RoutingFormResponsesTableContent() {
}
return (
<div className="flex-1">
<DataTableWrapper
table={table}
isPending={isFetching && !data}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
ToolbarLeft={
<>
<OrgTeamsFilter
selectedType={orgTeamsType}
selectedTeamId={selectedTeamId}
onSelected={(params) => {
setOrgTeamsType(params.type);
setSelectedTeamId(params.teamId);
}}
/>
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
</>
}
ToolbarRight={
<>
<DateRangeFilter column={createdAtColumn} />
<RoutingFormResponsesDownload
startDate={startDate}
endDate={endDate}
teamId={teamId}
userId={userId}
isAll={isAll}
memberUserIds={memberUserIds}
routingFormId={routingFormId}
columnFilters={columnFilters}
sorting={sorting}
/>
<DataTableFilters.ColumnVisibilityButton table={table} />
</>
}>
<RoutingKPICards given={{ isAll, teamId, userId }} />
</DataTableWrapper>
</div>
<>
<div className="flex-1">
<DataTableWrapper
table={table}
isPending={isFetching && !data}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
ToolbarLeft={
<>
<OrgTeamsFilter />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.ClearFiltersButton exclude={["createdAt"]} />
</>
}
ToolbarRight={
<>
<DateRangeFilter column={createdAtColumn} />
<RoutingFormResponsesDownload sorting={sorting} />
<DataTableFilters.ColumnVisibilityButton table={table} />
</>
}>
<RoutingKPICards />
</DataTableWrapper>
</div>
</>
);
}
@@ -4,81 +4,56 @@ 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 { useInsightsParameters } from "../hooks/useInsightsParameters";
import { valueFormatter } from "../lib";
import { CardInsights } from "./Card";
export const RoutingKPICards = ({
given,
}: {
given?: { isAll: boolean; teamId: number | undefined; userId: number | undefined };
}) => {
export const RoutingKPICards = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const { teamId, startDate, endDate, userId, memberUserIds, isAll, routingFormId, columnFilters } =
useInsightsParameters();
const userId = given?.userId ?? filter.selectedUserId;
const isAll = given?.isAll ?? filter.isAll;
const teamId = given?.teamId ?? filter.selectedTeamId;
const {
dateRange,
selectedEventTypeId,
selectedMemberUserId,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady =
Boolean(given) || !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const { data, isSuccess, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
routingFormId: selectedRoutingFormId ?? undefined,
startDate,
endDate,
userId,
memberUserId: selectedMemberUserId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
memberUserIds,
isAll,
routingFormId,
columnFilters,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: initialConfigIsReady,
}
);
const categories: {
title: string;
index: "active" | "total_responses" | "total_responses_without_booking" | "total_responses_with_booking";
index: "total" | "totalWithoutBooking" | "totalWithBooking";
}[] = [
{
title: t("routing_forms_total_responses"),
index: "total_responses",
index: "total",
},
{
title: t("routing_forms_total_responses_without_booking"),
index: "total_responses_without_booking",
index: "totalWithoutBooking",
},
{
title: t("routing_forms_total_responses_with_booking"),
index: "total_responses_with_booking",
index: "totalWithBooking",
},
];
if (isPending) {
if (isPending || !data) {
return <LoadingKPICards categories={categories} />;
}
if (!isSuccess || !startDate || !endDate || (!teamId && !userId)) return null;
return (
<>
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
@@ -86,7 +61,6 @@ export const RoutingKPICards = ({
<CardInsights key={item.title}>
<Text className="text-default">{item.title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
{/* @ts-expect-error - theyre actually dynamic fields that we know the index of - but TS doesnt know that */}
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
</Flex>
</CardInsights>
@@ -0,0 +1,34 @@
"use client";
import { useSession } from "next-auth/react";
import { createContext, useState } from "react";
import type { OrgTeamsType } from "../filters/OrgTeamsFilter";
export type InsightsOrgTeamsContextType = {
orgTeamsType: OrgTeamsType;
setOrgTeamsType: (type: OrgTeamsType) => void;
selectedTeamId: number | undefined;
setSelectedTeamId: (id: number | undefined) => void;
};
export const InsightsOrgTeamsContext = createContext<InsightsOrgTeamsContextType | null>(null);
export function InsightsOrgTeamsProvider({ children }: { children: React.ReactNode }) {
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const [orgTeamsType, setOrgTeamsType] = useState<OrgTeamsType>(currentOrgId ? "org" : "yours");
const [selectedTeamId, setSelectedTeamId] = useState<number | undefined>();
return (
<InsightsOrgTeamsContext.Provider
value={{
orgTeamsType,
setOrgTeamsType,
selectedTeamId,
setSelectedTeamId,
}}>
{children}
</InsightsOrgTeamsContext.Provider>
);
}
@@ -1,7 +1,7 @@
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import type { ColumnFilter, SortingState } from "@calcom/features/data-table";
import type { SortingState } from "@calcom/features/data-table";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -9,17 +9,11 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
import { showToast } from "@calcom/ui";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
type Props = {
teamId: number | undefined;
userId: number | undefined;
memberUserIds: number[] | undefined;
routingFormId: string | undefined;
isAll: boolean;
startDate: string;
endDate: string;
columnFilters: ColumnFilter[];
sorting: SortingState;
};
@@ -28,18 +22,10 @@ type Batch = {
nextCursor: number | undefined;
};
export const RoutingFormResponsesDownload = ({
teamId,
userId,
memberUserIds,
routingFormId,
isAll,
startDate,
endDate,
columnFilters,
sorting,
}: Props) => {
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
const { t } = useLocale();
const { teamId, userId, memberUserIds, routingFormId, isAll, startDate, endDate, columnFilters } =
useInsightsParameters();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useUtils();
@@ -10,19 +10,14 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, Avatar, Divider, Icon, FilterSearchField } from "@calcom/ui";
import { useInsightsOrgTeams } from "../hooks/useInsightsOrgTeams";
export type OrgTeamsType = "org" | "team" | "yours";
// This is a clone of TeamAndSelfList, but without useFilterContext().
// It's meant to be used in the RoutingFormResponsesTable.
export const OrgTeamsFilter = ({
selectedType,
selectedTeamId,
onSelected,
}: {
selectedType: OrgTeamsType;
selectedTeamId?: number;
onSelected: (params: { type: OrgTeamsType; teamId?: number }) => void;
}) => {
export const OrgTeamsFilter = () => {
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = useInsightsOrgTeams();
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
@@ -41,12 +36,17 @@ export const OrgTeamsFilter = ({
},
});
const onSelected = (params: { type: OrgTeamsType; teamId?: number }) => {
setOrgTeamsType(params.type);
setSelectedTeamId(params.teamId);
};
const getPopoverProps = () => {
if (selectedType === "org") {
if (orgTeamsType === "org") {
return { text: t("all"), placeholder: undefined, imageUrl: data?.[0].logoUrl };
} else if (selectedType === "yours") {
} else if (orgTeamsType === "yours") {
return { text: t("yours"), placeholder: currentUserName, imageUrl: session.data?.user.avatarUrl };
} else if (selectedType === "team") {
} else if (orgTeamsType === "team") {
const selectedTeam = data?.find((item) => {
return item.id === selectedTeamId;
});
@@ -64,7 +64,7 @@ export const OrgTeamsFilter = ({
const isOrgDataAvailable = !!data && data.length > 0 && !!data[0].isOrg;
const PrefixComponent =
selectedType !== undefined && (imageUrl || placeholder) ? (
orgTeamsType !== undefined && (imageUrl || placeholder) ? (
<Avatar
alt={`${placeholder} logo`}
imageSrc={getPlaceholderAvatar(imageUrl, placeholder)}
@@ -87,7 +87,7 @@ export const OrgTeamsFilter = ({
<FilterCheckboxField
id="all"
icon={<Icon name="layers" className="h-4 w-4" />}
checked={selectedType === "org"}
checked={orgTeamsType === "org"}
onChange={(e) => {
onSelected({ type: "org", teamId: undefined });
}}
@@ -104,7 +104,7 @@ export const OrgTeamsFilter = ({
size="xsm"
/>
}
checked={selectedType === "yours"}
checked={orgTeamsType === "yours"}
onChange={(e) => {
if (e.target.checked) {
onSelected({ type: "yours", teamId: undefined });
@@ -125,7 +125,7 @@ export const OrgTeamsFilter = ({
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && selectedType === "team"}
checked={selectedTeamId === team.id && orgTeamsType === "team"}
onChange={(e) => {
if (e.target.checked) {
onSelected({ type: "team", teamId: team.id });
@@ -0,0 +1,26 @@
import { useSession } from "next-auth/react";
import { useContext } from "react";
import { InsightsOrgTeamsContext } from "../context/InsightsOrgTeamsProvider";
export function useInsightsOrgTeams() {
const context = useContext(InsightsOrgTeamsContext);
if (!context) {
throw new Error("useInsightsOrgTeams must be used within a InsightsOrgTeamsProvider");
}
const { orgTeamsType, selectedTeamId, setOrgTeamsType, setSelectedTeamId } = context;
const session = useSession();
const isAll = orgTeamsType === "org";
const teamId = orgTeamsType === "team" ? selectedTeamId : undefined;
const userId = orgTeamsType === "yours" ? session.data?.user.id : undefined;
return {
orgTeamsType,
setOrgTeamsType,
selectedTeamId,
setSelectedTeamId,
isAll,
teamId,
userId,
};
}
@@ -0,0 +1,42 @@
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import {
useFilterValue,
useColumnFilters,
ZMultiSelectFilterValue,
ZSingleSelectFilterValue,
ZDateRangeFilterValue,
} from "@calcom/features/data-table";
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
export function useInsightsParameters() {
const { isAll, teamId, userId } = useInsightsOrgTeams();
const memberUserIds = useFilterValue("bookingUserId", ZMultiSelectFilterValue)?.data as
| number[]
| undefined;
const routingFormId = useFilterValue("formId", ZSingleSelectFilterValue)?.data as string | undefined;
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
const startDate = useMemo(
() => createdAtRange?.startDate ?? dayjs().subtract(1, "week").startOf("day").toISOString(),
[createdAtRange?.startDate]
);
const endDate = useMemo(
() => createdAtRange?.endDate ?? dayjs().endOf("day").toISOString(),
[createdAtRange?.endDate]
);
const columnFilters = useColumnFilters({ exclude: ["bookingUserId", "formId", "createdAt"] });
return {
isAll,
teamId,
userId,
memberUserIds,
routingFormId,
startDate,
endDate,
columnFilters,
};
}
@@ -25,7 +25,7 @@ export const routingFormResponsesInputSchema = z.object({
cursor: z.number().optional(),
limit: z.number().optional(),
columnFilters: z.array(ZColumnFilter),
sorting: z.array(ZSorting),
sorting: z.array(ZSorting).optional(),
});
export type RoutingFormResponsesInput = z.infer<typeof routingFormResponsesInputSchema>;
@@ -93,91 +93,48 @@ class RoutingEventsInsights {
teamId,
startDate,
endDate,
isAll = false,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserId,
searchQuery,
bookingStatus,
fieldFilter,
}: Omit<RoutingFormInsightsFilter, "columnFilters">) {
// Get team IDs based on organization if applicable
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const whereClause = await this.getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
});
// 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(),
},
}),
...(memberUserId || bookingStatus || searchQuery
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(memberUserId && { userId: memberUserId }),
...(searchQuery && {
user: {
OR: [
{ email: { contains: searchQuery, mode: "insensitive" } },
{ name: { contains: searchQuery, mode: "insensitive" } },
],
},
}),
...(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,
const totalPromise = prisma.routingFormResponse.count({
where: whereClause,
});
// Get total responses
const totalResponsesPromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
// Get responses without booking
const responsesWithoutBookingPromise = prisma.app_RoutingForms_FormResponse.count({
const totalWithoutBookingPromise = prisma.routingFormResponse.count({
where: {
...responsesWhereCondition,
routedToBookingUid: null,
...whereClause,
bookingUid: null,
},
});
const [totalForms, totalResponses, responsesWithoutBooking] = await Promise.all([
totalFormsPromise,
totalResponsesPromise,
responsesWithoutBookingPromise,
]);
const [total, totalWithoutBooking] = await Promise.all([totalPromise, totalWithoutBookingPromise]);
return {
created: totalForms,
total_responses: totalResponses,
total_responses_without_booking: responsesWithoutBooking,
total_responses_with_booking: totalResponses - responsesWithoutBooking,
total,
totalWithoutBooking,
totalWithBooking: total - totalWithoutBooking,
};
}
@@ -213,19 +170,16 @@ class RoutingEventsInsights {
});
}
static async getRoutingFormPaginatedResponses({
static async getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
@@ -306,6 +260,38 @@ class RoutingEventsInsights {
}),
};
return whereClause;
}
static async getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const whereClause = await this.getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
memberUserIds,
columnFilters,
sorting,
});
const totalResponsePromise = prisma.routingFormResponse.count({
where: whereClause,
});
@@ -331,7 +317,7 @@ class RoutingEventsInsights {
createdAt: true,
},
where: whereClause,
orderBy: sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
cursor: cursor ? { id: cursor } : undefined,
});
@@ -1559,35 +1559,22 @@ export const insightsRouter = router({
});
}),
routingFormsByStatus: userBelongsToTeamProcedure
.input(
rawDataInputSchema.extend({
routingFormId: z.string().optional(),
bookingStatus: bookingStatusSchema,
fieldFilter: z
.object({
fieldId: z.string(),
optionId: z.string(),
})
.optional(),
})
)
.input(routingFormResponsesInputSchema)
.query(async ({ ctx, input }) => {
const { startDate, endDate } = input;
const stats = await RoutingEventsInsights.getRoutingFormStats({
teamId: input.teamId ?? null,
startDate,
endDate,
isAll: input.isAll ?? false,
return await RoutingEventsInsights.getRoutingFormStats({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId ?? null,
userId: input.userId ?? null,
memberUserId: input.memberUserId ?? null,
bookingStatus: input.bookingStatus ?? null,
fieldFilter: input.fieldFilter ?? null,
routingFormId: input.routingFormId,
cursor: input.cursor,
userId: input.userId,
memberUserIds: input.memberUserIds,
limit: input.limit,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
return stats;
}),
routingFormResponses: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema)