diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 56b903a45a..1c6870906a 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -11,13 +11,14 @@ import { AverageEventDurationChart, BookingKPICards, BookingStatusLineChart, - LeastBookedTeamMembersTable, - MostBookedTeamMembersTable, - PopularEventsTable, HighestNoShowHostTable, - RecentFeedbackTable, HighestRatedMembersTable, + LeastBookedTeamMembersTable, LowestRatedMembersTable, + MostBookedTeamMembersTable, + MostCancelledBookingsTables, + PopularEventsTable, + RecentFeedbackTable, } from "@calcom/features/insights/components"; import "@calcom/features/insights/components/tremor.css"; import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider"; @@ -76,6 +77,9 @@ function InsightsPageContent() { +
+ +
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4e15163333..79b253c140 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3069,6 +3069,7 @@ "display": "Display", "could_not_find_slug_to_publish_org": "Could not find slug to publish the organization", "picklist": "Picklist", + "most_cancelled_bookings": "Most Cancelled Bookings", "name_or_email": "Name or Email", "salesforce_round_robin_skip_fallback_to_lead_owner": "If no contact is found, fallback to lead owner if it exists", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" diff --git a/packages/features/insights/components/MostCancelledBookingsTables.tsx b/packages/features/insights/components/MostCancelledBookingsTables.tsx new file mode 100644 index 0000000000..e452f79fbd --- /dev/null +++ b/packages/features/insights/components/MostCancelledBookingsTables.tsx @@ -0,0 +1,39 @@ +import { Title } from "@tremor/react"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; + +import { useInsightsParameters } from "../hooks/useInsightsParameters"; +import { CardInsights } from "./Card"; +import { LoadingInsight } from "./LoadingInsights"; +import { TotalBookingUsersTable } from "./TotalBookingUsersTable"; + +export const MostCancelledBookingsTables = () => { + const { t } = useLocale(); + const { isAll, teamId, startDate, endDate, eventTypeId } = useInsightsParameters(); + + const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostCancelledBookings.useQuery( + { + startDate, + endDate, + teamId, + eventTypeId, + isAll, + }, + { + staleTime: 30000, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + return ( + + {t("most_cancelled_bookings")} + {!isSuccess || !startDate || !endDate || !teamId ? null : } + + ); +}; diff --git a/packages/features/insights/components/index.ts b/packages/features/insights/components/index.ts index e86ff5ba29..368c8145db 100644 --- a/packages/features/insights/components/index.ts +++ b/packages/features/insights/components/index.ts @@ -1,14 +1,15 @@ export { AverageEventDurationChart } from "./AverageEventDurationChart"; export { BookingKPICards } from "./BookingKPICards"; -export { RoutingKPICards } from "./RoutingKPICards"; export { BookingStatusLineChart } from "./BookingStatusLineChart"; -export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; -export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; -export { PopularEventsTable } from "./PopularEventsTable"; -export { RecentFeedbackTable } from "./RecentFeedbackTable"; +export { FailedBookingsByField } from "./FailedBookingsByField"; export { HighestNoShowHostTable } from "./HighestNoShowHostTable"; export { HighestRatedMembersTable } from "./HighestRatedMembersTable"; +export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable"; export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; -export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable"; -export { FailedBookingsByField } from "./FailedBookingsByField"; +export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; +export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; +export { PopularEventsTable } from "./PopularEventsTable"; +export { RecentFeedbackTable } from "./RecentFeedbackTable"; export { RoutedToPerPeriod } from "./RoutedToPerPeriod"; +export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable"; +export { RoutingKPICards } from "./RoutingKPICards"; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 68080c63ff..75219f76c5 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -755,6 +755,89 @@ export const insightsRouter = router({ return result; }), + membersWithMostCancelledBookings: userBelongsToTeamProcedure + .input(rawDataInputSchema) + .query(async ({ ctx, input }) => { + const { teamId, startDate, endDate, eventTypeId, isAll, userId, memberUserId } = input; + + if (!teamId) { + return []; + } + const user = ctx.user; + + const r = await buildBaseWhereCondition({ + teamId, + eventTypeId: eventTypeId ?? undefined, + memberUserId: memberUserId ?? undefined, + userId: userId ?? undefined, + isAll: isAll ?? false, + ctx: { + userIsOwnerAdminOfParentTeam: ctx.user.isOwnerAdminOfParentTeam, + userOrganizationId: ctx.user.organizationId, + insightsDb: ctx.insightsDb, + }, + }); + + let { whereCondition: bookingWhere } = r; + + bookingWhere = { + ...bookingWhere, + createdAt: { + gte: dayjs(startDate).startOf("day").toDate(), + lte: dayjs(endDate).endOf("day").toDate(), + }, + status: "CANCELLED", + }; + + const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({ + by: ["userId"], + where: bookingWhere, + _count: { + id: true, + }, + orderBy: { + _count: { + id: "desc", + }, + }, + take: 10, + }); + + const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => { + if (typeof booking.userId === "number" && !userIds.includes(booking.userId)) { + userIds.push(booking.userId); + } + return userIds; + }, []); + + if (userIds.length === 0) { + return []; + } + + const usersFromTeam = await ctx.insightsDb.user.findMany({ + where: { + id: { + in: userIds, + }, + }, + select: userSelect, + }); + + const userHashMap = buildHashMapForUsers(usersFromTeam); + + const result = bookingsFromTeam.map((booking) => { + return { + userId: booking.userId, + // We know with 100% certainty that userHashMap.get(...) will retrieve a user + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + user: userHashMap.get(booking.userId)!, + emailMd5: md5(user?.email), + count: booking._count.id, + }; + }); + + return result; + }), membersWithMostBookings: userBelongsToTeamProcedure .input(rawDataInputSchema) .query(async ({ ctx, input }) => {