Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e55cee443b | ||
|
|
c3634b3aba | ||
|
|
cd576f5378 | ||
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getUserAvailability } from "@calcom/lib/getUserAvailability";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -191,8 +191,9 @@ const availabilitySchema = z
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { isSystemWideAdmin, userId: reqUserId } = req;
|
||||
const { username, userId, eventTypeId, dateTo, dateFrom, teamId } = availabilitySchema.parse(req.query);
|
||||
const userAvailabilityService = getUserAvailabilityService()
|
||||
if (!teamId)
|
||||
return getUserAvailability({
|
||||
return userAvailabilityService.getUserAvailability({
|
||||
username,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
@@ -230,7 +231,7 @@ async function handler(req: NextApiRequest) {
|
||||
const availabilities = members.map(async (user) => {
|
||||
return {
|
||||
userId: user.id,
|
||||
availability: await getUserAvailability({
|
||||
availability: await userAvailabilityService.getUserAvailability({
|
||||
userId: user.id,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.283",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
+4
-1
@@ -290,7 +290,10 @@ describe("Event types Endpoints", () => {
|
||||
expect(responseBookingFields).toBeDefined();
|
||||
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
|
||||
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
|
||||
expect(responseBookingField).toEqual(bookingFields[0]);
|
||||
const fields = responseBookingField
|
||||
//@ts-ignore
|
||||
delete fields.labelAsSafeHtml
|
||||
expect(fields).toEqual(bookingFields[0]);
|
||||
eventType.bookingFields = responseBookingFields;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CacheService } from "@/lib/services/cache.service";
|
||||
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { UserAvailabilityService } from "@/lib/services/user-availability.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
@@ -28,6 +29,7 @@ import { Module } from "@nestjs/common";
|
||||
CheckBookingLimitsService,
|
||||
CacheService,
|
||||
AvailableSlotsService,
|
||||
UserAvailabilityService
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.s
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||
import { UserAvailabilityService } from "./user-availability.service";
|
||||
|
||||
@Injectable()
|
||||
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
@@ -37,6 +38,7 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
userRepo: userRepository,
|
||||
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
|
||||
cacheService: new CacheService(featuresRepository),
|
||||
userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
|
||||
|
||||
@Injectable()
|
||||
export class UserAvailabilityService extends BaseUserAvailabilityService {
|
||||
constructor(
|
||||
oooRepoDependency: PrismaOOORepository,
|
||||
bookingRepository: PrismaBookingRepository,
|
||||
eventTypeRepository: PrismaEventTypeRepository,
|
||||
|
||||
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
|
||||
bookingRepo: bookingRepository,
|
||||
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,10 @@ export class BillingService implements OnModuleDestroy {
|
||||
fromReschedule?: string | null;
|
||||
}
|
||||
) {
|
||||
|
||||
if (this.configService.get("e2e")) {
|
||||
return true;
|
||||
}
|
||||
const { uid, startTime, fromReschedule } = booking;
|
||||
|
||||
const delay = startTime.getTime() - Date.now();
|
||||
@@ -427,6 +431,9 @@ export class BillingService implements OnModuleDestroy {
|
||||
* Removing an attendee from a booking does not cancel the usage increment job.
|
||||
*/
|
||||
async cancelUsageByBookingUid(bookingUid: string) {
|
||||
if (this.configService.get("e2e")) {
|
||||
return true;
|
||||
}
|
||||
const job = await this.billingQueue.getJob(`increment-${bookingUid}`);
|
||||
if (job) {
|
||||
await job.remove();
|
||||
@@ -458,6 +465,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy() {
|
||||
try {
|
||||
|
||||
await this.billingQueue.close();
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhooksList() {
|
||||
revalidatePath("/settings/developer/webhooks");
|
||||
export async function revalidateWebhooksListGetByViewer() {
|
||||
revalidateTag("viewer.webhook.getByViewer");
|
||||
}
|
||||
|
||||
+16
-4
@@ -1,13 +1,13 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/webhooks"
|
||||
);
|
||||
|
||||
const getCachedWebhooksList = unstable_cache(
|
||||
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
|
||||
return await WebhookRepository.getAllWebhooksByUserId({
|
||||
userId,
|
||||
userRole,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
|
||||
);
|
||||
|
||||
const WebhooksViewServerWrapper = async () => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
|
||||
}
|
||||
|
||||
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
|
||||
const caller = await createRouterCaller(webhookRouter);
|
||||
const data = await caller.getByViewer();
|
||||
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
|
||||
|
||||
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
|
||||
|
||||
return <WebhooksView data={data} isAdmin={isAdmin} />;
|
||||
};
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhookById(id: string) {
|
||||
revalidateTag(`viewer.webhook.get:${id}`);
|
||||
}
|
||||
+14
-1
@@ -1,5 +1,6 @@
|
||||
import type { PageProps } from "app/_types";
|
||||
import { getTranslate, _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
|
||||
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
|
||||
`/settings/developer/webhooks/${(await params).id}`
|
||||
);
|
||||
|
||||
const getCachedWebhook = (id?: string) => {
|
||||
const fn = unstable_cache(
|
||||
async () => {
|
||||
return await WebhookRepository.findByWebhookId(id);
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
|
||||
);
|
||||
|
||||
return fn();
|
||||
};
|
||||
|
||||
const Page = async ({ params: _params }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const params = await _params;
|
||||
const id = typeof params?.id === "string" ? params.id : undefined;
|
||||
|
||||
const webhook = await WebhookRepository.findByWebhookId(id);
|
||||
const webhook = await getCachedWebhook(id);
|
||||
|
||||
return (
|
||||
<SettingsHeader
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
export async function revalidateWebhookList() {
|
||||
revalidateTag("viewer.webhook.list");
|
||||
}
|
||||
+22
-6
@@ -1,10 +1,16 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
|
||||
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
|
||||
"/settings/developer/webhooks/new"
|
||||
);
|
||||
|
||||
const getCachedWebhooksList = unstable_cache(
|
||||
async ({ userId }: { userId: number }) => {
|
||||
return await WebhookRepository.findWebhooksByFilters({ userId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
|
||||
);
|
||||
|
||||
const Page = async () => {
|
||||
const [appsCaller, webhookCaller] = await Promise.all([
|
||||
createRouterCaller(appsRouter),
|
||||
createRouterCaller(webhookRouter),
|
||||
]);
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
}
|
||||
|
||||
const appsCaller = await createRouterCaller(appsRouter);
|
||||
|
||||
const [installedApps, webhooks] = await Promise.all([
|
||||
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
|
||||
webhookCaller.list(),
|
||||
getCachedWebhooksList({ userId: session.user.id }),
|
||||
]);
|
||||
|
||||
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
|
||||
|
||||
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
|
||||
autoComplete="none"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className={classNames(
|
||||
"mb-0 mt-0 rounded-md rounded-l-none",
|
||||
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
|
||||
markAsError
|
||||
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
|
||||
: ""
|
||||
|
||||
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
|
||||
});
|
||||
|
||||
// If eventType exists, it means user is either owner, host or user
|
||||
return !eventType;
|
||||
return !!eventType;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
RecentFeedbackTable,
|
||||
TimezoneBadge,
|
||||
} from "@calcom/features/insights/components/booking";
|
||||
import "@calcom/features/insights/components/tremor.css";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { Download } from "@calcom/features/insights/filters/Download";
|
||||
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { ProgressBar } from "@calcom/ui/components/progress-bar";
|
||||
import { showToast } from "@calcom/ui/toast";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.5.10",
|
||||
"version": "5.5.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -76,7 +76,6 @@
|
||||
"@stripe/stripe-js": "^1.35.0",
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@tremor/react": "^2.11.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@unkey/ratelimit": "^0.1.1",
|
||||
"@upstash/redis": "^1.21.0",
|
||||
|
||||
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Choose User filter item from dropdown
|
||||
await addFilter(page, "bookingUserId");
|
||||
await addFilter(page, "userId");
|
||||
|
||||
// Wait for the URL to include bookingUserId
|
||||
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
|
||||
// Wait for the URL to include userId
|
||||
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await openFilter(page, "bookingUserId");
|
||||
await openFilter(page, "userId");
|
||||
|
||||
await page
|
||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(0)
|
||||
.click();
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||
|
||||
await page
|
||||
.locator('[data-testid="select-filter-options-bookingUserId"]')
|
||||
.getByRole("option")
|
||||
.nth(1)
|
||||
.click();
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await clearFilters(page);
|
||||
|
||||
await expect(page.url()).not.toContain("bookingUserId");
|
||||
await expect(page.url()).not.toContain("userId");
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -3402,6 +3402,7 @@
|
||||
"license_key_saved": "License key saved successfully",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"failed_bookings": "Failed bookings",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
"cache_status": "Cache Status",
|
||||
"cache_last_updated": "Last updated: {{timestamp}}",
|
||||
@@ -3429,5 +3430,7 @@
|
||||
"usage_based_expiration_description": "This link can be used for {{count}} booking",
|
||||
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
|
||||
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
...base,
|
||||
content: [
|
||||
...base.content,
|
||||
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
|
||||
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
|
||||
plugins: [...base.plugins, require("tailwindcss-animate")],
|
||||
};
|
||||
|
||||
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => key,
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@
|
||||
"group": "Embeds",
|
||||
"icon": "code-simple",
|
||||
"pages": ["developing/guides/embeds/embed-events"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"group": "Insights",
|
||||
"icon": "chart-bar",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar
|
||||
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { handleErrorsJson } from "@calcom/lib/errors";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@@ -91,8 +92,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Office365 Calendar: Received calendar response", {
|
||||
userId: req.session?.user?.id,
|
||||
status: calRequest.status,
|
||||
statusText: calRequest.statusText,
|
||||
url: calRequest.url,
|
||||
});
|
||||
|
||||
let calBody = await handleErrorsJson<{ value: OfficeCalendar[]; "@odata.nextLink"?: string }>(calRequest);
|
||||
|
||||
logger.info("Office365 Calendar: handleErrorsJson completed", {
|
||||
userId: req.session?.user?.id,
|
||||
calendarCount: calBody.value.length ?? 0,
|
||||
});
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker
|
||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
|
||||
import { getUsersAvailability } from "@calcom/lib/getUserAvailability";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||
import { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||
import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits";
|
||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||
@@ -62,6 +62,7 @@ const _ensureAvailableUsers = async (
|
||||
shouldServeCache?: boolean
|
||||
// ReturnType hint of at least one IsFixedAwareUser, as it's made sure at least one entry exists
|
||||
): Promise<[IsFixedAwareUser, ...IsFixedAwareUser[]]> => {
|
||||
const userAvailabilityService = getUserAvailabilityService()
|
||||
const availableUsers: IsFixedAwareUser[] = [];
|
||||
|
||||
const startDateTimeUtc = getDateTimeInUtc(input.dateFrom, input.timeZone);
|
||||
@@ -86,7 +87,7 @@ const _ensureAvailableUsers = async (
|
||||
})
|
||||
: [];
|
||||
|
||||
const usersAvailability = await getUsersAvailability({
|
||||
const usersAvailability = await userAvailabilityService.getUsersAvailability({
|
||||
users: eventType.users,
|
||||
query: {
|
||||
...input,
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
import type { Booking, User, Webhook } from "@prisma/client";
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { DailyLocationType } from "@calcom/app-store/locations";
|
||||
import { getMeetingSessionsFromRoomName } from "@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName";
|
||||
import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow";
|
||||
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import { scheduleNoShowTriggers } from "./scheduleNoShowTriggers";
|
||||
|
||||
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
|
||||
getMeetingSessionsFromRoomName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
|
||||
sendGenericWebhookPayload: vi.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
describe("scheduleNoShowTriggers Integration", () => {
|
||||
let testUser: User;
|
||||
let hostWebhook: Webhook;
|
||||
let guestWebhook: Webhook;
|
||||
const testEventTypeId = 67890;
|
||||
const testBookingIds = [98765, 98766];
|
||||
const testBookingId = 123456;
|
||||
|
||||
beforeAll(async () => {
|
||||
testUser = await prisma.user.create({
|
||||
data: {
|
||||
email: "test-user-no-show@example.com",
|
||||
username: "test-user-no-show",
|
||||
name: "Test User No Show",
|
||||
},
|
||||
});
|
||||
|
||||
[hostWebhook, guestWebhook] = await Promise.all([
|
||||
prisma.webhook.create({
|
||||
data: {
|
||||
id: "test-host-webhook-id",
|
||||
userId: testUser.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
active: true,
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
},
|
||||
}),
|
||||
prisma.webhook.create({
|
||||
data: {
|
||||
id: "test-guest-webhook-id",
|
||||
userId: testUser.id,
|
||||
subscriberUrl: "https://example.com/guest-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
|
||||
active: true,
|
||||
time: 10,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.task.deleteMany({
|
||||
where: {
|
||||
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.webhook.deleteMany({
|
||||
where: {
|
||||
userId: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.booking.deleteMany({
|
||||
where: {
|
||||
id: testBookingId,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: testUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.eventType.deleteMany({
|
||||
where: {
|
||||
id: testEventTypeId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("scheduling a webhook creates the correct Tasker jobs for no-show webhooks", async () => {
|
||||
const currentDate = new Date("2023-01-01T10:00:00.000Z");
|
||||
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
|
||||
id: testBookingIds[0],
|
||||
startTime: currentDate,
|
||||
endTime: new Date("2023-01-01T10:30:00.000Z"),
|
||||
location: DailyLocationType,
|
||||
};
|
||||
|
||||
await scheduleNoShowTriggers({
|
||||
booking: bookingData,
|
||||
organizerUser: { id: testUser.id },
|
||||
eventTypeId: testEventTypeId,
|
||||
triggerForUser: true,
|
||||
});
|
||||
|
||||
const createdTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(createdTasks.length).toBe(2);
|
||||
|
||||
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
|
||||
expect(hostTask).toBeDefined();
|
||||
|
||||
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
|
||||
expect(guestTask).toBeDefined();
|
||||
});
|
||||
|
||||
test("created task payloads for no-show webhooks are correct", async () => {
|
||||
const currentDate = new Date("2023-01-01T11:00:00.000Z");
|
||||
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
|
||||
id: testBookingIds[1],
|
||||
startTime: currentDate,
|
||||
endTime: new Date("2023-01-01T11:30:00.000Z"),
|
||||
location: DailyLocationType,
|
||||
};
|
||||
|
||||
await scheduleNoShowTriggers({
|
||||
booking: bookingData,
|
||||
organizerUser: { id: testUser.id },
|
||||
eventTypeId: testEventTypeId,
|
||||
triggerForUser: true,
|
||||
});
|
||||
|
||||
const createdTasks = await prisma.task.findMany({
|
||||
where: {
|
||||
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
|
||||
payload: {
|
||||
contains: bookingData.id.toString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
|
||||
expect(hostTask).toBeDefined();
|
||||
|
||||
let hostPayload;
|
||||
try {
|
||||
hostPayload = JSON.parse(hostTask?.payload ?? "");
|
||||
} catch (e) {
|
||||
console.error("Failed to parse host task payload.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
expect(hostPayload).toEqual({
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
bookingId: bookingData.id,
|
||||
webhook: expect.objectContaining({
|
||||
id: hostWebhook.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
}),
|
||||
});
|
||||
|
||||
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
|
||||
expect(guestTask).toBeDefined();
|
||||
|
||||
let guestPayload;
|
||||
try {
|
||||
guestPayload = JSON.parse(guestTask?.payload ?? "");
|
||||
} catch (e) {
|
||||
console.error("Failed to parse guest task payload.");
|
||||
throw e;
|
||||
}
|
||||
|
||||
expect(guestPayload).toEqual({
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
|
||||
bookingId: bookingData.id,
|
||||
webhook: expect.objectContaining({
|
||||
id: guestWebhook.id,
|
||||
subscriberUrl: "https://example.com/guest-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
|
||||
time: 10,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("task handler runs properly with the correct payload", async () => {
|
||||
await prisma.eventType.create({
|
||||
data: {
|
||||
id: testEventTypeId,
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 30,
|
||||
userId: testUser.id,
|
||||
description: "Test description",
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
await prisma.booking.create({
|
||||
data: {
|
||||
id: testBookingId,
|
||||
uid: "test-uid-no-show",
|
||||
title: "Test No-Show",
|
||||
eventTypeId: testEventTypeId,
|
||||
status: "ACCEPTED",
|
||||
startTime: now,
|
||||
endTime: new Date(now.getTime() + 30 * 60 * 1000),
|
||||
location: DailyLocationType,
|
||||
userId: testUser.id,
|
||||
references: {
|
||||
create: {
|
||||
type: "daily_video",
|
||||
uid: "meeting-test-uid",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue({
|
||||
total_count: 1,
|
||||
data: [
|
||||
{
|
||||
id: "meeting-test-id",
|
||||
room: "meeting-test-uid",
|
||||
start_time: Math.floor(now.getTime() / 1000),
|
||||
duration: 30,
|
||||
max_participants: 1,
|
||||
participants: [
|
||||
{
|
||||
user_id: "999999",
|
||||
participant_id: "participant-test-id",
|
||||
user_name: "Guest User",
|
||||
join_time: Math.floor(now.getTime() / 1000),
|
||||
duration: 30,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const taskPayload = JSON.stringify({
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
bookingId: testBookingId,
|
||||
webhook: {
|
||||
id: hostWebhook.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
|
||||
active: true,
|
||||
time: 5,
|
||||
timeUnit: TimeUnit.MINUTE,
|
||||
payloadTemplate: null,
|
||||
secret: null,
|
||||
appId: null,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerHostNoShow(taskPayload);
|
||||
|
||||
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
webhook: expect.objectContaining({
|
||||
id: hostWebhook.id,
|
||||
subscriberUrl: "https://example.com/host-webhook",
|
||||
}),
|
||||
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
|
||||
data: expect.objectContaining({
|
||||
bookingId: testBookingId,
|
||||
bookingUid: "test-uid-no-show",
|
||||
message: expect.stringContaining("Host with email"),
|
||||
participants: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
user_id: "999999",
|
||||
user_name: "Guest User",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const updatedBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: testBookingId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedBooking?.noShowHost).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,17 @@ vi.mock("@calcom/prisma", () => {
|
||||
|
||||
vi.mock("stripe");
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/lib/constants", async () => {
|
||||
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
|
||||
return {
|
||||
|
||||
+11
@@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({
|
||||
createDomain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const mockOrganizationOnboarding = {
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
|
||||
@@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", (
|
||||
sendOrScheduleWorkflowEmails: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
describe("reminderScheduler", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createInstance } from "i18next";
|
||||
import { expect, test, describe } from "vitest";
|
||||
import { vi, expect, test, describe } from "vitest";
|
||||
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||
@@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions";
|
||||
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
|
||||
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const translation = async () => {
|
||||
const _i18n = createInstance();
|
||||
await _i18n.init({
|
||||
|
||||
@@ -66,11 +66,13 @@ export default function CreateEventTypeForm({
|
||||
!isPlatform ? (
|
||||
<Tooltip content={!isManagedEventType ? pageSlug : t("username_placeholder")}>
|
||||
<span className="max-w-24 md:max-w-56">
|
||||
/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
||||
{`/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className="pl-0"
|
||||
{...register("slug")}
|
||||
onChange={(e) => {
|
||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||
@@ -91,11 +93,13 @@ export default function CreateEventTypeForm({
|
||||
<Tooltip
|
||||
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
|
||||
<span className="max-w-24 md:max-w-56">
|
||||
{urlPrefix}/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
|
||||
{`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
containerClassName="[&>div]:gap-0"
|
||||
className="pl-0"
|
||||
{...register("slug")}
|
||||
onChange={(e) => {
|
||||
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
|
||||
|
||||
@@ -208,9 +208,12 @@ export const EventSetupTab = (
|
||||
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
|
||||
defaultValue={eventType.slug}
|
||||
data-testid="event-slug"
|
||||
containerClassName={classNames(customClassNames?.titleSection?.urlInput?.container)}
|
||||
containerClassName={classNames(
|
||||
"[&>div]:gap-0",
|
||||
customClassNames?.titleSection?.urlInput?.container
|
||||
)}
|
||||
labelClassName={classNames(customClassNames?.titleSection?.urlInput?.label)}
|
||||
className={classNames(customClassNames?.titleSection?.urlInput?.input)}
|
||||
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
|
||||
addOnLeading={
|
||||
isPlatform ? undefined : (
|
||||
<>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function InsightsPage() {
|
||||
|
||||
## Step 4: Create tRPC Handler
|
||||
|
||||
Add the tRPC endpoint in the insights router using the `createInsightsBookingService()` helper:
|
||||
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
|
||||
|
||||
```typescript
|
||||
// packages/features/insights/server/trpc-router.ts
|
||||
@@ -118,6 +118,7 @@ export const insightsRouter = router({
|
||||
myNewChartData: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
// `createInsightsBookingService` is defined at the root level in this file
|
||||
const insightsBookingService = createInsightsBookingService(ctx, input);
|
||||
|
||||
try {
|
||||
@@ -129,13 +130,13 @@ export const insightsRouter = router({
|
||||
});
|
||||
```
|
||||
|
||||
## Step 5: Add Service Method to InsightsBookingService
|
||||
## Step 5: Add Service Method to InsightsBookingBaseService
|
||||
|
||||
Add your new method to the `InsightsBookingService` class:
|
||||
Add your new method to the `InsightsBookingBaseService` class:
|
||||
|
||||
```typescript
|
||||
// packages/lib/server/service/insightsBooking.ts
|
||||
export class InsightsBookingService {
|
||||
// packages/lib/server/service/InsightsBookingBaseService.ts
|
||||
export class InsightsBookingBaseService {
|
||||
// ... existing methods
|
||||
|
||||
async getMyNewChartData() {
|
||||
@@ -168,7 +169,7 @@ export class InsightsBookingService {
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `createInsightsBookingService()`**: Always use the helper function for consistent service creation
|
||||
1. **Use `getInsightsBookingService()`**: Always use the DI container function for consistent service creation
|
||||
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
|
||||
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
|
||||
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Card } from "@tremor/react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
interface ICardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const CardInsights = (props: ICardProps) => {
|
||||
const { children, className = "", ...rest } = props;
|
||||
|
||||
return (
|
||||
<Card className={classNames(`ring-subtle bg-muted shadow-none `, className)} {...rest}>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -21,7 +21,7 @@ export function ChartCard({
|
||||
legendSize,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
title: string | React.ReactNode;
|
||||
subtitle?: string;
|
||||
cta?: { label: string; onClick: () => void };
|
||||
legend?: Array<LegendItem>;
|
||||
@@ -31,7 +31,11 @@ export function ChartCard({
|
||||
return (
|
||||
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
|
||||
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
|
||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
||||
{typeof title === "string" ? (
|
||||
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
|
||||
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
|
||||
{cta && (
|
||||
@@ -80,13 +84,13 @@ function Legend({ items, size = "default" }: { items: LegendItem[]; size?: Legen
|
||||
{items.map((item, index) => (
|
||||
<Fragment key={item.label}>
|
||||
<div
|
||||
className="relative flex items-center gap-2 rounded-md px-1.5 py-1"
|
||||
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
|
||||
style={{ backgroundColor: `${item.color}33` }}>
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
|
||||
<Tooltip content={item.label}>
|
||||
<p
|
||||
className={classNames(
|
||||
"text-default truncate text-sm font-medium leading-none",
|
||||
"text-default truncate py-0.5 text-sm font-medium leading-none",
|
||||
size === "sm" ? "w-16" : ""
|
||||
)}>
|
||||
{item.label}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { calculateDeltaType, colors, valueFormatter } from "../lib";
|
||||
import { calculateDeltaType, valueFormatter } from "../lib";
|
||||
|
||||
export const KPICard = ({
|
||||
title,
|
||||
@@ -13,7 +13,6 @@ export const KPICard = ({
|
||||
previousDateRange,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
previousMetricData: {
|
||||
count: number;
|
||||
deltaPrevious: number;
|
||||
@@ -21,22 +20,54 @@ export const KPICard = ({
|
||||
previousDateRange: { startDate: string; endDate: string };
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const deltaType = calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count);
|
||||
const deltaValue = Number(previousMetricData.deltaPrevious).toFixed(0);
|
||||
|
||||
const getBadgeVariant = (type: string) => {
|
||||
switch (type) {
|
||||
case "increase":
|
||||
case "moderateIncrease":
|
||||
return "success" as const;
|
||||
case "decrease":
|
||||
case "moderateDecrease":
|
||||
return "error" as const;
|
||||
case "unchanged":
|
||||
default:
|
||||
return "gray" as const;
|
||||
}
|
||||
};
|
||||
|
||||
// Get appropriate icon for delta
|
||||
const getDeltaIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "increase":
|
||||
case "moderateIncrease":
|
||||
return "arrow-up" as const;
|
||||
case "decrease":
|
||||
case "moderateDecrease":
|
||||
return "arrow-down" as const;
|
||||
case "unchanged":
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const badgeVariant = getBadgeVariant(deltaType);
|
||||
const deltaIcon = getDeltaIcon(deltaType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text className="text-default">{title}</Text>
|
||||
<Flex className="items-baseline justify-start space-x-3 truncate">
|
||||
<Metric className="text-emphasis">{valueFormatter(previousMetricData.count)}</Metric>
|
||||
</Flex>
|
||||
<Flex className="mt-4 justify-start space-x-2">
|
||||
<BadgeDelta
|
||||
deltaType={calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)}
|
||||
/>
|
||||
<Flex className="justify-start space-x-1 truncate">
|
||||
<Text
|
||||
color={colors[calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)]}>
|
||||
{Number(previousMetricData.deltaPrevious).toFixed(0)}%
|
||||
</Text>
|
||||
|
||||
<div className="text-default text-sm">{title}</div>
|
||||
<div className="flex items-baseline justify-start space-x-3 truncate">
|
||||
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(previousMetricData.count)}</div>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-start space-x-2">
|
||||
<Badge variant={badgeVariant} className="flex items-center gap-1">
|
||||
{deltaIcon && <Icon name={deltaIcon} className="h-3 w-3" />}
|
||||
{deltaValue}%
|
||||
</Badge>
|
||||
<div className="flex justify-start space-x-1 truncate">
|
||||
<Tooltip
|
||||
content={t("from_to_date_period", {
|
||||
startDate: previousDateRange.startDate,
|
||||
@@ -46,8 +77,8 @@ export const KPICard = ({
|
||||
{t("from_last_period")}
|
||||
</small>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { LineChart as ExternalLineChart } from "@tremor/react";
|
||||
|
||||
import type { LineChartProps } from "./tremor.types";
|
||||
|
||||
// Honestly this is a mess. Why are all chart libraries in existance horrible to theme
|
||||
export const LineChart = (props: LineChartProps) => {
|
||||
return <ExternalLineChart {...props} />;
|
||||
};
|
||||
@@ -1,26 +1,25 @@
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { CardInsights } from "./Card";
|
||||
import { ChartCard } from "./ChartCard";
|
||||
|
||||
export const LoadingInsight = () => {
|
||||
return (
|
||||
<CardInsights>
|
||||
<SkeletonText className="w-32" />
|
||||
<ChartCard title={<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"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
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"
|
||||
/>
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
className="animate-spin">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
</div>
|
||||
</CardInsights>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,25 +2,24 @@
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
import { ChartCardItem } from "./ChartCard";
|
||||
|
||||
export const UserStatsTable = ({
|
||||
data,
|
||||
}: {
|
||||
data:
|
||||
| {
|
||||
userId: number | null;
|
||||
user: Pick<User, "avatarUrl" | "name">;
|
||||
emailMd5?: string;
|
||||
count?: number;
|
||||
averageRating?: number | null;
|
||||
username?: string;
|
||||
}[]
|
||||
| undefined;
|
||||
}) => {
|
||||
type UserStatsData = {
|
||||
userId: number;
|
||||
user: {
|
||||
id: number;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
emailMd5: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
export const UserStatsTable = ({ data }: { data: UserStatsData }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Filter out items without user data
|
||||
@@ -32,7 +31,7 @@ export const UserStatsTable = ({
|
||||
filteredData.map((item) => (
|
||||
<ChartCardItem
|
||||
key={item.userId || `user-${Math.random()}`}
|
||||
count={item.averageRating ? item.averageRating.toFixed(1) : item.count}
|
||||
count={Number.isInteger(item.count) ? item.count : item.count.toFixed(1)}
|
||||
className="py-3">
|
||||
<div className="flex items-center">
|
||||
<Avatar
|
||||
|
||||
@@ -1,14 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LineChart } from "../LineChart";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
AVERAGE: "#3b82f6",
|
||||
};
|
||||
|
||||
type AverageEventDurationData = RouterOutputs["viewer"]["insights"]["averageEventDuration"][number];
|
||||
|
||||
// Custom duration formatter that shows hours and minutes
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (!minutes) return "0m";
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainingMinutes = minutes % 60;
|
||||
const remainingMinutesStr = remainingMinutes.toFixed(1);
|
||||
|
||||
if (hours > 0 && remainingMinutes > 0) {
|
||||
return `${hours}h ${remainingMinutesStr}m`;
|
||||
} else if (hours > 0) {
|
||||
return `${hours}h`;
|
||||
} else {
|
||||
return `${remainingMinutesStr}m`;
|
||||
}
|
||||
};
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: AverageEventDurationData;
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{label}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }}>
|
||||
{entry.name}: {formatDuration(entry.value)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AverageEventDurationChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
@@ -36,14 +92,32 @@ export const AverageEventDurationChart = () => {
|
||||
</div>
|
||||
)}
|
||||
{data && data.length > 0 && !isNoData && (
|
||||
<LineChart
|
||||
className="mt-4 h-80"
|
||||
data={data}
|
||||
index="Date"
|
||||
categories={["Average"]}
|
||||
colors={["blue"]}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatDuration}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="Average"
|
||||
name={t("average_event_duration")}
|
||||
stroke={COLOR.AVERAGE}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</ChartCard>
|
||||
);
|
||||
|
||||
@@ -84,7 +84,6 @@ export const BookingKPICards = () => {
|
||||
<StatItem key={item.title} index={index}>
|
||||
<KPICard
|
||||
title={item.title}
|
||||
value={data[item.index].count}
|
||||
previousMetricData={data[item.index]}
|
||||
previousDateRange={data.previousRange}
|
||||
/>
|
||||
@@ -99,7 +98,6 @@ export const BookingKPICards = () => {
|
||||
<StatItem key={item.title} index={index}>
|
||||
<KPICard
|
||||
title={item.title}
|
||||
value={data[item.index].count}
|
||||
previousMetricData={data[item.index]}
|
||||
previousDateRange={data.previousRange}
|
||||
/>
|
||||
|
||||
@@ -13,15 +13,13 @@ import {
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
type BookingsByHourData = {
|
||||
hour: number;
|
||||
count: number;
|
||||
};
|
||||
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
|
||||
|
||||
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -1,14 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { valueFormatter } from "../../lib/valueFormatter";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { LineChart } from "../LineChart";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
|
||||
const COLOR = {
|
||||
CREATED: "#a855f7",
|
||||
COMPLETED: "#22c55e",
|
||||
RESCHEDULED: "#3b82f6",
|
||||
CANCELLED: "#ef4444",
|
||||
NO_SHOW_HOST: "#64748b",
|
||||
NO_SHOW_GUEST: "#f97316",
|
||||
};
|
||||
|
||||
export const legend = [
|
||||
{ label: "Created", color: COLOR.CREATED },
|
||||
{ label: "Completed", color: COLOR.COMPLETED },
|
||||
{ label: "Rescheduled", color: COLOR.RESCHEDULED },
|
||||
{ label: "Cancelled", color: COLOR.CANCELLED },
|
||||
{ label: "No-Show (Host)", color: COLOR.NO_SHOW_HOST },
|
||||
{ label: "No-Show (Guest)", color: COLOR.NO_SHOW_GUEST },
|
||||
];
|
||||
|
||||
type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number];
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: EventTrendsData;
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index} style={{ color: entry.color }}>
|
||||
{entry.name}: {valueFormatter ? valueFormatter(entry.value) : entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const EventTrendsChart = () => {
|
||||
const { t } = useLocale();
|
||||
const insightsBookingParams = useInsightsBookingParameters();
|
||||
@@ -30,15 +84,36 @@ export const EventTrendsChart = () => {
|
||||
if (!isSuccess) return null;
|
||||
|
||||
return (
|
||||
<ChartCard title={t("event_trends")}>
|
||||
<LineChart
|
||||
className="linechart ml-4 mt-4 h-80 sm:ml-0"
|
||||
data={eventTrends ?? []}
|
||||
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
|
||||
index="Month"
|
||||
colors={["purple", "green", "blue", "red", "slate", "orange"]}
|
||||
valueFormatter={valueFormatter}
|
||||
/>
|
||||
<ChartCard title={t("event_trends")} legend={legend}>
|
||||
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
className="text-xs opacity-50"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={valueFormatter}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
{legend.map((item) => (
|
||||
<Line
|
||||
key={item.label}
|
||||
type="linear"
|
||||
dataKey={item.label}
|
||||
name={item.label}
|
||||
stroke={item.color}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
animationDuration={1000}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,8 +5,8 @@ import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { FeedbackTable } from "../FeedbackTable";
|
||||
import { LoadingInsight } from "../LoadingInsights";
|
||||
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
|
||||
|
||||
export const RecentFeedbackTable = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -26,7 +26,7 @@ export const RecentFeedbackTable = () => {
|
||||
|
||||
return (
|
||||
<ChartCard title={t("recent_ratings")}>
|
||||
<FeedbackTable data={data} />
|
||||
<RecentFeedbackTableContent data={data} />
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
+6
-17
@@ -2,31 +2,20 @@
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
export const FeedbackTable = ({
|
||||
data,
|
||||
}: {
|
||||
data:
|
||||
| {
|
||||
userId: number | null;
|
||||
user: Pick<User, "avatarUrl" | "name">;
|
||||
emailMd5?: string;
|
||||
username?: string;
|
||||
rating: number | null;
|
||||
feedback: string | null;
|
||||
}[]
|
||||
| undefined;
|
||||
}) => {
|
||||
type FeedbackData = RouterOutputs["viewer"]["insights"]["recentRatings"];
|
||||
|
||||
export const RecentFeedbackTableContent = ({ data }: { data: FeedbackData }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md">
|
||||
{data && data?.length > 0 ? (
|
||||
data?.map((item) => (
|
||||
{data && data.length > 0 ? (
|
||||
data.map((item) => (
|
||||
<div
|
||||
key={item.userId}
|
||||
className="border-subtle flex items-center justify-between border-b px-4 py-3 last:border-b-0">
|
||||
@@ -1,10 +1,8 @@
|
||||
export { BookedByCell } from "./BookedByCell";
|
||||
export { BookingAtCell } from "./BookingAtCell";
|
||||
export { BookingStatusBadge } from "./BookingStatusBadge";
|
||||
export { CardInsights } from "./Card";
|
||||
export { CellWithOverflowX } from "./CellWithOverflowX";
|
||||
export { ChartCard } from "./ChartCard";
|
||||
export { FeedbackTable } from "./FeedbackTable";
|
||||
export { KPICard } from "./KPICard";
|
||||
export { LineChart } from "./LineChart";
|
||||
export { LoadingInsight } from "./LoadingInsights";
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Rectangle,
|
||||
} from "recharts";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
@@ -8,7 +18,39 @@ import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
import { BarList } from "../tremor/BarList";
|
||||
|
||||
// Custom Tooltip component
|
||||
const CustomTooltip = ({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
value: number;
|
||||
dataKey: string;
|
||||
name: string;
|
||||
color: string;
|
||||
payload: { name: string; value: number };
|
||||
}>;
|
||||
label?: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
|
||||
<p className="text-default font-medium">{label}</p>
|
||||
{payload.map((entry, index: number) => (
|
||||
<p key={index}>
|
||||
{t("failed_bookings")}: {entry.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FormCardProps {
|
||||
formName: string;
|
||||
@@ -16,6 +58,7 @@ interface FormCardProps {
|
||||
}
|
||||
|
||||
function FormCard({ formName, fields }: FormCardProps) {
|
||||
const { t } = useLocale();
|
||||
const fieldNames = Object.keys(fields);
|
||||
const [selectedField, setSelectedField] = useState(fieldNames[0]);
|
||||
|
||||
@@ -29,6 +72,17 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
value: option.count,
|
||||
}));
|
||||
|
||||
const maxCount = Math.max(...(selectedFieldData?.map((item) => item.value) || [0]));
|
||||
const isEmpty = maxCount === 0;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="text-default flex h-60 text-center">
|
||||
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4">
|
||||
@@ -38,13 +92,36 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
className="w-fit"
|
||||
onValueChange={(value) => value && setSelectedField(value)}
|
||||
/>
|
||||
{selectedFieldData && (
|
||||
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
|
||||
<BarList
|
||||
data={selectedFieldData}
|
||||
valueFormatter={(value: number) => value.toString()}
|
||||
className="mt-2"
|
||||
/>
|
||||
{selectedFieldData && selectedFieldData.length > 0 ? (
|
||||
<div className="mt-4 h-80">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={selectedFieldData}
|
||||
layout="vertical"
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 10,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
barCategoryGap="10%">
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<YAxis dataKey="name" type="category" className="text-xs" axisLine={false} tickLine={false} />
|
||||
<Tooltip cursor={false} content={<CustomTooltip />} />
|
||||
<Bar
|
||||
dataKey="value"
|
||||
fill="var(--cal-bg-subtle)"
|
||||
radius={[0, 2, 2, 0]}
|
||||
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 p-4 text-center text-gray-500">
|
||||
No data available for selected field
|
||||
<div className="mt-2 text-xs">Data: {JSON.stringify(selectedFieldData, null, 2)}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -66,8 +143,9 @@ export function FailedBookingsByField() {
|
||||
|
||||
// routingFormId is always set, meaning data has only one entry.
|
||||
const [formName, fields] = Object.entries(data)[0];
|
||||
|
||||
return (
|
||||
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
|
||||
<ChartCard title={t("failed_bookings_by_field")}>
|
||||
<FormCard key={formName} formName={formName} fields={fields} />
|
||||
</ChartCard>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -165,14 +166,7 @@ function FormCard({
|
||||
);
|
||||
}
|
||||
|
||||
type RoutedToTableRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
stats: { [key: string]: number };
|
||||
performance: "above_average" | "at_average" | "below_average" | "median" | "no_data";
|
||||
totalBookings: number;
|
||||
};
|
||||
type RoutedToTableRow = RouterOutputs["viewer"]["insights"]["routedToPerPeriod"]["users"]["data"][number];
|
||||
|
||||
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
|
||||
switch (performance) {
|
||||
@@ -292,7 +286,7 @@ export function RoutedToPerPeriod() {
|
||||
.sort((a, b) => a.getTime() - b.getTime());
|
||||
}, [data?.pages]);
|
||||
|
||||
const processedData: RoutedToTableRow[] = useMemo(() => {
|
||||
const processedData = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
|
||||
// Create a map for quick lookup of stats
|
||||
@@ -419,7 +413,7 @@ export function RoutedToPerPeriod() {
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
|
||||
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name || ""} />
|
||||
<div className="flex flex-col gap-1 truncate">
|
||||
<span>{row.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -21,8 +21,8 @@ import { trpc } from "@calcom/trpc";
|
||||
import { RoutingFormResponsesDownload } from "../../filters/Download";
|
||||
import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter";
|
||||
import { useInsightsColumns } from "../../hooks/useInsightsColumns";
|
||||
import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues";
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
import { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues";
|
||||
import type { RoutingFormTableRow } from "../../lib/types";
|
||||
import { RoutingKPICards } from "./RoutingKPICards";
|
||||
|
||||
@@ -38,19 +38,21 @@ export function RoutingFormResponsesTable() {
|
||||
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
||||
useInsightsParameters();
|
||||
|
||||
const {
|
||||
data: headers,
|
||||
isLoading: isHeadersLoading,
|
||||
isSuccess: isHeadersSuccess,
|
||||
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||
const { data: headers, isSuccess: isHeadersSuccess } =
|
||||
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
|
||||
headers,
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
|
||||
|
||||
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
||||
|
||||
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
||||
@@ -109,10 +111,6 @@ export function RoutingFormResponsesTable() {
|
||||
}
|
||||
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
|
||||
|
||||
if (isHeadersLoading && !headers) {
|
||||
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useColumnFilters } from "@calcom/features/data-table";
|
||||
import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
|
||||
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
@@ -11,18 +10,9 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
|
||||
|
||||
export function RoutingFunnel() {
|
||||
const { t } = useLocale();
|
||||
const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["createdAt"],
|
||||
});
|
||||
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
|
||||
{
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
columnFilters,
|
||||
},
|
||||
insightsRoutingParams,
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { Grid } from "@tremor/react";
|
||||
import { Flex, Text, Metric } from "@tremor/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
import { valueFormatter } from "../../lib";
|
||||
import { CardInsights } from "../Card";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
|
||||
export const RoutingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -61,35 +60,46 @@ export const RoutingKPICards = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
|
||||
<ChartCard title={t("stats")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{categories.map((item) => (
|
||||
<CardInsights key={item.title}>
|
||||
<Text className="text-default">{item.title}</Text>
|
||||
<Flex className="items-baseline justify-start space-x-3 truncate">
|
||||
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
|
||||
</Flex>
|
||||
</CardInsights>
|
||||
<div
|
||||
key={item.title}
|
||||
className={classNames(
|
||||
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
|
||||
)}>
|
||||
<div className="text-default text-sm">{item.title}</div>
|
||||
<div className="flex items-baseline justify-start space-x-3 truncate">
|
||||
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
</>
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
|
||||
const { t } = useLocale();
|
||||
const { categories } = props;
|
||||
return (
|
||||
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
|
||||
{categories.map((item) => (
|
||||
<CardInsights key={item.title}>
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
<div className="mt-4 flex items-baseline space-x-3">
|
||||
<div className="h-8 w-16 rounded bg-gray-200" />
|
||||
<ChartCard title={t("stats")}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{categories.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={classNames(
|
||||
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
|
||||
)}>
|
||||
<div>
|
||||
<SkeletonText className="mt-1 h-5 w-32" />
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="h-6 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
</CardInsights>
|
||||
))}
|
||||
</Grid>
|
||||
))}
|
||||
</div>
|
||||
</ChartCard>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Media query for screens larger than 768px */
|
||||
@media (max-width: 639) {
|
||||
.custom-date > .tremor-DateRangePicker-root > .tremor-DateRangePicker-button {
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.recharts-cartesian-grid-horizontal line {
|
||||
@apply stroke-emphasis;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-button button {
|
||||
@apply border-default hover:border-emphasis !h-9 !max-h-9;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButton,
|
||||
.tremor-DateRangePicker-dropdownButton {
|
||||
@apply border-subtle bg-default focus-within:ring-emphasis hover:border-subtle dark:focus-within:ring-emphasis hover:bg-subtle text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-0;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-dropdownModal {
|
||||
@apply divide-none;
|
||||
}
|
||||
|
||||
.tremor-DropdownItem-root {
|
||||
@apply bg-default hover:bg-subtle text-default hover:text-emphasis !h-9 !max-h-9;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButtonText,
|
||||
.tremor-DateRangePicker-dropdownButtonText {
|
||||
@apply text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeaderText {
|
||||
@apply !text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader svg {
|
||||
@apply text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader button {
|
||||
@apply hover:bg-emphasis shadow-none focus:ring-0;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader button:hover svg {
|
||||
@apply text-emphasis;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarButtonIcon {
|
||||
@apply text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarModal,
|
||||
.tremor-DateRangePicker-dropdownModal {
|
||||
@apply bg-default border-subtle shadow-dropdown;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate button {
|
||||
@apply text-default hover:bg-emphasis;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate button:disabled,
|
||||
.tremor-DateRangePicker-calendarBodyDate button[disabled] {
|
||||
@apply opacity-25;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarHeader button {
|
||||
@apply border-default text-default;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate .bg-gray-100 {
|
||||
@apply bg-subtle;
|
||||
}
|
||||
|
||||
.tremor-DateRangePicker-calendarBodyDate .bg-gray-500 {
|
||||
@apply !bg-brand-default text-inverted;
|
||||
}
|
||||
|
||||
.tremor-Card-root {
|
||||
@apply bg-default p-5;
|
||||
}
|
||||
|
||||
.tremor-TableCell-root {
|
||||
@apply pl-0;
|
||||
}
|
||||
|
||||
.recharts-responsive-container {
|
||||
@apply -mx-4;
|
||||
}
|
||||
.tremor-Card-root > p {
|
||||
@apply mb-2 text-base font-semibold;
|
||||
}
|
||||
|
||||
.tremor-Legend-legendItem {
|
||||
@apply ml-2;
|
||||
}
|
||||
|
||||
.tremor-TableBody-root {
|
||||
@apply divide-subtle;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
type ValueFormatter = {
|
||||
(value: number): string;
|
||||
};
|
||||
|
||||
type Color =
|
||||
| "slate"
|
||||
| "gray"
|
||||
| "zinc"
|
||||
| "neutral"
|
||||
| "stone"
|
||||
| "red"
|
||||
| "orange"
|
||||
| "amber"
|
||||
| "yellow"
|
||||
| "lime"
|
||||
| "green"
|
||||
| "emerald"
|
||||
| "teal"
|
||||
| "cyan"
|
||||
| "sky"
|
||||
| "blue"
|
||||
| "indigo"
|
||||
| "violet"
|
||||
| "purple"
|
||||
| "fuchsia"
|
||||
| "pink"
|
||||
| "rose";
|
||||
|
||||
interface BaseChartProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: any[];
|
||||
categories: string[];
|
||||
index: string;
|
||||
colors?: Color[];
|
||||
valueFormatter?: ValueFormatter;
|
||||
startEndOnly?: boolean;
|
||||
showXAxis?: boolean;
|
||||
showYAxis?: boolean;
|
||||
yAxisWidth?: number;
|
||||
showAnimation?: boolean;
|
||||
showTooltip?: boolean;
|
||||
showGradient?: boolean;
|
||||
showLegend?: boolean;
|
||||
showGridLines?: boolean;
|
||||
autoMinValue?: boolean;
|
||||
minValue?: number;
|
||||
maxValue?: number;
|
||||
}
|
||||
|
||||
export type LineChartProps = BaseChartProps & React.RefAttributes<HTMLDivElement>;
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// Tremor BarList [v0.1.1]
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
type Bar<T> = T & {
|
||||
key?: string;
|
||||
href?: string;
|
||||
value: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const focusRing = [
|
||||
// base
|
||||
"outline outline-offset-2 outline-0 focus-visible:outline-2",
|
||||
// outline color
|
||||
"outline-brand",
|
||||
];
|
||||
|
||||
interface BarListProps<T = unknown> extends React.HTMLAttributes<HTMLDivElement> {
|
||||
data: Bar<T>[];
|
||||
valueFormatter?: (value: number) => string;
|
||||
showAnimation?: boolean;
|
||||
onValueChange?: (payload: Bar<T>) => void;
|
||||
sortOrder?: "ascending" | "descending" | "none";
|
||||
}
|
||||
|
||||
function BarListInner<T>(
|
||||
{
|
||||
data = [],
|
||||
valueFormatter = (value) => value.toString(),
|
||||
showAnimation = false,
|
||||
onValueChange,
|
||||
sortOrder = "descending",
|
||||
className,
|
||||
...props
|
||||
}: BarListProps<T>,
|
||||
forwardedRef: React.ForwardedRef<HTMLDivElement>
|
||||
) {
|
||||
const Component = onValueChange ? "button" : "div";
|
||||
const sortedData = React.useMemo(() => {
|
||||
if (sortOrder === "none") {
|
||||
return data;
|
||||
}
|
||||
return [...data].sort((a, b) => {
|
||||
return sortOrder === "ascending" ? a.value - b.value : b.value - a.value;
|
||||
});
|
||||
}, [data, sortOrder]);
|
||||
|
||||
const widths = React.useMemo(() => {
|
||||
const maxValue = Math.max(...sortedData.map((item) => item.value), 0);
|
||||
return sortedData.map((item) => (item.value === 0 ? 0 : Math.max((item.value / maxValue) * 100, 2)));
|
||||
}, [sortedData]);
|
||||
|
||||
const rowHeight = "h-8";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={classNames("flex justify-between space-x-6", className)}
|
||||
aria-sort={sortOrder}
|
||||
tremor-id="tremor-raw"
|
||||
{...props}>
|
||||
<div className="relative w-full space-y-1.5">
|
||||
{sortedData.map((item, index) => (
|
||||
<Component
|
||||
key={item.key ?? item.name}
|
||||
onClick={() => {
|
||||
onValueChange?.(item);
|
||||
}}
|
||||
className={classNames(
|
||||
// base
|
||||
"group w-full rounded",
|
||||
// focus
|
||||
focusRing,
|
||||
onValueChange
|
||||
? [
|
||||
"!-m-0 cursor-pointer",
|
||||
// hover
|
||||
"hover:bg-gray-50 hover:dark:bg-gray-900",
|
||||
]
|
||||
: ""
|
||||
)}>
|
||||
<div
|
||||
className={classNames(
|
||||
// base
|
||||
"flex items-center rounded transition-all",
|
||||
rowHeight,
|
||||
// background color
|
||||
"bg-blue-200 dark:bg-blue-900",
|
||||
onValueChange ? "group-hover:bg-blue-300 group-hover:dark:bg-blue-800" : "",
|
||||
// margin and duration
|
||||
{
|
||||
"mb-0": index === sortedData.length - 1,
|
||||
"duration-800": showAnimation,
|
||||
}
|
||||
)}
|
||||
style={{ width: `${widths[index]}%` }}>
|
||||
<div className={classNames("absolute left-2 flex max-w-full pr-2")}>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
className={classNames(
|
||||
// base
|
||||
"truncate whitespace-nowrap rounded text-sm",
|
||||
// text color
|
||||
"text-default",
|
||||
// hover
|
||||
"hover:underline hover:underline-offset-2",
|
||||
// focus
|
||||
focusRing
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{item.name}
|
||||
</a>
|
||||
) : (
|
||||
<p
|
||||
className={classNames(
|
||||
// base
|
||||
"truncate whitespace-nowrap text-sm",
|
||||
// text color
|
||||
"text-default"
|
||||
)}>
|
||||
{item.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Component>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{sortedData.map((item, index) => (
|
||||
<div
|
||||
key={item.key ?? item.name}
|
||||
className={classNames(
|
||||
"flex items-center justify-end",
|
||||
rowHeight,
|
||||
index === sortedData.length - 1 ? "mb-0" : "mb-1.5"
|
||||
)}>
|
||||
<p
|
||||
className={classNames(
|
||||
// base
|
||||
"truncate whitespace-nowrap text-sm leading-none",
|
||||
// text color
|
||||
"text-default"
|
||||
)}>
|
||||
{valueFormatter(item.value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BarListInner.displayName = "BarList";
|
||||
|
||||
const BarList = React.forwardRef(BarListInner) as <T>(
|
||||
p: BarListProps<T> & { ref?: React.ForwardedRef<HTMLDivElement> }
|
||||
) => ReturnType<typeof BarListInner>;
|
||||
|
||||
export { BarList, type BarListProps };
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { bookingStatusToText } from "../lib/bookingStatusToText";
|
||||
|
||||
const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.ACCEPTED]: 1,
|
||||
[BookingStatus.PENDING]: 2,
|
||||
[BookingStatus.AWAITING_HOST]: 3,
|
||||
[BookingStatus.CANCELLED]: 4,
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
export const useInsightsBookingFacetedUniqueValues = ({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
}: {
|
||||
userId: number | undefined;
|
||||
teamId: number | undefined;
|
||||
isAll: boolean;
|
||||
}) => {
|
||||
const { data: users } = trpc.viewer.insights.userList.useQuery(
|
||||
{
|
||||
teamId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
const { data: eventTypes } = trpc.viewer.insights.eventTypeList.useQuery(
|
||||
{
|
||||
teamId,
|
||||
userId,
|
||||
isAll,
|
||||
},
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
|
||||
if (columnId === "status") {
|
||||
return convertFacetedValuesToMap(
|
||||
Object.keys(statusOrder).map((status) => ({
|
||||
value: status.toLowerCase(),
|
||||
label: bookingStatusToText(status as BookingStatus),
|
||||
}))
|
||||
);
|
||||
} else if (columnId === "userId") {
|
||||
return convertFacetedValuesToMap(
|
||||
users?.map((user) => ({
|
||||
label: user.name ?? user.email,
|
||||
value: user.id,
|
||||
})) ?? []
|
||||
);
|
||||
} else if (columnId === "eventTypeId") {
|
||||
return convertFacetedValuesToMap(
|
||||
eventTypes?.map((eventType) => ({
|
||||
value: eventType.id,
|
||||
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
return new Map<FacetedValue, number>();
|
||||
},
|
||||
[users, eventTypes]
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZSingleSelectFilterValue, ZDateRangeFilterValue } from "@calcom/features/data-table";
|
||||
import { ZDateRangeFilterValue } from "@calcom/features/data-table";
|
||||
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
|
||||
import { useColumnFilters } from "@calcom/features/data-table/hooks/useColumnFilters";
|
||||
import { useDataTable } from "@calcom/features/data-table/hooks/useDataTable";
|
||||
import { useFilterValue } from "@calcom/features/data-table/hooks/useFilterValue";
|
||||
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
|
||||
@@ -14,8 +15,6 @@ export function useInsightsBookingParameters() {
|
||||
const { scope, selectedTeamId } = useInsightsOrgTeams();
|
||||
const { timeZone } = useDataTable();
|
||||
|
||||
const memberUserId = useFilterValue("bookingUserId", ZSingleSelectFilterValue)?.data as number | undefined;
|
||||
const eventTypeId = useFilterValue("eventTypeId", ZSingleSelectFilterValue)?.data as number | undefined;
|
||||
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
|
||||
// TODO for future: this preserving local time & startOf & endOf should be handled
|
||||
// from DateRangeFilter out of the box.
|
||||
@@ -34,6 +33,7 @@ export function useInsightsBookingParameters() {
|
||||
.toISOString();
|
||||
}, [createdAtRange?.endDate])
|
||||
);
|
||||
const columnFilters = useColumnFilters({ exclude: ["createdAt"] });
|
||||
|
||||
return {
|
||||
scope,
|
||||
@@ -41,7 +41,6 @@ export function useInsightsBookingParameters() {
|
||||
startDate,
|
||||
endDate,
|
||||
timeZone: timeZone || CURRENT_TIMEZONE,
|
||||
eventTypeId,
|
||||
memberUserId,
|
||||
columnFilters,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,25 +3,24 @@ import { useMemo } from "react";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { HeaderRow, RoutingFormTableRow } from "../lib/types";
|
||||
import { useInsightsFacetedUniqueValues } from "./useInsightsFacetedUniqueValues";
|
||||
import { useInsightsBookingFacetedUniqueValues } from "./useInsightsBookingFacetedUniqueValues";
|
||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||
|
||||
type DummyTableRow = {
|
||||
bookingUserId: RoutingFormTableRow["bookingUserId"];
|
||||
userId: number | null;
|
||||
eventTypeId: number | null;
|
||||
status: BookingStatus;
|
||||
};
|
||||
|
||||
const emptyData: DummyTableRow[] = [];
|
||||
const dummyHeaders: HeaderRow[] = [];
|
||||
|
||||
export const useInsightsBookings = () => {
|
||||
const { t } = useLocale();
|
||||
const { isAll, teamId, userId } = useInsightsOrgTeams();
|
||||
|
||||
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({
|
||||
headers: dummyHeaders,
|
||||
const getInsightsFacetedUniqueValues = useInsightsBookingFacetedUniqueValues({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
@@ -30,18 +29,6 @@ export const useInsightsBookings = () => {
|
||||
const columns = useMemo(() => {
|
||||
const columnHelper = createColumnHelper<DummyTableRow>();
|
||||
return [
|
||||
columnHelper.accessor("bookingUserId", {
|
||||
id: "bookingUserId",
|
||||
header: t("user"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("eventTypeId", {
|
||||
id: "eventTypeId",
|
||||
header: t("event_type"),
|
||||
@@ -55,6 +42,31 @@ export const useInsightsBookings = () => {
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("status", {
|
||||
id: "status",
|
||||
header: t("booking_status"),
|
||||
size: 200,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
}),
|
||||
columnHelper.accessor("userId", {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
];
|
||||
}, [t]);
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const statusOrder: Record<BookingStatus, number> = {
|
||||
[BookingStatus.REJECTED]: 5,
|
||||
};
|
||||
|
||||
export const useInsightsFacetedUniqueValues = ({
|
||||
export const useInsightsRoutingFacetedUniqueValues = ({
|
||||
headers,
|
||||
userId,
|
||||
teamId,
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { useFilterValue, useColumnFilters, ZDateRangeFilterValue } from "@calcom/features/data-table";
|
||||
import { useChangeTimeZoneWithPreservedLocalTime } from "@calcom/features/data-table/hooks/useChangeTimeZoneWithPreservedLocalTime";
|
||||
import { getDefaultStartDate, getDefaultEndDate } from "@calcom/features/data-table/lib/dateRange";
|
||||
|
||||
import { useInsightsOrgTeams } from "./useInsightsOrgTeams";
|
||||
|
||||
export function useInsightsRoutingParameters() {
|
||||
const { scope, selectedTeamId } = useInsightsOrgTeams();
|
||||
|
||||
const createdAtRange = useFilterValue("createdAt", ZDateRangeFilterValue)?.data;
|
||||
// TODO for future: this preserving local time & startOf & endOf should be handled
|
||||
// from DateRangeFilter out of the box.
|
||||
// When we do it, we also need to remove those timezone handling logic from the backend side at the same time.
|
||||
const startDate = useChangeTimeZoneWithPreservedLocalTime(
|
||||
useMemo(() => {
|
||||
return dayjs(createdAtRange?.startDate ?? getDefaultStartDate().toISOString())
|
||||
.startOf("day")
|
||||
.toISOString();
|
||||
}, [createdAtRange?.startDate])
|
||||
);
|
||||
const endDate = useChangeTimeZoneWithPreservedLocalTime(
|
||||
useMemo(() => {
|
||||
return dayjs(createdAtRange?.endDate ?? getDefaultEndDate().toISOString())
|
||||
.endOf("day")
|
||||
.toISOString();
|
||||
}, [createdAtRange?.endDate])
|
||||
);
|
||||
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["createdAt"],
|
||||
});
|
||||
|
||||
return {
|
||||
scope,
|
||||
selectedTeamId,
|
||||
startDate,
|
||||
endDate,
|
||||
columnFilters,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import type { Color } from "@tremor/react";
|
||||
|
||||
export const colors: { [key: string]: Color } = {
|
||||
increase: "emerald",
|
||||
moderateIncrease: "emerald",
|
||||
unchanged: "orange",
|
||||
moderateDecrease: "rose",
|
||||
decrease: "rose",
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export { calculateDeltaType } from "./calculateDeltaType";
|
||||
export { colors } from "./colors";
|
||||
export { valueFormatter } from "./valueFormatter";
|
||||
|
||||
@@ -76,6 +76,5 @@ export const bookingRepositoryBaseInputSchema = z.object({
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
timeZone: z.string(),
|
||||
eventTypeId: z.coerce.number().optional().nullable(),
|
||||
memberUserId: z.coerce.number().optional().nullable(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
routingRepositoryBaseInputSchema,
|
||||
bookingRepositoryBaseInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { getInsightsBookingService } from "@calcom/lib/di/containers/insights-booking";
|
||||
import { getInsightsRoutingService } from "@calcom/lib/di/containers/insights-routing";
|
||||
import { InsightsBookingService } from "@calcom/lib/server/service/insightsBooking";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
|
||||
@@ -316,14 +316,13 @@ const BATCH_SIZE = 1000; // Adjust based on your needs
|
||||
* Helper function to create InsightsBookingService with standardized parameters
|
||||
*/
|
||||
function createInsightsBookingService(
|
||||
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
|
||||
ctx: { user: { id: number; organizationId: number | null } },
|
||||
input: z.infer<typeof bookingRepositoryBaseInputSchema>,
|
||||
dateTarget: "createdAt" | "startTime" = "createdAt"
|
||||
) {
|
||||
const { scope, selectedTeamId, eventTypeId, memberUserId, startDate, endDate } = input;
|
||||
const { scope, selectedTeamId, startDate, endDate, columnFilters } = input;
|
||||
|
||||
return new InsightsBookingService({
|
||||
prisma: ctx.insightsDb,
|
||||
return getInsightsBookingService({
|
||||
options: {
|
||||
scope,
|
||||
userId: ctx.user.id,
|
||||
@@ -331,8 +330,7 @@ function createInsightsBookingService(
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
...(columnFilters && { columnFilters }),
|
||||
dateRange: {
|
||||
target: dateTarget,
|
||||
startDate,
|
||||
@@ -341,6 +339,26 @@ function createInsightsBookingService(
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createInsightsRoutingService(
|
||||
ctx: { insightsDb: typeof readonlyPrisma; user: { id: number; organizationId: number | null } },
|
||||
input: z.infer<typeof routingRepositoryBaseInputSchema>
|
||||
) {
|
||||
return getInsightsRoutingService({
|
||||
options: {
|
||||
scope: input.scope,
|
||||
teamId: input.selectedTeamId,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId,
|
||||
},
|
||||
filters: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
columnFilters: input.columnFilters,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const insightsRouter = router({
|
||||
bookingKPIStats: userBelongsToTeamProcedure
|
||||
.input(bookingRepositoryBaseInputSchema)
|
||||
@@ -1002,19 +1020,7 @@ export const insightsRouter = router({
|
||||
timeView,
|
||||
weekStart: ctx.user.weekStart,
|
||||
});
|
||||
const insightsRoutingService = getInsightsRoutingService({
|
||||
options: {
|
||||
scope: input.scope,
|
||||
teamId: input.selectedTeamId,
|
||||
userId: ctx.user.id,
|
||||
orgId: ctx.user.organizationId,
|
||||
},
|
||||
filters: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
columnFilters: input.columnFilters,
|
||||
},
|
||||
});
|
||||
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||
try {
|
||||
return await insightsRoutingService.getRoutingFunnelData(dateRanges);
|
||||
} catch (e) {
|
||||
|
||||
@@ -18,7 +18,8 @@ import { Switch } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { revalidateEventTypeEditPage } from "@calcom/web/app/(use-page-wrapper)/event-types/[type]/actions";
|
||||
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
|
||||
|
||||
type WebhookProps = {
|
||||
id: string;
|
||||
@@ -46,21 +47,19 @@ export default function WebhookListItem(props: {
|
||||
const deleteWebhook = trpc.viewer.webhook.delete.useMutation({
|
||||
async onSuccess() {
|
||||
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
revalidateWebhookList();
|
||||
showToast(t("webhook_removed_successfully"), "success");
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
});
|
||||
const toggleWebhook = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess(data) {
|
||||
if (webhook.eventTypeId) revalidateEventTypeEditPage(webhook.eventTypeId);
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
revalidateWebhookList();
|
||||
// TODO: Better success message
|
||||
showToast(t(data?.active ? "enabled" : "disabled"), "success");
|
||||
await utils.viewer.webhook.getByViewer.invalidate();
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.eventTypes.get.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhookById } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/[id]/actions";
|
||||
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
|
||||
|
||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
@@ -43,10 +45,10 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
|
||||
});
|
||||
const editWebhookMutation = trpc.viewer.webhook.edit.useMutation({
|
||||
async onSuccess() {
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
await utils.viewer.webhook.get.invalidate({ webhookId: webhook?.id });
|
||||
revalidateWebhookById(webhook?.id ?? "");
|
||||
revalidateWebhookList();
|
||||
showToast(t("webhook_updated_successfully"), "success");
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
router.push("/settings/developer/webhooks");
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -10,7 +10,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhooksListGetByViewer } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
|
||||
import { revalidateWebhookList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/new/actions";
|
||||
|
||||
import type { WebhookFormSubmitData } from "../components/WebhookForm";
|
||||
import WebhookForm from "../components/WebhookForm";
|
||||
@@ -35,7 +36,8 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
|
||||
async onSuccess() {
|
||||
showToast(t("webhook_created_successfully"), "success");
|
||||
await utils.viewer.webhook.list.invalidate();
|
||||
revalidateWebhooksList();
|
||||
revalidateWebhookList();
|
||||
revalidateWebhooksListGetByViewer();
|
||||
router.push("/settings/developer/webhooks");
|
||||
},
|
||||
onError(error) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { scheduleRepositoryModule } from "../modules/schedule";
|
||||
import { selectedSlotsRepositoryModule } from "../modules/selectedSlots";
|
||||
import { teamRepositoryModule } from "../modules/team";
|
||||
import { userRepositoryModule } from "../modules/user";
|
||||
import { getUserAvailabilityModule } from "../modules/get-user-availability";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
@@ -31,7 +32,7 @@ container.load(DI_TOKENS.FEATURES_REPOSITORY_MODULE, featuresRepositoryModule);
|
||||
container.load(DI_TOKENS.CACHE_SERVICE_MODULE, cacheModule);
|
||||
container.load(DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE_MODULE, checkBookingLimitsModule);
|
||||
container.load(DI_TOKENS.AVAILABLE_SLOTS_SERVICE_MODULE, availableSlotsModule);
|
||||
|
||||
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule)
|
||||
export function getAvailableSlotsService() {
|
||||
return container.get<AvailableSlotsService>(DI_TOKENS.AVAILABLE_SLOTS_SERVICE);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import { getUserAvailabilityModule } from "../modules/get-user-availability";
|
||||
import { bookingRepositoryModule } from "../modules/booking";
|
||||
|
||||
import { eventTypeRepositoryModule } from "../modules/eventType";
|
||||
|
||||
import { oooRepositoryModule } from "../modules/ooo";
|
||||
import { UserAvailabilityService } from "../../getUserAvailability";
|
||||
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.PRISMA_MODULE, prismaModule);
|
||||
container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
|
||||
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
|
||||
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
|
||||
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
|
||||
|
||||
export function getUserAvailabilityService() {
|
||||
return container.get<UserAvailabilityService>(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createContainer } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "@calcom/lib/di/tokens";
|
||||
import type {
|
||||
InsightsBookingServicePublicOptions,
|
||||
InsightsBookingServiceFilterOptions,
|
||||
InsightsBookingBaseService,
|
||||
} from "@calcom/lib/server/service/InsightsBookingBaseService";
|
||||
import type { InsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
|
||||
import { prismaModule } from "@calcom/prisma/prisma.module";
|
||||
|
||||
import { insightsBookingModule } from "../modules/insights-booking";
|
||||
|
||||
export function getInsightsBookingService({
|
||||
options,
|
||||
filters,
|
||||
}: {
|
||||
options: InsightsBookingServicePublicOptions;
|
||||
filters?: InsightsBookingServiceFilterOptions;
|
||||
}): InsightsBookingBaseService {
|
||||
const container = createContainer();
|
||||
container.load(DI_TOKENS.READ_ONLY_PRISMA_CLIENT, prismaModule);
|
||||
container.load(DI_TOKENS.INSIGHTS_BOOKING_SERVICE_MODULE, insightsBookingModule);
|
||||
|
||||
const diService = container.get<InsightsBookingService>(DI_TOKENS.INSIGHTS_BOOKING_SERVICE);
|
||||
return diService.create({ options, filters });
|
||||
}
|
||||
@@ -17,4 +17,5 @@ availableSlotsModule.bind(DI_TOKENS.AVAILABLE_SLOTS_SERVICE).toClass(AvailableSl
|
||||
routingFormResponseRepo: DI_TOKENS.ROUTING_FORM_RESPONSE_REPOSITORY,
|
||||
cacheService: DI_TOKENS.CACHE_SERVICE,
|
||||
checkBookingLimitsService: DI_TOKENS.CHECK_BOOKING_LIMITS_SERVICE,
|
||||
userAvailabilityService: DI_TOKENS.GET_USER_AVAILABILITY_SERVICE
|
||||
} satisfies Record<keyof IAvailableSlotsService, symbol>);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import { DI_TOKENS } from "../tokens";
|
||||
import { IUserAvailabilityService, UserAvailabilityService } from "../../getUserAvailability";
|
||||
|
||||
export const getUserAvailabilityModule = createModule();
|
||||
getUserAvailabilityModule.bind(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE).toClass(UserAvailabilityService, {
|
||||
oooRepo: DI_TOKENS.OOO_REPOSITORY,
|
||||
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
|
||||
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
|
||||
} satisfies Record<keyof IUserAvailabilityService, symbol>);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createModule } from "@evyweb/ioctopus";
|
||||
|
||||
import type { IInsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
|
||||
import { InsightsBookingService } from "@calcom/lib/server/service/InsightsBookingDIService";
|
||||
|
||||
import { DI_TOKENS } from "../tokens";
|
||||
|
||||
export const insightsBookingModule = createModule();
|
||||
insightsBookingModule.bind(DI_TOKENS.INSIGHTS_BOOKING_SERVICE).toClass(InsightsBookingService, {
|
||||
prisma: DI_TOKENS.READ_ONLY_PRISMA_CLIENT,
|
||||
} satisfies Record<keyof IInsightsBookingService, symbol>);
|
||||
@@ -22,6 +22,8 @@ export const DI_TOKENS = {
|
||||
AVAILABLE_SLOTS_SERVICE_MODULE: Symbol("AvailableSlotsModule"),
|
||||
INSIGHTS_ROUTING_SERVICE: Symbol("InsightsRoutingService"),
|
||||
INSIGHTS_ROUTING_SERVICE_MODULE: Symbol("InsightsRoutingServiceModule"),
|
||||
INSIGHTS_BOOKING_SERVICE: Symbol("InsightsBookingService"),
|
||||
INSIGHTS_BOOKING_SERVICE_MODULE: Symbol("InsightsBookingServiceModule"),
|
||||
FEATURES_REPOSITORY: Symbol("FeaturesRepository"),
|
||||
FEATURES_REPOSITORY_MODULE: Symbol("FeaturesRepositoryModule"),
|
||||
CACHE_SERVICE: Symbol("CacheService"),
|
||||
@@ -30,4 +32,6 @@ export const DI_TOKENS = {
|
||||
CHECK_BOOKING_LIMITS_SERVICE_MODULE: Symbol("CheckBookingLimitsServiceModule"),
|
||||
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE: Symbol("CheckBookingAndDurationLimitsService"),
|
||||
CHECK_BOOKING_AND_DURATION_LIMITS_SERVICE_MODULE: Symbol("CheckBookingAndDurationLimitsServiceModule"),
|
||||
GET_USER_AVAILABILITY_SERVICE: Symbol("GetUserAvailabilityService"),
|
||||
GET_USER_AVAILABILITY_SERVICE_MODULE: Symbol("GetUserAvailabilityModule"),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export async function fetchWithTimeout(url: string, options: RequestInit = {}, timeoutMs: number) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
+468
-609
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,9 @@
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getCheckBookingLimitsService } from "@calcom/lib/di/containers/booking-limits";
|
||||
import { getUserAvailabilityService } from "@calcom/lib/di/containers/get-user-availability";
|
||||
import { getStartEndDateforLimitCheck } from "@calcom/lib/getBusyTimes";
|
||||
import type { EventType } from "@calcom/lib/getUserAvailability";
|
||||
import { getPeriodStartDatesBetween } from "@calcom/lib/getUserAvailability";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import { getTotalBookingDuration } from "@calcom/lib/server/queries/booking";
|
||||
@@ -86,6 +86,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
|
||||
includeManagedEvents?: boolean;
|
||||
timeZone?: string | null;
|
||||
}) => {
|
||||
const userAvailabilityService = getUserAvailabilityService();
|
||||
const {
|
||||
bookings,
|
||||
bookingLimits,
|
||||
@@ -105,7 +106,7 @@ const _getBusyTimesFromBookingLimits = async (params: {
|
||||
if (!limit) continue;
|
||||
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
const periodStartDates = userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
|
||||
for (const periodStart of periodStartDates) {
|
||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||
@@ -162,12 +163,14 @@ const _getBusyTimesFromDurationLimits = async (
|
||||
timeZone: string,
|
||||
rescheduleUid?: string
|
||||
) => {
|
||||
const userAvailabilityService = getUserAvailabilityService();
|
||||
|
||||
for (const key of descendingLimitKeys) {
|
||||
const limit = durationLimits?.[key];
|
||||
if (!limit) continue;
|
||||
|
||||
const unit = intervalLimitKeyToUnit(key);
|
||||
const periodStartDates = getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
const periodStartDates = userAvailabilityService.getPeriodStartDatesBetween(dateFrom, dateTo, unit);
|
||||
|
||||
for (const periodStart of periodStartDates) {
|
||||
if (limitManager.isAlreadyBusy(periodStart, unit)) continue;
|
||||
|
||||
+25
-41
@@ -2,38 +2,15 @@ import { createInstance } from "i18next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { fetchWithTimeout } from "../fetchWithTimeout";
|
||||
import logger from "../logger";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { i18n } = require("@calcom/config/next-i18next.config");
|
||||
|
||||
const translationCache = new Map<string, Record<string, string>>();
|
||||
const i18nInstanceCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Loads English fallback translations for when requested locale translations fail
|
||||
* Implements caching to avoid redundant network requests
|
||||
* @returns {Promise<Record<string, string>>} English translations object or empty object on failure
|
||||
*/
|
||||
async function loadFallbackTranslations() {
|
||||
const cacheKey = "en-common";
|
||||
|
||||
if (translationCache.has(cacheKey)) {
|
||||
return translationCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${WEBAPP_URL}/static/locales/en/common.json`, {
|
||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch fallback translations: ${res.status}`);
|
||||
}
|
||||
|
||||
const translations = await res.json();
|
||||
translationCache.set(cacheKey, translations);
|
||||
return translations;
|
||||
} catch (error) {
|
||||
console.error("Could not fetch fallback translations:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
const SUPPORTED_NAMESPACES = ["common"];
|
||||
|
||||
/**
|
||||
* Loads translations for a specific locale and namespace with optimized caching
|
||||
@@ -41,31 +18,38 @@ async function loadFallbackTranslations() {
|
||||
* @param {string} ns - The namespace for the translations
|
||||
* @returns {Promise<Record<string, string>>} Translations object or fallback translations on failure
|
||||
*/
|
||||
export async function loadTranslations(_locale: string, ns: string) {
|
||||
const locale = _locale === "zh" ? "zh-CN" : _locale;
|
||||
export async function loadTranslations(_locale: string, _ns: string) {
|
||||
let locale = _locale === "zh" ? "zh-CN" : _locale;
|
||||
locale = i18n.locales.includes(locale) ? locale : "en";
|
||||
const ns = SUPPORTED_NAMESPACES.includes(_ns) ? _ns : "common";
|
||||
const cacheKey = `${locale}-${ns}`;
|
||||
|
||||
if (translationCache.has(cacheKey)) {
|
||||
return translationCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||
try {
|
||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||
const response = await fetch(url, {
|
||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||
});
|
||||
const response = await fetchWithTimeout(
|
||||
url,
|
||||
{
|
||||
cache: process.env.NODE_ENV === "production" ? "force-cache" : "no-store",
|
||||
},
|
||||
3000
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch translations: ${response.status}`);
|
||||
logger.error(`Failed to fetch translations: ${response.status}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const translations = await response.json();
|
||||
translationCache.set(cacheKey, translations);
|
||||
return translations;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to load translations for ${locale}/${ns}, falling back to English:`, error);
|
||||
const fallbackTranslations = await loadFallbackTranslations();
|
||||
return fallbackTranslations;
|
||||
} catch (err) {
|
||||
console.error("loadTranslations Error:", err);
|
||||
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -869,4 +869,26 @@ export class BookingRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAcceptedBookingByEventTypeId({eventTypeId, dateFrom, dateTo}: {eventTypeId?: number, dateFrom: string, dateTo: string}) {
|
||||
return this.prismaClient.booking.findMany({
|
||||
where: {
|
||||
eventTypeId,
|
||||
startTime: {
|
||||
gte: dateFrom,
|
||||
lte: dateTo,
|
||||
},
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
startTime: true,
|
||||
attendees: {
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1299,4 +1299,93 @@ export class EventTypeRepository {
|
||||
}) {
|
||||
return user.allSelectedCalendars.filter((calendar) => calendar.eventTypeId === eventTypeId);
|
||||
}
|
||||
|
||||
async findByIdForUserAvailability({ id }: { id: number }) {
|
||||
const eventType = await this.prismaClient.eventType.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
seatsPerTimeSlot: true,
|
||||
bookingLimits: true,
|
||||
useEventLevelSelectedCalendars: true,
|
||||
parent: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
bookingLimits: true,
|
||||
includeManagedEventsInLimits: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
bookingLimits: true,
|
||||
includeManagedEventsInLimits: true,
|
||||
},
|
||||
},
|
||||
hosts: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
email: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
schedule: {
|
||||
select: {
|
||||
availability: {
|
||||
select: {
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
},
|
||||
},
|
||||
timeZone: true,
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
durationLimits: true,
|
||||
assignAllTeamMembers: true,
|
||||
schedulingType: true,
|
||||
timeZone: true,
|
||||
length: true,
|
||||
metadata: true,
|
||||
schedule: {
|
||||
select: {
|
||||
id: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
},
|
||||
},
|
||||
timeZone: true,
|
||||
},
|
||||
},
|
||||
availability: {
|
||||
select: {
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
days: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!eventType) {
|
||||
return eventType;
|
||||
}
|
||||
return {
|
||||
...eventType,
|
||||
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,4 +79,71 @@ export class PrismaOOORepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findUserOOODays({userId, dateTo, dateFrom}: {userId: number, dateTo: string, dateFrom: string}) {
|
||||
return this.prismaClient.outOfOfficeEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
// outside of range
|
||||
// (start <= 'dateTo' AND end >= 'dateFrom')
|
||||
{
|
||||
start: {
|
||||
lte: dateTo,
|
||||
},
|
||||
end: {
|
||||
gte: dateFrom,
|
||||
},
|
||||
},
|
||||
// start is between dateFrom and dateTo but end is outside of range
|
||||
// (start <= 'dateTo' AND end >= 'dateTo')
|
||||
{
|
||||
start: {
|
||||
lte: dateTo,
|
||||
},
|
||||
|
||||
end: {
|
||||
gte: dateTo,
|
||||
},
|
||||
},
|
||||
// end is between dateFrom and dateTo but start is outside of range
|
||||
// (start <= 'dateFrom' OR end <= 'dateTo')
|
||||
{
|
||||
start: {
|
||||
lte: dateFrom,
|
||||
},
|
||||
|
||||
end: {
|
||||
lte: dateTo,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
start: true,
|
||||
end: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
toUser: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,11 @@ import { UserRepository } from "./user";
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => {
|
||||
return () => key;
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { compareMembership } from "@calcom/lib/event-types/getEventTypesByViewer";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
@@ -19,6 +23,24 @@ type WebhookGroup = {
|
||||
webhooks: Webhook[];
|
||||
};
|
||||
|
||||
const webhookIdAndEventTypeIdSchema = z.object({
|
||||
// Webhook ID
|
||||
id: z.string().optional(),
|
||||
eventTypeId: z.number().optional(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
const ZFindWebhooksByFiltersInputSchema = webhookIdAndEventTypeIdSchema
|
||||
.extend({
|
||||
appId: z.string().optional(),
|
||||
teamId: z.number().optional(),
|
||||
eventTypeId: z.number().optional(),
|
||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TFindWebhooksByFiltersInputSchema = z.infer<typeof ZFindWebhooksByFiltersInputSchema>;
|
||||
|
||||
const filterWebhooks = (webhook: Webhook) => {
|
||||
const appIds = [
|
||||
"zapier",
|
||||
@@ -180,4 +202,65 @@ export class WebhookRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findWebhooksByFilters({
|
||||
userId,
|
||||
input,
|
||||
}: {
|
||||
userId: number;
|
||||
input?: TFindWebhooksByFiltersInputSchema;
|
||||
}) {
|
||||
const where: Prisma.WebhookWhereInput = {
|
||||
/* Don't mixup zapier webhooks with normal ones */
|
||||
AND: [{ appId: !input?.appId ? null : input.appId }],
|
||||
};
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
teams: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (Array.isArray(where.AND)) {
|
||||
if (input?.eventTypeId) {
|
||||
const managedParentEvt = await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: input.eventTypeId,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
parentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (managedParentEvt?.parentId) {
|
||||
where.AND?.push({
|
||||
OR: [
|
||||
{ eventTypeId: input.eventTypeId },
|
||||
{ eventTypeId: managedParentEvt.parentId, active: true },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
where.AND?.push({ eventTypeId: input.eventTypeId });
|
||||
}
|
||||
} else {
|
||||
where.AND?.push({
|
||||
OR: [{ userId }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
|
||||
});
|
||||
}
|
||||
|
||||
if (input?.eventTriggers) {
|
||||
where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.webhook.findMany({
|
||||
where,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+57
-18
@@ -3,6 +3,9 @@ import md5 from "md5";
|
||||
import { z } from "zod";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { ZColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import type { ColumnFilter } from "@calcom/features/data-table/lib/types";
|
||||
import { isSingleSelectFilterValue, isMultiSelectFilterValue } from "@calcom/features/data-table/lib/utils";
|
||||
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
@@ -44,6 +47,19 @@ export type SelectedFields<T> = T extends undefined
|
||||
: never;
|
||||
};
|
||||
|
||||
type UserStatsData = {
|
||||
userId: number;
|
||||
user: {
|
||||
id: number;
|
||||
username: string | null;
|
||||
name: string | null;
|
||||
email: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
emailMd5: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
export const bookingDataSchema = z
|
||||
.object({
|
||||
id: z.number(),
|
||||
@@ -105,8 +121,6 @@ export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServic
|
||||
export type InsightsBookingServiceFilterOptions = z.infer<typeof insightsBookingServiceFilterOptionsSchema>;
|
||||
|
||||
export const insightsBookingServiceFilterOptionsSchema = z.object({
|
||||
eventTypeId: z.number().optional(),
|
||||
memberUserId: z.number().optional(),
|
||||
dateRange: z
|
||||
.object({
|
||||
target: z.enum(["createdAt", "startTime"]),
|
||||
@@ -114,13 +128,14 @@ export const insightsBookingServiceFilterOptionsSchema = z.object({
|
||||
endDate: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
const bookingDataKeys = new Set(Object.keys(bookingDataSchema.shape));
|
||||
|
||||
export class InsightsBookingService {
|
||||
export class InsightsBookingBaseService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsBookingServiceOptions | null;
|
||||
private filters: InsightsBookingServiceFilterOptions | null;
|
||||
@@ -238,14 +253,14 @@ export class InsightsBookingService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.filters.eventTypeId) {
|
||||
conditions.push(
|
||||
Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.filters.memberUserId) {
|
||||
conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`);
|
||||
// Process columnFilters using type-safe utility functions
|
||||
if (this.filters.columnFilters) {
|
||||
for (const filter of this.filters.columnFilters) {
|
||||
const condition = this.buildColumnFilterCondition(filter);
|
||||
if (condition) {
|
||||
conditions.push(condition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use dateRange object for date filtering
|
||||
@@ -276,6 +291,29 @@ export class InsightsBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
private buildColumnFilterCondition(filter: ColumnFilter): Prisma.Sql | null {
|
||||
const { id, value } = filter;
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
|
||||
}
|
||||
|
||||
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||
return Prisma.sql`"userId" = ${value.data}`;
|
||||
}
|
||||
|
||||
if (id === "status" && isMultiSelectFilterValue(value)) {
|
||||
const statusValues = value.data.map((status) => Prisma.sql`${status}::"BookingStatus"`);
|
||||
return Prisma.sql`"status" IN (${Prisma.join(statusValues)})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
|
||||
if (!this.options) {
|
||||
return NOTHING_CONDITION;
|
||||
@@ -657,8 +695,9 @@ export class InsightsBookingService {
|
||||
});
|
||||
|
||||
// Transform aggregate data into the expected format
|
||||
const result = dateRanges.map(({ formattedDate }) => {
|
||||
const result = dateRanges.map(({ formattedDate, formattedDateFull }) => {
|
||||
const eventData = {
|
||||
formattedDateFull: formattedDateFull,
|
||||
Month: formattedDate,
|
||||
Created: 0,
|
||||
Completed: 0,
|
||||
@@ -764,7 +803,7 @@ export class InsightsBookingService {
|
||||
async getMembersStatsWithCount(
|
||||
type: "all" | "cancelled" | "noShow" = "all",
|
||||
sortOrder: "ASC" | "DESC" = "DESC"
|
||||
) {
|
||||
): Promise<UserStatsData> {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
let additionalCondition = Prisma.sql``;
|
||||
@@ -832,22 +871,22 @@ export class InsightsBookingService {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC") {
|
||||
async getMembersRatingStats(sortOrder: "ASC" | "DESC" = "DESC"): Promise<UserStatsData> {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
const bookingsFromTeam = await this.prisma.$queryRaw<
|
||||
Array<{
|
||||
userId: number;
|
||||
averageRating: number;
|
||||
count: number;
|
||||
}>
|
||||
>`
|
||||
SELECT
|
||||
"userId",
|
||||
AVG("rating")::float as "averageRating"
|
||||
AVG("rating")::float as "count"
|
||||
FROM "BookingTimeStatusDenormalized"
|
||||
WHERE ${baseConditions} AND "userId" IS NOT NULL AND "rating" IS NOT NULL
|
||||
GROUP BY "userId"
|
||||
ORDER BY "averageRating" ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`}
|
||||
ORDER BY "count" ${sortOrder === "ASC" ? Prisma.sql`ASC` : Prisma.sql`DESC`}
|
||||
LIMIT 10
|
||||
`;
|
||||
|
||||
@@ -885,7 +924,7 @@ export class InsightsBookingService {
|
||||
userId: booking.userId,
|
||||
user,
|
||||
emailMd5: md5(user.email),
|
||||
averageRating: booking.averageRating,
|
||||
count: booking.count,
|
||||
};
|
||||
})
|
||||
.filter((item): item is NonNullable<typeof item> => item !== null);
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
|
||||
import {
|
||||
InsightsBookingBaseService,
|
||||
type InsightsBookingServicePublicOptions,
|
||||
type InsightsBookingServiceFilterOptions,
|
||||
} from "./InsightsBookingBaseService";
|
||||
|
||||
export interface IInsightsBookingService {
|
||||
prisma: typeof readonlyPrisma;
|
||||
}
|
||||
|
||||
export class InsightsBookingService {
|
||||
constructor(private readonly dependencies: IInsightsBookingService) {}
|
||||
|
||||
create({
|
||||
options,
|
||||
filters,
|
||||
}: {
|
||||
options: InsightsBookingServicePublicOptions;
|
||||
filters?: InsightsBookingServiceFilterOptions;
|
||||
}): InsightsBookingBaseService {
|
||||
return new InsightsBookingBaseService({
|
||||
prisma: this.dependencies.prisma,
|
||||
options,
|
||||
filters,
|
||||
});
|
||||
}
|
||||
}
|
||||
+80
-7
@@ -2,10 +2,11 @@ import type { Team, User, Membership } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsBookingService } from "../../service/insightsBooking";
|
||||
import { InsightsBookingBaseService as InsightsBookingService } from "../InsightsBookingBaseService";
|
||||
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
@@ -376,7 +377,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.eventType.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -388,7 +397,7 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build memberUserId filter conditions", async () => {
|
||||
it("should build userId filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
@@ -399,7 +408,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
memberUserId: testData.user.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "userId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -420,8 +437,22 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
memberUserId: testData.user.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.eventType.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "userId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.user.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -432,6 +463,40 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build status filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
columnFilters: [
|
||||
{
|
||||
id: "status",
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["pending", "accepted"],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`"status" IN (${Prisma.join([
|
||||
Prisma.sql`${"pending"}::"BookingStatus"`,
|
||||
Prisma.sql`${"accepted"}::"BookingStatus"`,
|
||||
])})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBaseConditions", () => {
|
||||
@@ -473,7 +538,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: userEventType.id,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: userEventType.id,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,8 +11,11 @@ import { UserCreationService } from "./userCreationService";
|
||||
|
||||
vi.mock("@calcom/lib/server/i18n", () => {
|
||||
return {
|
||||
getTranslation: (key: string) => {
|
||||
return () => key;
|
||||
getTranslation: async (locale: string, namespace: string) => {
|
||||
const t = (key: string) => key;
|
||||
t.locale = locale;
|
||||
t.namespace = namespace;
|
||||
return t;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
## 1.1.2
|
||||
|
||||
## 1.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#22886](https://github.com/calcom/cal.com/pull/22886) [`fb36497`](https://github.com/calcom/cal.com/commit/fb364971fadc1a6796e739956200f12af74e565c) Thanks [@supalarry](https://github.com/supalarry)! - bundle fonts to fix atoms in non-next apps
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22731](https://github.com/calcom/cal.com/pull/22731) [`ef66187`](https://github.com/calcom/cal.com/commit/ef6618743d5c1367fadf7b98cf15f7ba829535e3) Thanks [@devin-ai-integration](https://github.com/apps/devin-ai-integration)! - Added new startTime prop to the Booker component that allows users to decide the first available date when the booker loads
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,7 +7,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/55c55f0601d81cf3-s.woff2) format('woff2');
|
||||
src: url(./fonts/55c55f0601d81cf3-s.woff2) format('woff2');
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/26a46d62cd723877-s.woff2) format('woff2');
|
||||
src: url(./fonts/26a46d62cd723877-s.woff2) format('woff2');
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/97e0cb1ae144a2a9-s.woff2) format('woff2');
|
||||
src: url(./fonts/97e0cb1ae144a2a9-s.woff2) format('woff2');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/581909926a08bbc8-s.woff2) format('woff2');
|
||||
src: url(./fonts/581909926a08bbc8-s.woff2) format('woff2');
|
||||
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/df0a9ae256c0569c-s.woff2) format('woff2');
|
||||
src: url(./fonts/df0a9ae256c0569c-s.woff2) format('woff2');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/6d93bde91c0c2823-s.woff2) format('woff2');
|
||||
src: url(./fonts/6d93bde91c0c2823-s.woff2) format('woff2');
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(/_next/static/media/a34f9d1faa5f3315-s.p.woff2) format('woff2');
|
||||
src: url(./fonts/a34f9d1faa5f3315-s.p.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
\********************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
|
||||
@font-face {
|
||||
font-family: '__calFont_a90182';
|
||||
src: url(/_next/static/media/90475aac776488b6-s.p.woff2) format('woff2');
|
||||
src: url(./fonts/90475aac776488b6-s.p.woff2) format('woff2');
|
||||
font-display: block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
@@ -37,7 +37,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"globals.min.css"
|
||||
"globals.min.css",
|
||||
"fonts"
|
||||
],
|
||||
"module": "./dist/cal-atoms.js",
|
||||
"exports": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user