diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 1d466baa83086a..cbac224b79a810 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -22,6 +22,7 @@ import { MostCompletedTeamMembersTable, LeastCompletedTeamMembersTable, PopularEventsTable, + RecentNoShowGuestsChart, RecentFeedbackTable, TimezoneBadge, } from "@calcom/features/insights/components/booking"; @@ -94,7 +95,7 @@ function InsightsPageContent() {
- +
@@ -106,6 +107,12 @@ function InsightsPageContent() { +
+
+ +
+
+ {t("looking_for_more_insights")}{" "} void }; legend?: Array; legendSize?: LegendSize; + className?: string; + titleTooltip?: string; children: ReactNode; }) { const legendComponent = legend && legend.length > 0 ? : null; return ( - + {children} ); @@ -52,7 +62,7 @@ export function ChartCardItem({ "text-default border-muted flex items-center justify-between border-b px-3 py-3.5 last:border-b-0", className )}> -
{children}
+
{children}
{count !== undefined &&
{count}
} ); diff --git a/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx b/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx new file mode 100644 index 00000000000000..f59c0cdfa70f70 --- /dev/null +++ b/packages/features/insights/components/booking/RecentNoShowGuestsChart.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import { Button } from "@calcom/ui/components/button"; +import { showToast } from "@calcom/ui/components/toast"; + +import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters"; +import { ChartCard, ChartCardItem } from "../ChartCard"; +import { LoadingInsight } from "../LoadingInsights"; + +export const RecentNoShowGuestsChart = () => { + const { t } = useLocale(); + const { copyToClipboard, isCopied } = useCopy(); + const insightsBookingParams = useInsightsBookingParameters(); + const timeZone = insightsBookingParams.timeZone; + + const { data, isSuccess, isPending } = trpc.viewer.insights.recentNoShowGuests.useQuery( + insightsBookingParams, + { + staleTime: 180000, + refetchOnWindowFocus: false, + trpc: { + context: { skipBatch: true }, + }, + } + ); + + if (isPending) return ; + + if (!isSuccess || !data) return null; + + const handleCopyEmail = (email: string) => { + copyToClipboard(email); + showToast(t("email_copied"), "success"); + }; + + return ( + +
+ {data.map((item) => ( + +
+
+
+
+

{item.guestName}

+
+

{item.eventTypeName}

+

+ {Intl.DateTimeFormat(undefined, { + timeZone, + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(item.startTime))} +

+
+
+
+ +
+ + ))} +
+ {data.length === 0 && ( +
+

{t("insights_no_data_found_for_filter")}

+
+ )} + + ); +}; diff --git a/packages/features/insights/components/booking/index.ts b/packages/features/insights/components/booking/index.ts index b0b287a166f5da..924cc5b7e14a77 100644 --- a/packages/features/insights/components/booking/index.ts +++ b/packages/features/insights/components/booking/index.ts @@ -9,6 +9,7 @@ export { LowestRatedMembersTable } from "./LowestRatedMembersTable"; export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable"; export { MostCancelledBookingsTables } from "./MostCancelledBookingsTables"; export { PopularEventsTable } from "./PopularEventsTable"; +export { RecentNoShowGuestsChart } from "./RecentNoShowGuestsChart"; export { RecentFeedbackTable } from "./RecentFeedbackTable"; export { TimezoneBadge } from "./TimezoneBadge"; export { MostCompletedTeamMembersTable } from "./MostCompletedBookings"; diff --git a/packages/features/insights/server/trpc-router.ts b/packages/features/insights/server/trpc-router.ts index 05d700d0ae4786..845a1b946a0b31 100644 --- a/packages/features/insights/server/trpc-router.ts +++ b/packages/features/insights/server/trpc-router.ts @@ -1051,6 +1051,17 @@ export const insightsRouter = router({ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); } }), + recentNoShowGuests: userBelongsToTeamProcedure + .input(bookingRepositoryBaseInputSchema) + .query(async ({ ctx, input }) => { + const insightsBookingService = createInsightsBookingService(ctx, input, "startTime"); + + try { + return await insightsBookingService.getRecentNoShowGuests(); + } catch (e) { + throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); + } + }), }); async function getEventTypeList({ diff --git a/packages/lib/server/service/InsightsBookingBaseService.ts b/packages/lib/server/service/InsightsBookingBaseService.ts index ac8b65f53f52ec..78b33a521ae5a0 100644 --- a/packages/lib/server/service/InsightsBookingBaseService.ts +++ b/packages/lib/server/service/InsightsBookingBaseService.ts @@ -1122,6 +1122,58 @@ export class InsightsBookingBaseService { }; } + async getRecentNoShowGuests() { + const baseConditions = await this.getBaseConditions(); + + const recentNoShowBookings = await this.prisma.$queryRaw< + Array<{ + bookingId: number; + startTime: Date; + eventTypeName: string; + guestName: string; + guestEmail: string; + }> + >` + WITH booking_attendee_stats AS ( + SELECT + b.id as booking_id, + b."startTime", + b.title as event_type_name, + COUNT(a.id) as total_attendees, + COUNT(CASE WHEN a."noShow" = true THEN 1 END) as no_show_attendees + FROM "BookingTimeStatusDenormalized" b + INNER JOIN "Attendee" a ON a."bookingId" = b.id + WHERE ${baseConditions} and b.status = 'accepted' + GROUP BY b.id, b."startTime", b.title + HAVING COUNT(a.id) > 0 AND COUNT(a.id) = COUNT(CASE WHEN a."noShow" = true THEN 1 END) + ), + recent_no_shows AS ( + SELECT + bas.booking_id, + bas."startTime", + bas.event_type_name, + a.name as guest_name, + a.email as guest_email, + ROW_NUMBER() OVER (PARTITION BY bas.booking_id ORDER BY a.id) as rn + FROM booking_attendee_stats bas + INNER JOIN "Attendee" a ON a."bookingId" = bas.booking_id + WHERE a."noShow" = true + ) + SELECT + booking_id as "bookingId", + "startTime", + event_type_name as "eventTypeName", + guest_name as "guestName", + guest_email as "guestEmail" + FROM recent_no_shows + WHERE rn = 1 + ORDER BY "startTime" DESC + LIMIT 10 + `; + + return recentNoShowBookings; + } + calculatePreviousPeriodDates() { if (!this.filters?.dateRange) { throw new Error("Date range is required for calculating previous period"); diff --git a/packages/ui/components/card/PanelCard.tsx b/packages/ui/components/card/PanelCard.tsx index 1339d7d949c2dc..93552042682a02 100644 --- a/packages/ui/components/card/PanelCard.tsx +++ b/packages/ui/components/card/PanelCard.tsx @@ -1,5 +1,7 @@ import type { ReactNode } from "react"; +import classNames from "@calcom/ui/classNames"; +import { InfoBadge } from "@calcom/ui/components/badge"; import { Button } from "@calcom/ui/components/button"; export function PanelCard({ @@ -7,19 +9,30 @@ export function PanelCard({ subtitle, cta, headerContent, + className, + titleTooltip, children, }: { title: string | ReactNode; subtitle?: string; cta?: { label: string; onClick: () => void }; headerContent?: ReactNode; + className?: string; + titleTooltip?: string; children: ReactNode; }) { return ( -
+
{typeof title === "string" ? ( -

{title}

+
+

{title}

+ {titleTooltip && } +
) : ( title )}