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