From fc6e39c78a3d40a6354b64a899caa77734e6183e Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Wed, 11 Dec 2024 08:12:06 +0000 Subject: [PATCH] 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 --- .../insights/insights-routing-view.tsx | 3 + apps/web/public/static/locales/en/common.json | 5 + .../components/DataTableSkeleton.tsx | 48 ++ .../features/data-table/components/index.ts | 1 + .../insights/components/RoutedToPerPeriod.tsx | 454 ++++++++++++++++ .../components/RoutingFormResponsesTable.tsx | 54 +- .../features/insights/components/index.ts | 1 + .../insights/server/routing-events.ts | 489 +++++++++++++++++- .../features/insights/server/trpc-router.ts | 64 +++ packages/prisma/seed-insights.ts | 22 +- packages/prisma/seed-utils.ts | 14 +- .../viewer/admin/listPaginated.handler.ts | 6 +- packages/ui/package.json | 1 + 13 files changed, 1101 insertions(+), 61 deletions(-) create mode 100644 packages/features/data-table/components/DataTableSkeleton.tsx create mode 100644 packages/features/insights/components/RoutedToPerPeriod.tsx diff --git a/apps/web/modules/insights/insights-routing-view.tsx b/apps/web/modules/insights/insights-routing-view.tsx index cddc6f97d9..87f1c4be5f 100644 --- a/apps/web/modules/insights/insights-routing-view.tsx +++ b/apps/web/modules/insights/insights-routing-view.tsx @@ -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() { )} + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 5db1d376cb..70cd3e6bd9 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/data-table/components/DataTableSkeleton.tsx b/packages/features/data-table/components/DataTableSkeleton.tsx new file mode 100644 index 0000000000..7a148d392b --- /dev/null +++ b/packages/features/data-table/components/DataTableSkeleton.tsx @@ -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 ( + + + + + + {[...Array(columns)].map((_, index) => ( + + + + ))} + + + + {[...Array(rows)].map((_, rowIndex) => ( + + {[...Array(columns)].map((_, colIndex) => ( + + + + ))} + + ))} + + + + + ); +} diff --git a/packages/features/data-table/components/index.ts b/packages/features/data-table/components/index.ts index 4b60a21365..059d7cc6a4 100644 --- a/packages/features/data-table/components/index.ts +++ b/packages/features/data-table/components/index.ts @@ -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"; diff --git a/packages/features/insights/components/RoutedToPerPeriod.tsx b/packages/features/insights/components/RoutedToPerPeriod.tsx new file mode 100644 index 0000000000..c73c39a0b3 --- /dev/null +++ b/packages/features/insights/components/RoutedToPerPeriod.tsx @@ -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 ( + + ); +} + +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 ( + + + + + value && onPeriodChange(value)} + /> + + + onSearchChange(e.target.value)} + className="w-full" + /> + + + + + {children} + + + + ); +} + +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 ( + + + {t("above_average")} + + + ); + case "below_average": + return ( + + + {t("below_average")} + + + ); + case "median": + return ( + + + {t("median")} + + + ); + case "at_average": + return ( + + + {t("at_average")} + + + ); + default: + return ( + + + {t("no_data")} + + + ); + } +}; + +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(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(); + 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(); + 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 ( + + + {t("routed_to_per_period")} + + + + + + + + + ); + } + + 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 ( + + + {t("routed_to_per_period")} + + + + + + + + + {t("user")} + {uniquePeriods.map((period, index) => { + const date = period; + const today = new Date(); + + const isCurrent = isCurrentPeriod(date, today, selectedPeriod); + + return ( + + + {date.toLocaleDateString()} + + + ); + })} + + + + {processedData.map((row, index) => { + return ( + + + + + + + + {row.name} + + + + + + + {row.name} + + + + {t("total_bookings_per_period")}: {row.totalBookings} + {t("status")}: {getPerformanceBadge(row.performance, t)} + + + + + + + {uniquePeriods.map((period) => { + return ( + + {row.stats[period.toISOString()] === 0 ? null : ( + <>{row.stats[period.toISOString()]}> + )} + + ); + })} + + ); + })} + + + + + + + ); +} diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 740e1cf84c..17259e886f 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -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 ( - - - - - - {[...Array(4)].map((_, index) => ( - - - - ))} - - - - {[...Array(10)].map((_, rowIndex) => ( - - {[...Array(4)].map((_, colIndex) => ( - - - - ))} - - ))} - - - - - ); + return ; } return ( diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index 3aaa85c3ac..e86ff5ba29 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -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"; diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index c759fe60c3..5e35c42ecb 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -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); + + 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); + + // 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>); + + // 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 + ); + + 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[]) { + 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 }; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index d9f0a36602..4a71a873f0 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -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" }); + } + }), }); diff --git a/packages/prisma/seed-insights.ts b/packages/prisma/seed-insights.ts index 32dc6d32cf..bafa4b7a26 100644 --- a/packages/prisma/seed-insights.ts +++ b/packages/prisma/seed-insights.ts @@ -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 ); diff --git a/packages/prisma/seed-utils.ts b/packages/prisma/seed-utils.ts index 0a04f154f1..b6c0049d66 100644 --- a/packages/prisma/seed-utils.ts +++ b/packages/prisma/seed-utils.ts @@ -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", diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts index 226d31bd7f..3fbd3dfc15 100644 --- a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts @@ -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", }, diff --git a/packages/ui/package.json b/packages/ui/package.json index 065517be7d..c9b6be02c0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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",