Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2965450929 | ||
|
|
db9b9c45dc | ||
|
|
37f500526c | ||
|
|
006aecb066 | ||
|
|
0829929e98 | ||
|
|
a61148cb51 | ||
|
|
593c48c8b4 | ||
|
|
fb364971fa | ||
|
|
a2205d497d | ||
|
|
cf4e7a5d2c | ||
|
|
0e2ee3adb2 | ||
|
|
dbe8a85cf5 | ||
|
|
84210dc1b4 | ||
|
|
4061bfb3fe |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
+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[] };
|
||||
}
|
||||
|
||||
+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 : (
|
||||
<>
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -320,7 +320,7 @@ function createInsightsBookingService(
|
||||
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,
|
||||
@@ -331,8 +331,7 @@ function createInsightsBookingService(
|
||||
...(selectedTeamId && { teamId: selectedTeamId }),
|
||||
},
|
||||
filters: {
|
||||
...(eventTypeId && { eventTypeId }),
|
||||
...(memberUserId && { memberUserId }),
|
||||
...(columnFilters && { columnFilters }),
|
||||
dateRange: {
|
||||
target: dateTarget,
|
||||
startDate,
|
||||
@@ -341,6 +340,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 +1021,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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
-47
@@ -1,39 +1,15 @@
|
||||
import { createInstance } from "i18next";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const { i18n } = require("@calcom/config/next-i18next.config");
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { fetchWithTimeout } from "../fetchWithTimeout";
|
||||
import logger from "../logger";
|
||||
|
||||
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,32 +17,33 @@ 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);
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||
const response = await fetch(url, {
|
||||
const url = `${WEBAPP_URL}/static/locales/${locale}/${ns}.json`;
|
||||
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}`);
|
||||
}
|
||||
|
||||
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;
|
||||
if (!response.ok) {
|
||||
logger.error(`Failed to fetch translations: ${response.status}`);
|
||||
return {};
|
||||
}
|
||||
|
||||
const translations = await response.json();
|
||||
translationCache.set(cacheKey, translations);
|
||||
return translations;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,6 +128,7 @@ export const insightsBookingServiceFilterOptionsSchema = z.object({
|
||||
endDate: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
});
|
||||
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2,12 +2,7 @@ const base = require("@calcom/config/tailwind-preset");
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
...base,
|
||||
content: [
|
||||
...base.content,
|
||||
"../../../packages/ui/!(node_modules)/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"../../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
|
||||
"./**/*.tsx",
|
||||
],
|
||||
content: [...base.content, "../../../packages/ui/!(node_modules)/**/*.{js,ts,jsx,tsx,mdx}", "./**/*.tsx"],
|
||||
plugins: [...base.plugins, require("tailwindcss-animate")],
|
||||
theme: {
|
||||
...base.theme,
|
||||
|
||||
Binary file not shown.
+197
@@ -0,0 +1,197 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
chunk_size INTEGER := 1000;
|
||||
start_id INTEGER := 1; -- Starting ID
|
||||
end_id INTEGER; -- Will be set dynamically
|
||||
current_id INTEGER;
|
||||
|
||||
processed_count INTEGER := 0;
|
||||
chunk_updated_count INTEGER := 0;
|
||||
total_count INTEGER;
|
||||
BEGIN
|
||||
-- Get the maximum ID and total count from the App_RoutingForms_FormResponse table
|
||||
SELECT COALESCE(MAX(id), 0) INTO end_id FROM "App_RoutingForms_FormResponse";
|
||||
SELECT COUNT(*) INTO total_count FROM "App_RoutingForms_FormResponse";
|
||||
|
||||
-- Handle case where there are no records to process
|
||||
IF total_count = 0 THEN
|
||||
RAISE NOTICE 'No records found in App_RoutingForms_FormResponse table. Migration completed.';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Starting migration: processing up to ID % (total responses: %)', end_id, total_count;
|
||||
|
||||
FOR current_id IN SELECT * FROM generate_series(start_id, end_id, chunk_size)
|
||||
LOOP
|
||||
-- Use UPSERT to only update records that are missing or have incorrect data
|
||||
WITH expected_data AS (
|
||||
SELECT
|
||||
r.id,
|
||||
r."formId" as expected_form_id,
|
||||
f.name as expected_form_name,
|
||||
f."teamId" as expected_form_team_id,
|
||||
f."userId" as expected_form_user_id,
|
||||
b.uid as expected_booking_uid,
|
||||
b.id as expected_booking_id,
|
||||
b.status as expected_booking_status,
|
||||
calculate_booking_status_order(b.status::text) as expected_booking_status_order,
|
||||
b."createdAt" as expected_booking_created_at,
|
||||
b."startTime" as expected_booking_start_time,
|
||||
b."endTime" as expected_booking_end_time,
|
||||
b."userId" as expected_booking_user_id,
|
||||
u.name as expected_booking_user_name,
|
||||
u.email as expected_booking_user_email,
|
||||
u."avatarUrl" as expected_booking_user_avatar_url,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT ar."reasonString"
|
||||
FROM "AssignmentReason" ar
|
||||
WHERE ar."bookingId" = b.id
|
||||
LIMIT 1
|
||||
),
|
||||
''
|
||||
) as expected_booking_assignment_reason,
|
||||
et.id as expected_event_type_id,
|
||||
et."parentId" as expected_event_type_parent_id,
|
||||
et."schedulingType"::text as expected_event_type_scheduling_type,
|
||||
r."createdAt" as expected_created_at,
|
||||
t.utm_source as expected_utm_source,
|
||||
t.utm_medium as expected_utm_medium,
|
||||
t.utm_campaign as expected_utm_campaign,
|
||||
t.utm_term as expected_utm_term,
|
||||
t.utm_content as expected_utm_content
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
INNER JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
LEFT JOIN "users" u ON f."userId" = u.id
|
||||
LEFT JOIN "Booking" b ON b.uid = r."routedToBookingUid"
|
||||
LEFT JOIN "EventType" et ON b."eventTypeId" = et.id
|
||||
LEFT JOIN "Tracking" t ON t."bookingId" = b.id
|
||||
WHERE r.id BETWEEN current_id AND current_id + chunk_size - 1
|
||||
),
|
||||
records_to_update AS (
|
||||
SELECT e.*
|
||||
FROM expected_data e
|
||||
LEFT JOIN "RoutingFormResponseDenormalized" d ON e.id = d.id
|
||||
WHERE CASE
|
||||
WHEN d.id IS NULL THEN true -- Include missing records
|
||||
WHEN d."formId" IS DISTINCT FROM e.expected_form_id THEN true
|
||||
WHEN d."formName" IS DISTINCT FROM e.expected_form_name THEN true
|
||||
WHEN d."formTeamId" IS DISTINCT FROM e.expected_form_team_id THEN true
|
||||
WHEN d."formUserId" IS DISTINCT FROM e.expected_form_user_id THEN true
|
||||
WHEN d."bookingUid" IS DISTINCT FROM e.expected_booking_uid THEN true
|
||||
WHEN d."bookingId" IS DISTINCT FROM e.expected_booking_id THEN true
|
||||
WHEN d."bookingStatus" IS DISTINCT FROM e.expected_booking_status THEN true
|
||||
WHEN d."bookingStatusOrder" IS DISTINCT FROM e.expected_booking_status_order THEN true
|
||||
WHEN d."bookingCreatedAt" IS DISTINCT FROM e.expected_booking_created_at THEN true
|
||||
WHEN d."bookingStartTime" IS DISTINCT FROM e.expected_booking_start_time THEN true
|
||||
WHEN d."bookingEndTime" IS DISTINCT FROM e.expected_booking_end_time THEN true
|
||||
WHEN d."bookingUserId" IS DISTINCT FROM e.expected_booking_user_id THEN true
|
||||
WHEN d."bookingUserName" IS DISTINCT FROM e.expected_booking_user_name THEN true
|
||||
WHEN d."bookingUserEmail" IS DISTINCT FROM e.expected_booking_user_email THEN true
|
||||
WHEN d."bookingUserAvatarUrl" IS DISTINCT FROM e.expected_booking_user_avatar_url THEN true
|
||||
WHEN d."bookingAssignmentReason" IS DISTINCT FROM e.expected_booking_assignment_reason THEN true
|
||||
WHEN d."eventTypeId" IS DISTINCT FROM e.expected_event_type_id THEN true
|
||||
WHEN d."eventTypeParentId" IS DISTINCT FROM e.expected_event_type_parent_id THEN true
|
||||
WHEN d."eventTypeSchedulingType"::text IS DISTINCT FROM e.expected_event_type_scheduling_type THEN true
|
||||
WHEN d."createdAt" IS DISTINCT FROM e.expected_created_at THEN true
|
||||
WHEN d."utm_source" IS DISTINCT FROM e.expected_utm_source THEN true
|
||||
WHEN d."utm_medium" IS DISTINCT FROM e.expected_utm_medium THEN true
|
||||
WHEN d."utm_campaign" IS DISTINCT FROM e.expected_utm_campaign THEN true
|
||||
WHEN d."utm_term" IS DISTINCT FROM e.expected_utm_term THEN true
|
||||
WHEN d."utm_content" IS DISTINCT FROM e.expected_utm_content THEN true
|
||||
ELSE false -- Don't include valid records
|
||||
END
|
||||
)
|
||||
INSERT INTO "RoutingFormResponseDenormalized" (
|
||||
id,
|
||||
"formId",
|
||||
"formName",
|
||||
"formTeamId",
|
||||
"formUserId",
|
||||
"bookingUid",
|
||||
"bookingId",
|
||||
"bookingStatus",
|
||||
"bookingStatusOrder",
|
||||
"bookingCreatedAt",
|
||||
"bookingStartTime",
|
||||
"bookingEndTime",
|
||||
"bookingUserId",
|
||||
"bookingUserName",
|
||||
"bookingUserEmail",
|
||||
"bookingUserAvatarUrl",
|
||||
"bookingAssignmentReason",
|
||||
"eventTypeId",
|
||||
"eventTypeParentId",
|
||||
"eventTypeSchedulingType",
|
||||
"createdAt",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"utm_content"
|
||||
)
|
||||
SELECT
|
||||
r.id,
|
||||
r.expected_form_id,
|
||||
r.expected_form_name,
|
||||
r.expected_form_team_id,
|
||||
r.expected_form_user_id,
|
||||
r.expected_booking_uid,
|
||||
r.expected_booking_id,
|
||||
r.expected_booking_status,
|
||||
r.expected_booking_status_order,
|
||||
r.expected_booking_created_at,
|
||||
r.expected_booking_start_time,
|
||||
r.expected_booking_end_time,
|
||||
r.expected_booking_user_id,
|
||||
r.expected_booking_user_name,
|
||||
r.expected_booking_user_email,
|
||||
r.expected_booking_user_avatar_url,
|
||||
r.expected_booking_assignment_reason,
|
||||
r.expected_event_type_id,
|
||||
r.expected_event_type_parent_id,
|
||||
r.expected_event_type_scheduling_type::text,
|
||||
r.expected_created_at,
|
||||
r.expected_utm_source,
|
||||
r.expected_utm_medium,
|
||||
r.expected_utm_campaign,
|
||||
r.expected_utm_term,
|
||||
r.expected_utm_content
|
||||
FROM records_to_update r
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
"formId" = EXCLUDED."formId",
|
||||
"formName" = EXCLUDED."formName",
|
||||
"formTeamId" = EXCLUDED."formTeamId",
|
||||
"formUserId" = EXCLUDED."formUserId",
|
||||
"bookingUid" = EXCLUDED."bookingUid",
|
||||
"bookingId" = EXCLUDED."bookingId",
|
||||
"bookingStatus" = EXCLUDED."bookingStatus",
|
||||
"bookingStatusOrder" = EXCLUDED."bookingStatusOrder",
|
||||
"bookingCreatedAt" = EXCLUDED."bookingCreatedAt",
|
||||
"bookingStartTime" = EXCLUDED."bookingStartTime",
|
||||
"bookingEndTime" = EXCLUDED."bookingEndTime",
|
||||
"bookingUserId" = EXCLUDED."bookingUserId",
|
||||
"bookingUserName" = EXCLUDED."bookingUserName",
|
||||
"bookingUserEmail" = EXCLUDED."bookingUserEmail",
|
||||
"bookingUserAvatarUrl" = EXCLUDED."bookingUserAvatarUrl",
|
||||
"bookingAssignmentReason" = EXCLUDED."bookingAssignmentReason",
|
||||
"eventTypeId" = EXCLUDED."eventTypeId",
|
||||
"eventTypeParentId" = EXCLUDED."eventTypeParentId",
|
||||
"eventTypeSchedulingType" = EXCLUDED."eventTypeSchedulingType",
|
||||
"createdAt" = EXCLUDED."createdAt",
|
||||
"utm_source" = EXCLUDED."utm_source",
|
||||
"utm_medium" = EXCLUDED."utm_medium",
|
||||
"utm_campaign" = EXCLUDED."utm_campaign",
|
||||
"utm_term" = EXCLUDED."utm_term",
|
||||
"utm_content" = EXCLUDED."utm_content";
|
||||
|
||||
GET DIAGNOSTICS chunk_updated_count = ROW_COUNT;
|
||||
processed_count := processed_count + chunk_updated_count;
|
||||
|
||||
RAISE NOTICE 'Chunk processed: IDs %-% (updated/inserted: % records, total updated: %)',
|
||||
current_id, current_id + chunk_size - 1, chunk_updated_count, processed_count;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Migration completed: processed up to ID % (total updated/inserted: % records out of % total records)',
|
||||
end_id, processed_count, total_count;
|
||||
END $$;
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
DO $$
|
||||
DECLARE
|
||||
chunk_size INTEGER := 1000;
|
||||
sleep_interval FLOAT := 1;
|
||||
start_id INTEGER := 1;
|
||||
end_id INTEGER;
|
||||
current_id INTEGER;
|
||||
response_record RECORD;
|
||||
processed_count INTEGER := 0;
|
||||
chunk_updated_count INTEGER := 0;
|
||||
total_count INTEGER;
|
||||
BEGIN
|
||||
-- Get the maximum ID and total count from the App_RoutingForms_FormResponse table
|
||||
SELECT COALESCE(MAX(id), 0) INTO end_id FROM "App_RoutingForms_FormResponse";
|
||||
SELECT COUNT(*) INTO total_count FROM "App_RoutingForms_FormResponse";
|
||||
|
||||
-- Handle case where there are no records to process
|
||||
IF total_count = 0 THEN
|
||||
RAISE NOTICE 'No records found in App_RoutingForms_FormResponse table. Migration completed.';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Starting migration: processing up to ID % (total responses: %)', end_id, total_count;
|
||||
|
||||
FOR current_id IN SELECT * FROM generate_series(start_id, end_id, chunk_size)
|
||||
LOOP
|
||||
-- Process only responses that have missing or incorrect field data
|
||||
FOR response_record IN
|
||||
WITH form_response_fields AS (
|
||||
-- Extract expected fields from the response data
|
||||
SELECT
|
||||
r.id as "responseId",
|
||||
r."formId",
|
||||
field->>'id' as "fieldId",
|
||||
field->>'type' as "fieldType",
|
||||
response_value->>'value' as raw_value,
|
||||
jsonb_typeof(response_value->'value') as value_type,
|
||||
-- Extract array values for multiselect
|
||||
CASE
|
||||
WHEN field->>'type' = 'multiselect' AND jsonb_typeof(response_value->'value') = 'array'
|
||||
THEN ARRAY(SELECT jsonb_array_elements_text(response_value->'value'))
|
||||
END as expected_array,
|
||||
-- Extract first element for select type when it's an array
|
||||
CASE
|
||||
WHEN field->>'type' = 'select' AND jsonb_typeof(response_value->'value') = 'array'
|
||||
THEN (response_value->'value'->0)::text
|
||||
WHEN field->>'type' = 'select' AND jsonb_typeof(response_value->'value') = 'string'
|
||||
THEN response_value->>'value'
|
||||
END as expected_select_value
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
CROSS JOIN LATERAL jsonb_array_elements(
|
||||
(
|
||||
SELECT fields::jsonb
|
||||
FROM "App_RoutingForms_Form" f
|
||||
WHERE f.id = r."formId"
|
||||
)
|
||||
) as field
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT r.response::jsonb->(field->>'id') as response_value
|
||||
) as rv
|
||||
WHERE r.id BETWEEN current_id AND current_id + chunk_size - 1
|
||||
AND r.response::jsonb ? (field->>'id') -- Only include fields that exist in response
|
||||
),
|
||||
responses_needing_update AS (
|
||||
SELECT DISTINCT f."responseId"
|
||||
FROM form_response_fields f
|
||||
LEFT JOIN "RoutingFormResponseField" rf ON
|
||||
rf."responseId" = f."responseId" AND
|
||||
rf."fieldId" = f."fieldId"
|
||||
WHERE
|
||||
-- Case 1: Field doesn't exist in denormalized table
|
||||
rf.id IS NULL OR
|
||||
-- Case 2: Multiselect field validation - compare actual array contents
|
||||
(f."fieldType" = 'multiselect' AND f.value_type = 'array' AND (
|
||||
rf."valueStringArray" IS NULL OR
|
||||
rf."valueStringArray" != f.expected_array OR
|
||||
array_length(rf."valueStringArray", 1) != array_length(f.expected_array, 1)
|
||||
)) OR
|
||||
-- Case 3: Number field validation - compare as numbers
|
||||
(f."fieldType" = 'number' AND f.value_type = 'number' AND (
|
||||
rf."valueNumber" IS NULL OR
|
||||
rf."valueNumber" != (f.raw_value)::decimal
|
||||
)) OR
|
||||
-- Case 4: Select field validation - compare expected select value
|
||||
(f."fieldType" = 'select' AND (
|
||||
rf."valueString" IS NULL OR
|
||||
rf."valueString" != f.expected_select_value
|
||||
)) OR
|
||||
-- Case 5: Other string field validation
|
||||
(f."fieldType" NOT IN ('multiselect', 'number', 'select') AND (
|
||||
rf."valueString" IS NULL OR
|
||||
rf."valueString" != f.raw_value
|
||||
))
|
||||
)
|
||||
SELECT r.id
|
||||
FROM responses_needing_update rnu
|
||||
INNER JOIN "App_RoutingForms_FormResponse" r ON r.id = rnu."responseId"
|
||||
ORDER BY r.id
|
||||
LOOP
|
||||
BEGIN
|
||||
-- Use the reprocess_routing_form_response_fields function
|
||||
PERFORM reprocess_routing_form_response_fields(response_record.id);
|
||||
chunk_updated_count := chunk_updated_count + 1;
|
||||
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
RAISE WARNING 'Failed to process responseId %: %', response_record.id, SQLERRM;
|
||||
CONTINUE;
|
||||
END;
|
||||
END LOOP;
|
||||
|
||||
processed_count := processed_count + chunk_updated_count;
|
||||
|
||||
RAISE NOTICE 'Chunk processed: IDs %-% (updated: % records, total updated: %)',
|
||||
current_id, current_id + chunk_size - 1, chunk_updated_count, processed_count;
|
||||
|
||||
-- Reset chunk counter for next iteration
|
||||
chunk_updated_count := 0;
|
||||
|
||||
-- Sleep after each chunk (outside transaction to avoid holding locks)
|
||||
PERFORM pg_sleep(sleep_interval);
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Migration completed: processed up to ID % (total updated: % records out of % total records)',
|
||||
end_id, processed_count, total_count;
|
||||
END $$;
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import type { TListInputSchema } from "./list.schema";
|
||||
@@ -13,53 +11,5 @@ type ListOptions = {
|
||||
};
|
||||
|
||||
export const listHandler = async ({ ctx, input }: ListOptions) => {
|
||||
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: ctx.user.id,
|
||||
},
|
||||
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: ctx.user.id }, { teamId: { in: user?.teams.map((membership) => membership.teamId) } }],
|
||||
});
|
||||
}
|
||||
|
||||
if (input?.eventTriggers) {
|
||||
where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
|
||||
}
|
||||
}
|
||||
|
||||
return await prisma.webhook.findMany({
|
||||
where,
|
||||
});
|
||||
return await WebhookRepository.findWebhooksByFilters({ userId: ctx.user.id, input });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
const progressBarStyles = cva("h-2 rounded-full", {
|
||||
variants: {
|
||||
color: {
|
||||
green: "bg-green-500",
|
||||
blue: "bg-blue-500",
|
||||
red: "bg-red-500",
|
||||
yellow: "bg-yellow-500",
|
||||
gray: "bg-gray-500",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
color: "gray",
|
||||
},
|
||||
});
|
||||
|
||||
export interface ProgressBarProps extends VariantProps<typeof progressBarStyles> {
|
||||
percentageValue: number;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProgressBar({ color, percentageValue, label, className }: ProgressBarProps) {
|
||||
const clampedPercentage = Math.min(100, Math.max(0, percentageValue));
|
||||
|
||||
return (
|
||||
<div className={classNames("flex w-full items-center gap-3", className)}>
|
||||
<div className="h-2 flex-1 rounded-full bg-gray-200">
|
||||
<div className={progressBarStyles({ color })} style={{ width: `${clampedPercentage}%` }} />
|
||||
</div>
|
||||
{label && <span className="text-sm font-medium text-gray-700">{label}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export type { ProgressBarProps } from "./ProgressBar";
|
||||
@@ -43,6 +43,7 @@
|
||||
"./components/organization-banner": "./components/organization-banner/index.ts",
|
||||
"./components/pagination": "./components/pagination/index.ts",
|
||||
"./components/popover": "./components/popover/index.ts",
|
||||
"./components/progress-bar": "./components/progress-bar/index.ts",
|
||||
"./components/radio": "./components/radio/index.ts",
|
||||
"./components/scrollable": "./components/scrollable/index.ts",
|
||||
"./components/sheet": "./components/sheet/index.ts",
|
||||
|
||||
@@ -2096,15 +2096,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.21.0":
|
||||
version: 7.26.10
|
||||
resolution: "@babel/runtime@npm:7.26.10"
|
||||
dependencies:
|
||||
regenerator-runtime: ^0.14.0
|
||||
checksum: 22d2e0abb86e90de489ab16bb578db6fe2b63a88696db431198b24963749820c723f1982298cdbbea187f7b2b80fb4d98a514faf114ddb2fdc14a4b96277b955
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@babel/runtime@npm:^7.23.2":
|
||||
version: 7.23.5
|
||||
resolution: "@babel/runtime@npm:7.23.5"
|
||||
@@ -4061,7 +4052,6 @@ __metadata:
|
||||
"@tanstack/react-query": ^5.17.15
|
||||
"@team-plain/typescript-sdk": ^5.9.0
|
||||
"@testing-library/react": ^13.3.0
|
||||
"@tremor/react": ^2.11.0
|
||||
"@types/accept-language-parser": 1.5.2
|
||||
"@types/async": ^3.2.15
|
||||
"@types/bcryptjs": ^2.4.2
|
||||
@@ -5843,7 +5833,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/dom@npm:^1.0.1, @floating-ui/dom@npm:^1.2.1":
|
||||
"@floating-ui/dom@npm:^1.0.1":
|
||||
version: 1.2.6
|
||||
resolution: "@floating-ui/dom@npm:1.2.6"
|
||||
dependencies:
|
||||
@@ -5871,18 +5861,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/react-dom@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "@floating-ui/react-dom@npm:1.3.0"
|
||||
dependencies:
|
||||
"@floating-ui/dom": ^1.2.1
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: ce0ad3e3bbe43cfd15a6a0d5cccede02175c845862bfab52027995ab99c6b29630180dc7d146f76ebb34730f90a6ab9bf193c8984fe8d7f56062308e4ca98f77
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/react-dom@npm:^2.0.0":
|
||||
version: 2.0.1
|
||||
resolution: "@floating-ui/react-dom@npm:2.0.1"
|
||||
@@ -5895,20 +5873,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/react@npm:^0.19.1":
|
||||
version: 0.19.2
|
||||
resolution: "@floating-ui/react@npm:0.19.2"
|
||||
dependencies:
|
||||
"@floating-ui/react-dom": ^1.3.0
|
||||
aria-hidden: ^1.1.3
|
||||
tabbable: ^6.0.1
|
||||
peerDependencies:
|
||||
react: ">=16.8.0"
|
||||
react-dom: ">=16.8.0"
|
||||
checksum: 00fd827c2dcf879fec221d89ef5b90836bbecacc236ce2acc787db32ae7311d490cd136b13a8d0b6ab12842554a2ee1110605aa832af71a45c0a7297e342072c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@floating-ui/utils@npm:^0.2.9":
|
||||
version: 0.2.9
|
||||
resolution: "@floating-ui/utils@npm:0.2.9"
|
||||
@@ -16884,22 +16848,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tremor/react@npm:^2.11.0":
|
||||
version: 2.11.0
|
||||
resolution: "@tremor/react@npm:2.11.0"
|
||||
dependencies:
|
||||
"@floating-ui/react": ^0.19.1
|
||||
date-fns: ^2.28.0
|
||||
react-transition-group: ^4.4.5
|
||||
recharts: ^2.3.2
|
||||
tailwind-merge: ^1.9.1
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
react-dom: ">=16.6.0"
|
||||
checksum: 76bee230a819289a5e87204f51161724b49bcc281308b4c41a5918e57e93497e2e97eb85b951bd750b918093784fdb28a2d1060b89502544f1a9cf76232da6d3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@trivago/prettier-plugin-sort-imports@npm:4.1.1":
|
||||
version: 4.1.1
|
||||
resolution: "@trivago/prettier-plugin-sort-imports@npm:4.1.1"
|
||||
@@ -20088,15 +20036,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"aria-hidden@npm:^1.1.3":
|
||||
version: 1.2.3
|
||||
resolution: "aria-hidden@npm:1.2.3"
|
||||
dependencies:
|
||||
tslib: ^2.0.0
|
||||
checksum: 7d7d211629eef315e94ed3b064c6823d13617e609d3f9afab1c2ed86399bb8e90405f9bdd358a85506802766f3ecb468af985c67c846045a34b973bcc0289db9
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"aria-hidden@npm:^1.2.4":
|
||||
version: 1.2.4
|
||||
resolution: "aria-hidden@npm:1.2.4"
|
||||
@@ -23796,15 +23735,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"date-fns@npm:^2.28.0":
|
||||
version: 2.30.0
|
||||
resolution: "date-fns@npm:2.30.0"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.21.0
|
||||
checksum: f7be01523282e9bb06c0cd2693d34f245247a29098527d4420628966a2d9aad154bd0e90a6b1cf66d37adcb769cd108cf8a7bd49d76db0fb119af5cdd13644f4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"date-fns@npm:^3.6.0":
|
||||
version: 3.6.0
|
||||
resolution: "date-fns@npm:3.6.0"
|
||||
@@ -23927,7 +23857,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"decimal.js-light@npm:^2.4.1, decimal.js-light@npm:^2.5.1":
|
||||
"decimal.js-light@npm:^2.5.1":
|
||||
version: 2.5.1
|
||||
resolution: "decimal.js-light@npm:2.5.1"
|
||||
checksum: f5a2c7eac1c4541c8ab8a5c8abea64fc1761cefc7794bd5f8afd57a8a78d1b51785e0c4e4f85f4895a043eaa90ddca1edc3981d1263eb6ddce60f32bf5fe66c9
|
||||
@@ -26641,7 +26571,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.4":
|
||||
"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.4":
|
||||
version: 4.0.7
|
||||
resolution: "eventemitter3@npm:4.0.7"
|
||||
checksum: 1875311c42fcfe9c707b2712c32664a245629b42bb0a5a84439762dd0fd637fc54d078155ea83c2af9e0323c9ac13687e03cfba79b03af9f40c89b4960099374
|
||||
@@ -26900,13 +26830,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-equals@npm:^5.0.1":
|
||||
version: 5.2.2
|
||||
resolution: "fast-equals@npm:5.2.2"
|
||||
checksum: 7156bcade0be5ee4dc335969d255a5815348d57080e1876fa1584451eafd0c92588de5f5840e55f81841b6d907ade2a49a46e4ec33e6f7a283a209c0fd8f8a59
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"fast-glob@npm:3.3.1":
|
||||
version: 3.3.1
|
||||
resolution: "fast-glob@npm:3.3.1"
|
||||
@@ -39389,13 +39312,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-is@npm:^18.3.1":
|
||||
version: 18.3.1
|
||||
resolution: "react-is@npm:18.3.1"
|
||||
checksum: e20fe84c86ff172fc8d898251b7cc2c43645d108bf96d0b8edf39b98f9a2cae97b40520ee7ed8ee0085ccc94736c4886294456033304151c3f94978cec03df21
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-live-chat-loader@npm:^2.8.1":
|
||||
version: 2.8.1
|
||||
resolution: "react-live-chat-loader@npm:2.8.1"
|
||||
@@ -39709,20 +39625,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-smooth@npm:^4.0.4":
|
||||
version: 4.0.4
|
||||
resolution: "react-smooth@npm:4.0.4"
|
||||
dependencies:
|
||||
fast-equals: ^5.0.1
|
||||
prop-types: ^15.8.1
|
||||
react-transition-group: ^4.4.5
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: 909305d40bae79a011ff21a10c4bc7ddadc87ac5ff093b4a5f827f730f093ec4e044c4330688d29b3ad2db83aab8997c3bb1bae550a9c66de74521b8ed52cc53
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-sticky-box@npm:^2.0.4":
|
||||
version: 2.0.4
|
||||
resolution: "react-sticky-box@npm:2.0.4"
|
||||
@@ -39811,21 +39713,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-transition-group@npm:^4.4.5":
|
||||
version: 4.4.5
|
||||
resolution: "react-transition-group@npm:4.4.5"
|
||||
dependencies:
|
||||
"@babel/runtime": ^7.5.5
|
||||
dom-helpers: ^5.0.1
|
||||
loose-envify: ^1.4.0
|
||||
prop-types: ^15.6.2
|
||||
peerDependencies:
|
||||
react: ">=16.6.0"
|
||||
react-dom: ">=16.6.0"
|
||||
checksum: 75602840106aa9c6545149d6d7ae1502fb7b7abadcce70a6954c4b64a438ff1cd16fc77a0a1e5197cdd72da398f39eb929ea06f9005c45b132ed34e056ebdeb1
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-turnstile@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "react-turnstile@npm:1.1.3"
|
||||
@@ -40079,34 +39966,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts-scale@npm:^0.4.4":
|
||||
version: 0.4.5
|
||||
resolution: "recharts-scale@npm:0.4.5"
|
||||
dependencies:
|
||||
decimal.js-light: ^2.4.1
|
||||
checksum: e970377190a610e684a32c7461c7684ac3603c2e0ac0020bbba1eea9d099b38138143a8e80bf769bb49c0b7cecf22a2f5c6854885efed2d56f4540d4aa7052bd
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts@npm:^2.3.2":
|
||||
version: 2.15.1
|
||||
resolution: "recharts@npm:2.15.1"
|
||||
dependencies:
|
||||
clsx: ^2.0.0
|
||||
eventemitter3: ^4.0.1
|
||||
lodash: ^4.17.21
|
||||
react-is: ^18.3.1
|
||||
react-smooth: ^4.0.4
|
||||
recharts-scale: ^0.4.4
|
||||
tiny-invariant: ^1.3.1
|
||||
victory-vendor: ^36.6.8
|
||||
peerDependencies:
|
||||
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
checksum: c288012087424e7067bf4a48a3d50a7c0ea231188ecaaf17685d93711ef1846aedd37509dfc25c046052f671e7e19dd2de5d8b9c1e497a64c39995c9c338b40a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"recharts@npm:^3.0.2":
|
||||
version: 3.0.2
|
||||
resolution: "recharts@npm:3.0.2"
|
||||
@@ -43713,13 +43572,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tabbable@npm:^6.0.1":
|
||||
version: 6.1.1
|
||||
resolution: "tabbable@npm:6.1.1"
|
||||
checksum: 348639497262241ce8e0ccb0664ea582a386183107299ee8f27cf7b56bc84f36e09eaf667d3cb4201e789634012a91f7129bcbd49760abe874fbace35b4cf429
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tailwind-merge@npm:^1.13.2":
|
||||
version: 1.13.2
|
||||
resolution: "tailwind-merge@npm:1.13.2"
|
||||
@@ -43727,13 +43579,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tailwind-merge@npm:^1.9.1":
|
||||
version: 1.14.0
|
||||
resolution: "tailwind-merge@npm:1.14.0"
|
||||
checksum: 8cf5d37f51b3b32e4bdd5544feaed34357bfba2f64f14834cc4a21ac29b0ae22d7255386467a0f1dadb3e38499389efbbabeddcd0b16571af5a0d346a4921877
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tailwind-merge@npm:^2.5.4":
|
||||
version: 2.6.0
|
||||
resolution: "tailwind-merge@npm:2.6.0"
|
||||
@@ -46218,28 +46063,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"victory-vendor@npm:^36.6.8":
|
||||
version: 36.9.2
|
||||
resolution: "victory-vendor@npm:36.9.2"
|
||||
dependencies:
|
||||
"@types/d3-array": ^3.0.3
|
||||
"@types/d3-ease": ^3.0.0
|
||||
"@types/d3-interpolate": ^3.0.1
|
||||
"@types/d3-scale": ^4.0.2
|
||||
"@types/d3-shape": ^3.1.0
|
||||
"@types/d3-time": ^3.0.0
|
||||
"@types/d3-timer": ^3.0.0
|
||||
d3-array: ^3.1.6
|
||||
d3-ease: ^3.0.1
|
||||
d3-interpolate: ^3.0.1
|
||||
d3-scale: ^4.0.2
|
||||
d3-shape: ^3.1.0
|
||||
d3-time: ^3.0.0
|
||||
d3-timer: ^3.0.1
|
||||
checksum: a755110e287b700202d08ac81982093ab100edaa9d61beef1476d59e9705605bd8299a3aa41fa04b933a12bd66737f4c8f7d18448dd6488c69d4f72480023a2e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"victory-vendor@npm:^37.0.2":
|
||||
version: 37.3.6
|
||||
resolution: "victory-vendor@npm:37.3.6"
|
||||
|
||||
Reference in New Issue
Block a user