From fdc8e084ea692437a8a7bd0b10c5a6ab9f6c626b Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 31 Jan 2025 17:16:53 +0100 Subject: [PATCH] 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 --- .../insights/insights-routing-view.tsx | 39 +++--- .../data-table/components/DataTable.tsx | 16 ++- .../components/DataTableWrapper.tsx | 2 +- packages/features/data-table/lib/utils.ts | 16 +++ .../components/FailedBookingsByField.tsx | 24 ++-- .../insights/components/RoutedToPerPeriod.tsx | 64 +++++---- .../components/RoutingFormResponsesTable.tsx | 132 +++++++----------- .../insights/components/RoutingKPICards.tsx | 58 +++----- .../context/InsightsOrgTeamsProvider.tsx | 34 +++++ .../Download/RoutingFormResponsesDownload.tsx | 26 +--- .../insights/filters/OrgTeamsFilter.tsx | 32 ++--- .../insights/hooks/useInsightsOrgTeams.ts | 26 ++++ .../insights/hooks/useInsightsParameters.ts | 42 ++++++ .../insights/server/raw-data.schema.ts | 2 +- .../insights/server/routing-events.ts | 132 ++++++++---------- .../features/insights/server/trpc-router.ts | 39 ++---- 16 files changed, 357 insertions(+), 327 deletions(-) create mode 100644 packages/features/insights/context/InsightsOrgTeamsProvider.tsx create mode 100644 packages/features/insights/hooks/useInsightsOrgTeams.ts create mode 100644 packages/features/insights/hooks/useInsightsParameters.ts diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index 37e3d79d53..3d830a8076 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -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 ( - -
- + + +
+ - + - + - - {t("looking_for_more_insights")}{" "} - - {" "} - {t("contact_support")} - - -
- + + {t("looking_for_more_insights")}{" "} + + {" "} + {t("contact_support")} + + +
+ + ); } diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 9f5a8cff96..acc7c5770b 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -114,6 +114,19 @@ export function DataTable({ ...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={{ gridArea: "body" }}> ({ {headerGroup.headers.map((header) => { const { column } = header; - const meta = column.columnDef.meta; return ( ({ className={className} containerClassName={containerClassName} onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}> - {(ToolbarLeft || ToolbarRight) && ( + {(ToolbarLeft || ToolbarRight || children) && (
diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts index 3094604408..eb8511ff63 100644 --- a/packages/features/data-table/lib/utils.ts +++ b/packages/features/data-table/lib/utils.ts @@ -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; }; diff --git a/packages/features/insights/components/FailedBookingsByField.tsx b/packages/features/insights/components/FailedBookingsByField.tsx index b14c27d167..5280d218d8 100644 --- a/packages/features/insights/components/FailedBookingsByField.tsx +++ b/packages/features/insights/components/FailedBookingsByField.tsx @@ -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; diff --git a/packages/features/insights/components/RoutedToPerPeriod.tsx b/packages/features/insights/components/RoutedToPerPeriod.tsx index 8160b2f5ec..ada306c130 100644 --- a/packages/features/insights/components/RoutedToPerPeriod.tsx +++ b/packages/features/insights/components/RoutedToPerPeriod.tsx @@ -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}>
@@ -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}>
>; -export function RoutingFormResponsesTable() { - return ( - - - - ); -} - const createdAtColumn: Extract = { 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(currentOrgId ? "org" : "yours"); - const [selectedTeamId, setSelectedTeamId] = useState(); - - 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) => (
{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}
), + 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 ( -
- - { - setOrgTeamsType(params.type); - setSelectedTeamId(params.teamId); - }} - /> - - - - - } - ToolbarRight={ - <> - - - - - }> - - -
+ <> +
+ + + + + + + } + ToolbarRight={ + <> + + + + + }> + + +
+ ); } diff --git a/packages/features/insights/components/RoutingKPICards.tsx b/packages/features/insights/components/RoutingKPICards.tsx index 82b6a3cd88..1d8bab4d58 100644 --- a/packages/features/insights/components/RoutingKPICards.tsx +++ b/packages/features/insights/components/RoutingKPICards.tsx @@ -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 ; } - if (!isSuccess || !startDate || !endDate || (!teamId && !userId)) return null; - return ( <> @@ -86,7 +61,6 @@ export const RoutingKPICards = ({ {item.title} - {/* @ts-expect-error - theyre actually dynamic fields that we know the index of - but TS doesnt know that */} {valueFormatter(data[item.index])} diff --git a/packages/features/insights/context/InsightsOrgTeamsProvider.tsx b/packages/features/insights/context/InsightsOrgTeamsProvider.tsx new file mode 100644 index 0000000000..b0265b5df4 --- /dev/null +++ b/packages/features/insights/context/InsightsOrgTeamsProvider.tsx @@ -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(null); + +export function InsightsOrgTeamsProvider({ children }: { children: React.ReactNode }) { + const session = useSession(); + const currentOrgId = session.data?.user.org?.id; + const [orgTeamsType, setOrgTeamsType] = useState(currentOrgId ? "org" : "yours"); + const [selectedTeamId, setSelectedTeamId] = useState(); + + return ( + + {children} + + ); +} diff --git a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx index 042f45414a..d5b6f29fd3 100644 --- a/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx +++ b/packages/features/insights/filters/Download/RoutingFormResponsesDownload.tsx @@ -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(); diff --git a/packages/features/insights/filters/OrgTeamsFilter.tsx b/packages/features/insights/filters/OrgTeamsFilter.tsx index 9ab6d0a5f3..f718fd601a 100644 --- a/packages/features/insights/filters/OrgTeamsFilter.tsx +++ b/packages/features/insights/filters/OrgTeamsFilter.tsx @@ -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) ? ( } - 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 }); diff --git a/packages/features/insights/hooks/useInsightsOrgTeams.ts b/packages/features/insights/hooks/useInsightsOrgTeams.ts new file mode 100644 index 0000000000..0518a4d433 --- /dev/null +++ b/packages/features/insights/hooks/useInsightsOrgTeams.ts @@ -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, + }; +} diff --git a/packages/features/insights/hooks/useInsightsParameters.ts b/packages/features/insights/hooks/useInsightsParameters.ts new file mode 100644 index 0000000000..f31d408c7f --- /dev/null +++ b/packages/features/insights/hooks/useInsightsParameters.ts @@ -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, + }; +} diff --git a/packages/features/insights/server/raw-data.schema.ts b/packages/features/insights/server/raw-data.schema.ts index 80ae6e8447..83e7ee51ea 100644 --- a/packages/features/insights/server/raw-data.schema.ts +++ b/packages/features/insights/server/raw-data.schema.ts @@ -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; diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index 7b82b4cfb0..c262acd08e 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -93,91 +93,48 @@ class RoutingEventsInsights { teamId, startDate, endDate, - isAll = false, + isAll, organizationId, routingFormId, + cursor, + limit, userId, - memberUserId, - searchQuery, - bookingStatus, - fieldFilter, - }: Omit) { - // 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, }); diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index f0da4df6d5..7a3d553adc 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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)