feat: server side sorting for insights routing (#18113)
* feat: server side sorting for insights routing ^ Conflicts: ^ packages/prisma/playground.ts * fix download csv * fix hooks locations
This commit is contained in:
@@ -75,7 +75,7 @@ export function DataTable<TData, TValue>({
|
||||
const columnSizingVars = useColumnSizingVars({ table });
|
||||
|
||||
usePersistentColumnResizing({
|
||||
enabled: Boolean(enableColumnResizing),
|
||||
enabled: Boolean(enableColumnResizing && identifier),
|
||||
table,
|
||||
tableContainerRef,
|
||||
identifier,
|
||||
@@ -118,8 +118,10 @@ export function DataTable<TData, TValue>({
|
||||
width: `var(--header-${kebabCase(header?.id)}-size)`,
|
||||
}}
|
||||
className={classNames(
|
||||
"bg-subtle hover:bg-muted relative flex shrink-0 items-center",
|
||||
header.column.getCanSort() ? "cursor-pointer select-none" : "",
|
||||
"relative flex shrink-0 items-center",
|
||||
header.column.getCanSort()
|
||||
? "bg-subtle hover:bg-muted cursor-pointer select-none"
|
||||
: "",
|
||||
meta?.sticky && "top-0 z-20 sm:sticky"
|
||||
)}>
|
||||
<div
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import { Button, Input } from "@calcom/ui";
|
||||
|
||||
import { useColumnFilters } from "../lib/utils";
|
||||
import { useColumnFilters } from "../hooks";
|
||||
|
||||
interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useDataTable, useFilterValue } from "../../hooks";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { ZMultiSelectFilterValue } from "../../lib/types";
|
||||
import { useDataTable, useFilterValue } from "../../lib/utils";
|
||||
|
||||
export type MultiSelectFilterOptionsProps = {
|
||||
column: Extract<FilterableColumn, { type: "multi_select" }>;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Form, Input, Select, Button } from "@calcom/ui";
|
||||
|
||||
import { useFilterValue, useDataTable } from "../../hooks";
|
||||
import type { FilterableColumn, NumberFilterOperator } from "../../lib/types";
|
||||
import { ZNumberFilterValue } from "../../lib/types";
|
||||
import { useFilterValue, useDataTable } from "../../lib/utils";
|
||||
|
||||
export type NumberFilterOperatorOption = {
|
||||
label: string;
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useDataTable, useFilterValue } from "../../hooks";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
import { ZSingleSelectFilterValue } from "../../lib/types";
|
||||
import { useDataTable, useFilterValue } from "../../lib/utils";
|
||||
|
||||
export type SingleSelectFilterOptionsProps = {
|
||||
column: Extract<FilterableColumn, { type: "single_select" }>;
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useForm, Controller } from "react-hook-form";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Form, Input, Select, Button } from "@calcom/ui";
|
||||
|
||||
import { useFilterValue, useDataTable } from "../../hooks";
|
||||
import type { FilterableColumn, TextFilterOperator } from "../../lib/types";
|
||||
import { ZTextFilterValue } from "../../lib/types";
|
||||
import { useFilterValue, useDataTable } from "../../lib/utils";
|
||||
|
||||
export type TextFilterOperatorOption = {
|
||||
label: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { type Table } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
import { forwardRef, useState, useMemo, useCallback, Fragment } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
@@ -22,8 +24,8 @@ import {
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterableColumn, ExternalFilter } from "../../lib/types";
|
||||
import { convertToTitleCase, useDataTable } from "../../lib/utils";
|
||||
import { FilterOptions } from "./FilterOptions";
|
||||
|
||||
interface ColumnVisiblityProps<TData> {
|
||||
@@ -164,7 +166,7 @@ function AddFilterButtonComponent<TData>(
|
||||
key={column.id}
|
||||
onSelect={() => handleAddFilter(column.id)}
|
||||
className="px-4 py-2">
|
||||
{convertToTitleCase(column.title)}
|
||||
{startCase(column.title)}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
@@ -257,7 +259,7 @@ function ActiveFilters<TData>({ table, externalFilters }: ActiveFiltersProps<TDa
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary">
|
||||
<Icon name={icon} className="mr-2 h-4 w-4" />
|
||||
{convertToTitleCase(column.title)}
|
||||
{startCase(column.title)}
|
||||
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
export * from "./useDebouncedWidth";
|
||||
export * from "./useFetchMoreOnBottomReached";
|
||||
export * from "./useColumnSizingVars";
|
||||
export * from "./useFilterValue";
|
||||
export * from "./useColumnFilters";
|
||||
export * from "./useDataTable";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { ColumnFilter } from "../lib/types";
|
||||
import { ZFilterValue } from "../lib/types";
|
||||
import { isMultiSelectFilterValue } from "../lib/utils";
|
||||
import { useDataTable } from "./useDataTable";
|
||||
|
||||
export function useColumnFilters(): ColumnFilter[] {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
return (activeFilters || [])
|
||||
.filter(
|
||||
(filter) =>
|
||||
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
|
||||
)
|
||||
.map((filter) => {
|
||||
const parsedValue = ZFilterValue.safeParse(filter.v);
|
||||
if (!parsedValue.success) return null;
|
||||
return {
|
||||
id: filter.f,
|
||||
value: parsedValue.data,
|
||||
};
|
||||
})
|
||||
.filter((filter): filter is ColumnFilter => filter !== null)
|
||||
.filter((filter) => {
|
||||
// The empty arrays in `filtersSearchState` keep the filter UI component,
|
||||
// but we do not send them to the actual query.
|
||||
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
|
||||
if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [activeFilters]);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useContext } from "react";
|
||||
|
||||
import { DataTableContext } from "../lib/context";
|
||||
|
||||
export function useDataTable() {
|
||||
const context = useContext(DataTableContext);
|
||||
if (!context) {
|
||||
throw new Error("useDataTable must be used within a DataTableProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { useDataTable } from "./useDataTable";
|
||||
|
||||
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
|
||||
if (schema && value) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [activeFilters, columnId, schema]);
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export * from "./components";
|
||||
export * from "./lib/types";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/context";
|
||||
export * from "./lib/resizing";
|
||||
export * from "./hooks";
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { SortingState, OnChangeFn } from "@tanstack/react-table";
|
||||
import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs";
|
||||
import { createContext, useCallback, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import { type FilterValue, ZFilterValue } from "./types";
|
||||
import { type FilterValue, ZFilterValue, ZSorting } from "./types";
|
||||
|
||||
const dataTableFiltersSchema = z.object({
|
||||
const ZActiveFilter = z.object({
|
||||
f: z.string(),
|
||||
v: ZFilterValue.optional(),
|
||||
});
|
||||
|
||||
type ActiveFilter = z.infer<typeof dataTableFiltersSchema>;
|
||||
type ActiveFilter = z.infer<typeof ZActiveFilter>;
|
||||
|
||||
export type DataTableContextType = {
|
||||
activeFilters: ActiveFilter[];
|
||||
@@ -20,6 +21,9 @@ export type DataTableContextType = {
|
||||
updateFilter: (columnId: string, value: FilterValue) => void;
|
||||
removeFilter: (columnId: string) => void;
|
||||
|
||||
sorting: SortingState;
|
||||
setSorting: OnChangeFn<SortingState>;
|
||||
|
||||
displayedExternalFilters: string[];
|
||||
setDisplayedExternalFilters: Dispatch<SetStateAction<string[]>>;
|
||||
removeDisplayedExternalFilter: (key: string) => void;
|
||||
@@ -30,7 +34,11 @@ export const DataTableContext = createContext<DataTableContextType | null>(null)
|
||||
export function DataTableProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activeFilters, setActiveFilters] = useQueryState(
|
||||
"activeFilters",
|
||||
parseAsArrayOf(parseAsJson(dataTableFiltersSchema.parse)).withDefault([])
|
||||
parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault([])
|
||||
);
|
||||
const [sorting, setSorting] = useQueryState(
|
||||
"sorting",
|
||||
parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault([])
|
||||
);
|
||||
|
||||
const [displayedExternalFilters, setDisplayedExternalFilters] = useState<string[]>([]);
|
||||
@@ -70,6 +78,8 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) {
|
||||
clearAll,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
sorting,
|
||||
setSorting,
|
||||
displayedExternalFilters,
|
||||
setDisplayedExternalFilters,
|
||||
removeDisplayedExternalFilter,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilterValue } from "./types";
|
||||
import type { FilterValue, SortingState } from "./types";
|
||||
import {
|
||||
isSingleSelectFilterValue,
|
||||
isMultiSelectFilterValue,
|
||||
@@ -12,6 +12,14 @@ type makeWhereClauseProps = {
|
||||
json?: true | { path: string[] };
|
||||
};
|
||||
|
||||
export function makeOrderBy(sorting: SortingState) {
|
||||
if (!sorting || !sorting.length) return undefined;
|
||||
|
||||
return sorting.map((sort) => ({
|
||||
[sort.id]: sort.desc ? ("desc" as const) : ("asc" as const),
|
||||
}));
|
||||
}
|
||||
|
||||
export function makeWhereClause(props: makeWhereClauseProps) {
|
||||
const { columnName, filterValue } = props;
|
||||
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
|
||||
|
||||
@@ -24,7 +24,7 @@ export type SingleSelectFilterValue = z.infer<typeof ZSingleSelectFilterValue>;
|
||||
|
||||
export const ZMultiSelectFilterValue = z.object({
|
||||
type: z.literal("multi_select"),
|
||||
data: z.array(z.string()),
|
||||
data: z.union([z.string(), z.number()]).array(),
|
||||
});
|
||||
|
||||
export type MultiSelectFilterValue = z.infer<typeof ZMultiSelectFilterValue>;
|
||||
@@ -125,3 +125,10 @@ export type ExternalFilter = {
|
||||
titleKey: string;
|
||||
component: () => React.ReactNode;
|
||||
};
|
||||
|
||||
export const ZSorting = z.object({
|
||||
id: z.string(),
|
||||
desc: z.boolean(),
|
||||
});
|
||||
|
||||
export type SortingState = Array<z.infer<typeof ZSorting>>;
|
||||
|
||||
@@ -1,78 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useContext } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { DataTableContext } from "./context";
|
||||
import type {
|
||||
SingleSelectFilterValue,
|
||||
MultiSelectFilterValue,
|
||||
TextFilterValue,
|
||||
FilterValue,
|
||||
NumberFilterValue,
|
||||
ColumnFilter,
|
||||
} from "./types";
|
||||
import {
|
||||
ZFilterValue,
|
||||
ZNumberFilterValue,
|
||||
ZSingleSelectFilterValue,
|
||||
ZMultiSelectFilterValue,
|
||||
ZTextFilterValue,
|
||||
} from "./types";
|
||||
|
||||
export function useDataTable() {
|
||||
const context = useContext(DataTableContext);
|
||||
if (!context) {
|
||||
throw new Error("useDataTable must be used within a DataTableProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useFilterValue<T>(columnId: string, schema: z.ZodType<T>) {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
const value = activeFilters.find((filter) => filter.f === columnId)?.v;
|
||||
if (schema && value) {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}, [activeFilters, columnId, schema]);
|
||||
}
|
||||
|
||||
export function useColumnFilters(): ColumnFilter[] {
|
||||
const { activeFilters } = useDataTable();
|
||||
return useMemo(() => {
|
||||
return (activeFilters || [])
|
||||
.filter(
|
||||
(filter) =>
|
||||
typeof filter === "object" && filter && "f" in filter && "v" in filter && filter.v !== undefined
|
||||
)
|
||||
.map((filter) => {
|
||||
const parsedValue = ZFilterValue.safeParse(filter.v);
|
||||
if (!parsedValue.success) return null;
|
||||
return {
|
||||
id: filter.f,
|
||||
value: parsedValue.data,
|
||||
};
|
||||
})
|
||||
.filter((filter): filter is ColumnFilter => filter !== null)
|
||||
.filter((filter) => {
|
||||
// The empty arrays in `filtersSearchState` keep the filter UI component,
|
||||
// but we do not send them to the actual query.
|
||||
// Otherwise, `{ my_column_name: { in: []} }` would result in nothing being returned.
|
||||
if (isMultiSelectFilterValue(filter.value) && filter.value.data.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [activeFilters]);
|
||||
}
|
||||
|
||||
export const textFilter = (cellValue: unknown, filterValue: TextFilterValue) => {
|
||||
if (filterValue.data.operator === "isEmpty" && cellValue === undefined) {
|
||||
if (filterValue.data.operator === "isEmpty" && !cellValue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -106,13 +49,29 @@ export const isTextFilterValue = (filterValue: unknown): filterValue is TextFilt
|
||||
return ZTextFilterValue.safeParse(filterValue).success;
|
||||
};
|
||||
|
||||
const isAllString = (array: (string | number)[]): array is string[] => {
|
||||
return array.every((value) => typeof value === "string");
|
||||
};
|
||||
|
||||
const isAllNumber = (array: (string | number)[]): array is number[] => {
|
||||
return array.every((value) => typeof value === "number");
|
||||
};
|
||||
|
||||
export const multiSelectFilter = (cellValue: unknown | undefined, filterValue: MultiSelectFilterValue) => {
|
||||
const cellValueArray = Array.isArray(cellValue) ? cellValue : [cellValue];
|
||||
if (!cellValueArray.every((value) => typeof value === "string")) {
|
||||
return false;
|
||||
|
||||
const filterValueArray = filterValue.data;
|
||||
if (!filterValueArray || filterValueArray.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return filterValue.data.length === 0 ? true : cellValueArray.some((v) => filterValue.data.includes(v));
|
||||
if (isAllString(filterValueArray) && isAllString(cellValueArray)) {
|
||||
return cellValueArray.some((v) => filterValueArray.includes(v));
|
||||
} else if (isAllNumber(filterValueArray) && isAllNumber(cellValueArray)) {
|
||||
return cellValueArray.some((v) => filterValueArray.includes(v));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isMultiSelectFilterValue = (filterValue: unknown): filterValue is MultiSelectFilterValue => {
|
||||
@@ -166,7 +125,3 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) =>
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const convertToTitleCase = (str: string) => {
|
||||
return str.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
getSortedRowModel,
|
||||
createColumnHelper,
|
||||
} from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
import Link from "next/link";
|
||||
import { useRef, useMemo, useId } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { FilterValue, ExternalFilter } from "@calcom/features/data-table";
|
||||
import type { ExternalFilter } from "@calcom/features/data-table";
|
||||
import {
|
||||
DataTableProvider,
|
||||
DataTable,
|
||||
@@ -24,7 +26,6 @@ import {
|
||||
multiSelectFilter,
|
||||
textFilter,
|
||||
dataTableFilter,
|
||||
convertToTitleCase,
|
||||
useDataTable,
|
||||
} from "@calcom/features/data-table";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
@@ -47,33 +48,28 @@ import {
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { ClearFilters } from "../filters/ClearFilters";
|
||||
import { DateSelect } from "../filters/DateSelect";
|
||||
import { RoutingDownload } from "../filters/Download";
|
||||
import { RoutingFormResponsesDownload } from "../filters/Download";
|
||||
import { RoutingFormFilterList } from "../filters/RoutingFormFilterList";
|
||||
import { TeamAndSelfList } from "../filters/TeamAndSelfList";
|
||||
import { UserListInTeam } from "../filters/UserListInTeam";
|
||||
import {
|
||||
ZResponseMultipleValues,
|
||||
ZResponseSingleValue,
|
||||
ZResponseTextValue,
|
||||
ZResponseNumericValue,
|
||||
} from "../lib/types";
|
||||
import { RoutingKPICards } from "./RoutingKPICards";
|
||||
|
||||
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
|
||||
type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
|
||||
|
||||
type RoutingFormTableRow = {
|
||||
id: number;
|
||||
formName: string;
|
||||
createdAt: Date;
|
||||
routedToBooking: RoutingFormResponse["routedToBooking"];
|
||||
[key: string]: any;
|
||||
const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.ACCEPTED]: 1,
|
||||
[BookingStatus.PENDING]: 2,
|
||||
[BookingStatus.AWAITING_HOST]: 3,
|
||||
[BookingStatus.CANCELLED]: 4,
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
type FieldCellValue = { label: string; value: string };
|
||||
|
||||
const ZResponseValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZResponseValues = z.array(ZResponseValue);
|
||||
|
||||
type ResponseValues = FieldCellValue[];
|
||||
|
||||
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={classNames("group relative max-w-[200px]", className)}>
|
||||
@@ -110,7 +106,7 @@ function BookedByCell({
|
||||
attendees,
|
||||
rowId,
|
||||
}: {
|
||||
attendees: { email: string; timeZone: string }[] | undefined;
|
||||
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
@@ -120,8 +116,8 @@ function BookedByCell({
|
||||
<div className="flex min-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 variant="gray" className="whitespace-nowrap" title={attendee.email}>
|
||||
{attendee.name}
|
||||
</Badge>
|
||||
</CellWithOverflowX>
|
||||
))}
|
||||
@@ -129,7 +125,15 @@ function BookedByCell({
|
||||
);
|
||||
}
|
||||
|
||||
function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: number }) {
|
||||
function ResponseValueCell({
|
||||
optionMap,
|
||||
values,
|
||||
rowId,
|
||||
}: {
|
||||
optionMap: Record<string, string>;
|
||||
values: string[];
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
if (values.length === 0) return <div className="h-6 w-[200px]" />;
|
||||
|
||||
@@ -137,9 +141,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
|
||||
<CellWithOverflowX className="flex w-[200px] gap-1">
|
||||
{values.length > 2 ? (
|
||||
<>
|
||||
{values.slice(0, 2).map((value, i: number) => (
|
||||
{values.slice(0, 2).map((id: string, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{value.label}
|
||||
{optionMap[id] ?? id}
|
||||
</Badge>
|
||||
))}
|
||||
<HoverCard>
|
||||
@@ -149,9 +153,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent side="bottom" align="start" className="w-fit">
|
||||
<div className="flex flex-col gap-1">
|
||||
{values.slice(2).map((value, i: number) => (
|
||||
{values.slice(2).map((id: string, i: number) => (
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
||||
{value.label}
|
||||
{optionMap[id] ?? id}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -160,9 +164,9 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
|
||||
</HoverCard>
|
||||
</>
|
||||
) : (
|
||||
values.map((value, i: number) => (
|
||||
values.map((id: string, i: number) => (
|
||||
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
|
||||
{value.label}
|
||||
{optionMap[id] ?? id}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
@@ -170,12 +174,12 @@ function ResponseValueCell({ values, rowId }: { values: ResponseValues; rowId: n
|
||||
);
|
||||
}
|
||||
|
||||
function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedToBooking"] }) {
|
||||
function BookingStatusBadge({ bookingStatus }: { bookingStatus: BookingStatus | null }) {
|
||||
let badgeVariant: BadgeProps["variant"] = "success";
|
||||
|
||||
if (!booking) return null;
|
||||
if (!bookingStatus) return null;
|
||||
|
||||
switch (booking.status) {
|
||||
switch (bookingStatus) {
|
||||
case BookingStatus.REJECTED:
|
||||
case BookingStatus.AWAITING_HOST:
|
||||
case BookingStatus.PENDING:
|
||||
@@ -184,23 +188,23 @@ function BookingStatusBadge({ booking }: { booking: RoutingFormResponse["routedT
|
||||
break;
|
||||
}
|
||||
|
||||
return <Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>;
|
||||
return <Badge variant={badgeVariant}>{bookingStatusToText(bookingStatus)}</Badge>;
|
||||
}
|
||||
|
||||
function BookingAtCell({
|
||||
booking,
|
||||
row,
|
||||
rowId,
|
||||
copyToClipboard,
|
||||
t,
|
||||
}: {
|
||||
booking: RoutingFormResponse["routedToBooking"];
|
||||
row: RoutingFormTableRow;
|
||||
rowId: number;
|
||||
copyToClipboard: (text: string) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
|
||||
if (!booking || !booking.user) {
|
||||
if (!row.bookingUserId || !row.bookingCreatedAt) {
|
||||
return <div className="w-[250px]" />;
|
||||
}
|
||||
|
||||
@@ -208,9 +212,9 @@ function BookingAtCell({
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="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="gray">{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
<Avatar size="xs" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
|
||||
<Link href={`/booking/${row.bookingUid}`}>
|
||||
<Badge variant="gray">{dayjs(row.bookingCreatedAt).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
@@ -218,17 +222,17 @@ function BookingAtCell({
|
||||
<HoverCardContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
|
||||
<Avatar size="sm" imageSrc={row.bookingUserAvatarUrl ?? ""} alt={row.bookingUserName ?? ""} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{booking.user.name}</p>
|
||||
<p className="text-sm font-medium">{row.bookingUserName}</p>
|
||||
<p className="group/booking_status_email text-subtle flex items-center text-xs">
|
||||
<span className="truncate">{booking.user.email}</span>
|
||||
<span className="truncate">{row.bookingUserEmail}</span>
|
||||
<button
|
||||
className="invisible ml-2 group-hover/booking_status_email:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
copyToClipboard(booking.user?.email ?? "");
|
||||
copyToClipboard(row.bookingUserEmail ?? "");
|
||||
}}>
|
||||
<Icon name="copy" />
|
||||
</button>
|
||||
@@ -237,7 +241,7 @@ function BookingAtCell({
|
||||
</div>
|
||||
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
|
||||
<span>Status:</span>
|
||||
<BookingStatusBadge booking={booking} />
|
||||
<BookingStatusBadge bookingStatus={row.bookingStatus} />
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
@@ -274,41 +278,47 @@ export function RoutingFormResponsesTableContent({
|
||||
selectedRoutingFormId,
|
||||
selectedMemberUserId,
|
||||
selectedUserId,
|
||||
selectedBookingStatus,
|
||||
selectedRoutingFormFilter,
|
||||
} = filter;
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const columnFilters = useColumnFilters();
|
||||
|
||||
const teamId = selectedTeamId ?? undefined;
|
||||
const userId = selectedUserId ?? undefined;
|
||||
const memberUserId = selectedMemberUserId ?? undefined;
|
||||
const routingFormId = selectedRoutingFormId ?? undefined;
|
||||
|
||||
const {
|
||||
data: headers,
|
||||
isLoading: isHeadersLoading,
|
||||
isSuccess: isHeadersSuccess,
|
||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
|
||||
{
|
||||
userId: selectedUserId ?? undefined,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
userId,
|
||||
teamId,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
routingFormId,
|
||||
},
|
||||
{
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
|
||||
const { removeDisplayedExternalFilter, sorting, setSorting } = useDataTable();
|
||||
|
||||
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
|
||||
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
|
||||
{
|
||||
teamId: selectedTeamId,
|
||||
teamId,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
userId: selectedUserId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
userId,
|
||||
memberUserId,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
limit: 30,
|
||||
},
|
||||
{
|
||||
@@ -321,60 +331,10 @@ export function RoutingFormResponsesTableContent({
|
||||
}
|
||||
);
|
||||
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.data) ?? [], [data]);
|
||||
const totalDBRowCount = data?.pages?.[0]?.total ?? 0;
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (!isHeadersSuccess) return [];
|
||||
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 fieldHeader = (headers || []).find((h) => h.id === fieldId);
|
||||
|
||||
if (fieldHeader?.options) {
|
||||
if (Array.isArray(field.value)) {
|
||||
// Map the IDs to their corresponding labels for array values
|
||||
const labels = field.value
|
||||
.map((id) => {
|
||||
const option = fieldHeader.options?.find((opt) => opt.id === id);
|
||||
if (!option) {
|
||||
return undefined;
|
||||
}
|
||||
return { label: option.label, value: option.id };
|
||||
})
|
||||
.filter(Boolean);
|
||||
row[fieldId] = labels;
|
||||
} else {
|
||||
// Handle single value case
|
||||
const option = fieldHeader.options?.find((opt) => opt.id === field.value);
|
||||
if (option) {
|
||||
row[fieldId] = { label: option.label, value: option.id };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
row[fieldId] = field.value;
|
||||
}
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
}, [flatData, headers, isHeadersSuccess]);
|
||||
|
||||
const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.ACCEPTED]: 1,
|
||||
[BookingStatus.PENDING]: 2,
|
||||
[BookingStatus.AWAITING_HOST]: 3,
|
||||
[BookingStatus.CANCELLED]: 4,
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
return (data?.pages?.flatMap((page) => page.data) ?? []) as RoutingFormTableRow[];
|
||||
}, [data, isHeadersSuccess]);
|
||||
|
||||
const columnHelper = createColumnHelper<RoutingFormTableRow>();
|
||||
|
||||
@@ -382,16 +342,15 @@ export function RoutingFormResponsesTableContent({
|
||||
if (!isHeadersSuccess) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
id: "bookedBy",
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "bookingAttendees",
|
||||
header: t("routing_form_insights_booked_by"),
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const row = info.row.original;
|
||||
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
|
||||
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -416,26 +375,39 @@ export function RoutingFormResponsesTableContent({
|
||||
? "text"
|
||||
: "multi_select";
|
||||
|
||||
return columnHelper.accessor(fieldHeader.id, {
|
||||
const optionMap =
|
||||
fieldHeader.options?.reduce((acc, option) => {
|
||||
if (option.id) {
|
||||
acc[option.id] = option.label;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string>) ?? {};
|
||||
|
||||
return columnHelper.accessor(`response.${fieldHeader.id}`, {
|
||||
id: fieldHeader.id,
|
||||
header: convertToTitleCase(fieldHeader.label),
|
||||
header: startCase(fieldHeader.label),
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const values = info.getValue();
|
||||
if (isMultiSelect) {
|
||||
const result = ZResponseValues.safeParse(values);
|
||||
if (isMultiSelect || isSingleSelect) {
|
||||
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
|
||||
return (
|
||||
result.success && <ResponseValueCell values={result.data} rowId={info.row.original.id} />
|
||||
result.success && (
|
||||
<ResponseValueCell
|
||||
optionMap={optionMap}
|
||||
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
|
||||
rowId={info.row.original.id}
|
||||
/>
|
||||
)
|
||||
);
|
||||
} else if (isSingleSelect) {
|
||||
const result = ZResponseValue.safeParse(values);
|
||||
} else if (isText || isNumber) {
|
||||
const result = z.union([ZResponseTextValue, ZResponseNumericValue]).safeParse(values);
|
||||
return (
|
||||
result.success && <ResponseValueCell values={[result.data]} rowId={info.row.original.id} />
|
||||
);
|
||||
} else if (typeof values !== "object") {
|
||||
return (
|
||||
<div className="truncate">
|
||||
<span title={values}>{values}</span>
|
||||
</div>
|
||||
result.success && (
|
||||
<div className="truncate">
|
||||
<span title={String(result.data.value)}>{result.data.value}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
@@ -444,79 +416,76 @@ export function RoutingFormResponsesTableContent({
|
||||
meta: {
|
||||
filter: { type: filterType },
|
||||
},
|
||||
filterFn: (row, id, filterValue: FilterValue) => {
|
||||
let cellValue: unknown;
|
||||
|
||||
if (Array.isArray(fieldHeader.options)) {
|
||||
cellValue = Array.isArray(row.original[id])
|
||||
? row.original[id].map((item: FieldCellValue) => item.value)
|
||||
: row.original[id].value;
|
||||
} else {
|
||||
cellValue = row.original[id];
|
||||
}
|
||||
|
||||
filterFn: (row, id, filterValue) => {
|
||||
const cellValue = row.original.response[id].value;
|
||||
return dataTableFilter(cellValue, filterValue);
|
||||
},
|
||||
});
|
||||
}) ?? []),
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
id: "bookingStatus",
|
||||
columnHelper.accessor("bookingStatusOrder", {
|
||||
id: "bookingStatusOrder",
|
||||
header: t("routing_form_insights_booking_status"),
|
||||
sortDescFirst: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingStatusBadge booking={info.getValue()} />
|
||||
<BookingStatusBadge bookingStatus={info.row.original.bookingStatus} />
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
filter: { type: "multi_select", icon: "circle" },
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
return multiSelectFilter(row.original.routedToBooking?.status, filterValue);
|
||||
return multiSelectFilter(row.original.bookingStatusOrder, filterValue);
|
||||
},
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const statusA = rowA.original.routedToBooking?.status;
|
||||
const statusB = rowB.original.routedToBooking?.status;
|
||||
// Default to highest number (5) + 1 for null/undefined values to sort them last
|
||||
const orderA = statusA ? statusOrder[statusA] : 6;
|
||||
const orderB = statusB ? statusOrder[statusB] : 6;
|
||||
return orderA - orderB;
|
||||
const statusA = rowA.original.bookingStatusOrder ?? 6; // put it at the end if bookingStatusOrder is null
|
||||
const statusB = rowB.original.bookingStatusOrder ?? 6;
|
||||
return statusA - statusB;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
id: "bookingAt",
|
||||
columnHelper.accessor("bookingCreatedAt", {
|
||||
id: "bookingCreatedAt",
|
||||
header: t("routing_form_insights_booking_at"),
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
<BookingAtCell
|
||||
booking={info.getValue()}
|
||||
row={info.row.original}
|
||||
rowId={info.row.original.id}
|
||||
copyToClipboard={copyToClipboard}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
sortingFn: (rowA, rowB) => {
|
||||
const dateA = rowA.original.bookingCreatedAt;
|
||||
const dateB = rowB.original.bookingCreatedAt;
|
||||
if (!dateA && !dateB) return 0;
|
||||
if (!dateA) return -1;
|
||||
if (!dateB) return 1;
|
||||
if (!(dateA instanceof Date) || !(dateB instanceof Date)) return 0;
|
||||
|
||||
return dateA.getTime() - dateB.getTime();
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
id: "assignmentReason",
|
||||
columnHelper.accessor("bookingAssignmentReason", {
|
||||
id: "bookingAssignmentReason",
|
||||
header: t("routing_form_insights_assignment_reason"),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: { type: "text" },
|
||||
},
|
||||
cell: (info) => {
|
||||
const assignmentReason = info.getValue()?.assignmentReason;
|
||||
return (
|
||||
<div className="max-w-[250px]">
|
||||
{assignmentReason && assignmentReason.length > 0 ? assignmentReason[0].reasonString : ""}
|
||||
</div>
|
||||
);
|
||||
const assignmentReason = info.getValue();
|
||||
return <div className="max-w-[250px]">{assignmentReason}</div>;
|
||||
},
|
||||
filterFn: (row, id, filterValue) => {
|
||||
return textFilter(row.original.routedToBooking?.assignmentReason?.[0]?.reasonString, filterValue);
|
||||
const reason = row.original.bookingAssignmentReason;
|
||||
return textFilter(reason, filterValue);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "submittedAt",
|
||||
id: "createdAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
enableColumnFilter: false,
|
||||
cell: (info) => (
|
||||
@@ -538,8 +507,10 @@ export function RoutingFormResponsesTableContent({
|
||||
size: 150,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
getFacetedUniqueValues: (_, columnId) => () => {
|
||||
if (!headers) {
|
||||
return new Map();
|
||||
@@ -548,10 +519,13 @@ export function RoutingFormResponsesTableContent({
|
||||
const fieldHeader = headers.find((h) => h.id === columnId);
|
||||
if (fieldHeader?.options) {
|
||||
return new Map(fieldHeader.options.map((option) => [{ label: option.label, value: option.id }, 1]));
|
||||
} else if (columnId === "bookingStatus") {
|
||||
} else if (columnId === "bookingStatusOrder") {
|
||||
return new Map(
|
||||
Object.keys(BookingStatus).map((status) => [
|
||||
{ value: status, label: bookingStatusToText(status as BookingStatus) },
|
||||
Object.keys(statusOrder).map((status) => [
|
||||
{
|
||||
value: statusOrder[status as BookingStatus],
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
},
|
||||
1,
|
||||
])
|
||||
);
|
||||
@@ -567,8 +541,6 @@ export function RoutingFormResponsesTableContent({
|
||||
isFetching,
|
||||
});
|
||||
|
||||
const { removeDisplayedExternalFilter } = useDataTable();
|
||||
|
||||
const externalFilters = useMemo<ExternalFilter[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -610,7 +582,17 @@ export function RoutingFormResponsesTableContent({
|
||||
<ClearFilters />
|
||||
<div className="grow" />
|
||||
<DateSelect />
|
||||
<RoutingDownload />
|
||||
<RoutingFormResponsesDownload
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
teamId={teamId}
|
||||
userId={userId}
|
||||
isAll={isAll ?? false}
|
||||
memberUserId={memberUserId}
|
||||
routingFormId={routingFormId}
|
||||
columnFilters={columnFilters}
|
||||
sorting={sorting}
|
||||
/>
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
</div>
|
||||
<RoutingKPICards />
|
||||
|
||||
@@ -21,7 +21,7 @@ const RoutingDownload = () => {
|
||||
const { t } = useLocale();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const utils = trpc.useContext();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const fetchBatch = async (cursor: number | null = null) => {
|
||||
const result = await utils.viewer.insights.rawRoutingData.fetch({
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import type { ColumnFilter, SortingState } from "@calcom/features/data-table";
|
||||
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 RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
|
||||
|
||||
type Props = {
|
||||
teamId: number | undefined;
|
||||
userId: number | undefined;
|
||||
memberUserId: number | undefined;
|
||||
routingFormId: string | undefined;
|
||||
isAll: boolean;
|
||||
startDate: Dayjs;
|
||||
endDate: Dayjs;
|
||||
columnFilters: ColumnFilter[];
|
||||
sorting: SortingState;
|
||||
};
|
||||
|
||||
type Batch = {
|
||||
data: RoutingData[];
|
||||
nextCursor: number | undefined;
|
||||
};
|
||||
|
||||
export const RoutingFormResponsesDownload = ({
|
||||
teamId,
|
||||
userId,
|
||||
memberUserId,
|
||||
routingFormId,
|
||||
isAll,
|
||||
startDate,
|
||||
endDate,
|
||||
columnFilters,
|
||||
sorting,
|
||||
}: Props) => {
|
||||
const { t } = useLocale();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const fetchBatch = async (
|
||||
cursor: number | undefined = undefined
|
||||
): Promise<{
|
||||
data: RoutingData[];
|
||||
nextCursor: number | undefined;
|
||||
}> => {
|
||||
const { data, nextCursor } = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
||||
teamId,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
userId,
|
||||
memberUserId,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
cursor,
|
||||
});
|
||||
return {
|
||||
data,
|
||||
nextCursor,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDownloadClick = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
let allData: RoutingData[] = [];
|
||||
let hasMore = true;
|
||||
let cursor: number | undefined = undefined;
|
||||
|
||||
// Fetch data in batches until there's no more data
|
||||
while (hasMore) {
|
||||
const result: Batch = await fetchBatch(cursor);
|
||||
allData = [...allData, ...result.data];
|
||||
hasMore = result.nextCursor !== undefined;
|
||||
cursor = result.nextCursor;
|
||||
}
|
||||
|
||||
if (allData.length > 0) {
|
||||
const filename = `RoutingFormResponses-${startDate.format("YYYY-MM-DD")}-${endDate.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>
|
||||
);
|
||||
};
|
||||
@@ -1,2 +1,3 @@
|
||||
export { Download } from "./Download";
|
||||
export { RoutingDownload } from "./RoutingDownload";
|
||||
export { RoutingFormResponsesDownload } from "./RoutingFormResponsesDownload";
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZResponseMultipleValues = z.object({
|
||||
label: z.string(),
|
||||
value: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const ZResponseSingleValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const ZResponseTextValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
export const ZResponseNumericValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
|
||||
export const ZResponseValue = z.union([
|
||||
ZResponseMultipleValues,
|
||||
ZResponseSingleValue,
|
||||
ZResponseTextValue,
|
||||
ZResponseNumericValue,
|
||||
]);
|
||||
|
||||
export type ResponseValue = z.infer<typeof ZResponseValue>;
|
||||
|
||||
export const ZResponse = z.record(z.string(), ZResponseValue);
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import mapKeys from "lodash/mapKeys";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import startCase from "lodash/startCase";
|
||||
|
||||
import {
|
||||
zodFields as routingFormFieldsSchema,
|
||||
routingFormResponseInDbSchema,
|
||||
} from "@calcom/app-store/routing-forms/zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { ColumnFilter, TypedColumnFilter } from "@calcom/features/data-table";
|
||||
import { makeWhereClause } from "@calcom/features/data-table/lib/server";
|
||||
import type {
|
||||
ColumnFilter,
|
||||
TypedColumnFilter,
|
||||
ColumnFilterType,
|
||||
SortingState,
|
||||
} from "@calcom/features/data-table";
|
||||
import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server";
|
||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { type ResponseValue, ZResponse } from "../lib/types";
|
||||
|
||||
type RoutingFormInsightsTeamFilter = {
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
@@ -31,6 +42,8 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
|
||||
columnFilters: ColumnFilter[];
|
||||
};
|
||||
|
||||
type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
|
||||
|
||||
class RoutingEventsInsights {
|
||||
private static async getWhereForTeamOrAllTeams({
|
||||
userId,
|
||||
@@ -38,7 +51,7 @@ class RoutingEventsInsights {
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
}: RoutingFormInsightsTeamFilter) {
|
||||
}: RoutingFormInsightsTeamFilter): Promise<WhereForTeamOrAllTeams> {
|
||||
// Get team IDs based on organization if applicable
|
||||
let teamIds: number[] = [];
|
||||
if (isAll && organizationId) {
|
||||
@@ -56,7 +69,7 @@ class RoutingEventsInsights {
|
||||
}
|
||||
|
||||
// Base where condition for forms
|
||||
const formsWhereCondition: Prisma.App_RoutingForms_FormWhereInput = {
|
||||
const formsWhereCondition: WhereForTeamOrAllTeams = {
|
||||
...(teamIds.length > 0
|
||||
? {
|
||||
teamId: {
|
||||
@@ -211,7 +224,12 @@ class RoutingEventsInsights {
|
||||
userId,
|
||||
memberUserId,
|
||||
columnFilters,
|
||||
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & { cursor?: number; limit?: number }) {
|
||||
sorting,
|
||||
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & {
|
||||
sorting: SortingState;
|
||||
cursor?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -220,44 +238,57 @@ class RoutingEventsInsights {
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const bookingStatusFilter = columnFilters.find((filter) => filter.id === "bookingStatus");
|
||||
const assignmentReasonFilter = columnFilters.find((filter) => filter.id === "assignmentReason") as
|
||||
| TypedColumnFilter<"text">
|
||||
| undefined;
|
||||
const fieldFilters = columnFilters.filter(
|
||||
(filter) => filter.id !== "bookingStatus" && filter.id !== "assignmentReason"
|
||||
);
|
||||
|
||||
let bookingWhereInput: Prisma.BookingWhereInput = {};
|
||||
if (memberUserId) {
|
||||
bookingWhereInput.userId = memberUserId;
|
||||
}
|
||||
if (bookingStatusFilter) {
|
||||
bookingWhereInput = {
|
||||
...bookingWhereInput,
|
||||
...makeWhereClause({ columnName: "status", filterValue: bookingStatusFilter.value }),
|
||||
};
|
||||
}
|
||||
if (assignmentReasonFilter) {
|
||||
const operator = assignmentReasonFilter.value.data.operator;
|
||||
if (operator === "isEmpty") {
|
||||
bookingWhereInput.assignmentReason = { none: {} };
|
||||
} else if (operator === "isNotEmpty") {
|
||||
bookingWhereInput.assignmentReason = {
|
||||
some: {
|
||||
reasonString: {
|
||||
not: "",
|
||||
},
|
||||
const getLowercasedFilterValue = <TData extends ColumnFilterType>(filter: TypedColumnFilter<TData>) => {
|
||||
if (filter.value.type === "text") {
|
||||
return {
|
||||
...filter.value,
|
||||
data: {
|
||||
...filter.value.data,
|
||||
operand: filter.value.data.operand.toLowerCase(),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
bookingWhereInput.assignmentReason = {
|
||||
some: makeWhereClause({ columnName: "reasonString", filterValue: assignmentReasonFilter.value }),
|
||||
};
|
||||
}
|
||||
}
|
||||
return filter.value;
|
||||
};
|
||||
|
||||
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
|
||||
const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder");
|
||||
const bookingAssignmentReason = columnFilters.find(
|
||||
(filter) => filter.id === "bookingAssignmentReason"
|
||||
) as TypedColumnFilter<"text"> | undefined;
|
||||
const assignmentReasonValue = bookingAssignmentReason
|
||||
? getLowercasedFilterValue(bookingAssignmentReason)
|
||||
: undefined;
|
||||
|
||||
const responseFilters = columnFilters.filter(
|
||||
(filter) => filter.id !== "bookingStatusOrder" && filter.id !== "bookingAssignmentReason"
|
||||
);
|
||||
|
||||
const whereClause: Prisma.RoutingFormResponseWhereInput = {
|
||||
...(formsTeamWhereCondition.id !== undefined && {
|
||||
formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">,
|
||||
}),
|
||||
...(formsTeamWhereCondition.teamId !== undefined && {
|
||||
formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">,
|
||||
}),
|
||||
...(formsTeamWhereCondition.userId !== undefined && {
|
||||
formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">,
|
||||
}),
|
||||
|
||||
// bookingStatus
|
||||
...(bookingStatusOrder &&
|
||||
makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })),
|
||||
|
||||
// bookingAssignmentReason
|
||||
...(assignmentReasonValue &&
|
||||
makeWhereClause({
|
||||
columnName: "bookingAssignmentReasonLowercase",
|
||||
filterValue: assignmentReasonValue,
|
||||
})),
|
||||
|
||||
// memberUserId
|
||||
...(memberUserId && { bookingUserId: memberUserId }),
|
||||
|
||||
// createdAt
|
||||
...(startDate &&
|
||||
endDate && {
|
||||
createdAt: {
|
||||
@@ -265,77 +296,158 @@ class RoutingEventsInsights {
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
}),
|
||||
...(Object.keys(bookingWhereInput).length > 0 ? { routedToBooking: bookingWhereInput } : {}),
|
||||
form: formsTeamWhereCondition,
|
||||
|
||||
// AND clause
|
||||
...(responseFilters.length > 0 && {
|
||||
AND: responseFilters.map((fieldFilter) => {
|
||||
return makeWhereClause({
|
||||
columnName: "responseLowercase",
|
||||
filterValue: getLowercasedFilterValue(fieldFilter),
|
||||
json: { path: [fieldFilter.id, "value"] },
|
||||
});
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
if (fieldFilters.length > 0) {
|
||||
responsesWhereCondition.AND = fieldFilters.map((fieldFilter) => {
|
||||
// NOTE: We cannot perform case-insensitive search on `response` column,
|
||||
// until we normalize this table, use raw sql, or filter at the application level.
|
||||
return makeWhereClause({
|
||||
columnName: "response",
|
||||
filterValue: fieldFilter.value,
|
||||
json: { path: [fieldFilter.id, "value"] },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
|
||||
where: responsesWhereCondition,
|
||||
const totalResponsePromise = prisma.routingFormResponse.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
const responsesPromise = prisma.app_RoutingForms_FormResponse.findMany({
|
||||
const responsesPromise = prisma.routingFormResponse.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 },
|
||||
},
|
||||
assignmentReason: {
|
||||
select: { reasonString: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
formId: true,
|
||||
formName: true,
|
||||
bookingUid: true,
|
||||
bookingStatus: true,
|
||||
bookingStatusOrder: true,
|
||||
bookingCreatedAt: true,
|
||||
bookingAttendees: true,
|
||||
bookingUserId: true,
|
||||
bookingUserName: true,
|
||||
bookingUserEmail: true,
|
||||
bookingUserAvatarUrl: true,
|
||||
bookingAssignmentReason: true,
|
||||
bookingStartTime: true,
|
||||
bookingEndTime: true,
|
||||
createdAt: true,
|
||||
},
|
||||
where: responsesWhereCondition,
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
|
||||
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
});
|
||||
|
||||
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 };
|
||||
});
|
||||
const hasNextPage = responses.length > (limit ?? 0);
|
||||
const responsesToReturn = responses.slice(0, limit ? limit : responses.length);
|
||||
type Response = Omit<
|
||||
(typeof responsesToReturn)[number],
|
||||
"response" | "responseLowercase" | "bookingAttendees"
|
||||
> & {
|
||||
response: Record<string, ResponseValue>;
|
||||
responseLowercase: Record<string, ResponseValue>;
|
||||
bookingAttendees?: { name: string; email: string; timeZone: string }[];
|
||||
};
|
||||
|
||||
return {
|
||||
total: totalResponses,
|
||||
data: parsedResponses,
|
||||
nextCursor: responses.length > (limit ?? 0) ? responses[responses.length - 1].id : undefined,
|
||||
data: responsesToReturn as Response[],
|
||||
nextCursor: hasNextPage ? responsesToReturn[responsesToReturn.length - 1].id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
static async getRoutingFormPaginatedResponsesForDownload({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
limit,
|
||||
userId,
|
||||
memberUserId,
|
||||
columnFilters,
|
||||
sorting,
|
||||
}: Omit<RoutingFormInsightsFilter, "fieldFilter" | "bookingStatus"> & {
|
||||
sorting: SortingState;
|
||||
cursor?: number;
|
||||
limit?: number;
|
||||
}) {
|
||||
const headersPromise = this.getRoutingFormHeaders({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
});
|
||||
const dataPromise = this.getRoutingFormPaginatedResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
cursor,
|
||||
userId,
|
||||
memberUserId,
|
||||
limit,
|
||||
columnFilters,
|
||||
sorting,
|
||||
});
|
||||
|
||||
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
|
||||
|
||||
const dataWithFlatResponse = data.data.map((item) => {
|
||||
const { bookingAttendees } = item;
|
||||
const responseParseResult = ZResponse.safeParse(item.response);
|
||||
const response = responseParseResult.success ? responseParseResult.data : {};
|
||||
|
||||
const fields = (headers || []).reduce((acc, header) => {
|
||||
const id = header.id;
|
||||
if (!response[id]) {
|
||||
acc[header.label] = "";
|
||||
return acc;
|
||||
}
|
||||
if (header.type === "select") {
|
||||
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label;
|
||||
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) {
|
||||
acc[header.label] = (response[id].value as string[])
|
||||
.map((value) => header.options?.find((option) => option.id === value)?.label)
|
||||
.filter((label): label is string => label !== undefined)
|
||||
.sort()
|
||||
.join(", ");
|
||||
} else {
|
||||
acc[header.label] = response[id].value as string;
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
|
||||
return {
|
||||
"Response ID": item.id,
|
||||
"Form Name": item.formName,
|
||||
"Submitted At": item.createdAt.toISOString(),
|
||||
"Has Booking": item.bookingUid !== null,
|
||||
"Booking Status": item.bookingStatus || "NO_BOOKING",
|
||||
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
|
||||
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
|
||||
"Booking End Time": item.bookingEndTime?.toISOString() || "",
|
||||
"Attendee Name": (bookingAttendees as any)?.[0]?.name,
|
||||
"Attendee Email": (bookingAttendees as any)?.[0]?.email,
|
||||
"Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone,
|
||||
"Assignment Reason": item.bookingAssignmentReason || "",
|
||||
"Routed To Name": item.bookingUserName || "",
|
||||
"Routed To Email": item.bookingUserEmail || "",
|
||||
...mapKeys(fields, (_, key) => startCase(key)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
data: dataWithFlatResponse,
|
||||
nextCursor: data.nextCursor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import md5 from "md5";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZColumnFilter } from "@calcom/features/data-table";
|
||||
import { ZColumnFilter, ZSorting } from "@calcom/features/data-table";
|
||||
import { rawDataInputSchema } from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
@@ -1601,6 +1601,7 @@ export const insightsRouter = router({
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
columnFilters: z.array(ZColumnFilter),
|
||||
sorting: z.array(ZSorting),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -1617,6 +1618,33 @@ export const insightsRouter = router({
|
||||
memberUserId: input.memberUserId ?? null,
|
||||
limit: input.limit,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
}),
|
||||
routingFormResponsesForDownload: userBelongsToTeamProcedure
|
||||
.input(
|
||||
rawDataInputSchema.extend({
|
||||
routingFormId: z.string().optional(),
|
||||
cursor: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
columnFilters: z.array(ZColumnFilter),
|
||||
sorting: z.array(ZSorting),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
|
||||
teamId: input.teamId ?? null,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isAll: input.isAll ?? false,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId ?? null,
|
||||
cursor: input.cursor,
|
||||
userId: input.userId ?? null,
|
||||
memberUserId: input.memberUserId ?? null,
|
||||
limit: input.limit ?? BATCH_SIZE,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
}),
|
||||
getRoutingFormFieldOptions: userBelongsToTeamProcedure
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
CREATE OR REPLACE VIEW "RoutingFormResponse" AS
|
||||
WITH attendees_agg AS (
|
||||
SELECT
|
||||
a."bookingId",
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'name', a.name,
|
||||
'timeZone', a."timeZone",
|
||||
'email', a.email
|
||||
)
|
||||
) as attendees
|
||||
FROM "Attendee" a
|
||||
GROUP BY a."bookingId"
|
||||
),
|
||||
assignment_reasons_agg AS (
|
||||
SELECT
|
||||
ar."bookingId",
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'reasonString', ar."reasonString"
|
||||
)
|
||||
) as reasons
|
||||
FROM "AssignmentReason" ar
|
||||
GROUP BY ar."bookingId"
|
||||
)
|
||||
SELECT
|
||||
r.id,
|
||||
r.response,
|
||||
(
|
||||
SELECT jsonb_object_agg(
|
||||
key,
|
||||
CASE
|
||||
WHEN jsonb_typeof(value->'value') = 'string'
|
||||
THEN jsonb_build_object(
|
||||
'label', value->'label',
|
||||
'value', lower((value->>'value')::text)
|
||||
)
|
||||
ELSE value
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(r.response::jsonb)
|
||||
) as "responseLowercase",
|
||||
f.id as "formId",
|
||||
f.name as "formName",
|
||||
f."teamId" as "formTeamId",
|
||||
f."userId" as "formUserId",
|
||||
b.uid as "bookingUid",
|
||||
b.status as "bookingStatus",
|
||||
CASE b.status
|
||||
WHEN 'accepted' THEN 1
|
||||
WHEN 'pending' THEN 2
|
||||
WHEN 'awaiting_host' THEN 3
|
||||
WHEN 'cancelled' THEN 4
|
||||
WHEN 'rejected' THEN 5
|
||||
END as "bookingStatusOrder",
|
||||
b."createdAt" as "bookingCreatedAt",
|
||||
b."startTime" as "bookingStartTime",
|
||||
b."endTime" as "bookingEndTime",
|
||||
att.attendees as "bookingAttendees",
|
||||
u.id as "bookingUserId",
|
||||
u.name as "bookingUserName",
|
||||
u.email as "bookingUserEmail",
|
||||
u."avatarUrl" as "bookingUserAvatarUrl",
|
||||
COALESCE((ar.reasons->0)->>'reasonString', '') as "bookingAssignmentReason",
|
||||
LOWER(COALESCE((ar.reasons->0)->>'reasonString', '')) as "bookingAssignmentReasonLowercase",
|
||||
r."createdAt" as "createdAt"
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
LEFT JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
LEFT JOIN "Booking" b ON r."routedToBookingUid" = b.uid
|
||||
LEFT JOIN attendees_agg att ON b.id = att."bookingId"
|
||||
LEFT JOIN "users" u ON b."userId" = u.id
|
||||
LEFT JOIN assignment_reasons_agg ar ON b.id = ar."bookingId";
|
||||
@@ -1019,6 +1019,30 @@ model App_RoutingForms_FormResponse {
|
||||
@@index([formId])
|
||||
}
|
||||
|
||||
view RoutingFormResponse {
|
||||
id Int @unique
|
||||
response Json
|
||||
responseLowercase Json
|
||||
formId String
|
||||
formName String
|
||||
formTeamId Int?
|
||||
formUserId Int?
|
||||
bookingUid String?
|
||||
bookingStatus BookingStatus?
|
||||
bookingStatusOrder Int?
|
||||
bookingCreatedAt DateTime?
|
||||
bookingAttendees Json? // Array of {timeZone: string, email: string}
|
||||
bookingUserId Int?
|
||||
bookingUserName String?
|
||||
bookingUserEmail String?
|
||||
bookingUserAvatarUrl String?
|
||||
bookingAssignmentReason String?
|
||||
bookingAssignmentReasonLowercase String?
|
||||
bookingStartTime DateTime?
|
||||
bookingEndTime DateTime?
|
||||
createdAt DateTime
|
||||
}
|
||||
|
||||
model Feedback {
|
||||
id Int @id @default(autoincrement())
|
||||
date DateTime @default(now())
|
||||
|
||||
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { BookingStatus, AssignmentReasonEnum } from "@calcom/prisma/enums";
|
||||
|
||||
import { seedAttributes, seedRoutingFormResponses, seedRoutingForms } from "./seed-utils";
|
||||
|
||||
@@ -103,6 +103,35 @@ async function createAttendees(bookings: any[]) {
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBookingAssignments() {
|
||||
const assignmentReasons = [
|
||||
AssignmentReasonEnum.ROUTING_FORM_ROUTING,
|
||||
AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
|
||||
AssignmentReasonEnum.REASSIGNED,
|
||||
AssignmentReasonEnum.REROUTED,
|
||||
AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
|
||||
];
|
||||
// Get all booking IDs
|
||||
const bookings = await prisma.booking.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Take 20% of bookings randomly
|
||||
const numberOfBookingsToAssign = Math.floor(bookings.length * 0.2);
|
||||
const randomBookings = bookings.sort(() => Math.random() - 0.5).slice(0, numberOfBookingsToAssign);
|
||||
|
||||
// Create assignment reasons for the random bookings
|
||||
await prisma.assignmentReason.createMany({
|
||||
data: randomBookings.map((booking) => ({
|
||||
bookingId: booking.id,
|
||||
reasonString: faker.lorem.sentence(),
|
||||
reasonEnum: assignmentReasons[Math.floor(Math.random() * assignmentReasons.length)],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
// First find the organization we want to add insights to
|
||||
@@ -307,6 +336,8 @@ async function main() {
|
||||
if (seededForm) {
|
||||
await seedRoutingFormResponses(seededForm, attributes, insightsTeam.id);
|
||||
}
|
||||
|
||||
await seedBookingAssignments();
|
||||
}
|
||||
main()
|
||||
.then(async () => {
|
||||
|
||||
@@ -15,6 +15,10 @@ type GetOptions = {
|
||||
input: TListMembersSchema;
|
||||
};
|
||||
|
||||
const isAllString = (array: (string | number)[]): array is string[] => {
|
||||
return array.every((value) => typeof value === "string");
|
||||
};
|
||||
|
||||
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId;
|
||||
const searchTerm = input.searchTerm;
|
||||
@@ -103,7 +107,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
break;
|
||||
// We assume that if the filter is not one of the above, it must be an attribute filter
|
||||
default:
|
||||
if (filter.value.type === "multi_select") {
|
||||
if (filter.value.type === "multi_select" && isAllString(filter.value.data)) {
|
||||
const attributeOptionValues: string[] = [];
|
||||
filter.value.data.forEach((filterValueItem) => {
|
||||
attributeOptionValues.push(filterValueItem);
|
||||
|
||||
Reference in New Issue
Block a user