feat: assigned bookings per period (#17940)
* wip * bring in users median status for selected range * merge * i18n * routed to talbe period scroll x-y infinite * insights i18n + hover card on user * add search by name or email * wip download button * fix type error * type fix * type errors + status * type error
This commit is contained in:
1 parent
931f3d3396
commit
fc6e39c78a
13 files changed
+1101
-61
No files matched your search
@@ -4,6 +4,7 @@ import {
|
||||
FailedBookingsByField,
|
||||
RoutingFormResponsesTable,
|
||||
RoutingKPICards,
|
||||
RoutedToPerPeriod,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
|
||||
@@ -30,6 +31,8 @@ export default function InsightsPage() {
|
||||
)}
|
||||
</RoutingFormResponsesTable>
|
||||
|
||||
<RoutedToPerPeriod />
|
||||
|
||||
<FailedBookingsByField />
|
||||
|
||||
<small className="text-default block text-center">
|
||||
|
||||
@@ -2824,6 +2824,11 @@
|
||||
"routing_form_insights_booking_status": "Booking Status",
|
||||
"routing_form_insights_booking_at": "Booking At",
|
||||
"routing_form_insights_submitted_at": "Submitted At",
|
||||
"total_bookings_per_period":"Total Bookings per period",
|
||||
"routed_to_per_period":"Routed to per period",
|
||||
"per_day": "Per day",
|
||||
"per_week": "Per week",
|
||||
"per_month": "Per month",
|
||||
"routing_form_insights_assignment_reason":"Assignment Reason",
|
||||
"access_denied": "Access Denied",
|
||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui";
|
||||
|
||||
interface DataTableSkeletonProps {
|
||||
columns: number;
|
||||
rows?: number;
|
||||
columnWidths?: number[];
|
||||
}
|
||||
|
||||
export function DataTableSkeleton({ columns, rows = 10, columnWidths = [] }: DataTableSkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className="grid h-[75dvh]"
|
||||
style={{ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'" }}>
|
||||
<div
|
||||
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
|
||||
style={{ gridArea: "body" }}>
|
||||
<TableNew>
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{[...Array(columns)].map((_, index) => (
|
||||
<TableHead key={`skeleton-header-${index}`}>
|
||||
<div
|
||||
className="bg-subtle h-4 animate-pulse rounded-md"
|
||||
style={{ width: columnWidths[index] ? `${columnWidths[index]}px` : "200px" }}
|
||||
/>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(rows)].map((_, rowIndex) => (
|
||||
<TableRow key={`skeleton-row-${rowIndex}`}>
|
||||
{[...Array(columns)].map((_, colIndex) => (
|
||||
<TableCell key={`skeleton-cell-${rowIndex}-${colIndex}`}>
|
||||
<div
|
||||
className="bg-subtle h-6 animate-pulse rounded-md"
|
||||
style={{ width: columnWidths[colIndex] ? `${columnWidths[colIndex]}px` : "200px" }}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</TableNew>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,4 +4,5 @@ export { DataTablePagination } from "./DataTablePagination";
|
||||
export { DataTableFilters } from "./filters";
|
||||
export { useFetchMoreOnBottomReached } from "./useFetchMoreOnBottomReached";
|
||||
export { DataTable } from "./DataTable";
|
||||
export { DataTableSkeleton } from "./DataTableSkeleton";
|
||||
export type { DataTableProps } from "./DataTable";
|
||||
@@ -0,0 +1,454 @@
|
||||
import type { TFunction } from "next-i18next";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { type ReactNode, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { DataTableSkeleton } from "@calcom/features/data-table";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import {
|
||||
Avatar,
|
||||
ToggleGroup,
|
||||
Badge,
|
||||
Tooltip,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Input,
|
||||
Button,
|
||||
} from "@calcom/ui";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@calcom/ui/components/table/TableNew";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
interface DownloadButtonProps {
|
||||
teamId?: number;
|
||||
isAll?: boolean;
|
||||
routingFormId?: string;
|
||||
dateRange: [Dayjs, Dayjs, string | null];
|
||||
selectedPeriod: string;
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
function DownloadButton({
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
dateRange,
|
||||
selectedPeriod,
|
||||
searchQuery,
|
||||
}: DownloadButtonProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const utils = trpc.useContext();
|
||||
const { t } = useLocale();
|
||||
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
e.preventDefault(); // Prevent default form submission
|
||||
|
||||
try {
|
||||
const result = await utils.viewer.insights.routedToPerPeriodCsv.fetch({
|
||||
teamId: teamId ?? undefined,
|
||||
startDate: dateRange[0]?.toISOString() ?? "",
|
||||
endDate: dateRange[1]?.toISOString() ?? "",
|
||||
period: selectedPeriod as "perDay" | "perWeek" | "perMonth",
|
||||
isAll: !!isAll,
|
||||
routingFormId: routingFormId ?? undefined,
|
||||
searchQuery: searchQuery || undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
throw new Error("No data received");
|
||||
}
|
||||
|
||||
downloadAsCsv(result.data, result.filename || "routing-data.csv");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button" // Change from submit to button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
onClick={handleDownload}
|
||||
disabled={isDownloading}
|
||||
StartIcon={isDownloading ? "rotate-ccw" : "download"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FormCardProps {
|
||||
selectedPeriod: string;
|
||||
onPeriodChange: (value: string) => void;
|
||||
searchQuery: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
children: ReactNode;
|
||||
teamId?: number;
|
||||
isAll?: boolean;
|
||||
routingFormId?: string;
|
||||
dateRange: [Dayjs, Dayjs, string | null];
|
||||
}
|
||||
|
||||
function FormCard({
|
||||
selectedPeriod,
|
||||
onPeriodChange,
|
||||
searchQuery,
|
||||
onSearchChange,
|
||||
children,
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
dateRange,
|
||||
}: FormCardProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<div className="border-subtle w-full rounded-md border">
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<ToggleGroup
|
||||
options={[
|
||||
{ label: t("per_day"), value: "perDay" },
|
||||
{ label: t("per_week"), value: "perWeek" },
|
||||
{ label: t("per_month"), value: "perMonth" },
|
||||
]}
|
||||
className="w-fit"
|
||||
value={selectedPeriod}
|
||||
onValueChange={(value) => value && onPeriodChange(value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<div className="w-64">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DownloadButton
|
||||
teamId={teamId}
|
||||
isAll={isAll}
|
||||
routingFormId={routingFormId}
|
||||
dateRange={dateRange}
|
||||
selectedPeriod={selectedPeriod}
|
||||
searchQuery={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RoutedToTableRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
stats: { [key: string]: number };
|
||||
performance: "above_average" | "at_average" | "below_average" | "median" | "no_data";
|
||||
totalBookings: number;
|
||||
};
|
||||
|
||||
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
|
||||
switch (performance) {
|
||||
case "above_average":
|
||||
return (
|
||||
<Tooltip content={t("above_average")}>
|
||||
<Badge variant="success" className="w-fit gap-1">
|
||||
{t("above_average")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
case "below_average":
|
||||
return (
|
||||
<Tooltip content={t("below_average")}>
|
||||
<Badge variant="red" className="w-fit gap-1">
|
||||
{t("below_average")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
case "median":
|
||||
return (
|
||||
<Tooltip content={t("median")}>
|
||||
<Badge variant="orange" className="w-fit gap-1">
|
||||
{t("median")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
case "at_average":
|
||||
return (
|
||||
<Tooltip content={t("at_average")}>
|
||||
<Badge variant="blue" className="w-fit gap-1">
|
||||
{t("at_average")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tooltip content={t("no_data")}>
|
||||
<Badge variant="gray" className="w-fit gap-1">
|
||||
{t("no_data")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function RoutedToPerPeriod() {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { selectedTeamId, isAll, selectedRoutingFormId, dateRange } = filter;
|
||||
const [selectedPeriod, setSelectedPeriod] = useQueryState("selectedPeriod", {
|
||||
defaultValue: "perWeek",
|
||||
});
|
||||
const [searchQuery, setSearchQuery] = useQueryState("search", {
|
||||
defaultValue: "",
|
||||
});
|
||||
|
||||
const { ref: loadMoreRef } = useInViewObserver(() => {
|
||||
if (hasNextPage && !isFetchingNextPage) {
|
||||
fetchNextPage();
|
||||
}
|
||||
});
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, fetchNextPage, isFetchingNextPage, hasNextPage, isLoading } =
|
||||
trpc.viewer.insights.routedToPerPeriod.useInfiniteQuery(
|
||||
{
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
startDate: dateRange[0]?.toISOString() ?? "",
|
||||
endDate: dateRange[1]?.toISOString() ?? "",
|
||||
period: selectedPeriod as "perDay" | "perWeek" | "perMonth",
|
||||
isAll: !!isAll,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
searchQuery: searchQuery || undefined,
|
||||
limit: 10,
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => {
|
||||
if (!lastPage.users.nextCursor && !lastPage.periodStats.nextCursor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
userCursor: lastPage.users.nextCursor,
|
||||
periodCursor: lastPage.periodStats.nextCursor,
|
||||
};
|
||||
},
|
||||
enabled: !!dateRange[0] && !!dateRange[1],
|
||||
}
|
||||
);
|
||||
|
||||
const flattenedUsers = useMemo(() => {
|
||||
const userMap = new Map();
|
||||
data?.pages.forEach((page) => {
|
||||
page.users.data.forEach((user) => {
|
||||
if (!userMap.has(user.id)) {
|
||||
userMap.set(user.id, user);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(userMap.values());
|
||||
}, [data?.pages]);
|
||||
|
||||
const uniquePeriods = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
|
||||
// Get all unique periods from all pages
|
||||
const periods = new Set<string>();
|
||||
data.pages.forEach((page) => {
|
||||
page.periodStats.data.forEach((stat) => {
|
||||
periods.add(stat.period_start.toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(periods)
|
||||
.map((dateStr) => new Date(dateStr))
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
}, [data?.pages]);
|
||||
|
||||
const processedData: RoutedToTableRow[] = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
|
||||
// Create a map for quick lookup of stats
|
||||
const statsMap = new Map<string, number>();
|
||||
data.pages.forEach((page) => {
|
||||
page.periodStats.data.forEach((stat) => {
|
||||
const key = `${stat.userId}-${stat.period_start.toISOString()}`;
|
||||
statsMap.set(key, stat.total);
|
||||
});
|
||||
});
|
||||
|
||||
return flattenedUsers.map((user) => {
|
||||
const stats = uniquePeriods.reduce((acc, period) => {
|
||||
const key = `${user.id}-${period.toISOString()}`;
|
||||
acc[period.toISOString()] = statsMap.get(key) ?? 0;
|
||||
return acc;
|
||||
}, {} as { [key: string]: number });
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
avatarUrl: user.avatarUrl,
|
||||
stats,
|
||||
performance: user.performance,
|
||||
totalBookings: user.totalBookings,
|
||||
};
|
||||
});
|
||||
}, [data?.pages, flattenedUsers, uniquePeriods]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full text-sm">
|
||||
<div className="flex h-12 items-center">
|
||||
<h2 className="text-emphasis text-md font-semibold">{t("routed_to_per_period")}</h2>
|
||||
</div>
|
||||
|
||||
<FormCard
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
teamId={selectedTeamId ?? undefined}
|
||||
isAll={isAll}
|
||||
routingFormId={selectedRoutingFormId ?? undefined}
|
||||
dateRange={dateRange}>
|
||||
<div className="mt-6">
|
||||
<DataTableSkeleton columns={5} columnWidths={[200, 120, 120, 120, 120]} />
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isCurrentPeriod = (date: Date, today: Date, selectedPeriod: string): boolean => {
|
||||
if (selectedPeriod === "perDay") {
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
} else if (selectedPeriod === "perWeek") {
|
||||
const weekStart = new Date(today);
|
||||
weekStart.setDate(today.getDate() - today.getDay());
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekStart.getDate() + 6);
|
||||
return date >= weekStart && date <= weekEnd;
|
||||
} else if (selectedPeriod === "perMonth") {
|
||||
return date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full text-sm">
|
||||
<div className="flex h-12 items-center">
|
||||
<h2 className="text-emphasis text-md font-semibold">{t("routed_to_per_period")}</h2>
|
||||
</div>
|
||||
|
||||
<FormCard
|
||||
selectedPeriod={selectedPeriod}
|
||||
onPeriodChange={setSelectedPeriod}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
teamId={selectedTeamId ?? undefined}
|
||||
isAll={isAll}
|
||||
routingFormId={selectedRoutingFormId ?? undefined}
|
||||
dateRange={dateRange}>
|
||||
<div className="mt-6">
|
||||
<div
|
||||
className="scrollbar-thin border-subtle relaitve relative h-[80dvh] overflow-auto rounded-md border"
|
||||
ref={tableContainerRef}>
|
||||
<Table className="border-0">
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead className="bg-subtle sticky left-0 z-30 w-[200px]">{t("user")}</TableHead>
|
||||
{uniquePeriods.map((period, index) => {
|
||||
const date = period;
|
||||
const today = new Date();
|
||||
|
||||
const isCurrent = isCurrentPeriod(date, today, selectedPeriod);
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={period.toISOString()}
|
||||
className="text-center"
|
||||
data-is-current={isCurrent}>
|
||||
<span className={classNames(isCurrent && "font-bold")}>
|
||||
{date.toLocaleDateString()}
|
||||
</span>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody className="relative">
|
||||
{processedData.map((row, index) => {
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
ref={index === processedData.length - 1 ? loadMoreRef : undefined}
|
||||
className="divide-muted divide-x">
|
||||
<TableCell className="bg-default w-[200px]">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
|
||||
<div className="flex flex-col gap-1 truncate">
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-default font-medium">{row.name}</span>
|
||||
</div>
|
||||
<div className="text-subtle flex flex-col text-sm">
|
||||
<div>
|
||||
{t("total_bookings_per_period")}: <Badge>{row.totalBookings}</Badge>
|
||||
{t("status")}: {getPerformanceBadge(row.performance, t)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</TableCell>
|
||||
{uniquePeriods.map((period) => {
|
||||
return (
|
||||
<TableCell key={period.toISOString()} className="text-center">
|
||||
{row.stats[period.toISOString()] === 0 ? null : (
|
||||
<>{row.stats[period.toISOString()]}</>
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</FormCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import Link from "next/link";
|
||||
import { useRef, useMemo, useId } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { DataTable, useFetchMoreOnBottomReached } from "@calcom/features/data-table";
|
||||
import { DataTable, DataTableSkeleton, useFetchMoreOnBottomReached } from "@calcom/features/data-table";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -25,15 +25,9 @@ import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
type BadgeProps,
|
||||
HoverCardPortal,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@calcom/ui";
|
||||
import type { BadgeProps } from "@calcom/ui/components/badge/Badge";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
@@ -437,49 +431,7 @@ export function RoutingFormResponsesTable({
|
||||
);
|
||||
|
||||
if (isHeadersLoading || ((isFetching || isLoading) && !data)) {
|
||||
return (
|
||||
<div
|
||||
className="grid h-[85dvh]"
|
||||
style={{ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'" }}>
|
||||
<div
|
||||
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
|
||||
style={{ gridArea: "body" }}>
|
||||
<Table>
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{[...Array(4)].map((_, index) => (
|
||||
<TableHead key={`skeleton-header-${index}`}>
|
||||
<div className="bg-subtle h-4 w-[200px] animate-pulse rounded-md" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(10)].map((_, rowIndex) => (
|
||||
<TableRow key={`skeleton-row-${rowIndex}`}>
|
||||
{[...Array(4)].map((_, colIndex) => (
|
||||
<TableCell key={`skeleton-cell-${rowIndex}-${colIndex}`}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-subtle h-6 animate-pulse rounded-md",
|
||||
colIndex === 0
|
||||
? "w-[200px]"
|
||||
: colIndex === 2
|
||||
? "w-[250px]"
|
||||
: colIndex === 3
|
||||
? "w-[250px]"
|
||||
: "w-[200px]"
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,3 +11,4 @@ export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
|
||||
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
|
||||
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
|
||||
export { FailedBookingsByField } from "./FailedBookingsByField";
|
||||
export { RoutedToPerPeriod } from "./RoutedToPerPeriod";
|
||||
@@ -19,6 +19,7 @@ type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
userId?: number | null;
|
||||
searchQuery?: string | null;
|
||||
bookingStatus?: BookingStatus | "NO_BOOKING" | null;
|
||||
fieldFilter?: {
|
||||
fieldId: string;
|
||||
@@ -79,6 +80,7 @@ class RoutingEventsInsights {
|
||||
organizationId,
|
||||
routingFormId,
|
||||
userId,
|
||||
searchQuery,
|
||||
bookingStatus,
|
||||
fieldFilter,
|
||||
}: RoutingFormInsightsFilter) {
|
||||
@@ -99,13 +101,21 @@ class RoutingEventsInsights {
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
}),
|
||||
...(userId || bookingStatus
|
||||
...(userId || bookingStatus || searchQuery
|
||||
? {
|
||||
...(bookingStatus === "NO_BOOKING"
|
||||
? { routedToBooking: null }
|
||||
: {
|
||||
routedToBooking: {
|
||||
...(userId && { userId }),
|
||||
...(searchQuery && {
|
||||
user: {
|
||||
OR: [
|
||||
{ email: { contains: searchQuery, mode: "insensitive" } },
|
||||
{ name: { contains: searchQuery, mode: "insensitive" } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
...(bookingStatus && { status: bookingStatus }),
|
||||
},
|
||||
}),
|
||||
@@ -651,6 +661,483 @@ class RoutingEventsInsights {
|
||||
return flatResponse;
|
||||
});
|
||||
}
|
||||
|
||||
static async routedToPerPeriod({
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
period,
|
||||
cursor,
|
||||
userCursor,
|
||||
limit = 10,
|
||||
searchQuery,
|
||||
}: RoutingFormInsightsTeamFilter & {
|
||||
period: "perDay" | "perWeek" | "perMonth";
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
cursor?: string;
|
||||
userCursor?: number;
|
||||
limit?: number;
|
||||
searchQuery?: string;
|
||||
}) {
|
||||
const dayJsPeriodMap = {
|
||||
perDay: "day",
|
||||
perWeek: "week",
|
||||
perMonth: "month",
|
||||
} as const;
|
||||
|
||||
const dayjsPeriod = dayJsPeriodMap[period];
|
||||
const startDate = dayjs(_startDate).startOf(dayjsPeriod).toDate();
|
||||
const endDate = dayjs(_endDate).endOf(dayjsPeriod).toDate();
|
||||
|
||||
// Build the team conditions for the WHERE clause
|
||||
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const teamConditions = [];
|
||||
|
||||
// @ts-expect-error it does exist but TS isn't smart enough when it's number or int filter
|
||||
if (formsWhereCondition.teamId?.in) {
|
||||
// @ts-expect-error same as above
|
||||
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
|
||||
}
|
||||
if (routingFormId) {
|
||||
teamConditions.push(`f.id = '${routingFormId}'`);
|
||||
}
|
||||
|
||||
const searchCondition = searchQuery
|
||||
? Prisma.sql`AND (u.name ILIKE ${`%${searchQuery}%`} OR u.email ILIKE ${`%${searchQuery}%`})`
|
||||
: Prisma.empty;
|
||||
|
||||
const whereClause = teamConditions.length
|
||||
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
|
||||
: Prisma.empty;
|
||||
|
||||
// Get users who have been routed to during the period
|
||||
const usersQuery = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
}>
|
||||
>`
|
||||
WITH routed_responses AS (
|
||||
SELECT DISTINCT ON (b."userId")
|
||||
b."userId",
|
||||
u.id,
|
||||
u.name,
|
||||
u.email,
|
||||
u."avatarUrl"
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
|
||||
JOIN "users" u ON b."userId" = u.id
|
||||
WHERE r."routedToBookingUid" IS NOT NULL
|
||||
AND r."createdAt" >= ${startDate}
|
||||
AND r."createdAt" <= ${endDate}
|
||||
${searchCondition}
|
||||
${whereClause}
|
||||
${userCursor ? Prisma.sql`AND b."userId" > ${userCursor}` : Prisma.empty}
|
||||
ORDER BY b."userId", r."createdAt" DESC
|
||||
)
|
||||
SELECT *
|
||||
FROM routed_responses
|
||||
ORDER BY id ASC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const users = usersQuery;
|
||||
|
||||
const hasMoreUsers = users.length === limit;
|
||||
|
||||
// Get periods with pagination
|
||||
const periodStats = await prisma.$queryRaw<
|
||||
Array<{
|
||||
userId: number;
|
||||
period_start: Date;
|
||||
total: number;
|
||||
}>
|
||||
>`
|
||||
-- First, generate all months in the range
|
||||
WITH RECURSIVE date_range AS (
|
||||
SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
|
||||
UNION ALL
|
||||
SELECT date + (CASE
|
||||
WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
|
||||
WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
|
||||
WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
|
||||
END)
|
||||
FROM date_range
|
||||
WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
|
||||
),
|
||||
-- Get all unique users we want to show
|
||||
all_users AS (
|
||||
SELECT unnest(ARRAY[${Prisma.join(users.map((u) => u.id))}]) as user_id
|
||||
),
|
||||
-- Get periods with pagination
|
||||
paginated_periods AS (
|
||||
SELECT date as period_start
|
||||
FROM date_range
|
||||
ORDER BY date ASC
|
||||
${cursor ? Prisma.sql`OFFSET ${parseInt(cursor, 10)}` : Prisma.empty}
|
||||
LIMIT ${limit}
|
||||
),
|
||||
-- Generate combinations for paginated periods
|
||||
date_user_combinations AS (
|
||||
SELECT
|
||||
period_start,
|
||||
user_id as "userId"
|
||||
FROM paginated_periods
|
||||
CROSS JOIN all_users
|
||||
),
|
||||
-- Count bookings per user per period
|
||||
booking_counts AS (
|
||||
SELECT
|
||||
b."userId",
|
||||
date_trunc(${dayjsPeriod}, b."createdAt") as period_start,
|
||||
COUNT(DISTINCT b.id)::integer as total
|
||||
FROM "Booking" b
|
||||
JOIN "App_RoutingForms_FormResponse" r ON r."routedToBookingUid" = b.uid
|
||||
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
WHERE b."userId" IN (SELECT user_id FROM all_users)
|
||||
AND date_trunc(${dayjsPeriod}, b."createdAt") >= (SELECT MIN(period_start) FROM paginated_periods)
|
||||
AND date_trunc(${dayjsPeriod}, b."createdAt") <= (SELECT MAX(period_start) FROM paginated_periods)
|
||||
${whereClause}
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
-- Join everything together
|
||||
SELECT
|
||||
c."userId",
|
||||
c.period_start,
|
||||
COALESCE(b.total, 0)::integer as total
|
||||
FROM date_user_combinations c
|
||||
LEFT JOIN booking_counts b ON
|
||||
b."userId" = c."userId" AND
|
||||
b.period_start = c.period_start
|
||||
ORDER BY c.period_start ASC, c."userId" ASC
|
||||
`;
|
||||
|
||||
// Get total number of periods to determine if there are more
|
||||
const totalPeriodsQuery = await prisma.$queryRaw<[{ count: number }]>`
|
||||
WITH RECURSIVE date_range AS (
|
||||
SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
|
||||
UNION ALL
|
||||
SELECT date + (CASE
|
||||
WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
|
||||
WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
|
||||
WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
|
||||
END)
|
||||
FROM date_range
|
||||
WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
|
||||
)
|
||||
SELECT COUNT(*)::integer as count FROM date_range
|
||||
`;
|
||||
|
||||
// Get statistics for the entire period for comparison
|
||||
const statsQuery = await prisma.$queryRaw<
|
||||
Array<{
|
||||
userId: number;
|
||||
total_bookings: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
b."userId",
|
||||
COUNT(*)::integer as total_bookings
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
|
||||
WHERE r."routedToBookingUid" IS NOT NULL
|
||||
AND r."createdAt" >= ${startDate}
|
||||
AND r."createdAt" <= ${endDate}
|
||||
${whereClause}
|
||||
GROUP BY b."userId"
|
||||
ORDER BY total_bookings ASC
|
||||
`;
|
||||
|
||||
// Calculate average and median
|
||||
const average =
|
||||
statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
|
||||
const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
|
||||
|
||||
// Create a map of user performance indicators
|
||||
const userPerformance = statsQuery.reduce((acc, stat) => {
|
||||
acc[stat.userId] = {
|
||||
total: stat.total_bookings,
|
||||
performance:
|
||||
stat.total_bookings > average
|
||||
? "above_average"
|
||||
: stat.total_bookings === median
|
||||
? "median"
|
||||
: stat.total_bookings < average
|
||||
? "below_average"
|
||||
: "at_average",
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
|
||||
|
||||
const totalPeriods = totalPeriodsQuery[0].count;
|
||||
const currentPeriodOffset = cursor ? parseInt(cursor, 10) : 0;
|
||||
const hasMorePeriods = currentPeriodOffset + limit < totalPeriods;
|
||||
|
||||
return {
|
||||
users: {
|
||||
data: users.map((user) => ({
|
||||
...user,
|
||||
performance: userPerformance[user.id]?.performance || "no_data",
|
||||
totalBookings: userPerformance[user.id]?.total || 0,
|
||||
})),
|
||||
nextCursor: hasMoreUsers ? users[users.length - 1].id : undefined,
|
||||
},
|
||||
periodStats: {
|
||||
data: periodStats,
|
||||
nextCursor: hasMorePeriods ? (currentPeriodOffset + limit).toString() : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static async routedToPerPeriodCsv({
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
startDate: _startDate,
|
||||
endDate: _endDate,
|
||||
period,
|
||||
searchQuery,
|
||||
}: RoutingFormInsightsTeamFilter & {
|
||||
period: "perDay" | "perWeek" | "perMonth";
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
searchQuery?: string;
|
||||
}) {
|
||||
const dayJsPeriodMap = {
|
||||
perDay: "day",
|
||||
perWeek: "week",
|
||||
perMonth: "month",
|
||||
} as const;
|
||||
|
||||
const dayjsPeriod = dayJsPeriodMap[period];
|
||||
const startDate = dayjs(_startDate).startOf(dayjsPeriod).toDate();
|
||||
const endDate = dayjs(_endDate).endOf(dayjsPeriod).toDate();
|
||||
|
||||
// Build the team conditions for the WHERE clause
|
||||
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const teamConditions = [];
|
||||
|
||||
// @ts-expect-error it does exist but TS isn't smart enough when it's number or int filter
|
||||
if (formsWhereCondition.teamId?.in) {
|
||||
// @ts-expect-error same as above
|
||||
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
|
||||
}
|
||||
if (routingFormId) {
|
||||
teamConditions.push(`f.id = '${routingFormId}'`);
|
||||
}
|
||||
|
||||
const searchCondition = searchQuery
|
||||
? Prisma.sql`AND (u.name ILIKE ${`%${searchQuery}%`} OR u.email ILIKE ${`%${searchQuery}%`})`
|
||||
: Prisma.empty;
|
||||
|
||||
const whereClause = teamConditions.length
|
||||
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
|
||||
: Prisma.empty;
|
||||
|
||||
// Get users who have been routed to during the period
|
||||
const usersQuery = await prisma.$queryRaw<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
}>
|
||||
>`
|
||||
WITH routed_responses AS (
|
||||
SELECT DISTINCT ON (b."userId")
|
||||
b."userId",
|
||||
u.id,
|
||||
u.name,
|
||||
u.email,
|
||||
u."avatarUrl"
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
|
||||
JOIN "users" u ON b."userId" = u.id
|
||||
WHERE r."routedToBookingUid" IS NOT NULL
|
||||
AND r."createdAt" >= ${startDate}
|
||||
AND r."createdAt" <= ${endDate}
|
||||
${searchCondition}
|
||||
${whereClause}
|
||||
ORDER BY b."userId", r."createdAt" DESC
|
||||
)
|
||||
SELECT *
|
||||
FROM routed_responses
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
|
||||
// Get all periods without pagination
|
||||
const periodStats = await prisma.$queryRaw<
|
||||
Array<{
|
||||
userId: number;
|
||||
period_start: Date;
|
||||
total: number;
|
||||
}>
|
||||
>`
|
||||
WITH RECURSIVE date_range AS (
|
||||
SELECT date_trunc(${dayjsPeriod}, ${startDate}::timestamp) as date
|
||||
UNION ALL
|
||||
SELECT date + (CASE
|
||||
WHEN ${dayjsPeriod} = 'day' THEN interval '1 day'
|
||||
WHEN ${dayjsPeriod} = 'week' THEN interval '1 week'
|
||||
WHEN ${dayjsPeriod} = 'month' THEN interval '1 month'
|
||||
END)
|
||||
FROM date_range
|
||||
WHERE date < date_trunc(${dayjsPeriod}, ${endDate}::timestamp + interval '1 day')
|
||||
),
|
||||
all_users AS (
|
||||
SELECT unnest(ARRAY[${Prisma.join(usersQuery.map((u) => u.id))}]) as user_id
|
||||
),
|
||||
date_user_combinations AS (
|
||||
SELECT
|
||||
date as period_start,
|
||||
user_id as "userId"
|
||||
FROM date_range
|
||||
CROSS JOIN all_users
|
||||
),
|
||||
booking_counts AS (
|
||||
SELECT
|
||||
b."userId",
|
||||
date_trunc(${dayjsPeriod}, b."createdAt") as period_start,
|
||||
COUNT(DISTINCT b.id)::integer as total
|
||||
FROM "Booking" b
|
||||
JOIN "App_RoutingForms_FormResponse" r ON r."routedToBookingUid" = b.uid
|
||||
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
WHERE b."userId" IN (SELECT user_id FROM all_users)
|
||||
AND date_trunc(${dayjsPeriod}, b."createdAt") >= (SELECT MIN(date) FROM date_range)
|
||||
AND date_trunc(${dayjsPeriod}, b."createdAt") <= (SELECT MAX(date) FROM date_range)
|
||||
${whereClause}
|
||||
GROUP BY 1, 2
|
||||
)
|
||||
SELECT
|
||||
c."userId",
|
||||
c.period_start,
|
||||
COALESCE(b.total, 0)::integer as total
|
||||
FROM date_user_combinations c
|
||||
LEFT JOIN booking_counts b ON
|
||||
b."userId" = c."userId" AND
|
||||
b.period_start = c.period_start
|
||||
ORDER BY c.period_start ASC, c."userId" ASC
|
||||
`;
|
||||
|
||||
// Get statistics for the entire period for comparison
|
||||
const statsQuery = await prisma.$queryRaw<
|
||||
Array<{
|
||||
userId: number;
|
||||
total_bookings: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
b."userId",
|
||||
COUNT(*)::integer as total_bookings
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
JOIN "Booking" b ON r."routedToBookingUid" = b.uid
|
||||
WHERE r."routedToBookingUid" IS NOT NULL
|
||||
AND r."createdAt" >= ${startDate}
|
||||
AND r."createdAt" <= ${endDate}
|
||||
${whereClause}
|
||||
GROUP BY b."userId"
|
||||
ORDER BY total_bookings ASC
|
||||
`;
|
||||
|
||||
// Calculate average and median
|
||||
const average =
|
||||
statsQuery.reduce((sum, stat) => sum + Number(stat.total_bookings), 0) / statsQuery.length || 0;
|
||||
const median = statsQuery[Math.floor(statsQuery.length / 2)]?.total_bookings || 0;
|
||||
|
||||
// Create a map of user performance indicators
|
||||
const userPerformance = statsQuery.reduce((acc, stat) => {
|
||||
acc[stat.userId] = {
|
||||
total: stat.total_bookings,
|
||||
performance:
|
||||
stat.total_bookings > average
|
||||
? "above_average"
|
||||
: stat.total_bookings === median
|
||||
? "median"
|
||||
: stat.total_bookings < average
|
||||
? "below_average"
|
||||
: "at_average",
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<number, { total: number; performance: "above_average" | "at_average" | "below_average" | "median" }>);
|
||||
|
||||
// Group period stats by user
|
||||
const userPeriodStats = periodStats.reduce((acc, stat) => {
|
||||
if (!acc[stat.userId]) {
|
||||
acc[stat.userId] = [];
|
||||
}
|
||||
acc[stat.userId].push({
|
||||
period_start: stat.period_start,
|
||||
total: stat.total,
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<number, Array<{ period_start: Date; total: number }>>);
|
||||
|
||||
// Format data for CSV
|
||||
return usersQuery.map((user) => {
|
||||
const stats = userPeriodStats[user.id] || [];
|
||||
const periodData = stats.reduce(
|
||||
(acc, stat) => ({
|
||||
...acc,
|
||||
[`Responses ${dayjs(stat.period_start).format("YYYY-MM-DD")}`]: stat.total.toString(),
|
||||
}),
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
return {
|
||||
"User ID": user.id.toString(),
|
||||
Name: user.name || "",
|
||||
Email: user.email,
|
||||
"Total Bookings": (userPerformance[user.id]?.total || 0).toString(),
|
||||
Performance: userPerformance[user.id]?.performance || "no_data",
|
||||
...periodData,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static objectToCsv(data: Record<string, string>[]) {
|
||||
if (!data.length) return "";
|
||||
|
||||
const headers = Object.keys(data[0]);
|
||||
const csvRows = [
|
||||
headers.join(","),
|
||||
...data.map((row) =>
|
||||
headers
|
||||
.map((header) => {
|
||||
const value = row[header]?.toString() || "";
|
||||
// Escape quotes and wrap in quotes if contains comma or newline
|
||||
return value.includes(",") || value.includes("\n") || value.includes('"')
|
||||
? `"${value.replace(/"/g, '""')}"` // escape double quotes
|
||||
: value;
|
||||
})
|
||||
.join(",")
|
||||
),
|
||||
];
|
||||
|
||||
return csvRows.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export { RoutingEventsInsights };
|
||||
@@ -1707,4 +1707,68 @@ export const insightsRouter = router({
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
routedToPerPeriod: userBelongsToTeamProcedure
|
||||
.input(
|
||||
rawDataInputSchema.extend({
|
||||
period: z.enum(["perDay", "perWeek", "perMonth"]),
|
||||
cursor: z
|
||||
.object({
|
||||
userCursor: z.number().optional(),
|
||||
periodCursor: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
routingFormId: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
searchQuery: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, period, cursor, limit, isAll, routingFormId, searchQuery } = input;
|
||||
|
||||
return await RoutingEventsInsights.routedToPerPeriod({
|
||||
teamId: teamId ?? null,
|
||||
startDate,
|
||||
endDate,
|
||||
period,
|
||||
cursor: cursor?.periodCursor,
|
||||
userCursor: cursor?.userCursor,
|
||||
limit,
|
||||
isAll: isAll ?? false,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: routingFormId ?? null,
|
||||
searchQuery: searchQuery,
|
||||
});
|
||||
}),
|
||||
routedToPerPeriodCsv: userBelongsToTeamProcedure
|
||||
.input(
|
||||
rawDataInputSchema.extend({
|
||||
period: z.enum(["perDay", "perWeek", "perMonth"]),
|
||||
searchQuery: z.string().optional(),
|
||||
routingFormId: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate } = input;
|
||||
try {
|
||||
const csvData = await RoutingEventsInsights.routedToPerPeriodCsv({
|
||||
teamId: input.teamId ?? null,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll: input.isAll ?? false,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId ?? null,
|
||||
period: input.period,
|
||||
searchQuery: input.searchQuery,
|
||||
});
|
||||
|
||||
const csvString = RoutingEventsInsights.objectToCsv(csvData);
|
||||
const downloadAs = `routed-to-${input.period}-${dayjs(startDate).format("YYYY-MM-DD")}-${dayjs(
|
||||
endDate
|
||||
).format("YYYY-MM-DD")}.csv`;
|
||||
|
||||
return { data: csvString, filename: downloadAs };
|
||||
} catch (e) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -67,7 +67,8 @@ const shuffle = (
|
||||
booking.uid = uuidv4();
|
||||
|
||||
if (usersIdsToPick && usersIdsToPick.length > 0) {
|
||||
booking.userId = Math.random() > 0.5 ? usersIdsToPick[0] : usersIdsToPick[1];
|
||||
// Pick a random user from all available users instead of just two
|
||||
booking.userId = usersIdsToPick[Math.floor(Math.random() * usersIdsToPick.length)];
|
||||
} else {
|
||||
booking.userId = randomEvent.userId;
|
||||
}
|
||||
@@ -208,6 +209,15 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
const insightsMembers = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: insightsTeam.id,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create bookings for the team events
|
||||
const baseBooking = {
|
||||
uid: "demoUID",
|
||||
@@ -215,7 +225,7 @@ async function main() {
|
||||
description: "Team Meeting Should be changed in shuffle",
|
||||
startTime: dayjs().toISOString(),
|
||||
endTime: dayjs().toISOString(),
|
||||
userId: orgMembers[0].id, // Use first org member as default
|
||||
userId: insightsMembers[0].user.id, // Use first org member as default
|
||||
};
|
||||
|
||||
// Create past bookings -2y, -1y, -0y
|
||||
@@ -226,7 +236,7 @@ async function main() {
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y") - 2,
|
||||
teamEvents,
|
||||
orgMembers.map((m) => m.id)
|
||||
insightsMembers.map((m) => m.user.id)
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -239,7 +249,7 @@ async function main() {
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y") - 1,
|
||||
teamEvents,
|
||||
orgMembers.map((m) => m.id)
|
||||
insightsMembers.map((m) => m.user.id)
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -252,7 +262,7 @@ async function main() {
|
||||
{ ...baseBooking },
|
||||
dayjs().get("y"),
|
||||
teamEvents,
|
||||
orgMembers.map((m) => m.id)
|
||||
insightsMembers.map((m) => m.user.id)
|
||||
)
|
||||
),
|
||||
],
|
||||
@@ -268,7 +278,7 @@ async function main() {
|
||||
}
|
||||
const seededForm = await seedRoutingForms(
|
||||
insightsTeam.id,
|
||||
owner?.user.id ?? orgMembers[0].user.id,
|
||||
owner?.user.id ?? insightsMembers[0].user.id,
|
||||
attributes
|
||||
);
|
||||
|
||||
|
||||
@@ -591,7 +591,7 @@ export async function seedRoutingFormResponses(
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
take: 250, // Limit to 250 responses
|
||||
take: 1000, // Limit to 1000 responses
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
@@ -610,11 +610,18 @@ export async function seedRoutingFormResponses(
|
||||
const shuffledOptions = [...attributeRaw[2].options].sort(() => Math.random() - 0.5);
|
||||
const selectedSkills = shuffledOptions.slice(0, numSkills);
|
||||
|
||||
// Generate a random date within the last 30 days
|
||||
const randomDate = dayjs()
|
||||
.subtract(Math.floor(Math.random() * 30), "days")
|
||||
.subtract(Math.floor(Math.random() * 24), "hours")
|
||||
.subtract(Math.floor(Math.random() * 60), "minutes");
|
||||
|
||||
// Create the form response with the routedToBookingUid field set
|
||||
const response = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: seededForm.id,
|
||||
formFillerId: randomUUID(),
|
||||
createdAt: randomDate.toDate(),
|
||||
response: {
|
||||
[seededForm.formFieldFilled.id]: {
|
||||
label: "skills",
|
||||
@@ -631,14 +638,17 @@ export async function seedRoutingFormResponses(
|
||||
},
|
||||
data: {
|
||||
routedFromRoutingFormReponse: { connect: { id: response.id } },
|
||||
startTime: randomDate.toDate(),
|
||||
endTime: randomDate.add(1, "hour").toDate(),
|
||||
createdAt: randomDate.toDate(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create some responses without a booking
|
||||
await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: seededForm.id,
|
||||
formFillerId: randomUUID(),
|
||||
createdAt: randomDate.subtract(2, "hour").toDate(),
|
||||
response: {
|
||||
[seededForm.formFieldFilled.id]: {
|
||||
label: "skills",
|
||||
|
||||
@@ -44,7 +44,11 @@ const listPaginatedHandler = async ({ input }: GetOptions) => {
|
||||
const users = await prisma.user.findMany({
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
take: limit + 1, // We take +1 as itll be used for the next cursor
|
||||
where: searchFilters,
|
||||
where: {
|
||||
...searchFilters,
|
||||
// To bypass the excludeLockedUsersExtension
|
||||
OR: [{ locked: false }, { locked: true }],
|
||||
},
|
||||
orderBy: {
|
||||
id: "asc",
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"./components/icon/Spinner": "./components/icon/Spinner.tsx",
|
||||
"./components/unpublished-entity/UnpublishedEntity": "./components/unpublished-entity/index.ts",
|
||||
"./components/form/timezone-select/TimezoneSelect": "./components/form/timezone-select/index.ts",
|
||||
"./components/table/TableNew": "./components/table/TableNew.tsx",
|
||||
"./__mocks__/ui": "./__mocks__/ui.tsx"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
Reference in new issue
Block a user