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:
sean-brydon authored and GitHub committed 2024-12-11 08:12:06 +00:00
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" });
}
}),
});
+16 -6
View File
@@ -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
);
+12 -2
View File
@@ -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",
},
+1
View File
@@ -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",