From 7ce5886b4fed749f16074855a2bcd3cb29a659ae Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 15 Nov 2024 19:54:51 +0000 Subject: [PATCH] feat: insights routing forms (#17583) Co-authored-by: Joe Au-Yeung --- .../insights/insights-routing-view.tsx | 42 + apps/web/modules/insights/insights-view.tsx | 121 +- apps/web/modules/insights/layout.tsx | 69 + apps/web/pages/insights/routing/index.tsx | 12 + apps/web/playwright/insights.e2e.ts | 22 +- apps/web/public/static/locales/en/common.json | 14 + .../bookings/components/PeopleFilter.tsx | 5 +- .../components/FailedBookingsByField.tsx | 89 ++ .../components/RoutingFormResponsesTable.tsx | 370 ++++++ .../insights/components/RoutingKPICards.tsx | 118 ++ .../features/insights/components/index.ts | 3 + .../insights/context/FiltersProvider.tsx | 48 +- .../features/insights/context/provider.ts | 9 +- .../insights/filters/BookingStatusFilter.tsx | 49 + .../insights/filters/Download/Download.tsx | 57 + .../filters/Download/RoutingDownload.tsx | 87 ++ .../insights/filters/Download/index.tsx | 60 +- .../insights/filters/EventTypeList.tsx | 60 +- .../features/insights/filters/FilterType.tsx | 104 +- .../filters/RoutingFormFieldFilter.tsx | 63 + .../filters/RoutingFormFilterList.tsx | 50 + .../insights/filters/TeamAndSelfList.tsx | 75 +- .../insights/filters/UsersListInTeam.tsx | 82 +- packages/features/insights/filters/index.tsx | 33 +- .../insights/hooks/useDefaultRoutingForm.ts | 60 + .../insights/server/routing-events.ts | 640 ++++++++++ .../features/insights/server/trpc-router.ts | 1121 +++++++++-------- .../features/shell/navigation/Navigation.tsx | 13 + packages/features/tips/UpgradeTip.tsx | 6 +- packages/lib/csvUtils.ts | 33 +- packages/prisma/seed-utils.ts | 14 + .../ui/components/filter-select/index.tsx | 105 ++ packages/ui/index.ts | 1 + packages/ui/package.json | 1 + 34 files changed, 2764 insertions(+), 872 deletions(-) create mode 100644 apps/web/modules/insights/insights-routing-view.tsx create mode 100644 apps/web/modules/insights/layout.tsx create mode 100644 apps/web/pages/insights/routing/index.tsx create mode 100644 packages/features/insights/components/FailedBookingsByField.tsx create mode 100644 packages/features/insights/components/RoutingFormResponsesTable.tsx create mode 100644 packages/features/insights/components/RoutingKPICards.tsx create mode 100644 packages/features/insights/filters/BookingStatusFilter.tsx create mode 100644 packages/features/insights/filters/Download/Download.tsx create mode 100644 packages/features/insights/filters/Download/RoutingDownload.tsx create mode 100644 packages/features/insights/filters/RoutingFormFieldFilter.tsx create mode 100644 packages/features/insights/filters/RoutingFormFilterList.tsx create mode 100644 packages/features/insights/hooks/useDefaultRoutingForm.ts create mode 100644 packages/features/insights/server/routing-events.ts create mode 100644 packages/ui/components/filter-select/index.tsx diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx new file mode 100644 index 0000000000..24cd91896b --- /dev/null +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { + RoutingFormResponsesTable, + RoutingKPICards, + FailedBookingsByField, +} from "@calcom/features/insights/components"; +import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider"; +import { Filters } from "@calcom/features/insights/filters"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + +import InsightsLayout from "./layout"; + +export default function InsightsPage() { + const { t } = useLocale(); + + return ( + + + + +
+ + + + + + + + {t("looking_for_more_insights")}{" "} + + {" "} + {t("contact_support")} + + +
+
+
+ ); +} diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 266d941eea..4a51126350 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -14,107 +14,54 @@ import { } from "@calcom/features/insights/components"; import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider"; import { Filters } from "@calcom/features/insights/filters"; -import Shell from "@calcom/features/shell/Shell"; -import { UpgradeTip } from "@calcom/features/tips"; -import { WEBAPP_URL } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { trpc } from "@calcom/trpc"; -import { Button, ButtonGroup } from "@calcom/ui"; -import { Icon } from "@calcom/ui"; import type { getServerSideProps } from "@lib/insights/getServerSideProps"; import type { inferSSRProps } from "@lib/types/inferSSRProps"; +import InsightsLayout from "./layout"; + export type PageProps = inferSSRProps; export default function InsightsPage() { const { t } = useLocale(); - const { data: user } = trpc.viewer.me.useQuery(); - - const features = [ - { - icon: , - title: t("view_bookings_across"), - description: t("view_bookings_across_description"), - }, - { - icon: , - title: t("identify_booking_trends"), - description: t("identify_booking_trends_description"), - }, - { - icon: , - title: t("spot_popular_event_types"), - description: t("spot_popular_event_types_description"), - }, - ]; return ( -
- - - - - - -
- }> - {!user ? ( - <> - ) : ( - - + + + -
- +
+ - + -
- +
+ - -
-
- - -
- -
- - - -
- - {t("looking_for_more_insights")}{" "} - - {" "} - {t("contact_support")} - - -
- - )} - - -
+ +
+
+ + +
+ +
+ + + +
+ + {t("looking_for_more_insights")}{" "} + + {" "} + {t("contact_support")} + + + +
+
); } diff --git a/apps/web/modules/insights/layout.tsx b/apps/web/modules/insights/layout.tsx new file mode 100644 index 0000000000..c80710a76a --- /dev/null +++ b/apps/web/modules/insights/layout.tsx @@ -0,0 +1,69 @@ +"use client"; + +import Shell from "@calcom/features/shell/Shell"; +import { UpgradeTip } from "@calcom/features/tips"; +import { WEBAPP_URL } from "@calcom/lib/constants"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { Button, ButtonGroup } from "@calcom/ui"; +import { Icon } from "@calcom/ui"; + +import type { getServerSideProps } from "@lib/insights/getServerSideProps"; +import type { inferSSRProps } from "@lib/types/inferSSRProps"; + +export type PageProps = inferSSRProps; + +export default function InsightsLayout({ children }: { children: React.ReactNode }) { + const { t } = useLocale(); + const { data: user } = trpc.viewer.me.useQuery(); + + const features = [ + { + icon: , + title: t("view_bookings_across"), + description: t("view_bookings_across_description"), + }, + { + icon: , + title: t("identify_booking_trends"), + description: t("identify_booking_trends_description"), + }, + { + icon: , + title: t("spot_popular_event_types"), + description: t("spot_popular_event_types_description"), + }, + ]; + + return ( +
+ + + + + + +
+ }> + {!user ? null : children} + + + + ); +} diff --git a/apps/web/pages/insights/routing/index.tsx b/apps/web/pages/insights/routing/index.tsx new file mode 100644 index 0000000000..5ae85bea53 --- /dev/null +++ b/apps/web/pages/insights/routing/index.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { getServerSideProps } from "@lib/insights/getServerSideProps"; + +import PageWrapper from "@components/PageWrapper"; + +import InsightsPage from "~/insights/insights-routing-view"; + +const Page = () => ; +Page.PageWrapper = PageWrapper; +export default Page; +export { getServerSideProps }; diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index 8085db2025..dbdd3b694d 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -209,20 +209,24 @@ test.describe("Insights", async () => { await page.getByRole("button", { name: "User" }).click(); //
People
- await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click(); + // await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click(); + await page.waitForSelector('[data-testid="people-filter-button"]'); + await page.click('[data-testid="people-filter-button"]'); await page - .locator( - 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - ) - .nth(0) + // .locator( + // 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' + // ) + .locator('[data-testid="people-filter-group-option-0"]') + // .nth(0) .click(); await page - .locator( - 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' - ) - .nth(1) + // .locator( + // 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]' + // ) + .locator('[data-testid="people-filter-group-option-1"]') + // .nth(1) .click(); // press escape button to close the filter await page.keyboard.press("Escape"); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8e23e00f79..652b052920 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -410,6 +410,8 @@ "back_to_bookings": "Back to bookings", "free_to_pick_another_event_type": "Feel free to pick another event anytime.", "cancelled": "Canceled", + "no_booking": "No booking", + "awaiting_host": "Awaiting host", "cancellation_successful": "Cancellation successful", "really_cancel_booking": "Really cancel your booking?", "cannot_cancel_booking": "You cannot cancel this booking", @@ -1578,6 +1580,7 @@ "exchange_version_2013_SP1": "2013 SP1", "exchange_version_2015": "2015", "exchange_version_2016": "2016", + "routing": "Routing", "routing_forms_description": "Create forms to direct attendees to the correct destinations", "routing_forms_send_email_owner": "Send Email to Owner", "routing_forms_send_email_owner_description": "Sends an email to the owner when the form is submitted", @@ -2764,6 +2767,17 @@ "booking_start_date": "Booking start date", "booking_created_date": "Booking created date", "booking_reassigned_to_host": "Booking reassigned to {{host}}", + "routing_forms_created": "Routing Forms Created", + "routing_forms_total_responses": "Total Responses", + "routing_forms_total_responses_without_booking": "Total Responses Without Booking", + "routing_forms_total_responses_with_booking": "Total Responses With Booking", + "routing_form_insights": "No Bookings", + "routing_form_insights_form_name_header": "Form Name", + "routing_form_insights_booking_status_header": "Booking", + "routing_form_insights_response_no_booking": "No Booking", + "routing_form_insights_booked_by": "Booked by", + "routing_form_insights_created_at": "Created at", + "routing_form_insights_booking_status": "Booking Status", "access_denied": "Access Denied", "salesforce_route_to_owner": "Contact owner will be the Round Robin host if available", "salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)", diff --git a/packages/features/bookings/components/PeopleFilter.tsx b/packages/features/bookings/components/PeopleFilter.tsx index da8c0f8c7d..bf1588dbeb 100644 --- a/packages/features/bookings/components/PeopleFilter.tsx +++ b/packages/features/bookings/components/PeopleFilter.tsx @@ -80,12 +80,13 @@ export const PeopleFilter = () => { icon={} /> ))} -
+
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",