feat: Add ratings to insights (#14687)
This commit is contained in:
@@ -7,6 +7,10 @@ import {
|
||||
LeastBookedTeamMembersTable,
|
||||
MostBookedTeamMembersTable,
|
||||
PopularEventsTable,
|
||||
HighestNoShowHostTable,
|
||||
RecentFeedbackTable,
|
||||
HighestRatedMembersTable,
|
||||
LowestRatedMembersTable,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
@@ -90,6 +94,12 @@ export default function InsightsPage() {
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<RecentFeedbackTable />
|
||||
<HighestNoShowHostTable />
|
||||
<HighestRatedMembersTable />
|
||||
<LowestRatedMembersTable />
|
||||
</div>
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
<a
|
||||
|
||||
@@ -2413,6 +2413,15 @@
|
||||
"no_show_url_info":"The URL for No Show Feedback",
|
||||
"no_support_needed":"No Support Needed?",
|
||||
"hide_support":"Hide Support",
|
||||
"event_ratings": "Average Ratings",
|
||||
"event_no_show": "Host No Show",
|
||||
"recent_ratings": "Recent ratings",
|
||||
"no_ratings": "No ratings submitted",
|
||||
"no_ratings_description": "Add a workflow with 'Rating' to collect ratings after meetings",
|
||||
"most_no_show_host": "Most No Show Members",
|
||||
"highest_rated_members": "Members With Highest Rated Meetings",
|
||||
"lowest_rated_members": "Members With Lowest Rated Meetings",
|
||||
"csat_score": "CSAT Score",
|
||||
"lockedSMS": "Locked SMS",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export const BookingKPICards = () => {
|
||||
|
||||
const categories: {
|
||||
title: string;
|
||||
index: "created" | "completed" | "rescheduled" | "cancelled";
|
||||
index: "created" | "completed" | "rescheduled" | "cancelled" | "no_show" | "rating" | "csat";
|
||||
}[] = [
|
||||
{
|
||||
title: t("events_created"),
|
||||
@@ -57,6 +57,18 @@ export const BookingKPICards = () => {
|
||||
title: t("events_cancelled"),
|
||||
index: "cancelled",
|
||||
},
|
||||
{
|
||||
title: t("event_ratings"),
|
||||
index: "rating",
|
||||
},
|
||||
{
|
||||
title: t("event_no_show"),
|
||||
index: "no_show",
|
||||
},
|
||||
{
|
||||
title: t("csat_score"),
|
||||
index: "csat",
|
||||
},
|
||||
];
|
||||
|
||||
if (isPending) {
|
||||
|
||||
@@ -59,9 +59,9 @@ export const BookingStatusLineChart = () => {
|
||||
<LineChart
|
||||
className="linechart mt-4 h-80"
|
||||
data={eventsTimeLine ?? []}
|
||||
categories={["Created", "Completed", "Rescheduled", "Cancelled"]}
|
||||
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)"]}
|
||||
index="Month"
|
||||
colors={["purple", "green", "blue", "red"]}
|
||||
colors={["purple", "green", "blue", "red", "slate"]}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
</CardInsights>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { Avatar, EmptyScreen, Button } from "@calcom/ui";
|
||||
|
||||
export const FeedbackTable = ({
|
||||
data,
|
||||
}: {
|
||||
data:
|
||||
| {
|
||||
userId: number | null;
|
||||
user: Pick<User, "avatarUrl" | "name">;
|
||||
emailMd5?: string;
|
||||
username?: string;
|
||||
rating: number | null;
|
||||
feedback: string | null;
|
||||
}[]
|
||||
| undefined;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<>
|
||||
{data && data?.length > 0 ? (
|
||||
data?.map((item) => (
|
||||
<TableRow key={item.userId}>
|
||||
<TableCell className="flex flex-row">
|
||||
<Avatar
|
||||
alt={item.user.name || ""}
|
||||
size="sm"
|
||||
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
|
||||
title={item.user.name || ""}
|
||||
className="m-2"
|
||||
/>
|
||||
<p className="text-default mx-0 my-auto">
|
||||
<strong>{item.user.name}</strong>
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>
|
||||
<strong className="text-default">{item.rating}</strong>
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>
|
||||
<strong className="text-default">{item.feedback}</strong>
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<EmptyScreen
|
||||
Icon="zap"
|
||||
headline={t("no_ratings")}
|
||||
description={t("no_ratings_description")}
|
||||
buttonRaw={
|
||||
<Button target="_blank" color="secondary" href="/workflows">
|
||||
{t("workflows")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Title } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
|
||||
|
||||
export const HighestNoShowHostTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithMostNoShow.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
|
||||
return data && data.length > 0 ? (
|
||||
<CardInsights className="shadow-none">
|
||||
<Title className="text-emphasis">{t("most_no_show_host")}</Title>
|
||||
<TotalUserFeedbackTable data={data} />
|
||||
</CardInsights>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Title } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
|
||||
|
||||
export const HighestRatedMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithHighestRatings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
|
||||
return data && data.length > 0 ? (
|
||||
<CardInsights className="shadow-none">
|
||||
<Title className="text-emphasis">{t("highest_rated_members")}</Title>
|
||||
<TotalUserFeedbackTable data={data} />
|
||||
</CardInsights>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Title } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalUserFeedbackTable } from "./TotalUserFeedbackTable";
|
||||
|
||||
export const LowestRatedMembersTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.membersWithLowestRatings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
|
||||
return data && data.length > 0 ? (
|
||||
<CardInsights className="shadow-none">
|
||||
<Title className="text-emphasis">{t("lowest_rated_members")}</Title>
|
||||
<TotalUserFeedbackTable data={data} />
|
||||
</CardInsights>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Title } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { FeedbackTable } from "./FeedbackTable";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const RecentFeedbackTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, selectedTeamId: teamId, isAll, initialConfig } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const { data, isSuccess, isPending } = trpc.viewer.insights.recentRatings.useQuery(
|
||||
{
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
enabled: !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll),
|
||||
}
|
||||
);
|
||||
|
||||
if (isPending) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
|
||||
return (
|
||||
<CardInsights>
|
||||
<Title className="text-emphasis">{t("recent_ratings")}</Title>
|
||||
<FeedbackTable data={data} />
|
||||
</CardInsights>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
@@ -27,7 +28,7 @@ export const TotalBookingUsersTable = ({
|
||||
<Avatar
|
||||
alt={item.user.name || ""}
|
||||
size="sm"
|
||||
imageSrc={item.user.avatarUrl}
|
||||
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
|
||||
title={item.user.name || ""}
|
||||
className="m-2"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Table, TableBody, TableCell, TableRow, Text } from "@tremor/react";
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
export const TotalUserFeedbackTable = ({
|
||||
data,
|
||||
}: {
|
||||
data:
|
||||
| {
|
||||
userId: number | null;
|
||||
user: Pick<User, "avatarUrl" | "name">;
|
||||
emailMd5?: string;
|
||||
count?: number;
|
||||
averageRating?: number | null;
|
||||
username?: string;
|
||||
}[]
|
||||
| undefined;
|
||||
}) => {
|
||||
return (
|
||||
<Table>
|
||||
<TableBody>
|
||||
<>
|
||||
{data &&
|
||||
data?.length > 0 &&
|
||||
data?.map((item) => (
|
||||
<TableRow key={item.userId}>
|
||||
<TableCell className="flex flex-row">
|
||||
<Avatar
|
||||
alt={item.user.name || ""}
|
||||
size="sm"
|
||||
imageSrc={getUserAvatarUrl({ avatarUrl: item.user.avatarUrl })}
|
||||
title={item.user.name || ""}
|
||||
className="m-2"
|
||||
/>
|
||||
<p className="text-default mx-0 my-auto">
|
||||
<strong>{item.user.name}</strong>
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>
|
||||
<strong className="text-default">
|
||||
{item.averageRating ? item.averageRating.toFixed(1) : item.count}
|
||||
</strong>
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
@@ -4,3 +4,7 @@ export { BookingStatusLineChart } from "./BookingStatusLineChart";
|
||||
export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable";
|
||||
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
|
||||
export { PopularEventsTable } from "./PopularEventsTable";
|
||||
export { RecentFeedbackTable } from "./RecentFeedbackTable";
|
||||
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
|
||||
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
|
||||
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
|
||||
|
||||
@@ -77,6 +77,18 @@ class EventsInsights {
|
||||
return result;
|
||||
};
|
||||
|
||||
static getNoShowHostsInTimeRange = async (
|
||||
timeRange: ITimeRange,
|
||||
where: Prisma.BookingTimeStatusWhereInput
|
||||
) => {
|
||||
const result = await this.getBookingsInTimeRange(timeRange, {
|
||||
...where,
|
||||
noShowHost: true,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
static getBaseBookingCountForEventStatus = async (where: Prisma.BookingTimeStatusWhereInput) => {
|
||||
const baseBookings = await prisma.bookingTimeStatus.count({
|
||||
where,
|
||||
@@ -112,6 +124,47 @@ class EventsInsights {
|
||||
});
|
||||
};
|
||||
|
||||
static getAverageRating = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => {
|
||||
return await prisma.bookingTimeStatus.aggregate({
|
||||
_avg: {
|
||||
rating: true,
|
||||
},
|
||||
where: {
|
||||
...whereConditional,
|
||||
rating: {
|
||||
not: null, // Exclude null ratings
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
static getTotalNoShows = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => {
|
||||
return await prisma.bookingTimeStatus.count({
|
||||
where: {
|
||||
...whereConditional,
|
||||
noShowHost: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
static getTotalCSAT = async (whereConditional: Prisma.BookingTimeStatusWhereInput) => {
|
||||
const result = await prisma.bookingTimeStatus.findMany({
|
||||
where: {
|
||||
...whereConditional,
|
||||
rating: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: { rating: true },
|
||||
});
|
||||
|
||||
const totalResponses = result.length;
|
||||
const satisfactoryResponses = result.filter((item) => item.rating && item.rating > 3).length;
|
||||
const csat = totalResponses > 0 ? (satisfactoryResponses / totalResponses) * 100 : 0;
|
||||
|
||||
return csat;
|
||||
};
|
||||
|
||||
static getTimeLine = async (timeView: TimeViewType, startDate: Dayjs, endDate: Dayjs) => {
|
||||
let resultTimeLine: string[] = [];
|
||||
|
||||
@@ -239,6 +292,9 @@ class EventsInsights {
|
||||
paid: true,
|
||||
userEmail: true,
|
||||
username: true,
|
||||
rating: true,
|
||||
ratingFeedback: true,
|
||||
noShowHost: true,
|
||||
},
|
||||
where: whereConditional,
|
||||
});
|
||||
|
||||
@@ -112,6 +112,18 @@ const emptyResponseEventsByStatus = {
|
||||
count: 0,
|
||||
deltaPrevious: 0,
|
||||
},
|
||||
rating: {
|
||||
count: 0,
|
||||
deltaPrevious: 0,
|
||||
},
|
||||
no_show: {
|
||||
count: 0,
|
||||
deltaPrevious: 0,
|
||||
},
|
||||
csat: {
|
||||
count: 0,
|
||||
deltaPrevious: 0,
|
||||
},
|
||||
previousRange: {
|
||||
startDate: dayjs().toISOString(),
|
||||
endDate: dayjs().toISOString(),
|
||||
@@ -257,6 +269,14 @@ export const insightsRouter = router({
|
||||
|
||||
const totalCancelled = await EventsInsights.getTotalCancelledEvents(baseWhereCondition);
|
||||
|
||||
const totalRatingsAggregate = await EventsInsights.getAverageRating(baseWhereCondition);
|
||||
const averageRating = totalRatingsAggregate._avg.rating
|
||||
? parseFloat(totalRatingsAggregate._avg.rating.toFixed(1))
|
||||
: 0;
|
||||
|
||||
const totalNoShow = await EventsInsights.getTotalNoShows(baseWhereCondition);
|
||||
const totalCSAT = await EventsInsights.getTotalCSAT(baseWhereCondition);
|
||||
|
||||
const lastPeriodStartDate = dayjs(startDate).subtract(startTimeEndTimeDiff, "day");
|
||||
const lastPeriodEndDate = dayjs(endDate).subtract(startTimeEndTimeDiff, "day");
|
||||
|
||||
@@ -278,6 +298,14 @@ export const insightsRouter = router({
|
||||
);
|
||||
|
||||
const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseCondition);
|
||||
const lastPeriodTotalRatingsAggregate = await EventsInsights.getAverageRating(lastPeriodBaseCondition);
|
||||
const lastPeriodAverageRating = lastPeriodTotalRatingsAggregate._avg.rating
|
||||
? parseFloat(lastPeriodTotalRatingsAggregate._avg.rating.toFixed(1))
|
||||
: 0;
|
||||
|
||||
const lastPeriodTotalNoShow = await EventsInsights.getTotalNoShows(lastPeriodBaseCondition);
|
||||
const lastPeriodTotalCSAT = await EventsInsights.getTotalCSAT(lastPeriodBaseCondition);
|
||||
|
||||
const result = {
|
||||
empty: false,
|
||||
created: {
|
||||
@@ -299,6 +327,18 @@ export const insightsRouter = router({
|
||||
count: totalCancelled,
|
||||
deltaPrevious: EventsInsights.getPercentage(totalCancelled, lastPeriodTotalCancelled),
|
||||
},
|
||||
no_show: {
|
||||
count: totalNoShow,
|
||||
deltaPrevious: EventsInsights.getPercentage(totalNoShow, lastPeriodTotalNoShow),
|
||||
},
|
||||
rating: {
|
||||
count: averageRating,
|
||||
deltaPrevious: EventsInsights.getPercentage(averageRating, lastPeriodAverageRating),
|
||||
},
|
||||
csat: {
|
||||
count: totalCSAT,
|
||||
deltaPrevious: EventsInsights.getPercentage(totalCSAT, lastPeriodTotalCSAT),
|
||||
},
|
||||
previousRange: {
|
||||
startDate: lastPeriodStartDate.format("YYYY-MM-DD"),
|
||||
endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
|
||||
@@ -308,7 +348,9 @@ export const insightsRouter = router({
|
||||
result.created.count === 0 &&
|
||||
result.completed.count === 0 &&
|
||||
result.rescheduled.count === 0 &&
|
||||
result.cancelled.count === 0
|
||||
result.cancelled.count === 0 &&
|
||||
result.no_show.count === 0 &&
|
||||
result.rating.count === 0
|
||||
) {
|
||||
return emptyResponseEventsByStatus;
|
||||
}
|
||||
@@ -474,6 +516,7 @@ export const insightsRouter = router({
|
||||
Completed: 0,
|
||||
Rescheduled: 0,
|
||||
Cancelled: 0,
|
||||
"No-Show (Host)": 0,
|
||||
};
|
||||
const startOfEndOf = timeView;
|
||||
let startDate = dayjs(date).startOf(startOfEndOf);
|
||||
@@ -511,11 +554,19 @@ export const insightsRouter = router({
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
EventsInsights.getNoShowHostsInTimeRange(
|
||||
{
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
},
|
||||
whereConditional
|
||||
),
|
||||
]);
|
||||
EventData["Created"] = promisesResult[0];
|
||||
EventData["Completed"] = promisesResult[1];
|
||||
EventData["Rescheduled"] = promisesResult[2];
|
||||
EventData["Cancelled"] = promisesResult[3];
|
||||
EventData["No-Show (Host)"] = promisesResult[4];
|
||||
result.push(EventData);
|
||||
}
|
||||
|
||||
@@ -1437,6 +1488,538 @@ export const insightsRouter = router({
|
||||
|
||||
return eventTypeResult;
|
||||
}),
|
||||
recentRatings: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().nullable().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
isAll: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
const user = ctx.user;
|
||||
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
teamId,
|
||||
eventTypeId,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
ratingFeedback: { not: null },
|
||||
};
|
||||
|
||||
if (isAll && user.isOwnerAdminOfParentTeam) {
|
||||
delete bookingWhere.teamId;
|
||||
const teamsFromOrg = await ctx.insightsDb.team.findMany({
|
||||
where: {
|
||||
parentId: user?.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: usersFromTeam.map((u) => u.userId),
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (teamId && !isAll) {
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.findMany({
|
||||
where: bookingWhere,
|
||||
orderBy: {
|
||||
endTime: "desc",
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
rating: true,
|
||||
ratingFeedback: true,
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const userIds = bookingsFromTeam.reduce((userIds: number[], booking) => {
|
||||
if (!!booking.userId && !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) => ({
|
||||
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),
|
||||
rating: booking.rating,
|
||||
feedback: booking.ratingFeedback,
|
||||
}));
|
||||
|
||||
return result;
|
||||
}),
|
||||
membersWithMostNoShow: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().nullable().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
isAll: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
const user = ctx.user;
|
||||
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
teamId,
|
||||
eventTypeId,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
noShowHost: true,
|
||||
};
|
||||
|
||||
if (isAll && user.isOwnerAdminOfParentTeam) {
|
||||
delete bookingWhere.teamId;
|
||||
const teamsFromOrg = await ctx.insightsDb.team.findMany({
|
||||
where: {
|
||||
parentId: user?.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: usersFromTeam.map((u) => u.userId),
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (teamId && !isAll) {
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: "asc",
|
||||
},
|
||||
},
|
||||
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) => ({
|
||||
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;
|
||||
}),
|
||||
membersWithHighestRatings: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().nullable().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
isAll: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
const user = ctx.user;
|
||||
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
teamId,
|
||||
eventTypeId,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
rating: { not: null },
|
||||
};
|
||||
|
||||
if (isAll && user.isOwnerAdminOfParentTeam) {
|
||||
delete bookingWhere.teamId;
|
||||
const teamsFromOrg = await ctx.insightsDb.team.findMany({
|
||||
where: {
|
||||
parentId: user?.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: usersFromTeam.map((u) => u.userId),
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (teamId && !isAll) {
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_avg: {
|
||||
rating: true,
|
||||
},
|
||||
orderBy: {
|
||||
_avg: {
|
||||
rating: "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) => ({
|
||||
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),
|
||||
averageRating: booking._avg.rating,
|
||||
}));
|
||||
|
||||
return result;
|
||||
}),
|
||||
membersWithLowestRatings: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().nullable().optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
isAll: z.boolean().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, eventTypeId, isAll } = input;
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
const user = ctx.user;
|
||||
|
||||
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
|
||||
teamId,
|
||||
eventTypeId,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
rating: { not: null },
|
||||
};
|
||||
|
||||
if (isAll && user.isOwnerAdminOfParentTeam) {
|
||||
delete bookingWhere.teamId;
|
||||
const teamsFromOrg = await ctx.insightsDb.team.findMany({
|
||||
where: {
|
||||
parentId: user?.organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId: {
|
||||
in: teamsFromOrg.map((t) => t.id),
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: usersFromTeam.map((u) => u.userId),
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (teamId && !isAll) {
|
||||
const usersFromTeam = await ctx.insightsDb.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
teamId,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
teamId: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
const bookingsFromTeam = await ctx.insightsDb.bookingTimeStatus.groupBy({
|
||||
by: ["userId"],
|
||||
where: bookingWhere,
|
||||
_avg: {
|
||||
rating: true,
|
||||
},
|
||||
orderBy: {
|
||||
_avg: {
|
||||
rating: "asc",
|
||||
},
|
||||
},
|
||||
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) => ({
|
||||
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),
|
||||
averageRating: booking._avg.rating,
|
||||
}));
|
||||
|
||||
return result;
|
||||
}),
|
||||
rawData: userBelongsToTeamProcedure.input(rawDataInputSchema).query(async ({ ctx, input }) => {
|
||||
const { startDate, endDate, teamId, userId, memberUserId, isAll, eventTypeId } = input;
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE OR REPLACE VIEW public."BookingTimeStatus"
|
||||
AS
|
||||
SELECT
|
||||
"Booking".id,
|
||||
"Booking".uid,
|
||||
"Booking"."eventTypeId",
|
||||
"Booking".title,
|
||||
"Booking".description,
|
||||
"Booking"."startTime",
|
||||
"Booking"."endTime",
|
||||
"Booking"."createdAt",
|
||||
"Booking".location,
|
||||
"Booking".paid,
|
||||
"Booking".status,
|
||||
"Booking".rescheduled,
|
||||
"Booking"."userId",
|
||||
et."teamId",
|
||||
et.length AS "eventLength",
|
||||
CASE
|
||||
WHEN "Booking".rescheduled IS TRUE THEN 'rescheduled'::text
|
||||
WHEN "Booking".status = 'cancelled'::"BookingStatus" AND "Booking".rescheduled IS NULL THEN 'cancelled'::text
|
||||
WHEN "Booking"."endTime" < now() THEN 'completed'::text
|
||||
WHEN "Booking"."endTime" > now() THEN 'uncompleted'::text
|
||||
ELSE NULL::text
|
||||
END AS "timeStatus",
|
||||
et."parentId" AS "eventParentId",
|
||||
"u"."email" AS "userEmail",
|
||||
"u"."username" AS "username",
|
||||
"Booking"."ratingFeedback",
|
||||
"Booking"."rating",
|
||||
"Booking"."noShowHost"
|
||||
FROM "Booking"
|
||||
LEFT JOIN "EventType" et ON "Booking"."eventTypeId" = et.id
|
||||
LEFT JOIN users u ON u.id = "Booking"."userId";
|
||||
@@ -1123,25 +1123,28 @@ enum AccessScope {
|
||||
}
|
||||
|
||||
view BookingTimeStatus {
|
||||
id Int @unique
|
||||
uid String?
|
||||
eventTypeId Int?
|
||||
title String?
|
||||
description String?
|
||||
startTime DateTime?
|
||||
endTime DateTime?
|
||||
createdAt DateTime?
|
||||
location String?
|
||||
paid Boolean?
|
||||
status BookingStatus?
|
||||
rescheduled Boolean?
|
||||
userId Int?
|
||||
teamId Int?
|
||||
eventLength Int?
|
||||
timeStatus String?
|
||||
eventParentId Int?
|
||||
userEmail String?
|
||||
username String?
|
||||
id Int @unique
|
||||
uid String?
|
||||
eventTypeId Int?
|
||||
title String?
|
||||
description String?
|
||||
startTime DateTime?
|
||||
endTime DateTime?
|
||||
createdAt DateTime?
|
||||
location String?
|
||||
paid Boolean?
|
||||
status BookingStatus?
|
||||
rescheduled Boolean?
|
||||
userId Int?
|
||||
teamId Int?
|
||||
eventLength Int?
|
||||
timeStatus String?
|
||||
eventParentId Int?
|
||||
userEmail String?
|
||||
username String?
|
||||
ratingFeedback String?
|
||||
rating Int?
|
||||
noShowHost Boolean?
|
||||
}
|
||||
|
||||
model CalendarCache {
|
||||
|
||||
@@ -6,6 +6,18 @@ import dayjs from "@calcom/dayjs";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
function getRandomRatingFeedback() {
|
||||
const feedbacks = [
|
||||
"Great chat!",
|
||||
"Okay-ish",
|
||||
"Quite Poor",
|
||||
"Excellent chat!",
|
||||
"Could be better",
|
||||
"Wonderful!",
|
||||
];
|
||||
return feedbacks[Math.floor(Math.random() * feedbacks.length)];
|
||||
}
|
||||
|
||||
const shuffle = (
|
||||
booking: any,
|
||||
year: number,
|
||||
@@ -62,6 +74,10 @@ const shuffle = (
|
||||
console.log("This should not happen");
|
||||
}
|
||||
|
||||
booking.rating = Math.floor(Math.random() * 5) + 1; // Generates a random rating from 1 to 5
|
||||
booking.ratingFeedback = getRandomRatingFeedback(); // Random feedback from a predefined list
|
||||
booking.noShowHost = Math.random() < 0.5;
|
||||
|
||||
return booking;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user