Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1d553b74 | ||
|
|
b8c2fdac44 | ||
|
|
e24c40f518 | ||
|
|
c5cefc3e61 | ||
|
|
2c57ad0288 | ||
|
|
af9bf14d9e | ||
|
|
b1a0cdc8e2 | ||
|
|
7fef3e7b85 | ||
|
|
384deab0ce | ||
|
|
22da681f8c | ||
|
|
f4f4afad39 | ||
|
|
1c05952077 |
@@ -65,7 +65,6 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
- uses: ./.github/actions/yarn-install
|
||||
- uses: ./.github/actions/yarn-playwright-install
|
||||
- uses: ./.github/actions/cache-db
|
||||
- name: Run Tests
|
||||
working-directory: apps/api/v2
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import prismock from "../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { v4 } from "uuid";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
|
||||
import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler";
|
||||
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "../getTeamMemberEmailFromCrm";
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({
|
||||
default: {
|
||||
salesforce: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/app-store/_utils/CRMRoundRobinSkip", () => ({
|
||||
getCRMContactOwnerForRRLeadSkip: vi.fn(),
|
||||
}));
|
||||
|
||||
function mockGetCRMContactOwnerForRRLeadSkip({
|
||||
bookerEmail,
|
||||
teamMemberEmail,
|
||||
}: {
|
||||
bookerEmail: string;
|
||||
teamMemberEmail: string;
|
||||
}) {
|
||||
vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => {
|
||||
if (_bookerEmail === bookerEmail) {
|
||||
return Promise.resolve(teamMemberEmail);
|
||||
}
|
||||
return Promise.resolve(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
function mockBookingFormHandler({
|
||||
bookerEmail,
|
||||
teamMemberEmail,
|
||||
}: {
|
||||
bookerEmail: string;
|
||||
teamMemberEmail: string;
|
||||
}) {
|
||||
vi.mocked(bookingFormHandlers.salesforce).mockImplementation(
|
||||
(_bookerEmail, _attributeRoutingConfig, _eventTypeId) => {
|
||||
if (_bookerEmail === bookerEmail) {
|
||||
return Promise.resolve({ email: teamMemberEmail });
|
||||
}
|
||||
return Promise.resolve({ email: null });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function createRoutingFormWithResponse({
|
||||
formData,
|
||||
}: {
|
||||
formData: {
|
||||
routes: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
queryValue?: any;
|
||||
action: {
|
||||
eventTypeId: number;
|
||||
type: RouteActionType;
|
||||
value: string;
|
||||
};
|
||||
attributeRoutingConfig: {
|
||||
skipContactOwner: boolean;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}) {
|
||||
const user = await prismock.user.create({
|
||||
data: {
|
||||
name: "Routing Form User",
|
||||
username: "routing-form-user",
|
||||
email: "routing-form-user@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const form = await prismock.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
...formData,
|
||||
name: "Test Form",
|
||||
routes: formData.routes.map((route) => ({
|
||||
queryValue: {
|
||||
id: v4(),
|
||||
type: "group",
|
||||
children1: [],
|
||||
properties: {},
|
||||
},
|
||||
id: v4(),
|
||||
...route,
|
||||
})),
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const responseRecord = await prismock.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
response: {},
|
||||
form: {
|
||||
connect: {
|
||||
id: form.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { form, responseRecord };
|
||||
}
|
||||
|
||||
async function createHostForEvent({
|
||||
eventTypeData,
|
||||
userData,
|
||||
}: {
|
||||
eventTypeData: { id: number } & Partial<{
|
||||
title: string;
|
||||
slug: string;
|
||||
length: number;
|
||||
}>;
|
||||
userData: {
|
||||
email: string;
|
||||
} & Partial<{
|
||||
name: string;
|
||||
username: string;
|
||||
}>;
|
||||
}) {
|
||||
const user = await prismock.user.create({
|
||||
data: {
|
||||
name: "Test User",
|
||||
username: "testuser",
|
||||
...userData,
|
||||
},
|
||||
});
|
||||
await prismock.eventType.create({
|
||||
data: {
|
||||
title: "Title",
|
||||
slug: "slug",
|
||||
length: 30,
|
||||
...eventTypeData,
|
||||
hosts: {
|
||||
create: {
|
||||
userId: user.id,
|
||||
isFixed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
const mockEventData = {
|
||||
id: 1,
|
||||
isInstantEvent: false,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
metadata: null,
|
||||
length: 30,
|
||||
};
|
||||
|
||||
it("should return null when email is not provided in query", async () => {
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when scheduling type is not ROUND_ROBIN", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "teamMember@example.com";
|
||||
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: { email: bookerEmail },
|
||||
eventData: { ...mockEventData, schedulingType: SchedulingType.COLLECTIVE },
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return CRM owner email when valid", async () => {
|
||||
const ownerEmail = "owner@example.com";
|
||||
const bookerEmail = "booker@example.com";
|
||||
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: ownerEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: { email: "booker@example.com" },
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBe(ownerEmail);
|
||||
});
|
||||
|
||||
it("should return null when CRM owner is not found", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "teamMember@example.com";
|
||||
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: { email: bookerEmail },
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when CRM owner is not part of event type", async () => {
|
||||
const ownerEmail = "owner@example.com";
|
||||
const bookerEmail = "booker@example.com";
|
||||
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: { email: bookerEmail },
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
describe("Booking form handler", () => {
|
||||
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBe(teamMemberEmail);
|
||||
});
|
||||
|
||||
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
// Because skipContactOwner is true, the booking form handler should return null
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
const SOME_OTHER_EVENT_ID = 200;
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: SOME_OTHER_EVENT_ID,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.salesforce.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
|
||||
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
|
||||
const bookerEmail = "booker@example.com";
|
||||
const teamMemberEmail = "owner@example.com";
|
||||
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
|
||||
await createHostForEvent({
|
||||
eventTypeData: {
|
||||
id: mockEventData.id,
|
||||
},
|
||||
userData: {
|
||||
email: teamMemberEmail,
|
||||
},
|
||||
});
|
||||
|
||||
const { responseRecord } = await createRoutingFormWithResponse({
|
||||
formData: {
|
||||
routes: [
|
||||
{
|
||||
action: {
|
||||
eventTypeId: 1,
|
||||
type: RouteActionType.EventTypeRedirectUrl,
|
||||
value: "/team/sales",
|
||||
},
|
||||
attributeRoutingConfig: { skipContactOwner: false },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: {
|
||||
email: bookerEmail,
|
||||
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
|
||||
"cal.somethingelse.xxx": "true",
|
||||
},
|
||||
eventData: mockEventData,
|
||||
});
|
||||
|
||||
expect(result).toBe(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,21 @@ interface EventData {
|
||||
length: number;
|
||||
}
|
||||
|
||||
async function findUserByEmailWhoIsAHostOfEventType({
|
||||
email,
|
||||
eventTypeId,
|
||||
}: {
|
||||
email: string;
|
||||
eventTypeId: number;
|
||||
}) {
|
||||
return prisma.user.findFirst({
|
||||
where: {
|
||||
email,
|
||||
hosts: { some: { eventTypeId } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) {
|
||||
const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]);
|
||||
const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber;
|
||||
@@ -99,16 +114,11 @@ function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
|
||||
async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise<string | null> {
|
||||
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
|
||||
if (!crmContactOwnerEmail) return null;
|
||||
|
||||
// Determine if the contactOwner is a part of the event type
|
||||
const contactOwnerQuery = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: crmContactOwnerEmail,
|
||||
hosts: {
|
||||
some: {
|
||||
eventTypeId: eventData.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({
|
||||
email: crmContactOwnerEmail,
|
||||
eventTypeId: eventData.id,
|
||||
});
|
||||
if (!contactOwnerQuery) return null;
|
||||
return crmContactOwnerEmail;
|
||||
@@ -138,7 +148,6 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
|
||||
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
|
||||
.default;
|
||||
const appHandler = appBookingFormHandler[crmAppSlug];
|
||||
|
||||
if (!appHandler) return nullReturnValue;
|
||||
|
||||
const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
|
||||
@@ -146,16 +155,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
|
||||
if (!userEmail) return nullReturnValue;
|
||||
|
||||
// Determine if the user is a part of the event type
|
||||
const userQuery = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: userEmail,
|
||||
hosts: {
|
||||
some: {
|
||||
eventTypeId: eventTypeId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
|
||||
|
||||
if (!userQuery) return nullReturnValue;
|
||||
|
||||
@@ -164,22 +164,21 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
|
||||
|
||||
async function getTeamMemberEmailForResponseOrContact({
|
||||
bookerEmail,
|
||||
eventTypeId,
|
||||
eventData,
|
||||
routingFormResponseId,
|
||||
chosenRoute,
|
||||
crmAppSlug,
|
||||
}: {
|
||||
bookerEmail: string;
|
||||
eventTypeId: number;
|
||||
eventData: EventData;
|
||||
routingFormResponseId?: number | null;
|
||||
/**
|
||||
* If provided, we won't go look for the route from DB.
|
||||
*/
|
||||
chosenRoute?: LocalRoute;
|
||||
crmAppSlug: string;
|
||||
crmAppSlug?: string;
|
||||
}) {
|
||||
const eventTypeId = eventData.id;
|
||||
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
|
||||
|
||||
const attributeRoutingConfigGetterData = routingFormResponseId
|
||||
@@ -214,12 +213,10 @@ async function getTeamMemberEmailForResponseOrContact({
|
||||
|
||||
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventTypeId,
|
||||
eventData,
|
||||
chosenRoute,
|
||||
}: {
|
||||
query: ParsedUrlQuery;
|
||||
eventTypeId: number;
|
||||
eventData: EventData;
|
||||
chosenRoute?: LocalRoute;
|
||||
}) {
|
||||
@@ -231,13 +228,11 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
|
||||
|
||||
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
|
||||
if (!crmAppSlug) return null;
|
||||
|
||||
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
|
||||
|
||||
return await getTeamMemberEmailForResponseOrContact({
|
||||
bookerEmail: query.email,
|
||||
eventTypeId,
|
||||
eventData,
|
||||
routingFormResponseId,
|
||||
chosenRoute,
|
||||
|
||||
@@ -114,7 +114,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
orgBannerUrl: team.parent?.bannerUrl ?? "",
|
||||
teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventTypeId,
|
||||
eventData,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.7.0",
|
||||
"version": "4.7.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "تم الحجز بواسطة",
|
||||
"routing_form_insights_created_at": "تم الإنشاء في",
|
||||
"routing_form_insights_booking_status": "حالة الحجز",
|
||||
"routing_form_insights_booking_at": "موعد الحجز",
|
||||
"routing_form_insights_submitted_at": "تاريخ التقديم",
|
||||
"access_denied": "تم رفض الوصول",
|
||||
"salesforce_route_to_owner": "سيكون مالك جهة الاتصال هو مضيف Round Robin إذا كان متاحاً",
|
||||
"salesforce_do_not_route_to_owner": "لن يتم فرض مالك جهة الاتصال (يمكن أن يظل مضيفاً إذا تطابق مع السمات ومعايير Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Rezervasiya edən",
|
||||
"routing_form_insights_created_at": "Yaradılma tarixi",
|
||||
"routing_form_insights_booking_status": "Rezervasiya statusu",
|
||||
"routing_form_insights_booking_at": "Rezervasiya tarixi",
|
||||
"routing_form_insights_submitted_at": "Göndərilmə tarixi",
|
||||
"access_denied": "Giriş qadağandır",
|
||||
"salesforce_route_to_owner": "Əlaqə sahibi mövcud olduqda Round Robin ev sahibi olacaq",
|
||||
"salesforce_do_not_route_to_owner": "Əlaqə sahibi məcbur edilməyəcək (əgər atributlara və Round Robin meyarlarına uyğun gəlirsə, yenə də ev sahibi ola bilər)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Rezervoval/a",
|
||||
"routing_form_insights_created_at": "Vytvořeno",
|
||||
"routing_form_insights_booking_status": "Stav rezervace",
|
||||
"routing_form_insights_booking_at": "Čas rezervace",
|
||||
"routing_form_insights_submitted_at": "Čas odeslání",
|
||||
"access_denied": "Přístup odepřen",
|
||||
"salesforce_route_to_owner": "Vlastník kontaktu bude hostem Round Robin, pokud je k dispozici",
|
||||
"salesforce_do_not_route_to_owner": "Vlastník kontaktu nebude nucen (stále může být hostem, pokud odpovídá atributům a kritériím Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Booket af",
|
||||
"routing_form_insights_created_at": "Oprettet den",
|
||||
"routing_form_insights_booking_status": "Bookingstatus",
|
||||
"routing_form_insights_booking_at": "Booket den",
|
||||
"routing_form_insights_submitted_at": "Indsendt den",
|
||||
"access_denied": "Adgang nægtet",
|
||||
"salesforce_route_to_owner": "Kontaktens ejer vil være Round Robin-vært, hvis tilgængelig",
|
||||
"salesforce_do_not_route_to_owner": "Kontaktens ejer vil ikke blive tvunget (kan stadig være vært, hvis det matcher attributterne og Round Robin-kriterierne)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Gebucht von",
|
||||
"routing_form_insights_created_at": "Erstellt am",
|
||||
"routing_form_insights_booking_status": "Buchungsstatus",
|
||||
"routing_form_insights_booking_at": "Gebucht am",
|
||||
"routing_form_insights_submitted_at": "Eingereicht am",
|
||||
"access_denied": "Zugriff verweigert",
|
||||
"salesforce_route_to_owner": "Der Kontaktinhaber wird der Round Robin Host sein, falls verfügbar",
|
||||
"salesforce_do_not_route_to_owner": "Der Kontaktinhaber wird nicht erzwungen (kann trotzdem Host sein, wenn die Attribute und Round Robin Kriterien übereinstimmen)",
|
||||
|
||||
@@ -2783,6 +2783,8 @@
|
||||
"routing_form_insights_booked_by": "Booked by",
|
||||
"routing_form_insights_created_at": "Created at",
|
||||
"routing_form_insights_booking_status": "Booking Status",
|
||||
"routing_form_insights_booking_at": "Booking At",
|
||||
"routing_form_insights_submitted_at": "Submitted At",
|
||||
"access_denied": "Access Denied",
|
||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Reservado por",
|
||||
"routing_form_insights_created_at": "Creado el",
|
||||
"routing_form_insights_booking_status": "Estado de la reserva",
|
||||
"routing_form_insights_booking_at": "Reservado el",
|
||||
"routing_form_insights_submitted_at": "Enviado el",
|
||||
"access_denied": "Acceso denegado",
|
||||
"salesforce_route_to_owner": "El propietario del contacto será el anfitrión de Round Robin si está disponible",
|
||||
"salesforce_do_not_route_to_owner": "El propietario del contacto no será forzado (aún puede ser anfitrión si coincide con los atributos y criterios de Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Reservado por",
|
||||
"routing_form_insights_created_at": "Creado el",
|
||||
"routing_form_insights_booking_status": "Estado de la reserva",
|
||||
"routing_form_insights_booking_at": "Fecha de reserva",
|
||||
"routing_form_insights_submitted_at": "Fecha de envío",
|
||||
"access_denied": "Acceso denegado",
|
||||
"salesforce_route_to_owner": "El propietario del contacto será el anfitrión de Round Robin si está disponible",
|
||||
"salesforce_do_not_route_to_owner": "No se forzará al propietario del contacto (aún puede ser anfitrión si coincide con los atributos y criterios de Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Broneeris",
|
||||
"routing_form_insights_created_at": "Loodud",
|
||||
"routing_form_insights_booking_status": "Broneeringu staatus",
|
||||
"routing_form_insights_booking_at": "Broneeritud",
|
||||
"routing_form_insights_submitted_at": "Esitatud",
|
||||
"access_denied": "Juurdepääs keelatud",
|
||||
"salesforce_route_to_owner": "Kontakti omanik on round robin host, kui saadaval",
|
||||
"salesforce_do_not_route_to_owner": "Kontakti omanikku ei sunnita (võib siiski olla host, kui vastab atribuutidele ja round robin kriteeriumitele)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Réservé par",
|
||||
"routing_form_insights_created_at": "Créé le",
|
||||
"routing_form_insights_booking_status": "Statut de la réservation",
|
||||
"routing_form_insights_booking_at": "Réservé le",
|
||||
"routing_form_insights_submitted_at": "Soumis le",
|
||||
"access_denied": "Accès refusé",
|
||||
"salesforce_route_to_owner": "Le propriétaire du contact sera l'hôte Round Robin si disponible",
|
||||
"salesforce_do_not_route_to_owner": "Le propriétaire du contact ne sera pas forcé (peut toujours être l'hôte si cela correspond aux attributs et aux critères Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "הוזמן על ידי",
|
||||
"routing_form_insights_created_at": "נוצר ב-",
|
||||
"routing_form_insights_booking_status": "סטטוס הזמנה",
|
||||
"routing_form_insights_booking_at": "מועד ההזמנה",
|
||||
"routing_form_insights_submitted_at": "מועד ההגשה",
|
||||
"access_denied": "הגישה נדחתה",
|
||||
"salesforce_route_to_owner": "בעל איש הקשר יהיה מארח ה-Round Robin אם זמין",
|
||||
"salesforce_do_not_route_to_owner": "בעל איש הקשר לא יהיה מאולץ (עדיין יכול להיות מארח אם תואם את המאפיינים וקריטריוני ה-Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Foglalva általa",
|
||||
"routing_form_insights_created_at": "Létrehozva",
|
||||
"routing_form_insights_booking_status": "Foglalás állapota",
|
||||
"routing_form_insights_booking_at": "Foglalás időpontja",
|
||||
"routing_form_insights_submitted_at": "Beküldés időpontja",
|
||||
"access_denied": "Hozzáférés megtagadva",
|
||||
"salesforce_route_to_owner": "A kapcsolattartó tulajdonosa lesz a round robin házigazda, ha elérhető",
|
||||
"salesforce_do_not_route_to_owner": "A kapcsolattartó tulajdonosa nem lesz kötelező (továbbra is lehet házigazda, ha megfelel a tulajdonságoknak és a round robin kritériumoknak)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Prenotato da",
|
||||
"routing_form_insights_created_at": "Creato il",
|
||||
"routing_form_insights_booking_status": "Stato della prenotazione",
|
||||
"routing_form_insights_booking_at": "Data prenotazione",
|
||||
"routing_form_insights_submitted_at": "Data invio",
|
||||
"access_denied": "Accesso negato",
|
||||
"salesforce_route_to_owner": "Il proprietario del contatto sarà l'host del Round Robin se disponibile",
|
||||
"salesforce_do_not_route_to_owner": "Il proprietario del contatto non sarà obbligato (può comunque essere l'host se corrisponde agli attributi e ai criteri del Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "予約者",
|
||||
"routing_form_insights_created_at": "作成日",
|
||||
"routing_form_insights_booking_status": "予約状況",
|
||||
"routing_form_insights_booking_at": "予約日時",
|
||||
"routing_form_insights_submitted_at": "送信日時",
|
||||
"access_denied": "アクセスが拒否されました",
|
||||
"salesforce_route_to_owner": "可能な場合、取引先責任者の所有者がラウンドロビンのホストになります",
|
||||
"salesforce_do_not_route_to_owner": "取引先責任者の所有者は強制されません(属性とラウンドロビンの基準に一致する場合はホストになる可能性があります)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "កក់ដោយ",
|
||||
"routing_form_insights_created_at": "បង្កើតនៅ",
|
||||
"routing_form_insights_booking_status": "ស្ថានភាពនៃការកក់",
|
||||
"routing_form_insights_booking_at": "កាលបរិច្ឆេទកក់",
|
||||
"routing_form_insights_submitted_at": "កាលបរិច្ឆេទដាក់ស្នើ",
|
||||
"access_denied": "ការចូលប្រើត្រូវបានបដិសេធ",
|
||||
"salesforce_route_to_owner": "ម្ចាស់ទំនាក់ទំនងនឹងជាម្ចាស់ Round Robin ប្រសិនបើមានស្រាប់។",
|
||||
"salesforce_do_not_route_to_owner": "ម្ចាស់ទំនាក់ទំនងនឹងមិនត្រូវបានបង្ខំ (អាចនៅតែជាម្ចាស់ប្រសិនបើវាដូចគ្នាជាមួយលក្ខណៈ និងលក្ខខណ្ឌ Round Robin)។",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "예약자",
|
||||
"routing_form_insights_created_at": "생성 시간",
|
||||
"routing_form_insights_booking_status": "예약 상태",
|
||||
"routing_form_insights_booking_at": "예약 시간",
|
||||
"routing_form_insights_submitted_at": "제출 시간",
|
||||
"access_denied": "접근 거부됨",
|
||||
"salesforce_route_to_owner": "연락처 소유자가 가능하면 Round Robin 호스트가 됩니다.",
|
||||
"salesforce_do_not_route_to_owner": "연락처 소유자가 강제되지 않습니다 (속성과 Round Robin 기준이 일치하면 여전히 호스트가 될 수 있습니다).",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Geboekt door",
|
||||
"routing_form_insights_created_at": "Aangemaakt op",
|
||||
"routing_form_insights_booking_status": "Boekingsstatus",
|
||||
"routing_form_insights_booking_at": "Geboekt op",
|
||||
"routing_form_insights_submitted_at": "Ingediend op",
|
||||
"access_denied": "Toegang geweigerd",
|
||||
"salesforce_route_to_owner": "Contacteigenaar zal de Round Robin host zijn indien beschikbaar",
|
||||
"salesforce_do_not_route_to_owner": "Contacteigenaar wordt niet geforceerd (kan nog steeds host zijn als het overeenkomt met de attributen en Round Robin criteria)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Bestilt av",
|
||||
"routing_form_insights_created_at": "Opprettet den",
|
||||
"routing_form_insights_booking_status": "Bestillingsstatus",
|
||||
"routing_form_insights_booking_at": "Booket",
|
||||
"routing_form_insights_submitted_at": "Sendt inn",
|
||||
"access_denied": "Tilgang nektet",
|
||||
"salesforce_route_to_owner": "Kontaktens eier vil være Round Robin-vert hvis tilgjengelig",
|
||||
"salesforce_do_not_route_to_owner": "Kontaktens eier vil ikke bli tvunget (kan fortsatt være vert hvis det samsvarer med attributtene og Round Robin-kriteriene)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Zarezerwowane przez",
|
||||
"routing_form_insights_created_at": "Utworzono",
|
||||
"routing_form_insights_booking_status": "Status rezerwacji",
|
||||
"routing_form_insights_booking_at": "Data rezerwacji",
|
||||
"routing_form_insights_submitted_at": "Data przesłania",
|
||||
"access_denied": "Odmowa dostępu",
|
||||
"salesforce_route_to_owner": "Właściciel kontaktu będzie hostem Round Robin, jeśli jest dostępny",
|
||||
"salesforce_do_not_route_to_owner": "Właściciel kontaktu nie będzie wymuszony (może nadal być hostem, jeśli spełnia atrybuty i kryteria Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Agendado por",
|
||||
"routing_form_insights_created_at": "Criado em",
|
||||
"routing_form_insights_booking_status": "Status do Agendamento",
|
||||
"routing_form_insights_booking_at": "Agendado em",
|
||||
"routing_form_insights_submitted_at": "Enviado em",
|
||||
"access_denied": "Acesso negado",
|
||||
"salesforce_route_to_owner": "O proprietário do contato será o anfitrião do Round Robin se disponível",
|
||||
"salesforce_do_not_route_to_owner": "O proprietário do contato não será forçado (ainda pode ser anfitrião se corresponder aos atributos e critérios do Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Agendado por",
|
||||
"routing_form_insights_created_at": "Criado em",
|
||||
"routing_form_insights_booking_status": "Status do agendamento",
|
||||
"routing_form_insights_booking_at": "Agendado em",
|
||||
"routing_form_insights_submitted_at": "Enviado em",
|
||||
"access_denied": "Acesso negado",
|
||||
"salesforce_route_to_owner": "O proprietário do contato será o anfitrião do Round Robin se disponível",
|
||||
"salesforce_do_not_route_to_owner": "O proprietário do contato não será forçado (ainda pode ser anfitrião se corresponder aos atributos e critérios do Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Programat de",
|
||||
"routing_form_insights_created_at": "Creat la",
|
||||
"routing_form_insights_booking_status": "Status programare",
|
||||
"routing_form_insights_booking_at": "Programat la",
|
||||
"routing_form_insights_submitted_at": "Trimis la",
|
||||
"access_denied": "Acces refuzat",
|
||||
"salesforce_route_to_owner": "Proprietarul contactului va fi gazda Round Robin dacă este disponibil",
|
||||
"salesforce_do_not_route_to_owner": "Proprietarul contactului nu va fi forțat (poate fi totuși gazdă dacă se potrivesc atributele și criteriile Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Rezervoval",
|
||||
"routing_form_insights_created_at": "Vytvorené",
|
||||
"routing_form_insights_booking_status": "Stav rezervácie",
|
||||
"routing_form_insights_booking_at": "Čas rezervácie",
|
||||
"routing_form_insights_submitted_at": "Čas odoslania",
|
||||
"access_denied": "Prístup odmietnutý",
|
||||
"salesforce_route_to_owner": "Vlastník kontaktu bude hostiteľom metódou Round Robin, ak je k dispozícii",
|
||||
"salesforce_do_not_route_to_owner": "Vlastník kontaktu nebude nútený (môže byť hostiteľom, ak zodpovedá atribútom a kritériám Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Rezervisao/la",
|
||||
"routing_form_insights_created_at": "Kreirano",
|
||||
"routing_form_insights_booking_status": "Status rezervacije",
|
||||
"routing_form_insights_booking_at": "Vreme rezervacije",
|
||||
"routing_form_insights_submitted_at": "Vreme podnošenja",
|
||||
"access_denied": "Pristup odbijen",
|
||||
"salesforce_route_to_owner": "Vlasnik kontakta će biti Round Robin domaćin ako je dostupan",
|
||||
"salesforce_do_not_route_to_owner": "Vlasnik kontakta neće biti primoran (može i dalje biti domaćin ako odgovara atributima i Round Robin kriterijumima)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Bokad av",
|
||||
"routing_form_insights_created_at": "Skapad",
|
||||
"routing_form_insights_booking_status": "Bokningsstatus",
|
||||
"routing_form_insights_booking_at": "Bokad den",
|
||||
"routing_form_insights_submitted_at": "Skickad den",
|
||||
"access_denied": "Åtkomst nekad",
|
||||
"salesforce_route_to_owner": "Kontaktägaren blir Round Robin-värd om tillgänglig",
|
||||
"salesforce_do_not_route_to_owner": "Kontaktägaren kommer inte att tvingas (kan fortfarande vara värd om det matchar attributen och Round Robin-kriterierna)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Rezervasyon yapan",
|
||||
"routing_form_insights_created_at": "Oluşturulma tarihi",
|
||||
"routing_form_insights_booking_status": "Rezervasyon durumu",
|
||||
"routing_form_insights_booking_at": "Rezervasyon tarihi",
|
||||
"routing_form_insights_submitted_at": "Gönderilme tarihi",
|
||||
"access_denied": "Erişim Reddedildi",
|
||||
"salesforce_route_to_owner": "Mümkünse kişi sahibi round robin ev sahibi olacak",
|
||||
"salesforce_do_not_route_to_owner": "Kişi sahibi zorlanmayacak (özellikler ve round robin kriterlerine uyuyorsa yine de ev sahibi olabilir)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Заброньовано",
|
||||
"routing_form_insights_created_at": "Створено",
|
||||
"routing_form_insights_booking_status": "Статус бронювання",
|
||||
"routing_form_insights_booking_at": "Час бронювання",
|
||||
"routing_form_insights_submitted_at": "Час подання",
|
||||
"access_denied": "Доступ заборонено",
|
||||
"salesforce_route_to_owner": "Власник контакту буде ведучим Round Robin, якщо доступний",
|
||||
"salesforce_do_not_route_to_owner": "Власник контакту не буде примусово призначений (може бути ведучим, якщо відповідає атрибутам і критеріям Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "Được đặt bởi",
|
||||
"routing_form_insights_created_at": "Được tạo vào",
|
||||
"routing_form_insights_booking_status": "Trạng thái đặt chỗ",
|
||||
"routing_form_insights_booking_at": "Thời gian đặt lịch",
|
||||
"routing_form_insights_submitted_at": "Thời gian gửi",
|
||||
"access_denied": "Từ chối truy cập",
|
||||
"salesforce_route_to_owner": "Người sở hữu liên hệ sẽ là người chủ trì Round Robin nếu khả dụng",
|
||||
"salesforce_do_not_route_to_owner": "Người sở hữu liên hệ sẽ không bị bắt buộc (vẫn có thể là người chủ trì nếu phù hợp với các thuộc tính và tiêu chí Round Robin)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "预订者",
|
||||
"routing_form_insights_created_at": "创建于",
|
||||
"routing_form_insights_booking_status": "预订状态",
|
||||
"routing_form_insights_booking_at": "预约时间",
|
||||
"routing_form_insights_submitted_at": "提交时间",
|
||||
"access_denied": "访问被拒绝",
|
||||
"salesforce_route_to_owner": "如果联系人所有者可用,将作为轮询主持人",
|
||||
"salesforce_do_not_route_to_owner": "不会强制指定联系人所有者(如果符合属性和轮询标准,仍可成为主持人)",
|
||||
|
||||
@@ -2779,6 +2779,8 @@
|
||||
"routing_form_insights_booked_by": "預約者",
|
||||
"routing_form_insights_created_at": "建立時間",
|
||||
"routing_form_insights_booking_status": "預約狀態",
|
||||
"routing_form_insights_booking_at": "預約時間",
|
||||
"routing_form_insights_submitted_at": "提交時間",
|
||||
"access_denied": "拒絕存取",
|
||||
"salesforce_route_to_owner": "如果聯絡人負責人可用,將成為Round Robin主持人",
|
||||
"salesforce_do_not_route_to_owner": "不會強制指定聯絡人負責人(如果符合屬性和Round Robin條件,仍可成為主持人)",
|
||||
|
||||
@@ -6,7 +6,7 @@ To test API in your local instance, you have the following pre-requisites:
|
||||
|
||||
<Steps>
|
||||
<Step title="Clone the cal.com repository">
|
||||
Please close the cal.com repository. You can do it by following the instructions provided in [Installation](/developing/local-development)
|
||||
Please clone the cal.com repository. You can do it by following the instructions provided in [Installation](/developing/local-development)
|
||||
</Step>
|
||||
<Step title="Add license key in .env">
|
||||
Add a staging license key that goes in as value for `CALCOM_LICENSE_KEY` in your root `.env` file.
|
||||
|
||||
@@ -2781,6 +2781,8 @@ checksums:
|
||||
routing_form_insights_booked_by: 7574cbcc0dd68a6469b6715452e035cc
|
||||
routing_form_insights_created_at: f98a3c3bc73494df9bbc14e4e47a018c
|
||||
routing_form_insights_booking_status: 08b945804d902b55184884401a840d57
|
||||
routing_form_insights_booking_at: ca8a804eb7068845bede9adbb6bb7d3b
|
||||
routing_form_insights_submitted_at: b95063c78796a32900c35743237512c4
|
||||
access_denied: 589456a5c4103e29c44e9df16a4eaf82
|
||||
salesforce_route_to_owner: 39a88a7afb1c1e8fb9215e27ae7a7033
|
||||
salesforce_do_not_route_to_owner: bcae6e5615c17b809c2334a541f75e78
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { BarList } from "@tremor/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
@@ -6,6 +5,7 @@ import { trpc } from "@calcom/trpc";
|
||||
import { ToggleGroup } from "@calcom/ui";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
import { BarList } from "./tremor/BarList";
|
||||
|
||||
interface FormCardProps {
|
||||
formName: string;
|
||||
@@ -27,7 +27,7 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="border-subtle h-[400px] w-full rounded-md border">
|
||||
<div className="border-subtle w-full rounded-md border">
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-subtle border-subtle flex h-12 items-center border-b">
|
||||
<h3 className="text-default px-2 text-left align-middle font-medium">{formName}</h3>
|
||||
@@ -40,7 +40,7 @@ function FormCard({ formName, fields }: FormCardProps) {
|
||||
onValueChange={(value) => value && setSelectedField(value)}
|
||||
/>
|
||||
{selectedFieldData && (
|
||||
<div className="scrollbar-thin mt-4 h-full overflow-y-auto">
|
||||
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
|
||||
<BarList
|
||||
data={selectedFieldData}
|
||||
valueFormatter={(value: number) => value.toString()}
|
||||
|
||||
@@ -17,7 +17,22 @@ import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { trpc, type RouterOutputs } from "@calcom/trpc";
|
||||
import { DataTable, useFetchMoreOnBottomReached, Badge, Avatar, Icon } from "@calcom/ui";
|
||||
import {
|
||||
DataTable,
|
||||
useFetchMoreOnBottomReached,
|
||||
Badge,
|
||||
Avatar,
|
||||
Icon,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@calcom/ui";
|
||||
import type { BadgeProps } from "@calcom/ui/components/badge/Badge";
|
||||
|
||||
import { useFilterContext } from "../context/provider";
|
||||
@@ -36,7 +51,7 @@ function CellWithOverflowX({ children, className }: { children: React.ReactNode;
|
||||
return (
|
||||
<div className={classNames("group relative max-w-[200px]", className)}>
|
||||
<div
|
||||
className="no-scrollbar overflow-x-auto whitespace-nowrap"
|
||||
className="no-scrollbar flex gap-1 overflow-x-auto whitespace-nowrap"
|
||||
ref={(el) => {
|
||||
// Only add shadow if the scroll width is greater than 200px
|
||||
if (!el) return;
|
||||
@@ -51,7 +66,7 @@ function CellWithOverflowX({ children, className }: { children: React.ReactNode;
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
<div className="absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l from-white to-transparent group-hover:from-gray-100" />
|
||||
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +107,7 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
|
||||
if (value.length === 0) return <div className="h-6 w-[200px]" />;
|
||||
|
||||
return (
|
||||
<div className="flex w-[200px] flex-wrap gap-1">
|
||||
<CellWithOverflowX className="flex w-[200px] gap-1">
|
||||
{value.length > 2 ? (
|
||||
<>
|
||||
{value.slice(0, 2).map((v: string, i: number) => (
|
||||
@@ -100,18 +115,20 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
|
||||
{v}
|
||||
</Badge>
|
||||
))}
|
||||
<div className="group/badge relative">
|
||||
<Badge variant="gray">+{value.length - 2}</Badge>
|
||||
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/badge:visible group-hover/badge:translate-y-0 group-hover/badge:opacity-100">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<Badge variant="gray">+{value.length - 2}</Badge>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent side="bottom" align="start" className="w-fit">
|
||||
<div className="flex flex-col gap-1">
|
||||
{value.slice(2).map((v: string, i: number) => (
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-sm text-gray-600">
|
||||
<span key={`${cellId}-overflow-${i}-${rowId}`} className="text-default text-sm">
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
</>
|
||||
) : (
|
||||
value.map((v: string, i: number) => (
|
||||
@@ -120,7 +137,7 @@ function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number })
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CellWithOverflowX>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,12 +175,16 @@ function BookingStatusCell({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group/booking_status relative flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
|
||||
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
|
||||
<Link href={`/booking/${booking.uid}`}>
|
||||
<Badge variant={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</Link>
|
||||
<div className="bg-default invisible absolute left-0 top-full z-20 translate-y-[-8px] rounded-md p-2 opacity-0 shadow-md transition-all duration-200 group-hover/booking_status:visible group-hover/booking_status:translate-y-0 group-hover/booking_status:opacity-100 ">
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<div className="flex items-center gap-2" key={`${cellId}-booking-${rowId}`}>
|
||||
<Avatar size="xs" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
|
||||
<Link href={`/booking/${booking.uid}`}>
|
||||
<Badge variant={badgeVariant}>{dayjs(booking.createdAt).format("MMM D, YYYY HH:mm")}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar size="sm" imageSrc={booking.user.avatarUrl ?? ""} alt={booking.user.name ?? ""} />
|
||||
@@ -188,11 +209,13 @@ function BookingStatusCell({
|
||||
<Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
|
||||
|
||||
export function RoutingFormResponsesTable() {
|
||||
const { t } = useLocale();
|
||||
const { filter } = useFilterContext();
|
||||
@@ -212,16 +235,17 @@ export function RoutingFormResponsesTable() {
|
||||
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
|
||||
const [startDate, endDate] = dateRange;
|
||||
|
||||
const { data: headers } = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
|
||||
{
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
},
|
||||
{
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
const { data: headers, isLoading: isHeadersLoading } =
|
||||
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
|
||||
{
|
||||
teamId: selectedTeamId ?? undefined,
|
||||
isAll: isAll ?? false,
|
||||
routingFormId: selectedRoutingFormId ?? undefined,
|
||||
},
|
||||
{
|
||||
enabled: initialConfigIsReady,
|
||||
}
|
||||
);
|
||||
|
||||
const { data, fetchNextPage, isFetching, hasNextPage } =
|
||||
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
|
||||
@@ -251,6 +275,7 @@ export function RoutingFormResponsesTable() {
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (isHeadersLoading) return [];
|
||||
return flatData.map((response) => {
|
||||
const row: RoutingFormTableRow = {
|
||||
id: response.id,
|
||||
@@ -283,7 +308,7 @@ export function RoutingFormResponsesTable() {
|
||||
|
||||
return row;
|
||||
});
|
||||
}, [flatData, headers]);
|
||||
}, [flatData, headers, isHeadersLoading]);
|
||||
|
||||
const columnHelper = createColumnHelper<RoutingFormTableRow>();
|
||||
|
||||
@@ -298,6 +323,7 @@ export function RoutingFormResponsesTable() {
|
||||
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
|
||||
},
|
||||
}),
|
||||
|
||||
...(headers?.map((header) => {
|
||||
return columnHelper.accessor(header.id, {
|
||||
id: header.id,
|
||||
@@ -314,10 +340,9 @@ export function RoutingFormResponsesTable() {
|
||||
},
|
||||
});
|
||||
}) ?? []),
|
||||
|
||||
columnHelper.accessor("routedToBooking", {
|
||||
id: "bookingStatus",
|
||||
header: t("routing_form_insights_booking_status"),
|
||||
header: t("routing_form_insights_booking_at"),
|
||||
size: 250,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">
|
||||
@@ -330,6 +355,14 @@ export function RoutingFormResponsesTable() {
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "submittedAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
size: 250,
|
||||
cell: (info) => (
|
||||
<div className="max-w-[250px]">{dayjs(info.getValue()).format("MMM D, YYYY HH:mm")}</div>
|
||||
),
|
||||
}),
|
||||
],
|
||||
[headers, t, copyToClipboard]
|
||||
);
|
||||
@@ -353,6 +386,52 @@ export function RoutingFormResponsesTable() {
|
||||
totalDBRowCount
|
||||
);
|
||||
|
||||
if (isHeadersLoading || (isFetching && !data)) {
|
||||
return (
|
||||
<div
|
||||
className="grid h-[75dvh]"
|
||||
style={{ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'" }}>
|
||||
<div
|
||||
className="scrollbar-thin border-subtle relative h-full overflow-auto rounded-md border"
|
||||
style={{ gridArea: "body" }}>
|
||||
<Table>
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableRow>
|
||||
{[...Array(4)].map((_, index) => (
|
||||
<TableHead key={`skeleton-header-${index}`}>
|
||||
<div className="bg-subtle h-4 w-[200px] animate-pulse rounded-md" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...Array(10)].map((_, rowIndex) => (
|
||||
<TableRow key={`skeleton-row-${rowIndex}`}>
|
||||
{[...Array(4)].map((_, colIndex) => (
|
||||
<TableCell key={`skeleton-cell-${rowIndex}-${colIndex}`}>
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-subtle h-6 animate-pulse rounded-md",
|
||||
colIndex === 0
|
||||
? "w-[200px]"
|
||||
: colIndex === 2
|
||||
? "w-[250px]"
|
||||
: colIndex === 3
|
||||
? "w-[250px]"
|
||||
: "w-[200px]"
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<DataTable
|
||||
|
||||
@@ -51,17 +51,8 @@ export const RoutingKPICards = () => {
|
||||
|
||||
const categories: {
|
||||
title: string;
|
||||
index:
|
||||
| "created"
|
||||
| "active"
|
||||
| "total_responses"
|
||||
| "total_responses_without_booking"
|
||||
| "total_responses_with_booking";
|
||||
index: "active" | "total_responses" | "total_responses_without_booking" | "total_responses_with_booking";
|
||||
}[] = [
|
||||
{
|
||||
title: t("routing_forms_created"),
|
||||
index: "created",
|
||||
},
|
||||
{
|
||||
title: t("routing_forms_total_responses"),
|
||||
index: "total_responses",
|
||||
@@ -84,7 +75,7 @@ export const RoutingKPICards = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
|
||||
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
|
||||
{categories.map((item) => (
|
||||
<CardInsights key={item.title}>
|
||||
<Text className="text-default">{item.title}</Text>
|
||||
|
||||
@@ -9,5 +9,5 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
|
||||
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
|
||||
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
|
||||
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
|
||||
export { RoutingFormResponsesTable } from "./RoutingFormResponsesTable";
|
||||
export { RoutingFormResponsesTable, type RoutingFormTableType } from "./RoutingFormResponsesTable";
|
||||
export { FailedBookingsByField } from "./FailedBookingsByField";
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Tremor BarList [v0.1.1]
|
||||
import React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
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 };
|
||||
@@ -108,9 +108,9 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
|
||||
</Tooltip>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="z-50 w-44">
|
||||
<DropdownMenuContent className="z-50">
|
||||
{filterOptions?.map((option) => (
|
||||
<DropdownMenuItem key={option.label} className="w-44">
|
||||
<DropdownMenuItem key={option.label} className="w-full">
|
||||
<DropdownItem
|
||||
type="button"
|
||||
StartIcon={option.StartIcon}
|
||||
@@ -124,9 +124,11 @@ export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?
|
||||
});
|
||||
}}
|
||||
childrenClassName="w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{t(option.label)}
|
||||
{selectedFilter?.includes(option.value) && <Icon name="check" className="h-4 w-4" />}
|
||||
<div className="flex w-full items-center justify-between whitespace-normal">
|
||||
<span className="mr-2">{t(option.label)}</span>
|
||||
{selectedFilter?.includes(option.value) && (
|
||||
<Icon name="check" className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -376,7 +376,13 @@ export class ProfileRepository {
|
||||
},
|
||||
},
|
||||
members: {
|
||||
distinct: ["role"],
|
||||
select: membershipSelect,
|
||||
where: {
|
||||
accepted: true,
|
||||
// Filter out memberships that are not owned by the user
|
||||
user: { profiles: { some: { id } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -664,25 +664,18 @@ export class UserRepository {
|
||||
return !!teams.length;
|
||||
}
|
||||
static async isAdminOrOwnerOfTeam({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
const team = await prisma.team.findUnique({
|
||||
const isAdminOrOwnerOfTeam = await prisma.membership.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
AND: [
|
||||
{
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
userId,
|
||||
teamId,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
return !!team;
|
||||
return !!isAdminOrOwnerOfTeam;
|
||||
}
|
||||
static async getTimeZoneAndDefaultScheduleId({ userId }: { userId: number }) {
|
||||
return await prisma.user.findUnique({
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "EventType_profileId_idx" ON "EventType"("profileId");
|
||||
@@ -163,6 +163,7 @@ model EventType {
|
||||
@@unique([userId, parentId])
|
||||
@@index([userId])
|
||||
@@index([teamId])
|
||||
@@index([profileId])
|
||||
@@index([scheduleId])
|
||||
@@index([secondaryEmailId])
|
||||
@@index([parentId])
|
||||
|
||||
@@ -102,23 +102,16 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
|
||||
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
|
||||
|
||||
const locale = user?.locale ?? ctx.locale;
|
||||
|
||||
const isOrgAdmin = !!user.profile?.organization?.members.filter(
|
||||
(member) => (member.role === "ADMIN" || member.role === "OWNER") && member.userId === user.id
|
||||
).length;
|
||||
const { members = [], ..._organization } = user.profile?.organization || {};
|
||||
const isOrgAdmin = members.some((member) => ["OWNER", "ADMIN"].includes(member.role));
|
||||
|
||||
if (isOrgAdmin) {
|
||||
logger.debug("User is an org admin", safeStringify({ userId: user.id }));
|
||||
} else {
|
||||
logger.debug("User is not an org admin", safeStringify({ userId: user.id }));
|
||||
}
|
||||
// Want to reduce the amount of data being sent
|
||||
if (isOrgAdmin && user.profile?.organization?.members) {
|
||||
user.profile.organization.members = [];
|
||||
}
|
||||
|
||||
const organization = {
|
||||
...user.profile?.organization,
|
||||
..._organization,
|
||||
id: user.profile?.organization?.id ?? null,
|
||||
isOrgAdmin,
|
||||
metadata: orgMetadata,
|
||||
|
||||
-1
@@ -214,7 +214,6 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
|
||||
const timeBeforeCrm = performance.now();
|
||||
const contactOwnerEmail = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query: fromEntriesWithDuplicateKeys(urlSearchParamsToForward.entries()),
|
||||
eventTypeId: eventType.id,
|
||||
eventData: eventType,
|
||||
chosenRoute: route,
|
||||
});
|
||||
|
||||
@@ -41,6 +41,9 @@ function ColumnVisibilityButtonComponent<TData>(
|
||||
const { t } = useLocale();
|
||||
const allColumns = table.getAllLeafColumns();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [visibleColumns, setVisibleColumns] = useState(
|
||||
() => new Set(allColumns.filter((col) => col.getIsVisible()).map((col) => col.id))
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -59,15 +62,28 @@ function ColumnVisibilityButtonComponent<TData>(
|
||||
const canHide = column.getCanHide();
|
||||
if (!column.columnDef.header || typeof column.columnDef.header !== "string" || !canHide)
|
||||
return null;
|
||||
const isVisible = column.getIsVisible();
|
||||
const isVisible = visibleColumns.has(column.id);
|
||||
return (
|
||||
<CommandItem key={column.id} onSelect={() => column.toggleVisibility(!isVisible)}>
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
onSelect={() => {
|
||||
column.toggleVisibility(!isVisible);
|
||||
setVisibleColumns((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isVisible) {
|
||||
next.delete(column.id);
|
||||
} else {
|
||||
next.add(column.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isVisible ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Icon name="check" className={classNames("h-4 w-4")} />
|
||||
<Icon name="check" className={classNames("h-4 w-4", !isVisible && "invisible")} />
|
||||
</div>
|
||||
{column.columnDef.header}
|
||||
</CommandItem>
|
||||
@@ -78,7 +94,10 @@ function ColumnVisibilityButtonComponent<TData>(
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => allColumns.forEach((column) => column.toggleVisibility(true))}
|
||||
onSelect={() => {
|
||||
allColumns.forEach((column) => column.toggleVisibility(true));
|
||||
setVisibleColumns(new Set(allColumns.map((col) => col.id)));
|
||||
}}
|
||||
className={classNames(
|
||||
"w-full justify-center text-center",
|
||||
buttonClasses({ color: "secondary" })
|
||||
|
||||
@@ -39,7 +39,7 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
|
||||
{...props}
|
||||
sideOffset={sideOffset}
|
||||
className={classNames(
|
||||
"shadow-dropdown w-50 bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
|
||||
"shadow-dropdown bg-default border-subtle relative z-10 ml-1.5 origin-top-right rounded-md border text-sm",
|
||||
"[&>*:first-child]:mt-1 [&>*:last-child]:mb-1",
|
||||
props.className
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import * as React from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root;
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={classNames(
|
||||
"bg-default text-default data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 border-subtle z-50 w-64 rounded-md border p-2 shadow-sm outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -170,6 +170,8 @@ export {
|
||||
CommandShortcut,
|
||||
} from "./components/command";
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent } from "./components/hover-card";
|
||||
|
||||
export { OrgBanner } from "./components/organization-banner";
|
||||
export type { OrgBannerProps } from "./components/organization-banner";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user