Follow up insights - user metrics (#8016)
* init page * init insights frontend * nit * nit * nit * merge * fixed icons * i18n, needs features * Init insights trpc * Using trpc on client * Added events timeline * Seed analytics script * connect ui with trpc * Added and fixed event time lines * WIP popular days and avg time duration event type * added new metric graphs * improved upgrade tip * always show upgrade screen * upgrade tremor.so and select inputs for page * Remove log * Move everything to components and add context * Fix select types using calcom ui one * Adding translations * Add missing translations * Add more translations * min fix * Fixes for date select * Prefer early return and mobile design fixes * Fix style for mobile * Fix data with userId filter from popular events * add user id to average time duration * fix types for select-react * Removed submodules * Delete website * Update yarn.lock * Code organization and type fixes * trpc fixes * Builds are now passing * Relocates server code * Add url state in insights * Update FiltersProvider.tsx * Cleanup * Update embed-iframe.ts * Update FilterType.tsx * Update seed-app-store.config.json * Update index.tsx * Renamed seeder * Update FiltersProvider.tsx * Fix for query params * no wrap on lg screen * Fix shadow borders from tremor components, fix title font * Add ring-gray to match filters * add cursor pointer * copy improvements * blue to black * fixed date select focus * text right for table * Adds missing translation strings * Fix url state for filter type * more layout improvements * more padding improvements * more UI fixes (padding, sizes) requested by ciaran * Apply suggestions from code review * WIP user without team metrics * yarn * Adds loading state for graphs and tables * fix average render when no data * Multiple fixes in insights * Fix most and least booked members querys * More fix for clear filters --------- Co-authored-by: Peer Richelsen <peer@cal.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
co-authored by
Peer Richelsen
Peer Richelsen
zomars
parent
bbf50dbc53
commit
30b72b687e
@@ -8,7 +8,6 @@ import {
|
||||
PopularEventsTable,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { Filters } from "@calcom/features/insights/filters";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { UpgradeTip } from "@calcom/features/tips";
|
||||
@@ -20,15 +19,11 @@ import { FiRefreshCcw, FiUserPlus, FiUsers } from "@calcom/ui/components/icon";
|
||||
|
||||
const Heading = () => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
filter: { selectedTeamName },
|
||||
} = useFilterContext();
|
||||
|
||||
return (
|
||||
<div className="min-w-52">
|
||||
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 hidden truncate text-xl font-semibold tracking-wide text-black md:block xl:max-w-full">
|
||||
{t("analytics_for_organisation", {
|
||||
organisationName: selectedTeamName,
|
||||
})}
|
||||
<div className="min-w-52 hidden md:block">
|
||||
<h3 className="font-cal max-w-28 sm:max-w-72 md:max-w-80 truncate text-xl font-semibold tracking-wide text-black xl:max-w-full">
|
||||
{t("analytics_for_organisation")}
|
||||
</h3>
|
||||
<p className="hidden text-sm text-gray-500 md:block">{t("subtitle_analytics")}</p>
|
||||
</div>
|
||||
@@ -80,7 +75,7 @@ export default function InsightsPage() {
|
||||
<></>
|
||||
) : (
|
||||
<FiltersProvider>
|
||||
<div className="mb-4 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
|
||||
<div className="mb-8 mt-0 ml-auto flex w-full flex-wrap justify-between md:-mt-8">
|
||||
<Heading />
|
||||
<Filters />
|
||||
</div>
|
||||
@@ -99,7 +94,7 @@ export default function InsightsPage() {
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
<small className="block text-center text-gray-600">
|
||||
{t("looking_for_more_analytics")}
|
||||
{t("looking_for_more_insights")}
|
||||
<a
|
||||
className="text-blue-500 hover:underline"
|
||||
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
|
||||
|
||||
@@ -404,7 +404,7 @@
|
||||
"add_time_availability": "Add new time slot",
|
||||
"add_an_extra_layer_of_security": "Add an extra layer of security to your account in case your password is stolen.",
|
||||
"2fa": "Two-Factor Authentication",
|
||||
"2fa_disabled":"Two-Factor authentication can only be enabled for email and password authentication",
|
||||
"2fa_disabled": "Two-Factor authentication can only be enabled for email and password authentication",
|
||||
"enable_2fa": "Enable two-factor authentication",
|
||||
"disable_2fa": "Disable two-factor authentication",
|
||||
"disable_2fa_recommendation": "If you need to disable 2FA, we recommend re-enabling it as soon as possible.",
|
||||
@@ -1673,7 +1673,7 @@
|
||||
"default_conferencing_bulk_title": "Bulk update existing event types",
|
||||
"requires_at_least_one_schedule": "You are required to have at least one schedule",
|
||||
"default_conferencing_bulk_description": "Update the locations for the selected event types",
|
||||
"looking_for_more_analytics": "Looking for more analytics?",
|
||||
"looking_for_more_insights": "Looking for more Insights?",
|
||||
"add_filter": "Add filter",
|
||||
"select_user": "Select User",
|
||||
"select_event_type": "Select Event Type",
|
||||
@@ -1689,11 +1689,12 @@
|
||||
"events_rescheduled": "Events Rescheduled",
|
||||
"from_last_period": "from last period",
|
||||
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
|
||||
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
|
||||
"analytics_for_organisation": "Insights for {{organisationName}}",
|
||||
"analytics_for_organisation": "Insights",
|
||||
"subtitle_analytics": "Learn more about your team's activity",
|
||||
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
|
||||
"event_trends": "Event Trends",
|
||||
"clear_filters": "Clear Filters",
|
||||
"insights": "Insights",
|
||||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"email_invite_team": "{{email}} has been invited"
|
||||
}
|
||||
|
||||
@@ -6,34 +6,45 @@ import { trpc } from "@calcom/trpc";
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { valueFormatter } from "../lib/valueFormatter";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const AverageEventDurationChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedUserId } = filter;
|
||||
const { dateRange, selectedMemberUserId } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
const { selectedTeamId: teamId, selectedUserId } = filter;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.averageEventDuration.useQuery({
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.averageEventDuration.useQuery({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
teamId: teamId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
});
|
||||
|
||||
if (!isSuccess || data?.length == 0 || !startDate || !endDate || !teamId) return null;
|
||||
if (isLoading) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
const isNoData = (data && data.length === 0) || data.every((item) => item["Average"] === 0);
|
||||
return (
|
||||
<CardInsights>
|
||||
<Title>{t("average_event_duration")}</Title>
|
||||
<LineChart
|
||||
className="mt-4 h-80"
|
||||
data={data}
|
||||
index="Date"
|
||||
categories={["Average"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
{isNoData && (
|
||||
<div className="flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && !isNoData && (
|
||||
<LineChart
|
||||
className="mt-4 h-80"
|
||||
data={data}
|
||||
index="Date"
|
||||
categories={["Average"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
)}
|
||||
</CardInsights>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,23 +2,26 @@ import { Grid } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { KPICard } from "./KPICard";
|
||||
|
||||
export const BookingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedEventTypeId, selectedUserId } = filter;
|
||||
const { dateRange, selectedEventTypeId, selectedUserId, selectedMemberUserId } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.eventsByStatus.useQuery({
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.eventsByStatus.useQuery({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
});
|
||||
|
||||
@@ -44,18 +47,41 @@ export const BookingKPICards = () => {
|
||||
},
|
||||
];
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId || data?.empty) return null;
|
||||
if (isLoading) {
|
||||
return <LoadingKPICards categories={categories} />;
|
||||
}
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid numColsSm={2} numColsLg={4} className="gap-x-6 gap-y-6">
|
||||
{categories.map((item) => (
|
||||
<KPICard
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
value={data[item.index].count}
|
||||
previousMetricData={data[item.index]}
|
||||
previousDateRange={data.previousRange}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
|
||||
const { categories } = props;
|
||||
return (
|
||||
<Grid numColsSm={2} numColsLg={4} className="gap-x-6 gap-y-6">
|
||||
{categories.map((item) => (
|
||||
<KPICard
|
||||
key={item.title}
|
||||
title={item.title}
|
||||
value={data[item.index].count}
|
||||
previousMetricData={data[item.index]}
|
||||
previousDateRange={data.previousRange}
|
||||
/>
|
||||
<CardInsights key={item.title}>
|
||||
<SkeletonContainer className="flex w-full flex-col">
|
||||
<SkeletonText className="mt-2 h-4 w-32" />
|
||||
<SkeletonText className="mt-2 h-6 w-16" />
|
||||
<SkeletonText className="mt-4 h-6 w-44" />
|
||||
</SkeletonContainer>
|
||||
</CardInsights>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
|
||||
@@ -6,23 +6,37 @@ import { trpc } from "@calcom/trpc";
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { valueFormatter } from "../lib/valueFormatter";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const BookingStatusLineChart = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { selectedTeamId, selectedTimeView = "week", dateRange, selectedEventTypeId } = filter;
|
||||
const {
|
||||
selectedTeamId,
|
||||
selectedUserId,
|
||||
selectedTimeView = "week",
|
||||
dateRange,
|
||||
selectedEventTypeId,
|
||||
} = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
if (!startDate || !endDate) return null;
|
||||
|
||||
const { data: eventsTimeLine, isSuccess } = trpc.viewer.insights.eventsTimeline.useQuery({
|
||||
const {
|
||||
data: eventsTimeLine,
|
||||
isSuccess,
|
||||
isLoading,
|
||||
} = trpc.viewer.insights.eventsTimeline.useQuery({
|
||||
timeView: selectedTimeView,
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId: selectedTeamId || -1,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Card } from "@tremor/react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
interface ICardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
@@ -9,7 +11,7 @@ export const CardInsights = (props: ICardProps) => {
|
||||
const { children, className = "", ...rest } = props;
|
||||
|
||||
return (
|
||||
<Card className={`shadow-none ring-gray-300 ${className}`} {...rest}>
|
||||
<Card className={classNames("shadow-none ring-gray-200", className)} {...rest}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
|
||||
|
||||
export const LeastBookedTeamMembersTable = () => {
|
||||
@@ -13,13 +14,15 @@ export const LeastBookedTeamMembersTable = () => {
|
||||
const { dateRange, selectedEventTypeId, selectedTeamId: teamId } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.membersWithLeastBookings.useQuery({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { SkeletonText } from "@calcom/ui";
|
||||
|
||||
import { CardInsights } from "./Card";
|
||||
|
||||
export const LoadingInsight = () => {
|
||||
return (
|
||||
<CardInsights>
|
||||
<SkeletonText className="w-32" />
|
||||
<div className="m-auto flex h-80 flex-col items-center justify-center">
|
||||
<svg
|
||||
className={classNames("mx-4 h-8 w-8 animate-spin", "text-black dark:text-white")}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</CardInsights>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
import { TotalBookingUsersTable } from "./TotalBookingUsersTable";
|
||||
|
||||
export const MostBookedTeamMembersTable = () => {
|
||||
@@ -14,13 +15,15 @@ export const MostBookedTeamMembersTable = () => {
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.membersWithMostBookings.useQuery({
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.membersWithMostBookings.useQuery({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
eventTypeId: selectedEventTypeId ?? undefined,
|
||||
});
|
||||
|
||||
if (isLoading) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId) return null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,22 +5,26 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { CardInsights } from "./Card";
|
||||
import { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
export const PopularEventsTable = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
const { dateRange, selectedUserId } = filter;
|
||||
const { dateRange, selectedMemberUserId, selectedUserId } = filter;
|
||||
const [startDate, endDate] = dateRange;
|
||||
const { selectedTeamId: teamId } = filter;
|
||||
|
||||
const { data, isSuccess } = trpc.viewer.insights.popularEventTypes.useQuery({
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.popularEventTypes.useQuery({
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
teamId,
|
||||
teamId: teamId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
memberUserId: selectedMemberUserId ?? undefined,
|
||||
});
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || !teamId || data?.length === 0) return null;
|
||||
if (isLoading) return <LoadingInsight />;
|
||||
|
||||
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
|
||||
|
||||
return (
|
||||
<CardInsights>
|
||||
@@ -30,7 +34,7 @@ export const PopularEventsTable = () => {
|
||||
{data.map((item) => (
|
||||
<TableRow key={item.eventTypeId}>
|
||||
<TableCell>{item.eventTypeName}</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>
|
||||
<strong>{item.count}</strong>
|
||||
</Text>
|
||||
@@ -39,6 +43,11 @@ export const PopularEventsTable = () => {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{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>
|
||||
)}
|
||||
</CardInsights>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export const TotalBookingUsersTable = ({
|
||||
<strong>{item.user.name}</strong>
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Text>
|
||||
<strong>{item.count}</strong>
|
||||
</Text>
|
||||
|
||||
@@ -10,17 +10,24 @@ import { FilterProvider } from "./provider";
|
||||
export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
// useRouter to get initial values from query params
|
||||
const router = useRouter();
|
||||
const { startTime, endTime, teamId, userId, eventTypeId, filter } = router.query;
|
||||
const { startTime, endTime, teamId, userId, eventTypeId, filter, memberUserId } = router.query;
|
||||
const querySchema = z.object({
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
teamId: z.coerce.number().optional(),
|
||||
userId: z.coerce.number().optional(),
|
||||
memberUserId: z.coerce.number().optional(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
filter: z.enum(["event-type", "user"]).optional(),
|
||||
});
|
||||
|
||||
let startTimeParsed, endTimeParsed, teamIdParsed, userIdParsed, eventTypeIdParsed, filterParsed;
|
||||
let startTimeParsed,
|
||||
endTimeParsed,
|
||||
teamIdParsed: number | undefined,
|
||||
userIdParsed,
|
||||
eventTypeIdParsed,
|
||||
filterParsed,
|
||||
memberUserIdParsed;
|
||||
|
||||
const safe = querySchema.safeParse({
|
||||
startTime,
|
||||
@@ -29,6 +36,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
userId,
|
||||
eventTypeId,
|
||||
filter,
|
||||
memberUserId,
|
||||
});
|
||||
|
||||
if (!safe.success) {
|
||||
@@ -40,6 +48,7 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
userIdParsed = safe.data.userId;
|
||||
eventTypeIdParsed = safe.data.eventTypeId;
|
||||
filterParsed = safe.data.filter;
|
||||
memberUserIdParsed = safe.data.memberUserId;
|
||||
}
|
||||
|
||||
// TODO: Sync insight filters with URL parameters
|
||||
@@ -48,6 +57,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
const [selectedUserId, setSelectedUserId] = useState<FilterContextType["filter"]["selectedUserId"]>(
|
||||
userIdParsed || null
|
||||
);
|
||||
const [selectedMemberUserId, setSelectedMemberUserId] = useState<
|
||||
FilterContextType["filter"]["selectedMemberUserId"]
|
||||
>(memberUserIdParsed || null);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<FilterContextType["filter"]["selectedTeamId"]>(
|
||||
teamIdParsed || null
|
||||
);
|
||||
@@ -64,21 +76,24 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
endTimeParsed ? dayjs(endTimeParsed) : dayjs(),
|
||||
"t",
|
||||
]);
|
||||
|
||||
return (
|
||||
<FilterProvider
|
||||
value={{
|
||||
filter: {
|
||||
dateRange,
|
||||
selectedTimeView,
|
||||
selectedUserId,
|
||||
selectedMemberUserId,
|
||||
selectedTeamId,
|
||||
selectedUserId,
|
||||
selectedTeamName,
|
||||
selectedEventTypeId,
|
||||
selectedFilter,
|
||||
},
|
||||
setSelectedFilter: (filter) => {
|
||||
setSelectedFilter(filter);
|
||||
const userId = filter?.[0] === "user" ? selectedUserId : undefined;
|
||||
const userId =
|
||||
filter?.[0] === "user" ? selectedMemberUserId : selectedUserId ? selectedUserId : undefined;
|
||||
const eventTypeId = filter?.[0] === "event-type" ? selectedEventTypeId : undefined;
|
||||
router.push({
|
||||
query: {
|
||||
@@ -100,24 +115,38 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
},
|
||||
setSelectedTimeView: (selectedTimeView) => setSelectedTimeView(selectedTimeView),
|
||||
setSelectedUserId: (selectedUserId) => {
|
||||
setSelectedUserId(selectedUserId);
|
||||
setSelectedMemberUserId: (selectedMemberUserId) => {
|
||||
setSelectedMemberUserId(selectedMemberUserId);
|
||||
router.push({
|
||||
query: {
|
||||
...router.query,
|
||||
userId: selectedUserId,
|
||||
memberUserId: selectedMemberUserId,
|
||||
},
|
||||
});
|
||||
},
|
||||
setSelectedTeamId: (selectedTeamId) => {
|
||||
setSelectedTeamId(selectedTeamId);
|
||||
router.push({
|
||||
setSelectedUserId(null);
|
||||
const { teamId, eventTypeId, memberUserId, ...rest } = router.query;
|
||||
router.replace({
|
||||
query: {
|
||||
...router.query,
|
||||
...rest,
|
||||
teamId: selectedTeamId,
|
||||
},
|
||||
});
|
||||
},
|
||||
setSelectedUserId: (selectedUserId) => {
|
||||
setSelectedUserId(selectedUserId);
|
||||
setSelectedTeamId(null);
|
||||
setSelectedTeamName(null);
|
||||
const { teamId, eventTypeId, memberUserId, ...rest } = router.query;
|
||||
router.replace({
|
||||
query: {
|
||||
...rest,
|
||||
userId: selectedUserId,
|
||||
},
|
||||
});
|
||||
},
|
||||
setSelectedTeamName: (selectedTeamName) => setSelectedTeamName(selectedTeamName),
|
||||
setSelectedEventTypeId: (selectedEventTypeId) => {
|
||||
setSelectedEventTypeId(selectedEventTypeId);
|
||||
@@ -128,6 +157,30 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
|
||||
},
|
||||
});
|
||||
},
|
||||
clearFilters: () => {
|
||||
setSelectedTeamName(null);
|
||||
setSelectedEventTypeId(null);
|
||||
setSelectedMemberUserId(null);
|
||||
setSelectedFilter(null);
|
||||
const { teamId, userId, ...rest } = router.query;
|
||||
const query: { teamId?: number; userId?: number } = {};
|
||||
const parsedTeamId = Number(Array.isArray(teamId) ? teamId[0] : teamId);
|
||||
const parsedUserId = Number(Array.isArray(userId) ? userId[0] : userId);
|
||||
|
||||
if ((teamId && !userId) || (userId && teamId)) {
|
||||
query.teamId = parsedTeamId;
|
||||
setSelectedTeamId(parsedTeamId);
|
||||
setSelectedUserId(null);
|
||||
} else if (userId && !teamId) {
|
||||
query.userId = parsedUserId;
|
||||
setSelectedUserId(parsedUserId);
|
||||
setSelectedTeamId(null);
|
||||
}
|
||||
|
||||
router.push({
|
||||
query,
|
||||
});
|
||||
},
|
||||
}}>
|
||||
{children}
|
||||
</FilterProvider>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type FilterContextType = {
|
||||
selectedTeamId: number | null;
|
||||
selectedTeamName: string | null;
|
||||
selectedUserId: number | null;
|
||||
selectedMemberUserId: number | null;
|
||||
selectedEventTypeId: number | null;
|
||||
};
|
||||
setDateRange: ([start, end, range]: [Dayjs, Dayjs, null | string]) => void;
|
||||
@@ -17,8 +18,10 @@ export type FilterContextType = {
|
||||
setSelectedTeamId: (teamId: number | null) => void;
|
||||
setSelectedTeamName: (teamName: string | null) => void;
|
||||
setSelectedUserId: (userId: number | null) => void;
|
||||
setSelectedMemberUserId: (userId: number | null) => void;
|
||||
setSelectedEventTypeId: (eventTypeId: number | null) => void;
|
||||
setSelectedTimeView: (timeView: "year" | "week" | "month") => void;
|
||||
clearFilters: () => void;
|
||||
};
|
||||
|
||||
export const FilterContext = React.createContext<FilterContextType | null>(null);
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
|
||||
box-shadow: none;
|
||||
max-width: 288px;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButton, .tremor-DateRangePicker-dropdownButton {
|
||||
@apply dark:bg-darkgray-100 dark:border-darkgray-300 border-gray-300 bg-white text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0 focus-within:ring-gray-800 hover:border-gray-400 dark:focus-within:ring-darkgray-900
|
||||
}
|
||||
|
||||
|
||||
.tremor-Card-root{
|
||||
@apply p-5
|
||||
}
|
||||
|
||||
.tremor-TableCell-root{
|
||||
@apply pl-0
|
||||
}
|
||||
|
||||
.recharts-responsive-container{
|
||||
@apply -mx-4
|
||||
}
|
||||
.tremor-Card-root > p {
|
||||
@apply text-base font-semibold text-black mb-2
|
||||
}
|
||||
|
||||
.tremor-Legend-legendItem{
|
||||
@apply ml-2
|
||||
}
|
||||
+6
-4
@@ -15,17 +15,18 @@ const mapEventTypeToOption = (eventType: EventType): Option => ({
|
||||
label: eventType.title,
|
||||
});
|
||||
|
||||
export const EventTypeListInTeam = () => {
|
||||
export const EventTypeList = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setSelectedEventTypeId } = useFilterContext();
|
||||
const { selectedTeamId, selectedEventTypeId } = filter;
|
||||
const { selectedTeamId, selectedEventTypeId, selectedUserId } = filter;
|
||||
const { selectedFilter } = filter;
|
||||
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
|
||||
teamId: selectedTeamId,
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
userId: selectedUserId ?? undefined,
|
||||
});
|
||||
|
||||
if (!selectedFilter?.includes("event-type")) return null;
|
||||
if (!selectedTeamId) return null;
|
||||
if (!selectedTeamId && !selectedUserId) return null;
|
||||
|
||||
const filterOptions =
|
||||
data?.map(mapEventTypeToOption) ?? ([{ label: "No event types found", value: "" }] as Option[]);
|
||||
@@ -46,6 +47,7 @@ export const EventTypeListInTeam = () => {
|
||||
}
|
||||
}}
|
||||
defaultValue={eventTypeValue}
|
||||
value={eventTypeValue}
|
||||
className="w-52 min-w-[180px]"
|
||||
placeholder={
|
||||
<div className="flex flex-row">
|
||||
@@ -8,10 +8,10 @@ type Option = { value: "event-type" | "user"; label: string };
|
||||
|
||||
export const FilterType = () => {
|
||||
const { t } = useLocale();
|
||||
const { setSelectedFilter, setSelectedUserId, setSelectedEventTypeId, filter } = useFilterContext();
|
||||
const { selectedFilter } = filter;
|
||||
const { setSelectedFilter, filter } = useFilterContext();
|
||||
const { selectedFilter, selectedUserId } = filter;
|
||||
|
||||
const filterOptions: Option[] = [
|
||||
let filterOptions: Option[] = [
|
||||
{
|
||||
label: t("event_type"),
|
||||
value: "event-type",
|
||||
@@ -22,9 +22,14 @@ export const FilterType = () => {
|
||||
},
|
||||
];
|
||||
|
||||
if (selectedUserId) {
|
||||
// remove user option from filterOptions
|
||||
filterOptions = filterOptions.filter((option) => option.value !== "user");
|
||||
}
|
||||
|
||||
const filterValue = selectedFilter
|
||||
? filterOptions.find((option) => option.value === selectedFilter[0])
|
||||
: undefined;
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Select<Option>
|
||||
|
||||
+24
-10
@@ -7,30 +7,42 @@ import { Select } from "@calcom/ui";
|
||||
import { useFilterContext } from "../context/provider";
|
||||
|
||||
type Team = RouterOutputs["viewer"]["insights"]["teamListForUser"][number];
|
||||
type Option = { value: number; label: string };
|
||||
type Option = { value: number; label: string | null; userId?: number };
|
||||
|
||||
const mapTeamToOption = (team: Team): Option => ({
|
||||
value: team.id,
|
||||
label: team.name ?? "",
|
||||
userId: team.userId,
|
||||
});
|
||||
|
||||
export const TeamList = () => {
|
||||
const { filter, setSelectedTeamId, setSelectedTeamName } = useFilterContext();
|
||||
const { selectedTeamId } = filter;
|
||||
export const TeamAndSelfList = () => {
|
||||
const { filter, setSelectedTeamId, setSelectedTeamName, setSelectedUserId } = useFilterContext();
|
||||
const { selectedTeamId, selectedUserId } = filter;
|
||||
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (data && data?.length > 0) {
|
||||
setSelectedTeamId(data[0].id);
|
||||
setSelectedTeamName(data[0].name);
|
||||
// We have a team?
|
||||
if (data[0].id) {
|
||||
setSelectedTeamId(data[0].id);
|
||||
setSelectedTeamName(data[0].name);
|
||||
} else if (data[0].userId) {
|
||||
// default to user
|
||||
setSelectedUserId(data[0].userId);
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const UserListOptions = data?.map(mapTeamToOption) || ([{ label: "Empty", value: -1 }] as Option[]);
|
||||
const selectedTeam = data?.find((item) => item.id === selectedTeamId);
|
||||
const selectedTeam = data?.find((item) => {
|
||||
if (!!selectedUserId && !selectedTeamId) {
|
||||
return item.userId === selectedUserId;
|
||||
}
|
||||
return item.id === selectedTeamId;
|
||||
});
|
||||
const teamValue = selectedTeam ? mapTeamToOption(selectedTeam) : null;
|
||||
|
||||
if (!isSuccess || !selectedTeamId || data?.length === 0) return null;
|
||||
if (!isSuccess || data?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -39,10 +51,12 @@ export const TeamList = () => {
|
||||
isMulti={false}
|
||||
value={teamValue}
|
||||
defaultValue={selectedTeamId ? { value: data[0].id, label: data[0].name } : null}
|
||||
className="h-[38px] w-[90vw] capitalize md:min-w-[100px] md:max-w-[100px]"
|
||||
className="h-[38px] w-[90vw] min-w-[160px] max-w-[100px]"
|
||||
options={UserListOptions}
|
||||
onChange={(input) => {
|
||||
if (input) {
|
||||
if (!!input?.userId) {
|
||||
setSelectedUserId(input.userId);
|
||||
} else if (input && input.value) {
|
||||
setSelectedTeamId(input.value);
|
||||
setSelectedTeamName(input.label);
|
||||
}
|
||||
@@ -15,8 +15,8 @@ const mapUserToOption = (user: User): Option => ({
|
||||
|
||||
export const UserListInTeam = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setSelectedUserId } = useFilterContext();
|
||||
const { selectedFilter, selectedTeamId, selectedUserId } = filter;
|
||||
const { filter, setSelectedMemberUserId } = useFilterContext();
|
||||
const { selectedFilter, selectedTeamId, selectedMemberUserId } = filter;
|
||||
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
|
||||
teamId: selectedTeamId,
|
||||
});
|
||||
@@ -25,20 +25,21 @@ export const UserListInTeam = () => {
|
||||
if (!selectedTeamId) return null;
|
||||
|
||||
const userListOptions = data?.map(mapUserToOption) ?? ([] as { value: number; label: string }[]);
|
||||
const selectedUser = data?.find((item) => item.id === selectedUserId);
|
||||
const userValue = selectedUser ? mapUserToOption(selectedUser) : null;
|
||||
const selectedTeamUser = data?.find((item) => item.id === selectedMemberUserId);
|
||||
const userValue = selectedTeamUser ? mapUserToOption(selectedTeamUser) : null;
|
||||
|
||||
if (!isSuccess || data?.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Select<Option>
|
||||
isSearchable={false}
|
||||
className="mb-0 ml-2 h-[38px] w-40 min-w-[140px] capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
isSearchable={true}
|
||||
className="mb-0 h-[38px] w-40 min-w-[140px] capitalize md:min-w-[150px] md:max-w-[200px]"
|
||||
defaultValue={userValue}
|
||||
value={userValue}
|
||||
options={userListOptions}
|
||||
onChange={(input) => {
|
||||
if (input) {
|
||||
setSelectedUserId(input.value);
|
||||
setSelectedMemberUserId(input.value);
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useFilterContext } from "@calcom/features/insights/context/provider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Tooltip } from "@calcom/ui";
|
||||
import { FiX } from "@calcom/ui/components/icon";
|
||||
|
||||
import { DateSelect } from "./DateSelect";
|
||||
import { EventTypeListInTeam } from "./EventTypeListInTeam";
|
||||
import { EventTypeList } from "./EventTypeList";
|
||||
import { FilterType } from "./FilterType";
|
||||
import { TeamList } from "./TeamList";
|
||||
import { TeamAndSelfList } from "./TeamAndSelfList";
|
||||
import { UserListInTeam } from "./UsersListInTeam";
|
||||
|
||||
const ClearFilters = () => {
|
||||
const { t } = useLocale();
|
||||
const { filter, setSelectedUserId, setSelectedFilter, setSelectedEventTypeId } = useFilterContext();
|
||||
const { filter, clearFilters } = useFilterContext();
|
||||
const { selectedFilter } = filter;
|
||||
|
||||
if (!selectedFilter || selectedFilter?.length < 1) return null;
|
||||
@@ -23,14 +22,12 @@ const ClearFilters = () => {
|
||||
color="secondary"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
StartIcon={FiX}
|
||||
className="h-[38px]"
|
||||
onClick={() => {
|
||||
setSelectedFilter(null);
|
||||
setSelectedUserId(null);
|
||||
setSelectedEventTypeId(null);
|
||||
}}
|
||||
/>
|
||||
clearFilters();
|
||||
}}>
|
||||
{t("clear_filters")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -38,13 +35,13 @@ const ClearFilters = () => {
|
||||
export const Filters = () => {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col flex-wrap gap-2 md:flex-row lg:flex-nowrap">
|
||||
<TeamList />
|
||||
<TeamAndSelfList />
|
||||
|
||||
<FilterType />
|
||||
|
||||
<UserListInTeam />
|
||||
|
||||
<EventTypeListInTeam />
|
||||
<EventTypeList />
|
||||
|
||||
<DateSelect />
|
||||
|
||||
|
||||
@@ -132,12 +132,15 @@ class EventsInsights {
|
||||
return resultTimeView;
|
||||
};
|
||||
|
||||
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs) {
|
||||
static getWeekTimeline(startDate: Dayjs, endDate: Dayjs): string[] {
|
||||
const now = dayjs();
|
||||
const endOfDay = now.endOf("day");
|
||||
let pivotDate = dayjs(startDate);
|
||||
const dates = [];
|
||||
while (pivotDate.isBefore(endDate)) {
|
||||
pivotDate = pivotDate.add(7, "day");
|
||||
const dates: string[] = [];
|
||||
while (pivotDate.isBefore(endDate) && pivotDate.isBefore(endOfDay)) {
|
||||
const weekEndDate = pivotDate.add(7, "day").isBefore(endOfDay) ? pivotDate.add(7, "day") : endOfDay;
|
||||
dates.push(pivotDate.format("YYYY-MM-DD"));
|
||||
pivotDate = weekEndDate.add(1, "day");
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TRPCError } from "@trpc/server";
|
||||
import { EventsInsights } from "./events";
|
||||
|
||||
const UserBelongsToTeamInput = z.object({
|
||||
teamId: z.coerce.number().optional(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
});
|
||||
|
||||
const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, rawInput }) => {
|
||||
@@ -19,14 +19,22 @@ const userBelongsToTeamMiddleware = isAuthed.unstable_pipe(async ({ ctx, next, r
|
||||
throw new TRPCError({ code: "BAD_REQUEST" });
|
||||
}
|
||||
|
||||
const team = await ctx.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
teamId: parse.data.teamId,
|
||||
},
|
||||
// If teamId is provided, check if user belongs to team
|
||||
// If teamId is not provided, check if user belongs to any team
|
||||
|
||||
const membershipWhereConditional: Prisma.MembershipWhereInput = {
|
||||
userId: ctx.user.id,
|
||||
};
|
||||
|
||||
if (parse.data.teamId) {
|
||||
membershipWhereConditional["teamId"] = parse.data.teamId;
|
||||
}
|
||||
|
||||
const membership = await ctx.prisma.membership.findFirst({
|
||||
where: membershipWhereConditional,
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
if (!membership) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
@@ -74,28 +82,59 @@ export const insightsRouter = router({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
memberUserId: z.coerce.number().optional(),
|
||||
userId: z.coerce.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, eventTypeId, userId } = input;
|
||||
const { teamId, startDate, endDate, eventTypeId, memberUserId, userId } = input;
|
||||
|
||||
if (!input.teamId) {
|
||||
return emptyResponseEventsByStatus;
|
||||
if (userId && userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const whereConditional: Prisma.BookingWhereInput = {
|
||||
eventType: {
|
||||
teamId: teamId,
|
||||
},
|
||||
};
|
||||
let whereConditional: Prisma.BookingWhereInput = {};
|
||||
|
||||
if (eventTypeId) {
|
||||
whereConditional["eventTypeId"] = eventTypeId;
|
||||
} else if (userId) {
|
||||
}
|
||||
if (memberUserId) {
|
||||
whereConditional["userId"] = memberUserId;
|
||||
}
|
||||
if (userId) {
|
||||
whereConditional["eventType"] = {
|
||||
teamId: null,
|
||||
};
|
||||
whereConditional["userId"] = userId;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const usersFromTeam = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
||||
whereConditional = {
|
||||
...whereConditional,
|
||||
OR: [
|
||||
{
|
||||
eventType: {
|
||||
teamId: teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeam,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Migrate to use prisma views
|
||||
const baseBookings = await EventsInsights.getBaseBookingForEventStatus({
|
||||
...whereConditional,
|
||||
@@ -103,10 +142,8 @@ export const insightsRouter = router({
|
||||
gte: new Date(startDate),
|
||||
lte: new Date(endDate),
|
||||
},
|
||||
eventType: {
|
||||
teamId: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const startTimeEndTimeDiff = dayjs(endDate).diff(dayjs(startDate), "day");
|
||||
|
||||
const baseBookingIds = baseBookings.map((b) => b.id);
|
||||
@@ -136,8 +173,7 @@ export const insightsRouter = router({
|
||||
);
|
||||
|
||||
const lastPeriodTotalCancelled = await EventsInsights.getTotalCancelledEvents(lastPeriodBaseBookingIds);
|
||||
|
||||
return {
|
||||
const result = {
|
||||
empty: false,
|
||||
created: {
|
||||
count: baseBookings.length,
|
||||
@@ -163,16 +199,26 @@ export const insightsRouter = router({
|
||||
endDate: lastPeriodEndDate.format("YYYY-MM-DD"),
|
||||
},
|
||||
};
|
||||
if (
|
||||
result.created.count === 0 &&
|
||||
result.completed.count === 0 &&
|
||||
result.rescheduled.count === 0 &&
|
||||
result.cancelled.count === 0
|
||||
) {
|
||||
return emptyResponseEventsByStatus;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
eventsTimeline: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().optional(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
eventTypeId: z.coerce.number().optional(),
|
||||
userId: z.coerce.number().optional(),
|
||||
memberUserId: z.coerce.number().optional(),
|
||||
timeView: z.enum(["week", "month", "year"]),
|
||||
userId: z.coerce.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -181,36 +227,75 @@ export const insightsRouter = router({
|
||||
startDate: startDateString,
|
||||
endDate: endDateString,
|
||||
eventTypeId,
|
||||
userId,
|
||||
memberUserId,
|
||||
timeView: inputTimeView,
|
||||
userId: selfUserId,
|
||||
} = input;
|
||||
|
||||
const startDate = dayjs(startDateString);
|
||||
const endDate = dayjs(endDateString);
|
||||
const user = ctx.user;
|
||||
|
||||
if (selfUserId && user?.id !== selfUserId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
if (!teamId && !selfUserId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const timeView = inputTimeView;
|
||||
|
||||
let whereConditional: Prisma.BookingWhereInput = {
|
||||
eventType: {
|
||||
teamId: teamId,
|
||||
},
|
||||
};
|
||||
let whereConditional: Prisma.BookingWhereInput = {};
|
||||
|
||||
if (teamId) {
|
||||
const usersFromTeam = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
|
||||
if (userId) {
|
||||
delete whereConditional.eventType;
|
||||
whereConditional = {
|
||||
...whereConditional,
|
||||
userId,
|
||||
OR: [
|
||||
{
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (eventTypeId && !!whereConditional) {
|
||||
delete whereConditional.eventType;
|
||||
delete whereConditional.userId;
|
||||
|
||||
if (memberUserId) {
|
||||
whereConditional = {
|
||||
...whereConditional,
|
||||
userId: memberUserId,
|
||||
};
|
||||
}
|
||||
|
||||
if (eventTypeId && !!whereConditional) {
|
||||
whereConditional = {
|
||||
eventTypeId: eventTypeId,
|
||||
};
|
||||
}
|
||||
|
||||
if (selfUserId && !!whereConditional) {
|
||||
// In this delete we are deleting the teamId filter
|
||||
whereConditional["userId"] = selfUserId;
|
||||
whereConditional["eventType"] = {
|
||||
teamId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Get timeline data
|
||||
const timeline = await EventsInsights.getTimeLine(timeView, dayjs(startDate), dayjs(endDate));
|
||||
|
||||
@@ -277,37 +362,73 @@ export const insightsRouter = router({
|
||||
popularEventTypes: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.coerce.number().optional(),
|
||||
memberUserId: z.coerce.number().optional(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
userId: z.coerce.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate, endDate, userId } = input;
|
||||
const { teamId, startDate, endDate, memberUserId, userId } = input;
|
||||
|
||||
const user = ctx.user;
|
||||
|
||||
if (!input.teamId) {
|
||||
if (userId && user?.id !== userId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
if (!teamId && !userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const eventTypeWhere: Prisma.EventTypeWhereInput = {
|
||||
teamId: teamId,
|
||||
};
|
||||
|
||||
const bookingWhere: Prisma.BookingWhereInput = {
|
||||
eventType: eventTypeWhere,
|
||||
let bookingWhere: Prisma.BookingWhereInput = {
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
};
|
||||
if (teamId) {
|
||||
const usersFromTeam = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
|
||||
bookingWhere = {
|
||||
...bookingWhere,
|
||||
OR: [
|
||||
{
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
bookingWhere.userId = userId;
|
||||
// Don't take bookings from any team
|
||||
bookingWhere.eventType = {
|
||||
teamId: null,
|
||||
};
|
||||
}
|
||||
|
||||
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
|
||||
if (memberUserId) {
|
||||
bookingWhere.userId = memberUserId;
|
||||
}
|
||||
|
||||
const bookingsFromSelected = await ctx.prisma.booking.groupBy({
|
||||
by: ["eventTypeId"],
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
@@ -320,66 +441,158 @@ export const insightsRouter = router({
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
const eventTypeIds = bookingsFromTeam
|
||||
|
||||
const eventTypeIds = bookingsFromSelected
|
||||
.filter((booking) => typeof booking.eventTypeId === "number")
|
||||
.map((booking) => booking.eventTypeId);
|
||||
const eventTypesFromTeam = await ctx.prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId: teamId,
|
||||
id: {
|
||||
in: eventTypeIds as number[],
|
||||
|
||||
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
|
||||
id: {
|
||||
in: eventTypeIds as number[],
|
||||
},
|
||||
};
|
||||
|
||||
const eventTypesFrom = await ctx.prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
teamId: true,
|
||||
userId: true,
|
||||
slug: true,
|
||||
users: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eventTypeWhereConditional,
|
||||
});
|
||||
|
||||
const eventTypeHashMap = new Map();
|
||||
eventTypesFromTeam.forEach((eventType) => {
|
||||
eventTypeHashMap.set(eventType.id, eventType.title);
|
||||
const eventTypeHashMap: Map<
|
||||
number,
|
||||
Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
title: true;
|
||||
teamId: true;
|
||||
userId: true;
|
||||
slug: true;
|
||||
users: {
|
||||
select: {
|
||||
username: true;
|
||||
};
|
||||
};
|
||||
team: {
|
||||
select: {
|
||||
slug: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>
|
||||
> = new Map();
|
||||
eventTypesFrom.forEach((eventType) => {
|
||||
eventTypeHashMap.set(eventType.id, eventType);
|
||||
});
|
||||
|
||||
const result = bookingsFromTeam.map((booking) => {
|
||||
const result = bookingsFromSelected.map((booking) => {
|
||||
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
|
||||
if (!eventTypeSelected) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let eventSlug = "";
|
||||
if (eventTypeSelected.userId) {
|
||||
eventSlug = `${eventTypeSelected?.slug}/${eventTypeSelected?.users[0]?.username}`;
|
||||
}
|
||||
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
|
||||
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
|
||||
}
|
||||
return {
|
||||
eventTypeId: booking.eventTypeId,
|
||||
eventTypeName: eventTypeHashMap.get(booking.eventTypeId),
|
||||
eventTypeName: eventSlug,
|
||||
count: booking._count.id,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
averageEventDuration: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
userId: z.coerce.number().optional(),
|
||||
memberUserId: z.coerce.number().optional(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
userId: z.coerce.number().optional(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { teamId, startDate: startDateString, endDate: endDateString, userId } = input;
|
||||
const { teamId, startDate: startDateString, endDate: endDateString, memberUserId, userId } = input;
|
||||
|
||||
if (!teamId) {
|
||||
if (userId && ctx.user?.id !== userId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
if (!teamId && !userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const user = ctx.user;
|
||||
const startDate = dayjs(startDateString);
|
||||
const endDate = dayjs(endDateString);
|
||||
|
||||
const whereConditional: Prisma.BookingWhereInput = {
|
||||
eventType: {
|
||||
teamId: teamId,
|
||||
},
|
||||
let whereConditional: Prisma.BookingWhereInput = {
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
};
|
||||
|
||||
if (userId) {
|
||||
delete whereConditional.eventType;
|
||||
whereConditional["userId"] = userId;
|
||||
}
|
||||
|
||||
if (teamId) {
|
||||
const usersFromTeam = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
|
||||
whereConditional = {
|
||||
...whereConditional,
|
||||
OR: [
|
||||
{
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (memberUserId) {
|
||||
whereConditional = {
|
||||
userId: memberUserId,
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const timeView = EventsInsights.getTimeView("week", startDate, endDate);
|
||||
const timeLine = await EventsInsights.getTimeLine("week", startDate, endDate);
|
||||
|
||||
@@ -448,15 +661,42 @@ export const insightsRouter = router({
|
||||
eventTypeWhere["id"] = eventTypeId;
|
||||
}
|
||||
|
||||
const bookingWhere: Prisma.BookingWhereInput = {
|
||||
eventType: eventTypeWhere,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
const usersFromTeam = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
delete bookingWhere.eventType;
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
|
||||
by: ["userId"],
|
||||
where: {
|
||||
eventType: eventTypeWhere,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
},
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
@@ -467,9 +707,13 @@ export const insightsRouter = router({
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
|
||||
const userIds = bookingsFromTeam
|
||||
.filter((booking) => typeof booking.userId === "number")
|
||||
.map((booking) => booking.userId);
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const usersFromTeam = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
@@ -487,10 +731,11 @@ export const insightsRouter = router({
|
||||
return {
|
||||
userId: booking.userId,
|
||||
user: userHashMap.get(booking.userId),
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"),
|
||||
count: booking._count.id,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
membersWithLeastBookings: userBelongsToTeamProcedure
|
||||
@@ -507,7 +752,7 @@ export const insightsRouter = router({
|
||||
if (!teamId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const user = ctx.user;
|
||||
const eventTypeWhere: Prisma.EventTypeWhereInput = {
|
||||
teamId: teamId,
|
||||
};
|
||||
@@ -515,15 +760,42 @@ export const insightsRouter = router({
|
||||
eventTypeWhere["id"] = eventTypeId;
|
||||
}
|
||||
|
||||
const bookingWhere: Prisma.BookingWhereInput = {
|
||||
eventType: eventTypeWhere,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
const usersFromTeam = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
const userIdsFromTeams = usersFromTeam.map((u) => u.userId);
|
||||
delete bookingWhere.eventType;
|
||||
bookingWhere["OR"] = [
|
||||
{
|
||||
eventType: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeams,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const bookingsFromTeam = await ctx.prisma.booking.groupBy({
|
||||
by: ["userId"],
|
||||
where: {
|
||||
eventType: eventTypeWhere,
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
},
|
||||
where: bookingWhere,
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
@@ -535,53 +807,31 @@ export const insightsRouter = router({
|
||||
take: 10,
|
||||
});
|
||||
|
||||
// Users are obtained from bookings so if a user has 0 they won't be in the list
|
||||
const userIds = bookingsFromTeam
|
||||
.filter((booking) => typeof booking.userId === "number")
|
||||
.map((booking) => booking.userId);
|
||||
|
||||
const usersWithNoBookings = await ctx.prisma.user.findMany({
|
||||
select: UserSelect,
|
||||
if (userIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const usersFromTeam = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
notIn: userIds as number[],
|
||||
},
|
||||
teams: {
|
||||
some: {
|
||||
teamId: teamId,
|
||||
},
|
||||
in: userIds as number[],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let usersFromTeam: Prisma.UserGetPayload<{
|
||||
select: typeof UserSelect;
|
||||
}>[] = [];
|
||||
if (usersWithNoBookings.length < 10) {
|
||||
usersFromTeam = await ctx.prisma.user.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: userIds as number[],
|
||||
},
|
||||
},
|
||||
select: UserSelect,
|
||||
take: 10 - usersWithNoBookings.length,
|
||||
});
|
||||
}
|
||||
|
||||
const userHashMap = new Map();
|
||||
[...usersWithNoBookings, ...usersFromTeam].forEach((user) => {
|
||||
usersFromTeam.forEach((user) => {
|
||||
userHashMap.set(user.id, user);
|
||||
});
|
||||
|
||||
const result = bookingsFromTeam.map((booking) => {
|
||||
const user = userHashMap.get(booking.userId);
|
||||
return {
|
||||
userId: booking.userId,
|
||||
user,
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
user: userHashMap.get(booking.userId),
|
||||
emailMd5: crypto.createHash("md5").update(user?.email).digest("hex"),
|
||||
count: booking._count.id,
|
||||
Username: user.name || "No Username found",
|
||||
};
|
||||
});
|
||||
|
||||
@@ -590,6 +840,18 @@ export const insightsRouter = router({
|
||||
teamListForUser: authedProcedure.query(async ({ ctx }) => {
|
||||
const user = ctx.user;
|
||||
|
||||
// Fetch user data
|
||||
const userData = await ctx.prisma.user.findUnique({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
avatar: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Look if user it's admin in multiple teams
|
||||
const belongsToTeams = await ctx.prisma.membership.findMany({
|
||||
where: {
|
||||
@@ -617,8 +879,25 @@ export const insightsRouter = router({
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = belongsToTeams.map((membership) => membership.team);
|
||||
|
||||
const result: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
name: string | null;
|
||||
logo: string | null;
|
||||
userId?: number;
|
||||
}[] = belongsToTeams.map((membership) => {
|
||||
return { ...membership.team };
|
||||
});
|
||||
if (userData && userData.id) {
|
||||
result.push({
|
||||
id: 0,
|
||||
slug: "",
|
||||
userId: userData.id,
|
||||
name: userData.name,
|
||||
logo: userData.avatar,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
userList: userBelongsToTeamProcedure
|
||||
@@ -665,58 +944,84 @@ export const insightsRouter = router({
|
||||
eventTypeList: userBelongsToTeamProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.coerce.number().nullable(),
|
||||
teamId: z.coerce.number().optional().nullable(),
|
||||
userId: z.coerce.number().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const user = ctx.user;
|
||||
const { prisma, user } = ctx;
|
||||
const { teamId, userId } = input;
|
||||
|
||||
if (!input.teamId) {
|
||||
if (!teamId && !userId) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const membership = await ctx.prisma.membership.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId: input.teamId,
|
||||
},
|
||||
});
|
||||
|
||||
if (membership && membership.role === "MEMBER") {
|
||||
const eventTypes = await ctx.prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
// user its listed as direct user or as part of a group
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
users: {
|
||||
some: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return eventTypes;
|
||||
const membershipWhereConditional: Prisma.MembershipWhereInput = {};
|
||||
if (teamId) {
|
||||
membershipWhereConditional["teamId"] = teamId;
|
||||
membershipWhereConditional["userId"] = user.id;
|
||||
}
|
||||
if (userId) {
|
||||
membershipWhereConditional["userId"] = userId;
|
||||
}
|
||||
|
||||
const eventTypes = await ctx.prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
},
|
||||
where: {
|
||||
teamId: input.teamId,
|
||||
},
|
||||
// I'm not using unique here since when userId comes from input we should look for every
|
||||
// event type that user owns
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: membershipWhereConditional,
|
||||
});
|
||||
|
||||
return eventTypes;
|
||||
if (!membership) {
|
||||
throw new Error("User is not part of a team");
|
||||
}
|
||||
|
||||
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {};
|
||||
if (teamId) {
|
||||
eventTypeWhereConditional["teamId"] = teamId;
|
||||
}
|
||||
if (userId) {
|
||||
eventTypeWhereConditional["userId"] = userId;
|
||||
}
|
||||
let eventTypeResult: Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
slug: true;
|
||||
teamId: true;
|
||||
title: true;
|
||||
};
|
||||
}>[] = [];
|
||||
|
||||
switch (membership?.role) {
|
||||
case "MEMBER":
|
||||
eventTypeWhereConditional["OR"] = {
|
||||
userId: user.id,
|
||||
users: { some: { id: user.id } },
|
||||
// @TODO this is not working as expected
|
||||
// hosts: { some: { id: user.id } },
|
||||
};
|
||||
eventTypeResult = await prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
},
|
||||
where: eventTypeWhereConditional,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
eventTypeResult = await prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
},
|
||||
where: eventTypeWhereConditional,
|
||||
});
|
||||
break;
|
||||
}
|
||||
return eventTypeResult;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,65 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||
|
||||
const shuffle = (
|
||||
booking: any,
|
||||
year: number,
|
||||
eventTypesToPick: Prisma.EventTypeGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
userId: true;
|
||||
title: true;
|
||||
length: true;
|
||||
description: true;
|
||||
};
|
||||
}>[],
|
||||
usersIdsToPick?: number[]
|
||||
) => {
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.add(Math.floor(Math.random() * 365), "day")
|
||||
.add(Math.floor(Math.random() * 24), "hour")
|
||||
.add(Math.floor(Math.random() * 60), "minute")
|
||||
.set("y", year);
|
||||
const randomEvent = eventTypesToPick[Math.floor(Math.random() * eventTypesToPick.length)];
|
||||
|
||||
const endTime = dayjs(startTime).add(Math.floor(Math.random() * randomEvent.length), "minute");
|
||||
|
||||
booking.title = randomEvent.title;
|
||||
booking.description = randomEvent.description;
|
||||
booking.startTime = startTime.toISOString();
|
||||
booking.endTime = endTime.toISOString();
|
||||
booking.createdAt = startTime.subtract(1, "day").toISOString();
|
||||
|
||||
// Pick a random status
|
||||
const randomStatusIndex = Math.floor(Math.random() * Object.keys(BookingStatus).length);
|
||||
const statusKey = Object.keys(BookingStatus)[randomStatusIndex];
|
||||
|
||||
booking.status = BookingStatus[statusKey];
|
||||
|
||||
booking.rescheduled = Math.random() > 0.5 && Math.random() > 0.5 && Math.random() > 0.5;
|
||||
|
||||
if (booking.rescheduled) {
|
||||
booking.status = "CANCELLED";
|
||||
}
|
||||
const randomEventTypeId = randomEvent.id;
|
||||
|
||||
booking.eventTypeId = randomEventTypeId;
|
||||
booking.uid = uuidv4();
|
||||
|
||||
if (usersIdsToPick && usersIdsToPick.length > 0) {
|
||||
booking.userId = Math.random() > 0.5 ? usersIdsToPick[0] : usersIdsToPick[1];
|
||||
} else {
|
||||
booking.userId = randomEvent.userId;
|
||||
}
|
||||
|
||||
if (booking.userId === undefined || booking.userId === null) {
|
||||
console.log({ randomEvent, usersIdsToPick });
|
||||
console.log("This should not happen");
|
||||
}
|
||||
|
||||
return booking;
|
||||
};
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
async function main() {
|
||||
// First find if not then create everything
|
||||
@@ -18,25 +77,30 @@ async function main() {
|
||||
insightsAdmin = await prisma.user.create({
|
||||
data: {
|
||||
email: "insights@example.com",
|
||||
password: await hashPassword("insights"),
|
||||
password: await hashPassword("insightsinsightsinsights...!"),
|
||||
name: "Insights Admin",
|
||||
role: "ADMIN",
|
||||
username: "insights",
|
||||
username: "insights-admin",
|
||||
completedOnboarding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!insightsAdmin) {
|
||||
// Throw error
|
||||
throw new Error("Insights Admin not created");
|
||||
}
|
||||
}
|
||||
|
||||
let insightsUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: "insightuser@example.com",
|
||||
email: "insightsuser@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
if (!insightsUser) {
|
||||
insightsUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "insightuser@example.com",
|
||||
email: "insightsuser@example.com",
|
||||
password: await hashPassword("insightsuser"),
|
||||
name: "Insights User",
|
||||
role: "USER",
|
||||
@@ -44,6 +108,10 @@ async function main() {
|
||||
completedOnboarding: true,
|
||||
},
|
||||
});
|
||||
if (!insightsUser) {
|
||||
// Throw error
|
||||
throw new Error("Insights Admin not created");
|
||||
}
|
||||
}
|
||||
|
||||
let insightsTeam = await prisma.team.findFirst({
|
||||
@@ -75,37 +143,33 @@ async function main() {
|
||||
},
|
||||
],
|
||||
});
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: insightsTeam.id,
|
||||
},
|
||||
data: {
|
||||
members: {
|
||||
connect: {
|
||||
userId_teamId: {
|
||||
userId: insightsAdmin.id,
|
||||
teamId: insightsTeam.id,
|
||||
if (!insightsTeam) {
|
||||
insightsTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: "Insights",
|
||||
slug: "insights-team",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (insightsAdmin && insightsTeam) {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: insightsTeam.id,
|
||||
},
|
||||
data: {
|
||||
members: {
|
||||
connect: {
|
||||
userId_teamId: {
|
||||
userId: insightsAdmin.id,
|
||||
teamId: insightsTeam.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: insightsTeam.id,
|
||||
},
|
||||
data: {
|
||||
members: {
|
||||
connect: {
|
||||
userId_teamId: {
|
||||
userId: insightsUser.id,
|
||||
teamId: insightsTeam.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let teamEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
@@ -113,6 +177,112 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
const userSingleEventsAdmin = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: insightsUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const userSingleEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: insightsUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (userSingleEventsAdmin.length === 0 && userSingleEvents.length === 0) {
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Single Event Admin",
|
||||
slug: "single-event-admin",
|
||||
description: "Single Event Admin",
|
||||
length: 60,
|
||||
userId: insightsAdmin.id,
|
||||
users: {
|
||||
connect: {
|
||||
id: insightsAdmin.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Single Event",
|
||||
slug: "single-event",
|
||||
description: "Single Event",
|
||||
length: 60,
|
||||
userId: insightsUser.id,
|
||||
users: {
|
||||
connect: {
|
||||
id: insightsUser.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const singleEventTypesAdmin = await prisma.eventType.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
title: true,
|
||||
length: true,
|
||||
description: true,
|
||||
},
|
||||
where: {
|
||||
userId: insightsAdmin.id,
|
||||
},
|
||||
});
|
||||
|
||||
const singleEventTypes = await prisma.eventType.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
length: true,
|
||||
title: true,
|
||||
description: true,
|
||||
},
|
||||
where: {
|
||||
userId: insightsUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!singleEventTypesAdmin || !singleEventTypes) {
|
||||
throw new Error("Could not create single event types");
|
||||
}
|
||||
|
||||
const singleEvents = [singleEventTypesAdmin, singleEventTypes];
|
||||
|
||||
// create bookings random for single event type
|
||||
const baseBookingSingle: Prisma.BookingCreateManyInput = {
|
||||
uid: "demoUIDSingle",
|
||||
title: "Single Event",
|
||||
description: "Single Event",
|
||||
startTime: dayjs().toISOString(),
|
||||
endTime: dayjs().toISOString(),
|
||||
// This gets overriden in shuffle
|
||||
userId: insightsUser.id,
|
||||
};
|
||||
|
||||
// Validate if they are null of undefined throw error
|
||||
if (!insightsAdmin || !insightsUser || !insightsAdmin.id || !insightsUser.id) {
|
||||
throw new Error("Could not find user");
|
||||
}
|
||||
|
||||
await prisma.booking.createMany({
|
||||
data: [
|
||||
...new Array(100)
|
||||
.fill(0)
|
||||
.map(() => shuffle({ ...baseBookingSingle }, dayjs().get("y") - 1, singleEvents)),
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.booking.createMany({
|
||||
data: [
|
||||
...new Array(100)
|
||||
.fill(0)
|
||||
.map(() => shuffle({ ...baseBookingSingle }, dayjs().get("y") - 0, singleEvents)),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (teamEvents.length === 0) {
|
||||
await prisma.eventType.createMany({
|
||||
data: [
|
||||
@@ -142,7 +312,6 @@ async function main() {
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
teamEvents = await prisma.eventType.findMany({
|
||||
where: {
|
||||
teamId: insightsTeam.id,
|
||||
@@ -152,61 +321,52 @@ async function main() {
|
||||
|
||||
const baseBooking: Prisma.BookingCreateManyInput = {
|
||||
uid: "demoUID",
|
||||
title: "Team Meeting",
|
||||
description: "Team Meeting",
|
||||
title: "Team Meeting should be changed in shuffle",
|
||||
description: "Team Meeting Should be changed in shuffle",
|
||||
startTime: dayjs().toISOString(),
|
||||
endTime: dayjs().toISOString(),
|
||||
userId: insightsUser.id,
|
||||
eventTypeId: teamEvents[0].id,
|
||||
};
|
||||
|
||||
const shuffle = (booking: typeof baseBooking, year) => {
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.add(Math.floor(Math.random() * 365), "day")
|
||||
.add(Math.floor(Math.random() * 24), "hour")
|
||||
.add(Math.floor(Math.random() * 60), "minute")
|
||||
.set("y", year);
|
||||
const randomEvent = teamEvents[Math.floor(Math.random() * teamEvents.length)];
|
||||
const endTime = dayjs(startTime).add(Math.floor(Math.random() * randomEvent.length), "minute");
|
||||
|
||||
booking.startTime = startTime.toISOString();
|
||||
booking.endTime = endTime.toISOString();
|
||||
booking.createdAt = startTime.subtract(1, "day").toISOString();
|
||||
|
||||
// Pick a random status
|
||||
const randomStatusIndex = Math.floor(Math.random() * Object.keys(BookingStatus).length);
|
||||
const statusKey = Object.keys(BookingStatus)[randomStatusIndex];
|
||||
|
||||
booking.status = BookingStatus[statusKey];
|
||||
|
||||
booking.rescheduled = Math.random() > 0.5 && Math.random() > 0.5 && Math.random() > 0.5;
|
||||
|
||||
if (booking.rescheduled) {
|
||||
booking.status = "CANCELLED";
|
||||
}
|
||||
const randomEventTypeId = teamEvents[Math.floor(Math.random() * teamEvents.length)].id;
|
||||
|
||||
booking.eventTypeId = randomEventTypeId;
|
||||
booking.uid = uuidv4();
|
||||
|
||||
if (insightsUser && insightsAdmin) {
|
||||
booking.userId = Math.random() > 0.5 ? insightsUser.id : insightsAdmin.id;
|
||||
}
|
||||
|
||||
return booking;
|
||||
};
|
||||
|
||||
// Create past bookings
|
||||
// Create past bookings -2y, -1y, -0y
|
||||
await prisma.booking.createMany({
|
||||
data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y") - 2))],
|
||||
data: [
|
||||
...new Array(100)
|
||||
.fill(0)
|
||||
.map(() =>
|
||||
shuffle({ ...baseBooking }, dayjs().get("y") - 2, teamEvents, [
|
||||
insightsAdmin?.id ?? 0,
|
||||
insightsUser?.id ?? 0,
|
||||
])
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.booking.createMany({
|
||||
data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y") - 1))],
|
||||
data: [
|
||||
...new Array(100)
|
||||
.fill(0)
|
||||
.map(() =>
|
||||
shuffle({ ...baseBooking }, dayjs().get("y") - 1, teamEvents, [
|
||||
insightsAdmin?.id ?? 0,
|
||||
insightsUser?.id ?? 0,
|
||||
])
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
await prisma.booking.createMany({
|
||||
data: [...new Array(100).fill(0).map(() => shuffle({ ...baseBooking }, dayjs().get("y")))],
|
||||
data: [
|
||||
...new Array(100)
|
||||
.fill(0)
|
||||
.map(() =>
|
||||
shuffle({ ...baseBooking }, dayjs().get("y"), teamEvents, [
|
||||
insightsAdmin?.id ?? 0,
|
||||
insightsUser?.id ?? 0,
|
||||
])
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user