feat: add Recent No-Show Guests chart to insights page (#23381)
* feat: add Recent No-Show Guests chart to insights page - Add RecentNoShowGuestsChart component with ChartCard wrapper - Add tRPC handler for recentNoShowGuests query - Add getRecentNoShowGuests method to InsightsBookingBaseService - Display guest name, booking time, event type, and copy email button - Filter for bookings where ALL attendees are no-shows - Add translation strings for new UI elements - Integrate chart into insights view grid layout Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * re-order charts * clean up * feat: add optional tooltip functionality to PanelCard - Add titleTooltip prop to PanelCard component with InfoBadge - Pass through titleTooltip prop in ChartCard - Add tooltip to RecentNoShowGuestsChart explaining complete no-show filtering - Add translation string for tooltip explanation Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * style adjustment * update style * address feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
MostCompletedTeamMembersTable,
|
||||
LeastCompletedTeamMembersTable,
|
||||
PopularEventsTable,
|
||||
RecentNoShowGuestsChart,
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} from "@calcom/features/insights/components/booking";
|
||||
@@ -94,7 +95,7 @@ function InsightsPageContent() {
|
||||
<MostCancelledBookingsTables />
|
||||
<HighestNoShowHostTable />
|
||||
<div className="sm:col-span-2">
|
||||
<PopularEventsTable />
|
||||
<RecentNoShowGuestsChart />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -106,6 +107,12 @@ function InsightsPageContent() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<div className="sm:col-span-2">
|
||||
<PopularEventsTable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
<a
|
||||
|
||||
@@ -3273,6 +3273,8 @@
|
||||
"routing_form_select_members_to_email": "Send email responses to",
|
||||
"routing_incomplete_booking_tab": "Incomplete Bookings",
|
||||
"include_no_show_in_rr_calculation": "Include no show bookings in round robin calculations",
|
||||
"recent_no_show_guests": "Recent No-Show Guests",
|
||||
"recent_no_show_guests_tooltip": "Shows bookings where all attendees were no-shows, not partial no-shows",
|
||||
"matching": "Matching",
|
||||
"event_redirect": "Event Redirect",
|
||||
"reset_form": "Reset Form",
|
||||
|
||||
@@ -20,18 +20,28 @@ export function ChartCard({
|
||||
legend,
|
||||
legendSize,
|
||||
children,
|
||||
className,
|
||||
titleTooltip,
|
||||
}: {
|
||||
title: string | ReactNode;
|
||||
subtitle?: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
legend?: Array<LegendItem>;
|
||||
legendSize?: LegendSize;
|
||||
className?: string;
|
||||
titleTooltip?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const legendComponent = legend && legend.length > 0 ? <Legend items={legend} size={legendSize} /> : null;
|
||||
|
||||
return (
|
||||
<PanelCard title={title} subtitle={subtitle} cta={cta} headerContent={legendComponent}>
|
||||
<PanelCard
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
cta={cta}
|
||||
headerContent={legendComponent}
|
||||
className={className}
|
||||
titleTooltip={titleTooltip}>
|
||||
{children}
|
||||
</PanelCard>
|
||||
);
|
||||
@@ -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
|
||||
)}>
|
||||
<div className="text-sm font-medium">{children}</div>
|
||||
<div className="grow text-sm font-medium">{children}</div>
|
||||
{count !== undefined && <div className="text-sm font-medium">{count}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !data) return null;
|
||||
|
||||
const handleCopyEmail = (email: string) => {
|
||||
copyToClipboard(email);
|
||||
showToast(t("email_copied"), "success");
|
||||
};
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
title={t("recent_no_show_guests")}
|
||||
titleTooltip={t("recent_no_show_guests_tooltip")}
|
||||
className="h-full">
|
||||
<div className="sm:max-h-[30.6rem] sm:overflow-y-auto">
|
||||
{data.map((item) => (
|
||||
<ChartCardItem key={item.bookingId}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-subtle h-16 w-[2px] shrink-0 rounded-sm" />
|
||||
<div className="flex flex-col space-y-1">
|
||||
<p className="text-sm font-medium">{item.guestName}</p>
|
||||
<div className="text-subtle text-sm leading-tight">
|
||||
<p>{item.eventTypeName}</p>
|
||||
<p>
|
||||
{Intl.DateTimeFormat(undefined, {
|
||||
timeZone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(item.startTime))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
color="minimal"
|
||||
size="sm"
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
onClick={() => handleCopyEmail(item.guestEmail)}>
|
||||
{!isCopied ? t("email") : t("copied")}
|
||||
</Button>
|
||||
</div>
|
||||
</ChartCardItem>
|
||||
))}
|
||||
</div>
|
||||
{data.length === 0 && (
|
||||
<div className="flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -1017,6 +1017,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" });
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export async function getEventTypeList({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1",
|
||||
className
|
||||
)}>
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
|
||||
{typeof title === "string" ? (
|
||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
||||
<div className="mr-4 flex shrink-0 items-center gap-1">
|
||||
<h2 className="text-emphasis shrink-0 text-sm font-semibold">{title}</h2>
|
||||
{titleTooltip && <InfoBadge content={titleTooltip} />}
|
||||
</div>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user