Compare commits

..
31 Commits
Author SHA1 Message Date
Alex van Andel fc1d553b74 chore: Release v4.7.2 (#17692)
release.yml / release (push) Canceled after 0s
2024-11-16 18:36:43 +00:00
Calcom BotandReplexica b8c2fdac44 feat: update translations via @replexica (#17691)
Co-authored-by: Replexica <support@replexica.com>
2024-11-16 18:32:52 +00:00
Abul Hassan SathuliandPrashant Varma e24c40f518 fix: how-to-setup-api-in-a-local-instance.mdx in docs with correction after api-v2 (#17667)
* Updating how-to-setup-api-in-a-local-instance.mdx with correction after api v2

After the implementation of api v2, some of the documents are not yet updated and could you make developers spend too much time on finding these things.

* fix: typo

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-11-16 18:27:02 +00:00
Alex van Andel c5cefc3e61 chore: Found a critical index missing on profileId (#17690) 2024-11-16 18:26:50 +00:00
Omar López 2c57ad0288 perf: optimize organization admin check (#17683) 2024-11-16 18:26:23 +00:00
sean-brydon af9bf14d9e fix: routing column filters (#17688)
* remove column filters

* remove column filters

* use a loading state while loading columns

* fix
2024-11-16 18:05:36 +00:00
sean-brydon b1a0cdc8e2 fix: large org routing insights UI issues (#17687)
* fix overflow colour in darkmode

* truncate/show overflow scroll indicator when overflowing on response values

* remove total created count

* overflow Y on failed response container

* fix darkmode styles on barlist

* radix hovercards to properly allow overflow in container

* column visible state

* remove constrained width on dropdowns to w-fit

* export type
2024-11-16 11:01:26 -03:00
Alex van AndelandPeer Richelsen 7fef3e7b85 chore: Release v4.7.1 (#17686)
release.yml / release (push) Canceled after 0s
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-16 10:00:16 +00:00
Hariom Balhara 384deab0ce test: findTeamMemberEmailFromCrm (#17685)
* We need to choose contact owner when crm app slug is not present

* Add unit tests for getTeamMemberEmailFromCrm module
2024-11-16 09:32:33 +00:00
Hariom Balhara 22da681f8c We need to choose contact owner when crm app slug is not present (#17684) 2024-11-16 09:02:24 +00:00
Omar López f4f4afad39 perf: optimize team admin check queries (#17681)
## What does this PR do?

Optimizes team and organization admin checks by:
- Removing unnecessary member data fetching in profile repository
- Simplifying team admin verification query to directly check membership
- Refactoring organization admin check to use the optimized team verification method

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs
- [ ] I confirm automated tests are in place

## How should this be tested?

1. Log in as a team admin/owner
2. Verify team admin privileges still work correctly
3. Log in as an organization admin
4. Confirm organization admin permissions are maintained
5. Check that admin-only features remain accessible

Expected behavior:
- Team admins/owners should retain all their existing permissions
- Organization admins should maintain their administrative capabilities
- No degradation in admin functionality while improving query performance

## Checklist

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-15 19:00:18 -07:00
Keith Williams 1c05952077 chore: Remove playwright install for API v2 E2E (#17677) 2024-11-15 23:47:26 +00:00
Keith Williams 1df70e75ed chore: v4.7.0 (#17664)
release.yml / release (push) Canceled after 0s
2024-11-15 20:11:00 -03:00
Udit Takkar 8962f6f08e fix: update current cursor (#17626) 2024-11-15 16:07:17 -07:00
Keith Williams 6272c881c8 revert: "feat: Populate gCal calendar cache via webhooks (#11928)" (#17675)
This reverts commit d294a74aad.
2024-11-15 19:28:30 -03:00
Omar López bdf94c7f48 fix: removed calendar cache admin page (#17676) 2024-11-15 21:46:08 +00:00
b79c5e7c86 feat: update translations via @replexica (#17674)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-15 21:34:07 +00:00
3eaccb8738 fix: Preview queue position and contact owner in preview (#17552)
* wip

* Add preview mode in router

* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* Add shortfall column and add tests

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* Add more tests and more columns to matching members queue

* fix issue from merge

* always send usersAndTheirBookingShortfalls

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 20:39:46 +00:00
Anik Dhabal Babu 9273226fb4 test: Add test to validate error when 'in' field in where clause is undefine and contain null values (#17670) 2024-11-15 19:55:34 +00:00
sean-brydonandJoe Au-Yeung 7ce5886b4f feat: insights routing forms (#17583)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-11-15 14:54:51 -05:00
Anik Dhabal Babu e85c0e1a85 fix: unable to lock individual event type creation (#17668) 2024-11-15 15:58:19 -03:00
Omar LópezandUdit Takkar d294a74aad feat: Populate gCal calendar cache via webhooks (#11928)
* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Update CalendarService.ts

* Type fixes

* WIP

* fix: improve cache hits

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Feedback

* Update CalendarService.ts

* Update CalendarService.ts

* Update _router.ts

* feedback

* WIP

* WIP

* Update schema.prisma

* feedback

* typefixes

* Update Calendar.d.ts

* fix: watches when adding a calendar

* Discard changes to packages/app-store/googlecalendar/api/add.ts

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WP

Signed-off-by: Omar López <zomars@me.com>

* Update calendar.ts

* Update calendar.ts

* Update callback.ts

* Update callback.ts

* Conflicts

* WIP

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/features/settings/layouts/SettingsLayout.tsx

* Update calendar-cache.repository.ts

* WIP

* Update getSelectedCalendarsToWatch.sql

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/app-store/googlecalendar/lib/CalendarService.ts

* Create CalendarService.wip.ts

* WIP

* Update CalendarService.ts

* Update getSelectedCalendarsToWatch.sql

* Delete CalendarService.wip.ts

* test updates

* cleanup

Signed-off-by: Omar López <zomars@me.com>

* Update CalendarService.test.ts

* Update CalendarService.ts

* type fixes

* Update OAuthManager.ts

* Update CalendarService.ts

* Almost there

* Update CalendarService.test.ts

* Update calendar.ts

* Update callback.ts

* Update toggleFeatureFlag.handler.ts

* fix: feedback

* Update getSelectedCalendarsToWatch.sql

* Fix unit tests

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-15 14:59:37 -03:00
5012a16794 feat: attribute weights and virtual queues (#17567)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* use findUnique

* add fallback weight

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 17:36:46 +00:00
Calcom BotandReplexica 322ca2a12c feat: update translations via @replexica (#17666)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-15 16:50:57 +00:00
Udit Takkar 9b79c037c2 fix: remove cal video logo from select (#17665)
* fix: remove cal video logo from managed event types

* fix: do not fetch cal video logo
2024-11-15 16:45:44 +00:00
Alex van Andel f76696032e feat: Implements maxLeadThreshold (#17643)
* feat: Implements maxLeadThreshold which puts a maximum to the amount of lead a given host is allowed to be ahead

* Refactor getLuckyUser + implement in handleNewBooking + move logic to repo

* Decoupled @prisma from findQualifiedHosts

* Fix deep type error

* Add maxLeadThreshold to builder.ts

* Removed a console.log :O + fix handleChildrenEventTypes input

* Add distribution UI to enable balanced distribution/maximize availability

* 0-1-2 instead of 0-1-2-3 (3 bookings instead of 4)
2024-11-15 15:54:02 +00:00
Udit Takkar c6aa57177a fix: filter cal video logo (#17663)
* fix: filter cal video logo

* chore: message
2024-11-15 14:38:28 +00:00
Anik Dhabal Babu 7f2db48c72 fix: meeting not created on hubspot for contacts (#17661) 2024-11-15 13:18:17 +00:00
c59e733b5a feat: exclude emails and domains (#17591)
* feat: add constants

* feat: add excludeEmails field in baseSchema

* feat: add ExcludeEmails Input field in FieldEditDialog

* feat: add excludedEmails check in FormBuilderField

* chore: move validations in getBookingResponsesSchema

* Update i18n string to be more generic

* Update apps/web/public/static/locales/en/common.json

* feat: add validation on excludeEmails field

* feat: add fieldSchema as resolver

* chore: remove comment

* chore: format comments

* feat: add tests for excluded email or domains

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-15 10:46:25 +00:00
sean-brydon e0713d78b3 fix: z index issue on dropdown menu (#17656)
* portal content

* fix(dropdown-z-index-issue): Fixes dropdown z index issue by portaling
2024-11-15 10:51:39 +01:00
Max Prilutskiy d868b325c4 feat(CAL-4730): add sk-SK language (#17652) 2024-11-15 09:41:36 +00:00
138 changed files with 10957 additions and 2014 deletions
-1
View File
@@ -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
+2 -2
View File
@@ -775,9 +775,9 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
>([]);
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data.result);
setTeamMembersMatchingAttributeLogic(data.result ? data.result.users : data.result);
},
});
@@ -164,28 +164,6 @@ vi.mock("@calcom/trpc/react", () => ({
isPending: false,
})),
},
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
});
}),
};
}),
},
},
eventTypes: {
get: {
@@ -204,11 +182,37 @@ vi.mock("@calcom/trpc/react", () => ({
})),
},
},
routingForms: {
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: {
users: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
},
});
}),
};
}),
},
},
},
},
}));
const mockMutateFn = vi.fn(({ __testOnSuccess }) => {
const mockReactQueryMutateFn = vi.fn(({ __testOnSuccess }) => {
__testOnSuccess({
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
});
@@ -218,7 +222,7 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn((payload) => {
mockMutateFn({
mockReactQueryMutateFn({
...payload,
__testOnSuccess: onSuccess,
});
@@ -441,7 +445,7 @@ describe("RerouteDialog", () => {
);
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect(mockReactQueryMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
// Shouldn't include the user who booked the booking
@@ -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);
});
});
});
+241
View File
@@ -0,0 +1,241 @@
import type { ParsedUrlQuery } from "querystring";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps";
import type { AttributeRoutingConfig, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["getTeamMemberEmailFromCrm"] });
interface EventData {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
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;
return routingFormResponseId;
}
async function getAttributeRoutingConfig(
data:
| {
routingFormResponseId: number;
eventTypeId: number;
}
| {
route: Pick<LocalRoute, "attributeRoutingConfig">;
}
) {
if ("route" in data) {
return data.route.attributeRoutingConfig ?? null;
}
const { routingFormResponseId, eventTypeId } = data;
const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({
where: {
responses: {
some: {
id: routingFormResponseId,
},
},
},
select: {
routes: true,
},
});
if (!routingFormQuery || !routingFormQuery?.routes) return null;
const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes);
if (!parsedRoutes.success || !parsedRoutes.data) return null;
// Find the route with the attributeRoutingConfig
// FIXME: There could be multiple routes with same action.eventTypeId, we should actually ensure we have the chosenRouteId in here and use that route.
const route = parsedRoutes.data.find((route) => {
if ("action" in route) {
return route.action.eventTypeId === eventTypeId;
}
});
if (!route || !("attributeRoutingConfig" in route)) return null;
// Get attributeRoutingConfig for the form
const attributeRoutingConfig = route.attributeRoutingConfig;
if (!attributeRoutingConfig) return null;
return attributeRoutingConfig;
}
function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler
// Routing form apps will have the format cal.appSlug
let enabledRoutingFormApp;
for (const key of Object.keys(query)) {
const keySplit = key.split(".");
const appSlug = keySplit[1];
if (enabledAppSlugs.includes(appSlug)) {
enabledRoutingFormApp = appSlug;
break;
}
}
return enabledRoutingFormApp;
}
/**
* Uses the owner of the contact directly from CRM
*/
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 findUserByEmailWhoIsAHostOfEventType({
email: crmContactOwnerEmail,
eventTypeId: eventData.id,
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
}
/**
* Handles custom lookup field logic
*/
async function getTeamMemberEmailUsingRoutingFormHandler({
bookerEmail,
eventTypeId,
attributeRoutingConfig,
crmAppSlug,
}: {
bookerEmail: string;
eventTypeId: number;
attributeRoutingConfig: AttributeRoutingConfig | null;
crmAppSlug: string;
}) {
const nullReturnValue = { email: null, skipContactOwner: false };
if (!attributeRoutingConfig) return nullReturnValue;
// If the skipContactOwner is enabled then don't return an team member email
if (attributeRoutingConfig.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true };
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);
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
if (!userQuery) return nullReturnValue;
return { ...nullReturnValue, email: userEmail };
}
async function getTeamMemberEmailForResponseOrContact({
bookerEmail,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
}: {
bookerEmail: string;
eventData: EventData;
routingFormResponseId?: number | null;
/**
* If provided, we won't go look for the route from DB.
*/
chosenRoute?: LocalRoute;
crmAppSlug?: string;
}) {
const eventTypeId = eventData.id;
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const attributeRoutingConfigGetterData = routingFormResponseId
? { routingFormResponseId, eventTypeId }
: chosenRoute
? { route: chosenRoute }
: null;
// If we have found crmAppSlug, it means that the CRM App in the routing-form will handle the logic
if (attributeRoutingConfigGetterData && crmAppSlug) {
log.debug(
"Using CRM App handler in routing-forms",
safeStringify({ attributeRoutingConfigGetterData, crmAppSlug })
);
const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData);
const { email, skipContactOwner } = await getTeamMemberEmailUsingRoutingFormHandler({
bookerEmail,
eventTypeId,
attributeRoutingConfig,
crmAppSlug,
});
if (skipContactOwner) return null;
if (email) return email;
} else {
log.debug("Getting the contact owner email from CRM");
return await getOwnerEmailFromCrm(eventData, bookerEmail);
}
return null;
}
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
chosenRoute,
}: {
query: ParsedUrlQuery;
eventData: EventData;
chosenRoute?: LocalRoute;
}) {
// Without email no lookup is possible
if (!query.email || typeof query.email !== "string") {
return null;
}
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
return await getTeamMemberEmailForResponseOrContact({
bookerEmail: query.email,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
});
}
@@ -1,12 +1,6 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import type { ParsedUrlQuery } from "querystring";
import { z } from "zod";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps";
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
@@ -14,7 +8,6 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/org
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -93,7 +86,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/web/lib/getTeamMemberEmailFromCrm"
);
return {
props: {
eventData: {
@@ -117,148 +112,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail: await handleGettingTeamMemberEmail(query, eventTypeId, eventData),
teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
}),
},
};
};
interface EventData {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
}
async function handleGettingTeamMemberEmail(
query: ParsedUrlQuery,
eventTypeId: number,
eventData: EventData
) {
if (
!query.email ||
typeof query.email !== "string" ||
eventData.schedulingType !== SchedulingType.ROUND_ROBIN
)
return null;
// Check if a routing form was completed and an routing form option is enabled
if (
ROUTING_FORM_RESPONSE_ID_QUERY_STRING in query &&
Object.values(query).some((value) => value === "true")
) {
const { email, skipContactOwner } = await handleRoutingFormOption(query, eventTypeId);
if (skipContactOwner) return null;
if (email) return email;
} else {
return await getTeamMemberEmail(eventData, query.email);
}
return null;
}
async function handleRoutingFormOption(query: ParsedUrlQuery, eventTypeId: number) {
const nullReturnValue = { email: null, skipContactOwner: false };
if (typeof query.email !== "string") return nullReturnValue;
const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({
where: {
responses: {
some: {
id: Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]),
},
},
},
select: {
routes: true,
},
});
if (!routingFormQuery || !routingFormQuery?.routes) return nullReturnValue;
const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes);
if (!parsedRoutes.success || !parsedRoutes.data) return nullReturnValue;
// Find the route with the attributeRoutingConfig
const route = parsedRoutes.data.find((route) => {
if ("action" in route) {
return route.action.eventTypeId === eventTypeId;
}
});
if (!route || !("attributeRoutingConfig" in route)) return nullReturnValue;
// Get attributeRoutingConfig for the form
const attributeRoutingConfig = route.attributeRoutingConfig;
if (!attributeRoutingConfig) return nullReturnValue;
// If the skipContactOwner is enabled then don't return an team member email
if (attributeRoutingConfig?.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true };
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler
// Routing form apps will have the format cal.appSlug
let enabledRoutingFormApp;
for (const key of Object.keys(query)) {
const keySplit = key.split(".");
const appSlug = keySplit[1];
if (enabledAppSlugs.includes(appSlug)) {
enabledRoutingFormApp = appSlug;
break;
}
}
if (!enabledRoutingFormApp) return nullReturnValue;
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[enabledRoutingFormApp];
if (!appHandler) return nullReturnValue;
const { email: userEmail } = await appHandler(query.email, attributeRoutingConfig, eventTypeId);
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await await prisma.user.findFirst({
where: {
email: userEmail,
hosts: {
some: {
eventTypeId: eventTypeId,
},
},
},
});
if (!userQuery) return nullReturnValue;
return { ...nullReturnValue, email: userEmail };
}
async function getTeamMemberEmail(eventData: EventData, email: string): Promise<string | null> {
// Pre-requisites
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return 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,
},
},
},
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
}
@@ -9,6 +9,7 @@ import {
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
@@ -55,6 +56,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
).profile
: null;
const calVideoLogo = profile?.organization
? await OrganizationRepository.findCalVideoLogoByOrgId({ id: profile.organization.id })
: null;
//daily.co calls have a 14 days exit buffer when a user enters a call when it's not available it will trigger the modals
const now = new Date();
const exitDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
@@ -134,6 +139,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: bookingObj.user,
},
hasTeamPlan: !!hasTeamPlan,
calVideoLogo,
trpcState: ssr.dehydrate(),
},
};
@@ -0,0 +1,42 @@
"use client";
import {
RoutingFormResponsesTable,
RoutingKPICards,
FailedBookingsByField,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<Filters showRoutingFilters />
<div className="mb-4 space-y-4">
<RoutingKPICards />
<RoutingFormResponsesTable />
<FailedBookingsByField />
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+34 -87
View File
@@ -14,107 +14,54 @@ import {
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import InsightsLayout from "./layout";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<Filters />
<InsightsLayout>
<FiltersProvider>
<Filters />
<div className="mb-4 space-y-4">
<BookingKPICards />
<div className="mb-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsLayout({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? null : children}
</UpgradeTip>
</Shell>
</div>
);
}
+3 -3
View File
@@ -624,7 +624,7 @@ export default function Signup({
</div>
</div>
</div>
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12 dark:bg-none">
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 dark:bg-none lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12">
{IS_CALCOM && (
<>
<div className="-mt-4 mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
@@ -675,7 +675,7 @@ export default function Signup({
</div>
</>
)}
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] lg:block lg:py-[6px] lg:pl-[6px] dark:bg-white/5">
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
<img
className="hidden dark:block"
@@ -686,7 +686,7 @@ export default function Signup({
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
{FEATURES.map((feature) => (
<>
<div className="mb-8 flex max-w-52 flex-col leading-none sm:mb-0">
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="text-emphasis items-center">
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
@@ -24,7 +24,7 @@ export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function JoinCall(props: PageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking, hasTeamPlan } = props;
const { meetingUrl, meetingPassword, booking, hasTeamPlan, calVideoLogo } = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
useEffect(() => {
@@ -103,10 +103,10 @@ export default function JoinCall(props: PageProps) {
<CalAiTranscribe />
</div>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
{calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
src={calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.6.24",
"version": "4.7.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { routingFormsRouter } from "@calcom/trpc/server/routers/viewer/routing-forms/_router";
export default createNextApiHandler(routingFormsRouter);
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import InsightsPage from "~/insights/insights-routing-view";
const Page = () => <InsightsPage />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps };
+13 -9
View File
@@ -209,20 +209,24 @@ test.describe("Insights", async () => {
await page.getByRole("button", { name: "User" }).click();
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
// await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
await page.waitForSelector('[data-testid="people-filter-button"]');
await page.click('[data-testid="people-filter-button"]');
await page
.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
)
.nth(0)
// .locator(
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
// )
.locator('[data-testid="people-filter-group-option-0"]')
// .nth(0)
.click();
await page
.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
)
.nth(1)
// .locator(
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
// )
.locator('[data-testid="people-filter-group-option-1"]')
// .nth(1)
.click();
// press escape button to close the filter
await page.keyboard.press("Escape");
@@ -410,6 +410,8 @@
"back_to_bookings": "العودة إلى عمليات الحجز",
"free_to_pick_another_event_type": "لا تتردد في اختيار حدث آخر في أي وقت.",
"cancelled": "تم الإلغاء",
"no_booking": "لا يوجد حجز",
"awaiting_host": "في انتظار المضيف",
"cancellation_successful": "تم الإلغاء بنجاح",
"really_cancel_booking": "هل تريد حقًا إلغاء الحجز؟",
"cannot_cancel_booking": "لا يمكنك إلغاء هذا الحجز",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "التوجيه",
"routing_forms_description": "أنشئ النماذج لتوجيه الحضور إلى الوجهات الصحيحة",
"routing_forms_send_email_owner": "إرسال رسالة إلكترونية إلى المالك",
"routing_forms_send_email_owner_description": "يرسل رسالة بريد إلكتروني إلى المالك عند إرسال النموذج",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "تم تحديث حدود الحجز بنجاح",
"you_are_unauthorized_to_make_this_change_to_the_booking": "أنت غير مخول لإجراء هذا التغيير على الحجز",
"matching_members": "الأعضاء المطابقون",
"matching_members_queue_using_attribute_weights": "قائمة انتظار مطابقة الأعضاء (باستخدام أوزان السمات)",
"matching_members_queue_using_event_assignee_weights": "قائمة انتظار مطابقة الأعضاء (باستخدام أوزان المسؤولين عن الفعاليات)",
"no_matching_members": "لا يوجد أعضاء مطابقون. سيتم الرجوع إلى استخدام أعضاء الفريق المعينين لنوع الحدث.",
"no_matching_members_will_fallback_to_all_assigned_members": "لا يوجد أعضاء مطابقين. سيتم الرجوع إلى استخدام أعضاء الفريق المعينين لنوع الحدث. فكر في إضافة بديل أو تصحيح منطق استخدام الأعضاء البديلين",
"hide_calendar_event_details": "إخفاء تفاصيل الأحداث في التقويمات المشتركة",
@@ -2722,6 +2727,8 @@
"add_new_field": "إضافة حقل جديد",
"you_dont_have_access_to_reroute_this_booking": "ليس لديك صلاحية لإعادة توجيه هذا الحجز",
"form_response_not_found": "لم يتم العثور على استجابة النموذج",
"contact_owner": "مالك جهة الاتصال",
"contact_owner_not_found": "غير موجود",
"using_fallback_members": "استخدام الأعضاء البديلين",
"chosen_route": "المسار المختار",
"attribute_logic_matched": "تطابق منطق السمات",
@@ -2760,11 +2767,33 @@
"booking_start_date": "تاريخ بدء الحجز",
"booking_created_date": "تاريخ إنشاء الحجز",
"booking_reassigned_to_host": "تم إعادة تعيين الحجز إلى {{host}}",
"no_contact_owner": "لا يوجد مالك لجهة الاتصال",
"routing_forms_created": "نماذج التوجيه التي تم إنشاؤها",
"routing_forms_total_responses": "إجمالي الردود",
"routing_forms_total_responses_without_booking": "إجمالي الردود بدون حجز",
"routing_forms_total_responses_with_booking": "إجمالي الردود مع الحجز",
"routing_form_insights": "لا توجد حجوزات",
"routing_form_insights_form_name_header": "اسم النموذج",
"routing_form_insights_booking_status_header": "الحجز",
"routing_form_insights_response_no_booking": "لا يوجد حجز",
"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)",
"salesforce_route_to_custom_lookup_field": "التوجيه إلى مستخدم يطابق حقل البحث في الحساب",
"salesforce_option": "خيار Salesforce",
"lookup_field_name": "اسم حقل البحث",
"rr_distribution_method": "التوزيع",
"rr_distribution_method_description": "يتيح تحسين التوزيع لتحقيق أقصى قدر من التوفر أو لتحقيق توزيع أكثر توازناً.",
"rr_distribution_method_availability_title": "تعظيم التوفر",
"rr_distribution_method_availability_description": "يسمح للحاجزين بحجز الاجتماعات عندما يكون المضيف متاحاً. استخدم هذا الخيار لزيادة عدد الاجتماعات المحتملة التي يمكن حجزها عندما يكون التوزيع المتساوي للاجتماعات بين المضيفين أقل أهمية.",
"rr_distribution_method_balanced_title": "توازن الأحمال",
"rr_distribution_method_balanced_description": "سنراقب عدد الحجوزات التي تمت مع كل مضيف ونقارنها مع الآخرين، مع تعطيل بعض المضيفين الذين تجاوزوا الحد بكثير لضمان توزيع الحجوزات بشكل متساوٍ.",
"exclude_emails_that_contain": "استبعاد رسائل البريد الإلكتروني التي تحتوي على ...",
"exclude_emails_match_found_error_message": "يرجى إدخال عنوان بريد إلكتروني صالح للعمل",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Rezervasiyalara geri dön",
"free_to_pick_another_event_type": "Başqa bir tədbir növünü seçməkdə sərbəstsiniz.",
"cancelled": "Ləğv edildi",
"no_booking": "Rezervasiya yoxdur",
"awaiting_host": "Ev sahibini gözləyir",
"cancellation_successful": "Ləğv uğurla tamamlandı",
"really_cancel_booking": "Rezervasiyanı həqiqətən ləğv etmək istəyirsiniz?",
"cannot_cancel_booking": "Bu rezervasiyanı ləğv edə bilməzsiniz",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Marşrutlaşdırma",
"routing_forms_description": "İştirakçıları düzgün istiqamətlərə yönləndirmək üçün formalar yaradın",
"routing_forms_send_email_owner": "Sahibə e-poçt göndər",
"routing_forms_send_email_owner_description": "Forma təqdim edildikdə sahibə e-poçt göndərir",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Rezervasiya məhdudiyyətləri uğurla yeniləndi",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu dəyişikliyi etmək üçün səlahiyyətiniz yoxdur",
"matching_members": "Uyğun üzvlər",
"matching_members_queue_using_attribute_weights": "Üzvlərin uyğunlaşdırılması növbəsi (atribut çəkilərindən istifadə edərək)",
"matching_members_queue_using_event_assignee_weights": "Üzvlərin uyğunlaşdırılması növbəsi (tədbir təyin edən çəkilərindən istifadə edərək)",
"no_matching_members": "Uyğun üzvlər yoxdur. Bu halda tədbir növünə təyin edilmiş komanda üzvləri istifadə olunacaq.",
"no_matching_members_will_fallback_to_all_assigned_members": "Uyğun üzvlər tapılmadı. Tədbir növünə təyin edilmiş komanda üzvlərindən istifadə ediləcək. Ehtiyat variant əlavə etməyi və ya using_fallback_members məntiqini düzəltməyi nəzərdən keçirin",
"hide_calendar_event_details": "Təqvim hadisə detallarını gizlət",
@@ -2722,6 +2727,8 @@
"add_new_field": "Yeni sahə əlavə et",
"you_dont_have_access_to_reroute_this_booking": "Bu rezervasiyanı yönləndirmək üçün icazəniz yoxdur",
"form_response_not_found": "Form cavabı tapılmadı",
"contact_owner": "Əlaqə sahibi",
"contact_owner_not_found": "Tapılmadı",
"using_fallback_members": "Ehtiyat üzvlərdən istifadə edilir",
"chosen_route": "Seçilmiş marşrut",
"attribute_logic_matched": "Atribut məntiqi uyğun gəldi",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Rezervasiyanın başlama tarixi",
"booking_created_date": "Rezervasiyanın yaradılma tarixi",
"booking_reassigned_to_host": "Rezervasiya {{host}}-a yenidən təyin edildi",
"no_contact_owner": "Əlaqə sahibi yoxdur",
"routing_forms_created": "Marşrutlaşdırma formaları yaradıldı",
"routing_forms_total_responses": "Ümumi cavablar",
"routing_forms_total_responses_without_booking": "Rezervasiyasız ümumi cavablar",
"routing_forms_total_responses_with_booking": "Rezervasiyalı ümumi cavablar",
"routing_form_insights": "Rezervasiya yoxdur",
"routing_form_insights_form_name_header": "Formanın adı",
"routing_form_insights_booking_status_header": "Rezervasiya",
"routing_form_insights_response_no_booking": "Rezervasiya yoxdur",
"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)",
"salesforce_route_to_custom_lookup_field": "Hesabda axtarış sahəsinə uyğun gələn istifadəçiyə yönləndir",
"salesforce_option": "Salesforce Seçimi",
"lookup_field_name": "Axtarış Sahəsinin Adı",
"rr_distribution_method": "Paylama",
"rr_distribution_method_description": "Maksimum əlçatanlıq üçün paylamanı optimallaşdırmağa və ya daha balanslı bir təyinat üçün çalışmağa imkan verir.",
"rr_distribution_method_availability_title": "Əlçatanlığı maksimuma çatdırmaq",
"rr_distribution_method_availability_description": "Görüş təşkilatçısı mövcud olduqda, görüşlərin bron edilməsinə imkan verir. Bu, potensial görüşlərin sayını maksimuma çatdırmaq və görüşlərin təşkilatçılar arasında bərabər paylanmasının daha az əhəmiyyətli olduğu hallarda istifadə olunur.",
"rr_distribution_method_balanced_title": "Yük balanslaşdırma",
"rr_distribution_method_balanced_description": "Hər bir təşkilatçı ilə neçə bron edildiyini izləyəcəyik və bunu digərləri ilə müqayisə edəcəyik, çox irəlidə olan bəzi təşkilatçıları deaktiv edərək bronların bərabər paylanmasını təmin edəcəyik.",
"exclude_emails_that_contain": "Tərkibində olan e-poçtları istisna et...",
"exclude_emails_match_found_error_message": "Zəhmət olmasa düzgün iş e-poçt ünvanı daxil edin",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Zpět na rezervace",
"free_to_pick_another_event_type": "Kdykoliv si můžete vybrat jinou událost.",
"cancelled": "Zrušeno",
"no_booking": "Žádná rezervace",
"awaiting_host": "Čeká se na hostitele",
"cancellation_successful": "Zrušení proběhlo úspěšně",
"really_cancel_booking": "Opravdu chcete zrušit svou rezervaci?",
"cannot_cancel_booking": "Tuto rezervaci nelze zrušit",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Směrování",
"routing_forms_description": "Vytvořit formuláře pro přesměrování účastníků do správných destinací",
"routing_forms_send_email_owner": "Odeslat e-mail vlastníkovi",
"routing_forms_send_email_owner_description": "Po odeslání formuláře bude vlastníkovi odeslán e-mail",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limity rezervací byly úspěšně aktualizovány",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemáte oprávnění provést tuto změnu v rezervaci",
"matching_members": "Odpovídající členové",
"matching_members_queue_using_attribute_weights": "Fronta přiřazování členů (podle vah atributů)",
"matching_members_queue_using_event_assignee_weights": "Fronta přiřazování členů (podle vah účastníků události)",
"no_matching_members": "Žádní odpovídající členové. Bude použito záložní řešení s členy týmu přiřazenými k typu události.",
"no_matching_members_will_fallback_to_all_assigned_members": "Žádní odpovídající členové. Použijí se všichni členové týmu přiřazení k typu události. Zvažte přidání záložního plánu nebo opravu logiky using_fallback_members",
"hide_calendar_event_details": "Skrýt podrobnosti událostí v kalendáři na sdílených kalendářích",
@@ -2722,6 +2727,8 @@
"add_new_field": "Přidat nové pole",
"you_dont_have_access_to_reroute_this_booking": "Nemáte přístup k přesměrování této rezervace",
"form_response_not_found": "Odpověď formuláře nebyla nalezena",
"contact_owner": "Vlastník kontaktu",
"contact_owner_not_found": "Nenalezeno",
"using_fallback_members": "Používají se záložní členové",
"chosen_route": "Zvolená cesta",
"attribute_logic_matched": "Logika atributů odpovídá",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Datum začátku rezervace",
"booking_created_date": "Datum vytvoření rezervace",
"booking_reassigned_to_host": "Rezervace přeřazena hostiteli {{host}}",
"no_contact_owner": "Bez vlastníka kontaktu",
"routing_forms_created": "Vytvořené směrovací formuláře",
"routing_forms_total_responses": "Celkový počet odpovědí",
"routing_forms_total_responses_without_booking": "Celkový počet odpovědí bez rezervace",
"routing_forms_total_responses_with_booking": "Celkový počet odpovědí s rezervací",
"routing_form_insights": "Žádné rezervace",
"routing_form_insights_form_name_header": "Název formuláře",
"routing_form_insights_booking_status_header": "Rezervace",
"routing_form_insights_response_no_booking": "Bez rezervace",
"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)",
"salesforce_route_to_custom_lookup_field": "Přiřadit uživateli, který odpovídá vyhledávacímu poli na účtu",
"salesforce_option": "Možnost Salesforce",
"lookup_field_name": "Název vyhledávacího pole",
"rr_distribution_method": "Distribuce",
"rr_distribution_method_description": "Umožňuje optimalizovat distribuci pro maximální dostupnost nebo pro vyváženější přidělování.",
"rr_distribution_method_availability_title": "Maximalizovat dostupnost",
"rr_distribution_method_availability_description": "Umožňuje rezervujícím sjednat schůzky kdykoliv, když je hostitel k dispozici. Použijte tuto možnost pro maximalizaci počtu potenciálních schůzek a v případech, kdy rovnoměrné rozdělení schůzek mezi hostitele není tak důležité.",
"rr_distribution_method_balanced_title": "Vyvažování zátěže",
"rr_distribution_method_balanced_description": "Budeme sledovat, kolik rezervací bylo provedeno u jednotlivých hostitelů a porovnávat je s ostatními. Některé hostitele, kteří jsou příliš napřed, dočasně deaktivujeme, aby byly rezervace rovnoměrně distribuovány.",
"exclude_emails_that_contain": "Vyloučit e-maily obsahující...",
"exclude_emails_match_found_error_message": "Zadejte prosím platnou pracovní e-mailovou adresu",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Tilbage til bookinger",
"free_to_pick_another_event_type": "Du er til enhver tid velkommen til at vælge en anden begivenhed.",
"cancelled": "Annulleret",
"no_booking": "Ingen booking",
"awaiting_host": "Venter på vært",
"cancellation_successful": "Annullering lykkedes",
"really_cancel_booking": "Vil du virkelig annullere din booking?",
"cannot_cancel_booking": "Du kan ikke annullere denne booking",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routing",
"routing_forms_description": "Opret formularer for at dirigere deltagere til de korrekte destinationer",
"routing_forms_send_email_owner": "Send e-mail til Ejer",
"routing_forms_send_email_owner_description": "Sender en e-mail til ejeren når formularen er indsendt",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Bookinggrænser opdateret med succes",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tilladelse til at foretage denne ændring i bookingen",
"matching_members": "Matchende medlemmer",
"matching_members_queue_using_attribute_weights": "Matchende medlemskø (bruger attributvægte)",
"matching_members_queue_using_event_assignee_weights": "Matchende medlemskø (bruger begivenhedsansvarlig-vægte)",
"no_matching_members": "Ingen matchende medlemmer. Det vil falde tilbage til at bruge de teammedlemmer, der er tildelt til begivenhedstypen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Ingen matchende medlemmer. Der vil falde tilbage til at bruge de teammedlemmer, der er tildelt begivenhedstypen. Overvej at tilføje en fallback eller korrigere logikken for using_fallback_members",
"hide_calendar_event_details": "Skjul kalenderbegivenhedsdetaljer på delte kalendere",
@@ -2722,6 +2727,8 @@
"add_new_field": "Tilføj nyt felt",
"you_dont_have_access_to_reroute_this_booking": "Du har ikke adgang til at omdirigere denne booking",
"form_response_not_found": "Formularbesvarelse ikke fundet",
"contact_owner": "Kontaktejer",
"contact_owner_not_found": "Ikke fundet",
"using_fallback_members": "Bruger fallback-medlemmer",
"chosen_route": "Valgt rute",
"attribute_logic_matched": "Attributlogik matchet",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Bookings startdato",
"booking_created_date": "Dato for oprettelse af booking",
"booking_reassigned_to_host": "Booking omfordelt til {{host}}",
"no_contact_owner": "Ingen kontaktejer",
"routing_forms_created": "Oprettede routingformularer",
"routing_forms_total_responses": "Samlede besvarelser",
"routing_forms_total_responses_without_booking": "Samlede besvarelser uden booking",
"routing_forms_total_responses_with_booking": "Samlede besvarelser med booking",
"routing_form_insights": "Ingen bookinger",
"routing_form_insights_form_name_header": "Formularnavn",
"routing_form_insights_booking_status_header": "Booking",
"routing_form_insights_response_no_booking": "Ingen booking",
"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)",
"salesforce_route_to_custom_lookup_field": "Rute til en bruger, der matcher et opslagfelt på en konto",
"salesforce_option": "Salesforce-indstilling",
"lookup_field_name": "Opslagsfeltnavn",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Gør det muligt at optimere distributionen for maksimal tilgængelighed eller for at opnå en mere balanceret tildeling.",
"rr_distribution_method_availability_title": "Maksimer tilgængelighed",
"rr_distribution_method_availability_description": "Giver bookere mulighed for at booke møder, når en vært er tilgængelig. Brug dette for at maksimere antallet af potentielle møder, der kan bookes, når den jævne fordeling af møder på tværs af værter er mindre vigtig.",
"rr_distribution_method_balanced_title": "Load balancing",
"rr_distribution_method_balanced_description": "Vi vil overvåge, hvor mange bookinger der er foretaget med hver vært og sammenligne dette med andre, og deaktivere nogle værter, der er for langt foran, så bookinger fordeles jævnt.",
"exclude_emails_that_contain": "Ekskluder e-mails, der indeholder ...",
"exclude_emails_match_found_error_message": "Indtast venligst en gyldig arbejds-e-mailadresse",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Zurück zu den Buchungen",
"free_to_pick_another_event_type": "Sie können jederzeit einen anderen Termin wählen.",
"cancelled": "Abgesagt",
"no_booking": "Keine Buchung",
"awaiting_host": "Warten auf Gastgeber",
"cancellation_successful": "Stornierung erfolgreich",
"really_cancel_booking": "Möchten Sie wirklich stornieren?",
"cannot_cancel_booking": "Sie können diese Buchung nicht stornieren",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Weiterleitung",
"routing_forms_description": "Erstellen Sie Formulare , um Teilnehmer zu den richtigen Stellen weiterzuleiten",
"routing_forms_send_email_owner": "E-Mail an Eigentümer senden",
"routing_forms_send_email_owner_description": "Sendet eine E-Mail an den Eigentümer, wenn das Formular abgeschickt wird",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Buchungslimits erfolgreich aktualisiert",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Sie sind nicht berechtigt, diese Änderung an der Buchung vorzunehmen",
"matching_members": "Passende Mitglieder",
"matching_members_queue_using_attribute_weights": "Warteschlange für passende Mitglieder (mit Attributgewichtung)",
"matching_members_queue_using_event_assignee_weights": "Warteschlange für passende Mitglieder (mit Gewichtung der Terminverantwortlichen)",
"no_matching_members": "Keine passenden Mitglieder gefunden. Es wird auf die dem Ereignistyp zugewiesenen Teammitglieder zurückgegriffen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Keine passenden Mitglieder gefunden. Es wird auf alle dem Ereignistyp zugewiesenen Teammitglieder zurückgegriffen. Erwägen Sie, eine Fallback-Option hinzuzufügen oder die Logik für using_fallback_members zu korrigieren",
"hide_calendar_event_details": "Kalenderereignisdetails in geteilten Kalendern verbergen",
@@ -2722,6 +2727,8 @@
"add_new_field": "Neues Feld hinzufügen",
"you_dont_have_access_to_reroute_this_booking": "Sie haben keine Berechtigung, diese Buchung umzuleiten",
"form_response_not_found": "Formularantwort nicht gefunden",
"contact_owner": "Kontaktinhaber",
"contact_owner_not_found": "Nicht gefunden",
"using_fallback_members": "Verwendung von Fallback-Mitgliedern",
"chosen_route": "Gewählte Route",
"attribute_logic_matched": "Attributlogik zutreffend",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Buchungs-Startdatum",
"booking_created_date": "Buchungs-Erstellungsdatum",
"booking_reassigned_to_host": "Buchung an {{host}} neu zugewiesen",
"no_contact_owner": "Kein Kontaktinhaber",
"routing_forms_created": "Erstellte Weiterleitungsformulare",
"routing_forms_total_responses": "Gesamtantworten",
"routing_forms_total_responses_without_booking": "Gesamtantworten ohne Buchung",
"routing_forms_total_responses_with_booking": "Gesamtantworten mit Buchung",
"routing_form_insights": "Keine Buchungen",
"routing_form_insights_form_name_header": "Formularname",
"routing_form_insights_booking_status_header": "Buchung",
"routing_form_insights_response_no_booking": "Keine Buchung",
"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)",
"salesforce_route_to_custom_lookup_field": "Weiterleitung an einen Benutzer, der mit einem Lookup-Feld eines Accounts übereinstimmt",
"salesforce_option": "Salesforce-Option",
"lookup_field_name": "Name des Lookup-Felds",
"rr_distribution_method": "Verteilung",
"rr_distribution_method_description": "Ermöglicht die Optimierung der Verteilung für maximale Verfügbarkeit oder eine ausgewogenere Zuweisung.",
"rr_distribution_method_availability_title": "Verfügbarkeit maximieren",
"rr_distribution_method_availability_description": "Ermöglicht Buchenden, Meetings zu buchen, wann immer ein Host verfügbar ist. Nutzen Sie dies, um die Anzahl potenzieller Meetings zu maximieren, wenn die gleichmäßige Verteilung der Meetings auf die Hosts weniger wichtig ist.",
"rr_distribution_method_balanced_title": "Lastverteilung",
"rr_distribution_method_balanced_description": "Wir überwachen, wie viele Buchungen bei jedem Host eingegangen sind und vergleichen dies mit anderen. Hosts, die zu weit voraus sind, werden temporär deaktiviert, damit die Buchungen gleichmäßig verteilt werden.",
"exclude_emails_that_contain": "E-Mails ausschließen, die ... enthalten",
"exclude_emails_match_found_error_message": "Bitte geben Sie eine gültige Geschäfts-E-Mail-Adresse ein",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Back to bookings",
"free_to_pick_another_event_type": "Feel free to pick another event anytime.",
"cancelled": "Canceled",
"no_booking": "No booking",
"awaiting_host": "Awaiting host",
"cancellation_successful": "Cancellation successful",
"really_cancel_booking": "Really cancel your booking?",
"cannot_cancel_booking": "You cannot cancel this booking",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routing",
"routing_forms_description": "Create forms to direct attendees to the correct destinations",
"routing_forms_send_email_owner": "Send Email to Owner",
"routing_forms_send_email_owner_description": "Sends an email to the owner when the form is submitted",
@@ -2677,6 +2680,8 @@
"booking_limits_updated_successfully": "Booking limits updated successfully",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"matching_members": "Matching members",
"matching_members_queue_using_attribute_weights": "Matching members queue (using attribute weights)",
"matching_members_queue_using_event_assignee_weights": "Matching members queue (using event assignee weights)",
"no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.",
"no_matching_members_will_fallback_to_all_assigned_members": "No matching members. It will fallback to using the team members assigned to the event type. Consider adding a fallback or correcting the logic of using_fallback_members",
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
@@ -2726,6 +2731,8 @@
"add_new_field": "Add new field",
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
"form_response_not_found": "Form response not found",
"contact_owner": "Contact owner",
"contact_owner_not_found": "Not found",
"using_fallback_members": "Using fallback members",
"chosen_route": "Chosen Route",
"attribute_logic_matched": "Attribute logic matched",
@@ -2764,11 +2771,33 @@
"booking_start_date": "Booking start date",
"booking_created_date": "Booking created date",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"no_contact_owner": "No contact owner",
"routing_forms_created": "Routing Forms Created",
"routing_forms_total_responses": "Total Responses",
"routing_forms_total_responses_without_booking": "Total Responses Without Booking",
"routing_forms_total_responses_with_booking": "Total Responses With Booking",
"routing_form_insights": "No Bookings",
"routing_form_insights_form_name_header": "Form Name",
"routing_form_insights_booking_status_header": "Booking",
"routing_form_insights_response_no_booking": "No Booking",
"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)",
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
"lookup_field_name": "Lookup Field Name",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"rr_distribution_method_availability_title": "Maximize availability",
"rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.",
"rr_distribution_method_balanced_title": "Load balancing",
"rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.",
"exclude_emails_that_contain" : "Exclude emails that contain ...",
"exclude_emails_match_found_error_message" : "Please enter a valid work email address",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Volver a las reservas",
"free_to_pick_another_event_type": "Siéntete libre de elegir otro evento en cualquier momento.",
"cancelled": "Cancelado",
"no_booking": "Sin reserva",
"awaiting_host": "Esperando al anfitrión",
"cancellation_successful": "Cancelación exitosa",
"really_cancel_booking": "¿Realmente quieres cancelar tu reserva?",
"cannot_cancel_booking": "No puedes cancelar esta reserva",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Enrutamiento",
"routing_forms_description": "Cree formularios para dirigir a los asistentes a los destinos correctos",
"routing_forms_send_email_owner": "Enviar correo electrónico al propietario",
"routing_forms_send_email_owner_description": "Envía un correo electrónico al propietario cuando se envía el formulario",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
"matching_members": "Miembros coincidentes",
"matching_members_queue_using_attribute_weights": "Cola de miembros coincidentes (usando pesos de atributos)",
"matching_members_queue_using_event_assignee_weights": "Cola de miembros coincidentes (usando pesos de asignados al evento)",
"no_matching_members": "No hay miembros coincidentes. Se utilizarán los miembros del equipo asignados al tipo de evento como alternativa.",
"no_matching_members_will_fallback_to_all_assigned_members": "No hay miembros que coincidan. Se utilizarán los miembros del equipo asignados al tipo de evento como respaldo. Considera agregar un respaldo o corregir la lógica de using_fallback_members",
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
@@ -2722,6 +2727,8 @@
"add_new_field": "Agregar nuevo campo",
"you_dont_have_access_to_reroute_this_booking": "No tienes acceso para redirigir esta reserva",
"form_response_not_found": "Respuesta del formulario no encontrada",
"contact_owner": "Propietario del contacto",
"contact_owner_not_found": "No encontrado",
"using_fallback_members": "Usando miembros de respaldo",
"chosen_route": "Ruta elegida",
"attribute_logic_matched": "Lógica de atributos coincidente",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Fecha de inicio de la reserva",
"booking_created_date": "Fecha de creación de la reserva",
"booking_reassigned_to_host": "Reserva reasignada a {{host}}",
"no_contact_owner": "Sin propietario del contacto",
"routing_forms_created": "Formularios de enrutamiento creados",
"routing_forms_total_responses": "Respuestas totales",
"routing_forms_total_responses_without_booking": "Respuestas totales sin reserva",
"routing_forms_total_responses_with_booking": "Respuestas totales con reserva",
"routing_form_insights": "Sin reservas",
"routing_form_insights_form_name_header": "Nombre del formulario",
"routing_form_insights_booking_status_header": "Reserva",
"routing_form_insights_response_no_booking": "Sin reserva",
"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)",
"salesforce_route_to_custom_lookup_field": "Dirigir a un usuario que coincida con un campo de búsqueda en una cuenta",
"salesforce_option": "Opción de Salesforce",
"lookup_field_name": "Nombre del campo de búsqueda",
"rr_distribution_method": "Distribución",
"rr_distribution_method_description": "Permite optimizar la distribución para maximizar la disponibilidad o lograr una asignación más equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidad",
"rr_distribution_method_availability_description": "Permite a los usuarios agendar reuniones cuando un anfitrión esté disponible. Usa esta opción para maximizar el número de reuniones potenciales y cuando la distribución equitativa entre anfitriones es menos importante.",
"rr_distribution_method_balanced_title": "Equilibrio de carga",
"rr_distribution_method_balanced_description": "Monitoreamos cuántas reservas se han realizado con cada anfitrión y las comparamos con otros, deshabilitando algunos anfitriones que están muy adelantados para que las reservas se distribuyan de manera uniforme.",
"exclude_emails_that_contain": "Excluir correos electrónicos que contengan ...",
"exclude_emails_match_found_error_message": "Por favor, ingresa una dirección de correo electrónico laboral válida",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Volver a Reservas",
"free_to_pick_another_event_type": "No dude en elegir otro evento en cualquier momento.",
"cancelled": "Cancelado",
"no_booking": "Sin reserva",
"awaiting_host": "Esperando al anfitrión",
"cancellation_successful": "Cancelación Realizada",
"really_cancel_booking": "¿Realmente deseas cancelar tu reserva?",
"cannot_cancel_booking": "No puedes cancelar esta reserva",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Enrutamiento",
"routing_forms_description": "Crea formularios para dirigir a los asistentes a sus destinos correctos",
"routing_forms_send_email_owner": "Enviar correo electrónico al propietario",
"routing_forms_send_email_owner_description": "Mande un correo electrónico al propietario cuando se envía el formulario",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
"matching_members": "Miembros coincidentes",
"matching_members_queue_using_attribute_weights": "Cola de miembros coincidentes (usando pesos de atributos)",
"matching_members_queue_using_event_assignee_weights": "Cola de miembros coincidentes (usando pesos de asignados al evento)",
"no_matching_members": "No hay miembros coincidentes. Se utilizarán los miembros del equipo asignados al tipo de evento como alternativa.",
"no_matching_members_will_fallback_to_all_assigned_members": "No hay miembros coincidentes. Se utilizarán los miembros del equipo asignados al tipo de evento como alternativa. Considera añadir una opción de respaldo o corregir la lógica de using_fallback_members",
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
@@ -2722,6 +2727,8 @@
"add_new_field": "Añadir nuevo campo",
"you_dont_have_access_to_reroute_this_booking": "No tienes acceso para redirigir esta reserva",
"form_response_not_found": "Respuesta del formulario no encontrada",
"contact_owner": "Propietario del contacto",
"contact_owner_not_found": "No encontrado",
"using_fallback_members": "Usando miembros de respaldo",
"chosen_route": "Ruta elegida",
"attribute_logic_matched": "Lógica de atributos coincidente",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Fecha de inicio de la reserva",
"booking_created_date": "Fecha de creación de la reserva",
"booking_reassigned_to_host": "Reserva reasignada a {{host}}",
"no_contact_owner": "Sin propietario del contacto",
"routing_forms_created": "Formularios de enrutamiento creados",
"routing_forms_total_responses": "Total de respuestas",
"routing_forms_total_responses_without_booking": "Total de respuestas sin reserva",
"routing_forms_total_responses_with_booking": "Total de respuestas con reserva",
"routing_form_insights": "Sin reservas",
"routing_form_insights_form_name_header": "Nombre del formulario",
"routing_form_insights_booking_status_header": "Reserva",
"routing_form_insights_response_no_booking": "Sin reserva",
"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)",
"salesforce_route_to_custom_lookup_field": "Dirigir a un usuario que coincida con un campo de búsqueda en una cuenta",
"salesforce_option": "Opción de Salesforce",
"lookup_field_name": "Nombre del campo de búsqueda",
"rr_distribution_method": "Distribución",
"rr_distribution_method_description": "Permite optimizar la distribución para maximizar la disponibilidad o lograr una asignación más equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidad",
"rr_distribution_method_availability_description": "Permite a los usuarios reservar reuniones cuando un anfitrión esté disponible. Utiliza esta opción para maximizar el número de reuniones potenciales y cuando la distribución equitativa entre anfitriones sea menos importante.",
"rr_distribution_method_balanced_title": "Equilibrio de carga",
"rr_distribution_method_balanced_description": "Monitorizaremos cuántas reservas se han realizado con cada anfitrión y las compararemos con otros, deshabilitando algunos anfitriones que estén muy por delante para que las reservas se distribuyan uniformemente.",
"exclude_emails_that_contain": "Excluir correos que contengan...",
"exclude_emails_match_found_error_message": "Por favor, introduce una dirección de correo electrónico laboral válida",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Tagasi broneeringute juurde",
"free_to_pick_another_event_type": "Valige igal ajal mõni muu sündmus.",
"cancelled": "Tühistatud",
"no_booking": "Broneering puudub",
"awaiting_host": "Ootab võõrustajat",
"cancellation_successful": "Tühistamine õnnestus",
"really_cancel_booking": "Kas tõesti tühistada oma broneering?",
"cannot_cancel_booking": "Sa ei saa seda broneeringut tühistada",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Marsruutimine",
"routing_forms_description": "Loo vormid osalejate suunamiseks õigetesse sihtkohtadesse",
"routing_forms_send_email_owner": "Saada e-kiri omanikule",
"routing_forms_send_email_owner_description": "Saadab vormi esitamisel omanikule meili",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Broneerimispiirangud on edukalt uuendatud",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Teil puuduvad õigused selle broneeringu muutmiseks",
"matching_members": "Sobivad liikmed",
"matching_members_queue_using_attribute_weights": "Liikmete järjekord vastavalt atribuudi kaaludele",
"matching_members_queue_using_event_assignee_weights": "Liikmete järjekord vastavalt sündmuse määratud isiku kaaludele",
"no_matching_members": "Sobivaid liikmeid ei leitud. Kasutatakse sündmuse tüübile määratud meeskonnaliikmeid.",
"no_matching_members_will_fallback_to_all_assigned_members": "Sobivaid liikmeid ei leitud. Kasutatakse sündmuse tüübile määratud meeskonnaliikmeid. Kaaluge varuvariant lisada või parandada using_fallback_members loogikat",
"hide_calendar_event_details": "Peida kalendrisündmuste üksikasjad jagatud kalendrites",
@@ -2722,6 +2727,8 @@
"add_new_field": "Lisa uus väli",
"you_dont_have_access_to_reroute_this_booking": "Sul pole õigust seda broneeringut ümber suunata",
"form_response_not_found": "Vormi vastust ei leitud",
"contact_owner": "Kontakti omanik",
"contact_owner_not_found": "Ei leitud",
"using_fallback_members": "Kasutatakse varuliikmeid",
"chosen_route": "Valitud tee",
"attribute_logic_matched": "Atribuudi loogika sobis",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Broneeringu alguskuupäev",
"booking_created_date": "Broneeringu loomise kuupäev",
"booking_reassigned_to_host": "Broneering määrati ümber {{host}}-le",
"no_contact_owner": "Kontakti omanik puudub",
"routing_forms_created": "Marsruutimisvormid loodud",
"routing_forms_total_responses": "Kokku vastuseid",
"routing_forms_total_responses_without_booking": "Vastused ilma broneeringuta",
"routing_forms_total_responses_with_booking": "Vastused broneeringuga",
"routing_form_insights": "Broneeringud puuduvad",
"routing_form_insights_form_name_header": "Vormi nimi",
"routing_form_insights_booking_status_header": "Broneering",
"routing_form_insights_response_no_booking": "Broneering puudub",
"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)",
"salesforce_route_to_custom_lookup_field": "Suuna kasutajale, kes vastab konto otsinguväljale",
"salesforce_option": "Salesforce'i valik",
"lookup_field_name": "Otsinguvälja nimi",
"rr_distribution_method": "Jaotamine",
"rr_distribution_method_description": "Võimaldab optimeerida jaotust maksimaalseks kättesaadavuseks või tasakaalustatumaks määramiseks.",
"rr_distribution_method_availability_title": "Maksimeeri kättesaadavus",
"rr_distribution_method_availability_description": "Võimaldab broneerijatel broneerida kohtumisi alati, kui võõrustaja on saadaval. Kasutage seda, et maksimeerida potentsiaalsete broneeritud kohtumiste arvu, kui kohtumiste ühtlane jaotamine võõrustajate vahel on vähem oluline.",
"rr_distribution_method_balanced_title": "Koormuse tasakaalustamine",
"rr_distribution_method_balanced_description": "Jälgime, kui palju broneeringuid on tehtud iga võõrustajaga ja võrdleme seda teistega, keelates mõned võõrustajad, kes on liiga kaugel ees, et broneeringud oleksid ühtlaselt jaotatud.",
"exclude_emails_that_contain": "Välista e-kirjad, mis sisaldavad ...",
"exclude_emails_match_found_error_message": "Palun sisestage kehtiv tööalane e-posti aadress",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Retour aux réservations",
"free_to_pick_another_event_type": "N'hésitez pas à choisir un autre événement à tout moment.",
"cancelled": "Annulées",
"no_booking": "Aucune réservation",
"awaiting_host": "En attente de l'hôte",
"cancellation_successful": "Annulation réussie",
"really_cancel_booking": "Voulez-vous vraiment annuler votre réservation ?",
"cannot_cancel_booking": "Vous ne pouvez pas annuler cette réservation",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routage",
"routing_forms_description": "Créez des formulaires pour acheminer les participants vers les bonnes destinations.",
"routing_forms_send_email_owner": "Envoyer un e-mail au propriétaire",
"routing_forms_send_email_owner_description": "Envoie un e-mail au propriétaire lorsque le formulaire est soumis",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limites de réservation mises à jour avec succès",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Vous n'êtes pas autorisé à apporter cette modification à la réservation",
"matching_members": "Membres correspondants",
"matching_members_queue_using_attribute_weights": "File d'attente des membres correspondants (selon les pondérations d'attributs)",
"matching_members_queue_using_event_assignee_weights": "File d'attente des membres correspondants (selon les pondérations des assignés à l'événement)",
"no_matching_members": "Aucun membre correspondant. Par défaut, les membres de l'équipe assignés au type d'événement seront utilisés.",
"no_matching_members_will_fallback_to_all_assigned_members": "Aucun membre correspondant. Le système utilisera par défaut les membres de l'équipe assignés au type d'événement. Envisagez d'ajouter une solution de repli ou de corriger la logique d'utilisation des membres de repli",
"hide_calendar_event_details": "Masquer les détails des événements du calendrier sur les calendriers partagés",
@@ -2722,6 +2727,8 @@
"add_new_field": "Ajouter un nouveau champ",
"you_dont_have_access_to_reroute_this_booking": "Vous n'avez pas accès pour rediriger cette réservation",
"form_response_not_found": "Réponse du formulaire non trouvée",
"contact_owner": "Propriétaire du contact",
"contact_owner_not_found": "Non trouvé",
"using_fallback_members": "Utilisation des membres de repli",
"chosen_route": "Itinéraire choisi",
"attribute_logic_matched": "Logique d'attribut correspondante",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Date de début de la réservation",
"booking_created_date": "Date de création de la réservation",
"booking_reassigned_to_host": "Réservation réassignée à {{host}}",
"no_contact_owner": "Aucun propriétaire de contact",
"routing_forms_created": "Formulaires de routage créés",
"routing_forms_total_responses": "Réponses totales",
"routing_forms_total_responses_without_booking": "Réponses totales sans réservation",
"routing_forms_total_responses_with_booking": "Réponses totales avec réservation",
"routing_form_insights": "Aucune réservation",
"routing_form_insights_form_name_header": "Nom du formulaire",
"routing_form_insights_booking_status_header": "Réservation",
"routing_form_insights_response_no_booking": "Aucune réservation",
"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)",
"salesforce_route_to_custom_lookup_field": "Acheminer vers un utilisateur correspondant à un champ de recherche sur un compte",
"salesforce_option": "Option Salesforce",
"lookup_field_name": "Nom du champ de recherche",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Permet d'optimiser la distribution pour une disponibilité maximale ou pour viser une répartition plus équilibrée.",
"rr_distribution_method_availability_title": "Maximiser la disponibilité",
"rr_distribution_method_availability_description": "Permet aux participants de réserver des réunions dès qu'un hôte est disponible. Utilisez cette option pour maximiser le nombre potentiel de réunions réservées lorsque la distribution équitable des réunions entre les hôtes est moins importante.",
"rr_distribution_method_balanced_title": "Équilibrage de charge",
"rr_distribution_method_balanced_description": "Nous surveillerons le nombre de réservations effectuées avec chaque hôte et le comparerons avec les autres, en désactivant certains hôtes trop en avance pour que les réservations soient réparties équitablement.",
"exclude_emails_that_contain": "Exclure les e-mails contenant ...",
"exclude_emails_match_found_error_message": "Veuillez entrer une adresse e-mail professionnelle valide",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "בחזרה להזמנות",
"free_to_pick_another_event_type": "ניתן לבחור אירוע אחר בכל עת.",
"cancelled": "בוטלה",
"no_booking": "אין הזמנה",
"awaiting_host": "ממתין למארח",
"cancellation_successful": "פעולת הביטול בוצעה בהצלחה",
"really_cancel_booking": "בטוח שברצונך לבטל את ההזמנה?",
"cannot_cancel_booking": "לא ניתן לבטל את ההזמנה הזו",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "‎2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "ניתוב",
"routing_forms_description": "צור טפסים להפניית משתתפים ליעדים הנכונים",
"routing_forms_send_email_owner": "שליחת דוא\"ל לבעלים",
"routing_forms_send_email_owner_description": "דוא\"ל נשלח לבעלים לאחר שליחת הטופס",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "מגבלות ההזמנה עודכנו בהצלחה",
"you_are_unauthorized_to_make_this_change_to_the_booking": "אין לך הרשאה לבצע שינוי זה בהזמנה",
"matching_members": "חברים תואמים",
"matching_members_queue_using_attribute_weights": "תור התאמת חברים (באמצעות משקלי מאפיינים)",
"matching_members_queue_using_event_assignee_weights": "תור התאמת חברים (באמצעות משקלי מוקצים לאירוע)",
"no_matching_members": "אין חברים תואמים. המערכת תשתמש בחברי הצוות שהוקצו לסוג האירוע.",
"no_matching_members_will_fallback_to_all_assigned_members": "אין חברי צוות תואמים. יחזור לשימוש בחברי הצוות המשויכים לסוג האירוע. שקול להוסיף גיבוי או לתקן את הלוגיקה של using_fallback_members",
"hide_calendar_event_details": "הסתר פרטי אירועי לוח שנה בלוחות שנה משותפים",
@@ -2722,6 +2727,8 @@
"add_new_field": "הוסף שדה חדש",
"you_dont_have_access_to_reroute_this_booking": "אין לך גישה לניתוב מחדש של הזמנה זו",
"form_response_not_found": "תגובת הטופס לא נמצאה",
"contact_owner": "בעל הקשר",
"contact_owner_not_found": "לא נמצא",
"using_fallback_members": "משתמש בחברי גיבוי",
"chosen_route": "מסלול נבחר",
"attribute_logic_matched": "לוגיקת המאפיינים תואמת",
@@ -2760,11 +2767,33 @@
"booking_start_date": "תאריך תחילת ההזמנה",
"booking_created_date": "תאריך יצירת ההזמנה",
"booking_reassigned_to_host": "ההזמנה הועברה למארח {{host}}",
"no_contact_owner": "אין בעל קשר",
"routing_forms_created": "טפסי ניתוב נוצרו",
"routing_forms_total_responses": "סך כל התגובות",
"routing_forms_total_responses_without_booking": "סך כל התגובות ללא הזמנה",
"routing_forms_total_responses_with_booking": "סך כל התגובות עם הזמנה",
"routing_form_insights": "אין הזמנות",
"routing_form_insights_form_name_header": "שם הטופס",
"routing_form_insights_booking_status_header": "סטטוס הזמנה",
"routing_form_insights_response_no_booking": "אין הזמנה",
"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)",
"salesforce_route_to_custom_lookup_field": "נתב למשתמש התואם לשדה חיפוש בחשבון",
"salesforce_option": "אפשרות Salesforce",
"lookup_field_name": "שם שדה החיפוש",
"rr_distribution_method": "שיטת חלוקה",
"rr_distribution_method_description": "מאפשר אופטימיזציה של החלוקה למקסימום זמינות או לחלוקה מאוזנת יותר",
"rr_distribution_method_availability_title": "מקסום זמינות",
"rr_distribution_method_availability_description": "מאפשר לקובעי פגישות לקבוע פגישות בכל פעם שמארח זמין. השתמשו באפשרות זו כדי למקסם את מספר הפגישות הפוטנציאליות ובמקרים בהם חלוקה שווה של פגישות בין המארחים פחות חשובה",
"rr_distribution_method_balanced_title": "איזון עומסים",
"rr_distribution_method_balanced_description": "אנחנו נעקוב אחר מספר ההזמנות שנקבעו עם כל מארח ונשווה זאת לאחרים, תוך השבתה זמנית של מארחים שקיבלו יותר מדי הזמנות כדי להבטיח חלוקה שווה",
"exclude_emails_that_contain": "הוצא כתובות דוא\"ל המכילות...",
"exclude_emails_match_found_error_message": "אנא הזן כתובת דוא\"ל עבודה תקינה",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Vissza a foglalásokhoz",
"free_to_pick_another_event_type": "Nyugodtan válasszon másik eseményt bármikor.",
"cancelled": "Lemondva",
"no_booking": "Nincs foglalás",
"awaiting_host": "Várakozás a házigazdára",
"cancellation_successful": "Sikeres lemondás",
"really_cancel_booking": "Biztosan lemondod a foglalásod?",
"cannot_cancel_booking": "Ezt a foglalást nem tudod lemondani",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Útvonal",
"routing_forms_description": "Hozzon létre űrlapokat, amelyek a résztvevőket a megfelelő helyre irányítják",
"routing_forms_send_email_owner": "E-mail küldése a tulajdonosnak",
"routing_forms_send_email_owner_description": "Az űrlap elküldésekor e-mailt küld a tulajdonosnak",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "A foglalási korlátok sikeresen frissítve",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nincs jogosultsága a foglalás módosításához",
"matching_members": "Megfelelő tagok",
"matching_members_queue_using_attribute_weights": "Tagok sorba állítása (attribútum súlyozás alapján)",
"matching_members_queue_using_event_assignee_weights": "Tagok sorba állítása (esemény felelős súlyozás alapján)",
"no_matching_members": "Nincsenek megfelelő tagok. Az eseménytípushoz rendelt csapattagok kerülnek felhasználásra.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nincs megfelelő tag. Visszaesik az eseménytípushoz rendelt csapattagok használatára. Fontolja meg egy tartalék hozzáadását vagy a using_fallback_members logika javítását",
"hide_calendar_event_details": "Naptáresemény részleteinek elrejtése megosztott naptárakon",
@@ -2722,6 +2727,8 @@
"add_new_field": "Új mező hozzáadása",
"you_dont_have_access_to_reroute_this_booking": "Nincs jogosultságod átirányítani ezt a foglalást",
"form_response_not_found": "Űrlap válasz nem található",
"contact_owner": "Kapcsolattartó",
"contact_owner_not_found": "Nem található",
"using_fallback_members": "Tartalék tagok használata",
"chosen_route": "Választott útvonal",
"attribute_logic_matched": "Attribútum logika egyezik",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Foglalás kezdő dátuma",
"booking_created_date": "Foglalás létrehozásának dátuma",
"booking_reassigned_to_host": "Foglalás átirányítva a következő házigazdához: {{host}}",
"no_contact_owner": "Nincs kapcsolattartó",
"routing_forms_created": "Létrehozott útvonalformák",
"routing_forms_total_responses": "Összes válasz",
"routing_forms_total_responses_without_booking": "Összes válasz foglalás nélkül",
"routing_forms_total_responses_with_booking": "Összes válasz foglalással",
"routing_form_insights": "Nincs foglalás",
"routing_form_insights_form_name_header": "Űrlap neve",
"routing_form_insights_booking_status_header": "Foglalás",
"routing_form_insights_response_no_booking": "Nincs foglalás",
"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)",
"salesforce_route_to_custom_lookup_field": "Átirányítás olyan felhasználóhoz, aki megfelel a fiók keresési mezőjének",
"salesforce_option": "Salesforce beállítás",
"lookup_field_name": "Keresési mező neve",
"rr_distribution_method": "Elosztás",
"rr_distribution_method_description": "Lehetővé teszi az elosztás optimalizálását a maximális rendelkezésre állás érdekében, vagy a kiegyensúlyozottabb feladatkiosztás eléréséhez.",
"rr_distribution_method_availability_title": "Rendelkezésre állás maximalizálása",
"rr_distribution_method_availability_description": "Lehetővé teszi a foglalóknak, hogy találkozókat foglaljanak, amikor a házigazda elérhető. Használja ezt a lehetőséget a potenciális találkozók számának maximalizálására, amikor a találkozók egyenletes elosztása a házigazdák között kevésbé fontos.",
"rr_distribution_method_balanced_title": "Terheléselosztás",
"rr_distribution_method_balanced_description": "Figyelemmel kísérjük, hogy hány foglalás történt az egyes házigazdákkal, és összehasonlítjuk másokkal, letiltva azokat a házigazdákat, akik túl sok foglalással rendelkeznek, így a foglalások egyenletesen oszlanak el.",
"exclude_emails_that_contain": "Zárja ki azokat az e-maileket, amelyek tartalmazzák ...",
"exclude_emails_match_found_error_message": "Kérjük, adjon meg egy érvényes munkahelyi e-mail címet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Torna alle prenotazioni",
"free_to_pick_another_event_type": "Sentiti libero di scegliere un altro evento in qualsiasi momento.",
"cancelled": "Cancellato",
"no_booking": "Nessuna prenotazione",
"awaiting_host": "In attesa dell'host",
"cancellation_successful": "Cancellazione riuscita",
"really_cancel_booking": "Davvero vuoi annullare la prenotazione?",
"cannot_cancel_booking": "Non puoi annullare questa prenotazione",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Instradamento",
"routing_forms_description": "Crea moduli per indirizzare i partecipanti alle destinazioni corrette",
"routing_forms_send_email_owner": "Invia e-mail al proprietario",
"routing_forms_send_email_owner_description": "Invia un'e-mail al proprietario quando il modulo viene inviato",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limiti di prenotazione aggiornati con successo",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Non sei autorizzato a effettuare questa modifica alla prenotazione",
"matching_members": "Membri corrispondenti",
"matching_members_queue_using_attribute_weights": "Coda di membri corrispondenti (utilizzando i pesi degli attributi)",
"matching_members_queue_using_event_assignee_weights": "Coda di membri corrispondenti (utilizzando i pesi degli assegnatari dell'evento)",
"no_matching_members": "Nessun membro corrispondente. Verrà utilizzato il fallback con i membri del team assegnati al tipo di evento.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nessun membro corrispondente. Si utilizzeranno i membri del team assegnati al tipo di evento come fallback. Considera di aggiungere un fallback o correggere la logica di using_fallback_members",
"hide_calendar_event_details": "Nascondi i dettagli degli eventi del calendario nei calendari condivisi",
@@ -2722,6 +2727,8 @@
"add_new_field": "Aggiungi nuovo campo",
"you_dont_have_access_to_reroute_this_booking": "Non hai l'accesso per reindirizzare questa prenotazione",
"form_response_not_found": "Risposta del modulo non trovata",
"contact_owner": "Proprietario del contatto",
"contact_owner_not_found": "Non trovato",
"using_fallback_members": "Utilizzo dei membri di fallback",
"chosen_route": "Percorso scelto",
"attribute_logic_matched": "Logica degli attributi corrispondente",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Data di inizio prenotazione",
"booking_created_date": "Data di creazione della prenotazione",
"booking_reassigned_to_host": "Prenotazione riassegnata a {{host}}",
"no_contact_owner": "Nessun proprietario del contatto",
"routing_forms_created": "Moduli di instradamento creati",
"routing_forms_total_responses": "Risposte totali",
"routing_forms_total_responses_without_booking": "Risposte totali senza prenotazione",
"routing_forms_total_responses_with_booking": "Risposte totali con prenotazione",
"routing_form_insights": "Nessuna prenotazione",
"routing_form_insights_form_name_header": "Nome del modulo",
"routing_form_insights_booking_status_header": "Prenotazione",
"routing_form_insights_response_no_booking": "Nessuna prenotazione",
"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)",
"salesforce_route_to_custom_lookup_field": "Instrada a un utente che corrisponde a un campo di ricerca su un account",
"salesforce_option": "Opzione Salesforce",
"lookup_field_name": "Nome del campo di ricerca",
"rr_distribution_method": "Distribuzione",
"rr_distribution_method_description": "Permette di ottimizzare la distribuzione per massimizzare la disponibilità o per ottenere un'assegnazione più bilanciata.",
"rr_distribution_method_availability_title": "Massimizza disponibilità",
"rr_distribution_method_availability_description": "Permette ai prenotanti di fissare incontri ogni volta che un host è disponibile. Usa questa opzione per massimizzare il numero di potenziali incontri prenotabili e quando la distribuzione uniforme degli incontri tra gli host è meno importante.",
"rr_distribution_method_balanced_title": "Bilanciamento del carico",
"rr_distribution_method_balanced_description": "Monitoreremo quante prenotazioni sono state effettuate con ciascun host e le confronteremo con gli altri, disabilitando alcuni host che sono troppo avanti per garantire una distribuzione uniforme delle prenotazioni.",
"exclude_emails_that_contain": "Escludi le email che contengono ...",
"exclude_emails_match_found_error_message": "Inserisci un indirizzo email di lavoro valido",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "予約に戻る",
"free_to_pick_another_event_type": "他の好きなイベントを選んでください。",
"cancelled": "キャンセル済み",
"no_booking": "予約なし",
"awaiting_host": "ホストを待機中",
"cancellation_successful": "キャンセルしました",
"really_cancel_booking": "本当に予約をキャンセルしますか?",
"cannot_cancel_booking": "この予約はキャンセルできません",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "ルーティング",
"routing_forms_description": "出席者を正しい目的地に案内するためのフォームを作成します。",
"routing_forms_send_email_owner": "所有者にメールを送信",
"routing_forms_send_email_owner_description": "フォームの送信時に所有者にメールを送信します",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "予約制限が正常に更新されました",
"you_are_unauthorized_to_make_this_change_to_the_booking": "この予約の変更を行う権限がありません",
"matching_members": "一致するメンバー",
"matching_members_queue_using_attribute_weights": "メンバーのマッチングキュー(属性の重み付けを使用)",
"matching_members_queue_using_event_assignee_weights": "メンバーのマッチングキュー(イベント担当者の重み付けを使用)",
"no_matching_members": "一致するメンバーがいません。イベントタイプに割り当てられたチームメンバーが使用されます。",
"no_matching_members_will_fallback_to_all_assigned_members": "一致するメンバーがいません。イベントタイプに割り当てられたチームメンバーを使用します。フォールバックを追加するか、using_fallback_membersのロジックを修正することを検討してください",
"hide_calendar_event_details": "共有カレンダーのイベント詳細を非表示にする",
@@ -2722,6 +2727,8 @@
"add_new_field": "新しいフィールドを追加",
"you_dont_have_access_to_reroute_this_booking": "この予約を変更する権限がありません",
"form_response_not_found": "フォームの回答が見つかりません",
"contact_owner": "担当者",
"contact_owner_not_found": "見つかりません",
"using_fallback_members": "フォールバックメンバーを使用中",
"chosen_route": "選択されたルート",
"attribute_logic_matched": "属性ロジックが一致しました",
@@ -2760,11 +2767,33 @@
"booking_start_date": "予約開始日",
"booking_created_date": "予約作成日",
"booking_reassigned_to_host": "予約が{{host}}に再割り当てされました",
"no_contact_owner": "担当者なし",
"routing_forms_created": "ルーティングフォーム作成済み",
"routing_forms_total_responses": "総回答数",
"routing_forms_total_responses_without_booking": "予約なしの総回答数",
"routing_forms_total_responses_with_booking": "予約ありの総回答数",
"routing_form_insights": "予約なし",
"routing_form_insights_form_name_header": "フォーム名",
"routing_form_insights_booking_status_header": "予約状況",
"routing_form_insights_response_no_booking": "予約なし",
"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": "取引先責任者の所有者は強制されません(属性とラウンドロビンの基準に一致する場合はホストになる可能性があります)",
"salesforce_route_to_custom_lookup_field": "取引先の参照項目に一致するユーザーに割り当てる",
"salesforce_option": "Salesforceオプション",
"lookup_field_name": "参照項目名",
"rr_distribution_method": "配分方式",
"rr_distribution_method_description": "最大限の予約可能性を確保するため、または均等な配分を目指すための最適化が可能です。",
"rr_distribution_method_availability_title": "予約可能性を最大化",
"rr_distribution_method_availability_description": "ホストが対応可能な時はいつでも予約を受け付けることができます。潜在的な予約数を最大化したい場合や、ホスト間での予約の均等な配分が重要でない場合に使用します。",
"rr_distribution_method_balanced_title": "負荷分散",
"rr_distribution_method_balanced_description": "各ホストの予約数を監視し、他のホストと比較して予約数が多すぎるホストを一時的に無効にすることで、予約を均等に配分します。",
"exclude_emails_that_contain": "...を含むメールアドレスを除外",
"exclude_emails_match_found_error_message": "有効な勤務用メールアドレスを入力してください",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "ត្រឡប់ទៅការកក់",
"free_to_pick_another_event_type": "អាចជ្រើសរើសប្រភេទព្រឹត្តិការណ៍ផ្សេងទៀតបានគ្រប់ពេលវេលា។",
"cancelled": "បានលុបចោល",
"no_booking": "គ្មានការកក់",
"awaiting_host": "កំពុងរង់ចាំម្ចាស់",
"cancellation_successful": "ការលុបចោលបានជោគជ័យ",
"really_cancel_booking": "តើអ្នកពិតជាចង់លុបចោលការកក់របស់អ្នកមែនទេ?",
"cannot_cancel_booking": "អ្នកមិនអាចលុបចោលការកក់នេះបានទេ",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "ការបញ្ជូនបន្ត",
"routing_forms_description": "បង្កើតទម្រង់ដើម្បីដឹកនាំអ្នកចូលរួមទៅកាន់គោលដៅត្រឹមត្រូវ",
"routing_forms_send_email_owner": "ផ្ញើអ៊ីមែលទៅម្ចាស់",
"routing_forms_send_email_owner_description": "ផ្ញើអ៊ីមែលទៅម្ចាស់នៅពេលទម្រង់ត្រូវបានដាក់ស្នើ",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "ដែនកំណត់ការកក់ត្រូវបានធ្វើបច្ចុប្បន្នភាពដោយជោគជ័យ",
"you_are_unauthorized_to_make_this_change_to_the_booking": "អ្នកមិនមានសិទ្ធិធ្វើការផ្លាស់ប្តូរនេះលើការកក់ទេ",
"matching_members": "សមាជិកដែលត្រូវគ្នា",
"matching_members_queue_using_attribute_weights": "ជួររៀងសមាជិកដែលត្រូវគ្នា (ប្រើទំងន់គុណលក្ខណៈ)",
"matching_members_queue_using_event_assignee_weights": "ជួររៀងសមាជិកដែលត្រូវគ្នា (ប្រើទំងន់អ្នកចាត់តាំងព្រឹត្តិការណ៍)",
"no_matching_members": "គ្មានសមាជិកដែលត្រូវគ្នា។ វានឹងត្រឡប់ទៅប្រើសមាជិកក្រុមដែលបានចាត់តាំងទៅប្រភេទព្រឹត្តិការណ៍។",
"no_matching_members_will_fallback_to_all_assigned_members": "គ្មានសមាជិកដែលត្រូវគ្នា។ វានឹងត្រឡប់ទៅប្រើសមាជិកក្រុមដែលបានចាត់តាំងឱ្យប្រភេទព្រឹត្តិការណ៍។ សូមពិចារណាបន្ថែមការត្រឡប់ក្រោយ ឬកែតម្រូវតក្កវិជ្ជានៃការប្រើប្រាស់សមាជិកត្រឡប់ក្រោយ",
"hide_calendar_event_details": "លាក់ព័ត៌មានលម្អិតនៃព្រឹត្តិការណ៍ប្រចាំប្រតិទិននៅលើប្រតិទិនដែលបានចែករំលែក",
@@ -2722,6 +2727,8 @@
"add_new_field": "បន្ថែមវាលថ្មី",
"you_dont_have_access_to_reroute_this_booking": "អ្នកមិនមានសិទ្ធិប្តូរទិសការកក់នេះទេ",
"form_response_not_found": "រកមិនឃើញការឆ្លើយតបទម្រង់",
"contact_owner": "ម្ចាស់ទំនាក់ទំនង",
"contact_owner_not_found": "រកមិនឃើញ",
"using_fallback_members": "កំពុងប្រើសមាជិកត្រឡប់ក្រោយ",
"chosen_route": "ផ្លូវដែលបានជ្រើសរើស",
"attribute_logic_matched": "តក្កវិជ្ជាលក្ខណៈសម្បត្តិត្រូវគ្នា",
@@ -2760,11 +2767,33 @@
"booking_start_date": "កាលបរិច្ឆេទចាប់ផ្តើមការកក់",
"booking_created_date": "កាលបរិច្ឆេទបង្កើតការកក់",
"booking_reassigned_to_host": "ការកក់ត្រូវបានកំណត់ឡើងវិញទៅ {{host}}",
"no_contact_owner": "គ្មានម្ចាស់ទំនាក់ទំនង",
"routing_forms_created": "ទម្រង់បញ្ជូនបន្តដែលបានបង្កើត",
"routing_forms_total_responses": "ចំនួនការឆ្លើយតបសរុប",
"routing_forms_total_responses_without_booking": "ចំនួនការឆ្លើយតបសរុបដោយគ្មានការកក់",
"routing_forms_total_responses_with_booking": "ចំនួនការឆ្លើយតបសរុបជាមួយការកក់",
"routing_form_insights": "គ្មានការកក់",
"routing_form_insights_form_name_header": "ឈ្មោះទម្រង់",
"routing_form_insights_booking_status_header": "ការកក់",
"routing_form_insights_response_no_booking": "គ្មានការកក់",
"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)។",
"salesforce_route_to_custom_lookup_field": "ផ្លូវទៅកាន់អ្នកប្រើដែលត្រូវនឹងវាលស្វែងរកនៅលើគណនី។",
"salesforce_option": "ជម្រើស Salesforce",
"lookup_field_name": "ឈ្មោះវាលស្វែងរក",
"rr_distribution_method": "ការចែកចាយ",
"rr_distribution_method_description": "អនុញ្ញាតឱ្យធ្វើការបែងចែកប្រសិទ្ធភាពដើម្បីបង្កើនភាពអាចរកបានអតិបរមា ឬដើម្បីសម្រេចបាននូវការចាត់តាំងដែលមានតុល្យភាពជាងមុន។",
"rr_distribution_method_availability_title": "បង្កើនភាពអាចរកបាន",
"rr_distribution_method_availability_description": "អនុញ្ញាតឱ្យអ្នកកក់អាចកក់កិច្ចប្រជុំនៅពេលដែលម្ចាស់មានពេលទំនេរ។ ប្រើវាដើម្បីបង្កើនចំនួនកិច្ចប្រជុំដែលអាចកក់បាន និងនៅពេលដែលការចែកចាយស្មើគ្នានៃកិច្ចប្រជុំរវាងម្ចាស់មានសារៈសំខាន់តិចជាង។",
"rr_distribution_method_balanced_title": "ការរក្សាតុល្យភាពបន្ទុក",
"rr_distribution_method_balanced_description": "យើងនឹងតាមដានចំនួនការកក់ដែលបានធ្វើឡើងជាមួយម្ចាស់នីមួយៗ និងប្រៀបធៀបជាមួយអ្នកផ្សេងទៀត ដោយបិទម្ចាស់មួយចំនួនដែលមានការកក់ច្រើនពេកដើម្បីឱ្យការកក់ត្រូវបានចែកចាយស្មើៗគ្នា។",
"exclude_emails_that_contain": "ច្រានចោលអ៊ីមែលដែលមាន ...",
"exclude_emails_match_found_error_message": "សូមបញ្ចូលអាសយដ្ឋានអ៊ីមែលការងារដែលមានសុពលភាព",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ បន្ថែមខ្សែអក្សរថ្មីរបស់អ្នកនៅខាងលើនេះ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "예약으로 돌아가기",
"free_to_pick_another_event_type": "언제든지 다른 이벤트를 선택할 수 있습니다.",
"cancelled": "취소됨",
"no_booking": "예약 없음",
"awaiting_host": "호스트 대기 중",
"cancellation_successful": "취소 성공",
"really_cancel_booking": "예약을 정말 취소하시겠습니까?",
"cannot_cancel_booking": "예약을 취소하실 수 없습니다.",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "라우팅",
"routing_forms_description": "참석자를 올바른 대상으로 안내하는 양식 만들기",
"routing_forms_send_email_owner": "소유자에게 이메일 보내기",
"routing_forms_send_email_owner_description": "양식이 제출되면 소유자에게 이메일을 발송합니다",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "예약 제한이 성공적으로 업데이트되었습니다",
"you_are_unauthorized_to_make_this_change_to_the_booking": "이 예약을 변경할 권한이 없습니다",
"matching_members": "일치하는 멤버",
"matching_members_queue_using_attribute_weights": "속성 가중치를 사용한 멤버 매칭 대기열",
"matching_members_queue_using_event_assignee_weights": "이벤트 담당자 가중치를 사용한 멤버 매칭 대기열",
"no_matching_members": "일치하는 멤버가 없습니다. 이벤트 유형에 할당된 팀 멤버를 사용할 것입니다.",
"no_matching_members_will_fallback_to_all_assigned_members": "일치하는 멤버가 없습니다. 이벤트 유형에 할당된 팀 멤버를 사용하는 것으로 대체됩니다. 대체 옵션을 추가하거나 using_fallback_members 로직을 수정하는 것을 고려해보세요.",
"hide_calendar_event_details": "공유 캘린더에서 이벤트 세부 정보 숨기기",
@@ -2722,6 +2727,8 @@
"add_new_field": "새 필드 추가",
"you_dont_have_access_to_reroute_this_booking": "이 예약을 재라우팅할 권한이 없습니다",
"form_response_not_found": "양식 응답을 찾을 수 없습니다",
"contact_owner": "연락처 소유자",
"contact_owner_not_found": "찾을 수 없음",
"using_fallback_members": "대체 멤버 사용 중",
"chosen_route": "선택된 경로",
"attribute_logic_matched": "속성 로직 일치함",
@@ -2760,11 +2767,33 @@
"booking_start_date": "예약 시작 날짜",
"booking_created_date": "예약 생성 날짜",
"booking_reassigned_to_host": "예약이 {{host}}에게 재할당됨",
"no_contact_owner": "연락처 소유자 없음",
"routing_forms_created": "생성된 라우팅 양식",
"routing_forms_total_responses": "전체 응답",
"routing_forms_total_responses_without_booking": "예약 없는 전체 응답",
"routing_forms_total_responses_with_booking": "예약이 있는 전체 응답",
"routing_form_insights": "예약 없음",
"routing_form_insights_form_name_header": "양식 이름",
"routing_form_insights_booking_status_header": "예약",
"routing_form_insights_response_no_booking": "예약 없음",
"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 기준이 일치하면 여전히 호스트가 될 수 있습니다).",
"salesforce_route_to_custom_lookup_field": "계정의 조회 필드와 일치하는 사용자에게 라우팅합니다.",
"salesforce_option": "Salesforce 옵션",
"lookup_field_name": "조회 필드 이름",
"rr_distribution_method": "분배 방식",
"rr_distribution_method_description": "최대 가용성을 위한 분배 최적화 또는 균형 잡힌 할당을 위한 설정이 가능합니다.",
"rr_distribution_method_availability_title": "가용성 최대화",
"rr_distribution_method_availability_description": "호스트가 가능한 시간대에 예약자가 미팅을 예약할 수 있습니다. 호스트 간 미팅 분배의 균형보다 잠재적 미팅 예약 수를 최대화하고 싶을 때 사용하세요.",
"rr_distribution_method_balanced_title": "부하 분산",
"rr_distribution_method_balanced_description": "각 호스트의 예약 수를 모니터링하고 다른 호스트들과 비교하여, 예약이 균등하게 분배되도록 예약이 너무 많은 호스트를 일시적으로 비활성화합니다.",
"exclude_emails_that_contain": "다음이 포함된 이메일 제외 ...",
"exclude_emails_match_found_error_message": "유효한 업무용 이메일 주소를 입력해 주세요.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Terug naar afspraken",
"free_to_pick_another_event_type": "U kunt op elk moment een andere afspraak maken.",
"cancelled": "Geannuleerd",
"no_booking": "Geen boeking",
"awaiting_host": "Wachten op host",
"cancellation_successful": "Annulering geslaagd",
"really_cancel_booking": "Weet u zeker dat u deze afspraak wilt annuleren?",
"cannot_cancel_booking": "U kunt deze afspraak niet annuleren",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routering",
"routing_forms_description": "Maak formulieren om deelnemers naar de juiste bestemmingen te verwijzen",
"routing_forms_send_email_owner": "E-mail naar eigenaar versturen",
"routing_forms_send_email_owner_description": "Verstuurt een e-mail naar de eigenaar wanneer het formulier is verzonden",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Boekingslimieten succesvol bijgewerkt",
"you_are_unauthorized_to_make_this_change_to_the_booking": "U bent niet gemachtigd om deze wijziging in de boeking aan te brengen",
"matching_members": "Overeenkomende leden",
"matching_members_queue_using_attribute_weights": "Wachtrij voor overeenkomende leden (op basis van kenmerkengewicht)",
"matching_members_queue_using_event_assignee_weights": "Wachtrij voor overeenkomende leden (op basis van toewijzingsgewicht)",
"no_matching_members": "Geen overeenkomende leden. Er wordt teruggevallen op de teamleden die aan het type evenement zijn toegewezen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Geen overeenkomende leden. Er wordt teruggevallen op de teamleden die aan het evenementtype zijn toegewezen. Overweeg een fallback toe te voegen of de logica van using_fallback_members te corrigeren",
"hide_calendar_event_details": "Verberg details van kalendergebeurtenissen op gedeelde kalenders",
@@ -2722,6 +2727,8 @@
"add_new_field": "Nieuw veld toevoegen",
"you_dont_have_access_to_reroute_this_booking": "Je hebt geen toegang om deze boeking om te leiden",
"form_response_not_found": "Formulierreactie niet gevonden",
"contact_owner": "Contacteigenaar",
"contact_owner_not_found": "Niet gevonden",
"using_fallback_members": "Gebruik van fallback-leden",
"chosen_route": "Gekozen route",
"attribute_logic_matched": "Attribuutlogica komt overeen",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Startdatum boeking",
"booking_created_date": "Aanmaakdatum boeking",
"booking_reassigned_to_host": "Boeking opnieuw toegewezen aan {{host}}",
"no_contact_owner": "Geen contacteigenaar",
"routing_forms_created": "Routeringsformulieren aangemaakt",
"routing_forms_total_responses": "Totaal aantal reacties",
"routing_forms_total_responses_without_booking": "Totaal aantal reacties zonder boeking",
"routing_forms_total_responses_with_booking": "Totaal aantal reacties met boeking",
"routing_form_insights": "Geen boekingen",
"routing_form_insights_form_name_header": "Formuliernaam",
"routing_form_insights_booking_status_header": "Boeking",
"routing_form_insights_response_no_booking": "Geen boeking",
"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)",
"salesforce_route_to_custom_lookup_field": "Routeer naar een gebruiker die overeenkomt met een zoekveld op een account",
"salesforce_option": "Salesforce-optie",
"lookup_field_name": "Zoekveldnaam",
"rr_distribution_method": "Distributie",
"rr_distribution_method_description": "Maakt het mogelijk om de distributie te optimaliseren voor maximale beschikbaarheid of voor een meer gebalanceerde toewijzing.",
"rr_distribution_method_availability_title": "Maximale beschikbaarheid",
"rr_distribution_method_availability_description": "Stelt boekers in staat om afspraken te maken wanneer een host beschikbaar is. Gebruik dit om het aantal potentiële afspraken te maximaliseren en wanneer de gelijke verdeling van afspraken over hosts minder belangrijk is.",
"rr_distribution_method_balanced_title": "Evenredige verdeling",
"rr_distribution_method_balanced_description": "We monitoren hoeveel boekingen er bij elke host zijn gemaakt en vergelijken dit met anderen. Hosts die te ver voorlopen worden tijdelijk uitgeschakeld zodat boekingen gelijkmatig worden verdeeld.",
"exclude_emails_that_contain": "E-mailadressen uitsluiten die ... bevatten",
"exclude_emails_match_found_error_message": "Voer een geldig zakelijk e-mailadres in",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Voeg uw nieuwe strings hierboven toe ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Tilbake til bookinger",
"free_to_pick_another_event_type": "Du er velkommen til å velge et annet event når som helst.",
"cancelled": "Avlyst",
"no_booking": "Ingen bestilling",
"awaiting_host": "Venter på vert",
"cancellation_successful": "Kanselleringen var vellykket",
"really_cancel_booking": "Sikker på at du vil kansellere bookingen din?",
"cannot_cancel_booking": "Du kan ikke avbryte denne bookingen",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Ruting",
"routing_forms_description": "Lag skjemaer for å lede deltakere til riktig destinasjon",
"routing_forms_send_email_owner": "Send E-post til Eier",
"routing_forms_send_email_owner_description": "Sender en e-post til eieren når skjemaet er sendt inn",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Bestillingsgrenser oppdatert vellykket",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tillatelse til å gjøre denne endringen i bestillingen",
"matching_members": "Matchende medlemmer",
"matching_members_queue_using_attribute_weights": "Kø for samsvarende medlemmer (bruker attributtvekting)",
"matching_members_queue_using_event_assignee_weights": "Kø for samsvarende medlemmer (bruker vekting for hendelsesansvarlige)",
"no_matching_members": "Ingen matchende medlemmer. Det vil falle tilbake til å bruke teammedlemmene som er tildelt hendelsestypen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Ingen matchende medlemmer. Det vil falle tilbake til å bruke teammedlemmene som er tildelt hendelsestypen. Vurder å legge til en reserveløsning eller korrigere logikken for using_fallback_members",
"hide_calendar_event_details": "Skjul kalenderhendelsesdetaljer på delte kalendere",
@@ -2722,6 +2727,8 @@
"add_new_field": "Legg til nytt felt",
"you_dont_have_access_to_reroute_this_booking": "Du har ikke tilgang til å omdirigere denne bookingen",
"form_response_not_found": "Skjemasvar ikke funnet",
"contact_owner": "Kontakteier",
"contact_owner_not_found": "Ikke funnet",
"using_fallback_members": "Bruker reservemedlemmer",
"chosen_route": "Valgt rute",
"attribute_logic_matched": "Attributtlogikk matchet",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Bookingstart-dato",
"booking_created_date": "Dato for opprettet booking",
"booking_reassigned_to_host": "Booking omfordelt til {{host}}",
"no_contact_owner": "Ingen kontakteier",
"routing_forms_created": "Ruteformularer opprettet",
"routing_forms_total_responses": "Totalt antall svar",
"routing_forms_total_responses_without_booking": "Totalt antall svar uten bestilling",
"routing_forms_total_responses_with_booking": "Totalt antall svar med bestilling",
"routing_form_insights": "Ingen bestillinger",
"routing_form_insights_form_name_header": "Skjemanavn",
"routing_form_insights_booking_status_header": "Bestilling",
"routing_form_insights_response_no_booking": "Ingen bestilling",
"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)",
"salesforce_route_to_custom_lookup_field": "Rute til en bruker som samsvarer med et oppslagsfelt på en konto",
"salesforce_option": "Salesforce-alternativ",
"lookup_field_name": "Oppslagsfeltnavn",
"rr_distribution_method": "Distribusjon",
"rr_distribution_method_description": "Gjør det mulig å optimalisere distribusjonen for maksimal tilgjengelighet eller for å oppnå en mer balansert fordeling.",
"rr_distribution_method_availability_title": "Maksimer tilgjengelighet",
"rr_distribution_method_availability_description": "Lar personer bestille møter når som helst en vert er tilgjengelig. Bruk dette for å maksimere antall potensielle møter som kan bestilles, og når jevn fordeling av møter mellom verter er mindre viktig.",
"rr_distribution_method_balanced_title": "Lastbalansering",
"rr_distribution_method_balanced_description": "Vi vil overvåke hvor mange bestillinger som er gjort med hver vert og sammenligne dette med andre, og deaktivere enkelte verter som ligger for langt foran slik at bestillinger blir jevnt fordelt.",
"exclude_emails_that_contain": "Ekskluder e-poster som inneholder ...",
"exclude_emails_match_found_error_message": "Vennligst skriv inn en gyldig arbeids-e-postadresse",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Legg til dine nye strenger over her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Powrót do rezerwacji",
"free_to_pick_another_event_type": "Możesz wybrać inne wydarzenie w dowolnym momencie.",
"cancelled": "Anulowano",
"no_booking": "Brak rezerwacji",
"awaiting_host": "Oczekiwanie na gospodarza",
"cancellation_successful": "Anulowano pomyślnie",
"really_cancel_booking": "Czy na pewno anulować Twoją rezerwację?",
"cannot_cancel_booking": "Nie możesz anulować tej rezerwacji",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routing",
"routing_forms_description": "Utwórz formularze kierujące uczestników do właściwych miejsc docelowych",
"routing_forms_send_email_owner": "Wyślij wiadomość e-mail do właściciela",
"routing_forms_send_email_owner_description": "Wysyła wiadomość e-mail do właściciela, gdy formularz zostanie przesłany.",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limity rezerwacji zostały pomyślnie zaktualizowane",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nie masz uprawnień do wprowadzenia tej zmiany w rezerwacji",
"matching_members": "Pasujący członkowie",
"matching_members_queue_using_attribute_weights": "Kolejka dopasowanych członków (używając wag atrybutów)",
"matching_members_queue_using_event_assignee_weights": "Kolejka dopasowanych członków (używając wag przypisanych do wydarzeń)",
"no_matching_members": "Brak pasujących członków. Zostaną użyci członkowie zespołu przypisani do typu wydarzenia.",
"no_matching_members_will_fallback_to_all_assigned_members": "Brak pasujących członków. Nastąpi powrót do użycia członków zespołu przypisanych do typu wydarzenia. Rozważ dodanie zapasowego rozwiązania lub poprawienie logiki using_fallback_members",
"hide_calendar_event_details": "Ukryj szczegóły wydarzeń w kalendarzu na udostępnionych kalendarzach",
@@ -2722,6 +2727,8 @@
"add_new_field": "Dodaj nowe pole",
"you_dont_have_access_to_reroute_this_booking": "Nie masz dostępu do przekierowania tej rezerwacji",
"form_response_not_found": "Nie znaleziono odpowiedzi formularza",
"contact_owner": "Właściciel kontaktu",
"contact_owner_not_found": "Nie znaleziono",
"using_fallback_members": "Używanie zapasowych członków",
"chosen_route": "Wybrana trasa",
"attribute_logic_matched": "Logika atrybutów dopasowana",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Data rozpoczęcia rezerwacji",
"booking_created_date": "Data utworzenia rezerwacji",
"booking_reassigned_to_host": "Rezerwacja przypisana ponownie do {{host}}",
"no_contact_owner": "Brak właściciela kontaktu",
"routing_forms_created": "Utworzone formularze routingu",
"routing_forms_total_responses": "Łączna liczba odpowiedzi",
"routing_forms_total_responses_without_booking": "Łączna liczba odpowiedzi bez rezerwacji",
"routing_forms_total_responses_with_booking": "Łączna liczba odpowiedzi z rezerwacją",
"routing_form_insights": "Brak rezerwacji",
"routing_form_insights_form_name_header": "Nazwa formularza",
"routing_form_insights_booking_status_header": "Rezerwacja",
"routing_form_insights_response_no_booking": "Brak rezerwacji",
"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)",
"salesforce_route_to_custom_lookup_field": "Przekieruj do użytkownika, który odpowiada polu wyszukiwania w koncie",
"salesforce_option": "Opcja Salesforce",
"lookup_field_name": "Nazwa pola wyszukiwania",
"rr_distribution_method": "Dystrybucja",
"rr_distribution_method_description": "Pozwala zoptymalizować dystrybucję pod kątem maksymalnej dostępności lub bardziej zrównoważonego przydziału spotkań.",
"rr_distribution_method_availability_title": "Maksymalizuj dostępność",
"rr_distribution_method_availability_description": "Umożliwia rezerwację spotkań, gdy którykolwiek z gospodarzy jest dostępny. Używaj tej opcji, aby zmaksymalizować liczbę potencjalnych spotkań, gdy równomierne rozłożenie spotkań między gospodarzami jest mniej istotne.",
"rr_distribution_method_balanced_title": "Równoważenie obciążenia",
"rr_distribution_method_balanced_description": "System będzie monitorować liczbę rezerwacji przypisanych do każdego gospodarza i porównywać ją z innymi, dezaktywując tymczasowo gospodarzy z większą liczbą spotkań, aby zapewnić równomierną dystrybucję.",
"exclude_emails_that_contain": "Wyklucz e-maile, które zawierają ...",
"exclude_emails_match_found_error_message": "Proszę wprowadzić prawidłowy adres e-mail służbowy",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodaj nowe ciągi powyżej ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Voltar às Reservas",
"free_to_pick_another_event_type": "Sinta-se à vontade para escolher outro evento a qualquer hora.",
"cancelled": "Cancelado",
"no_booking": "Sem agendamento",
"awaiting_host": "Aguardando anfitrião",
"cancellation_successful": "Cancelado com Sucesso",
"really_cancel_booking": "Deseja realmente cancelar a sua reserva?",
"cannot_cancel_booking": "Você não pode cancelar esta reserva",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Roteamento",
"routing_forms_description": "Criar formulários para direcionar os participantes para os destinos corretos",
"routing_forms_send_email_owner": "Enviar e-mail ao proprietário",
"routing_forms_send_email_owner_description": "Envia um e-mail ao proprietário após o envio do formulário",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limites de agendamento atualizados com sucesso",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Você não tem autorização para fazer essa alteração na reserva",
"matching_members": "Membros correspondentes",
"matching_members_queue_using_attribute_weights": "Fila de membros correspondentes (usando pesos de atributos)",
"matching_members_queue_using_event_assignee_weights": "Fila de membros correspondentes (usando pesos de designados do evento)",
"no_matching_members": "Nenhum membro correspondente. Será utilizado como alternativa os membros da equipe atribuídos ao tipo de evento.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nenhum membro correspondente. Será usado como fallback os membros da equipe atribuídos ao tipo de evento. Considere adicionar um fallback ou corrigir a lógica de using_fallback_members",
"hide_calendar_event_details": "Ocultar detalhes dos eventos do calendário em calendários compartilhados",
@@ -2722,6 +2727,8 @@
"add_new_field": "Adicionar novo campo",
"you_dont_have_access_to_reroute_this_booking": "Você não tem acesso para redirecionar esta reserva",
"form_response_not_found": "Resposta do formulário não encontrada",
"contact_owner": "Proprietário do contato",
"contact_owner_not_found": "Não encontrado",
"using_fallback_members": "Usando membros de fallback",
"chosen_route": "Rota escolhida",
"attribute_logic_matched": "Lógica de atributos correspondida",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Data de início da reserva",
"booking_created_date": "Data de criação da reserva",
"booking_reassigned_to_host": "Reserva reatribuída para {{host}}",
"no_contact_owner": "Sem proprietário do contato",
"routing_forms_created": "Formulários de Roteamento Criados",
"routing_forms_total_responses": "Total de Respostas",
"routing_forms_total_responses_without_booking": "Total de Respostas Sem Agendamento",
"routing_forms_total_responses_with_booking": "Total de Respostas Com Agendamento",
"routing_form_insights": "Sem Agendamentos",
"routing_form_insights_form_name_header": "Nome do Formulário",
"routing_form_insights_booking_status_header": "Agendamento",
"routing_form_insights_response_no_booking": "Sem Agendamento",
"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)",
"salesforce_route_to_custom_lookup_field": "Direcionar para um usuário que corresponda a um campo de pesquisa em uma conta",
"salesforce_option": "Opção do Salesforce",
"lookup_field_name": "Nome do campo de pesquisa",
"rr_distribution_method": "Distribuição",
"rr_distribution_method_description": "Permite otimizar a distribuição para máxima disponibilidade ou buscar uma atribuição mais equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidade",
"rr_distribution_method_availability_description": "Permite que os agendadores marquem reuniões sempre que um anfitrião estiver disponível. Use isso para maximizar o número de reuniões potenciais agendadas quando a distribuição uniforme de reuniões entre os anfitriões for menos importante.",
"rr_distribution_method_balanced_title": "Balanceamento de carga",
"rr_distribution_method_balanced_description": "Monitoraremos quantas reservas foram feitas com cada anfitrião e compararemos com os outros, desativando alguns anfitriões que estão muito à frente para que as reservas sejam distribuídas uniformemente.",
"exclude_emails_that_contain": "Excluir e-mails que contêm ...",
"exclude_emails_match_found_error_message": "Por favor, insira um endereço de e-mail corporativo válido",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Voltar às Reservas",
"free_to_pick_another_event_type": "Sinta-se à vontade para escolher outro evento a qualquer hora.",
"cancelled": "Cancelado",
"no_booking": "Sem agendamento",
"awaiting_host": "Aguardando anfitrião",
"cancellation_successful": "Cancelado com Sucesso",
"really_cancel_booking": "Deseja realmente cancelar a sua reserva?",
"cannot_cancel_booking": "Não pode cancelar esta reserva",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Roteamento",
"routing_forms_description": "Crie formulários para direcionar os participantes para os destinos corretos",
"routing_forms_send_email_owner": "Enviar e-mail ao proprietário",
"routing_forms_send_email_owner_description": "Envia um e-mail ao proprietário quando o formulário for submetido",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limites de reservas atualizados com sucesso",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Não tem autorização para fazer esta alteração na reserva",
"matching_members": "Membros correspondentes",
"matching_members_queue_using_attribute_weights": "Fila de membros correspondentes (usando pesos de atributos)",
"matching_members_queue_using_event_assignee_weights": "Fila de membros correspondentes (usando pesos de responsáveis por eventos)",
"no_matching_members": "Nenhum membro correspondente. Será utilizado como alternativa os membros da equipa atribuídos ao tipo de evento.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nenhum membro correspondente. Irá recorrer aos membros da equipa atribuídos ao tipo de evento. Considere adicionar um fallback ou corrigir a lógica de using_fallback_members",
"hide_calendar_event_details": "Ocultar detalhes dos eventos do calendário em calendários partilhados",
@@ -2722,6 +2727,8 @@
"add_new_field": "Adicionar novo campo",
"you_dont_have_access_to_reroute_this_booking": "Não tem acesso para redirecionar esta reserva",
"form_response_not_found": "Resposta do formulário não encontrada",
"contact_owner": "Proprietário do contato",
"contact_owner_not_found": "Não encontrado",
"using_fallback_members": "A usar membros de fallback",
"chosen_route": "Rota escolhida",
"attribute_logic_matched": "Lógica de atributos correspondida",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Data de início da reserva",
"booking_created_date": "Data de criação da reserva",
"booking_reassigned_to_host": "Reserva reatribuída a {{host}}",
"no_contact_owner": "Sem proprietário do contato",
"routing_forms_created": "Formulários de roteamento criados",
"routing_forms_total_responses": "Total de respostas",
"routing_forms_total_responses_without_booking": "Total de respostas sem agendamento",
"routing_forms_total_responses_with_booking": "Total de respostas com agendamento",
"routing_form_insights": "Sem agendamentos",
"routing_form_insights_form_name_header": "Nome do formulário",
"routing_form_insights_booking_status_header": "Agendamento",
"routing_form_insights_response_no_booking": "Sem agendamento",
"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)",
"salesforce_route_to_custom_lookup_field": "Direcionar para um usuário que corresponda a um campo de pesquisa em uma conta",
"salesforce_option": "Opção do Salesforce",
"lookup_field_name": "Nome do campo de pesquisa",
"rr_distribution_method": "Distribuição",
"rr_distribution_method_description": "Permite otimizar a distribuição para máxima disponibilidade ou para buscar uma atribuição mais equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidade",
"rr_distribution_method_availability_description": "Permite que os agendadores marquem reuniões sempre que um anfitrião estiver disponível. Use isso para maximizar o número de reuniões potenciais agendadas quando a distribuição uniforme de reuniões entre os anfitriões for menos importante.",
"rr_distribution_method_balanced_title": "Balanceamento de carga",
"rr_distribution_method_balanced_description": "Monitoraremos quantas reservas foram feitas com cada anfitrião e compararemos com os outros, desativando alguns anfitriões que estão muito à frente para que as reservas sejam distribuídas uniformemente.",
"exclude_emails_that_contain": "Excluir emails que contêm ...",
"exclude_emails_match_found_error_message": "Por favor, insira um endereço de email de trabalho válido",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Înapoi la rezervări",
"free_to_pick_another_event_type": "Puteți alege un alt eveniment oricând.",
"cancelled": "Anulat",
"no_booking": "Fără programare",
"awaiting_host": "Se așteaptă gazda",
"cancellation_successful": "Anulare cu succes",
"really_cancel_booking": "Chiar doriți să anulați rezervarea?",
"cannot_cancel_booking": "Nu puteți anula această rezervare",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Direcționare",
"routing_forms_description": "Creați formulare pentru a direcționa participanții către destinațiile corecte",
"routing_forms_send_email_owner": "Trimiteți un e-mail către proprietar",
"routing_forms_send_email_owner_description": "Trimite un e-mail către proprietar atunci când este trimis formularul",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Limitele de rezervare au fost actualizate cu succes",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nu aveți autorizația necesară pentru a face această modificare în rezervare",
"matching_members": "Membri corespunzători",
"matching_members_queue_using_attribute_weights": "Coadă de potrivire membri (folosind ponderi de atribute)",
"matching_members_queue_using_event_assignee_weights": "Coadă de potrivire membri (folosind ponderi pentru responsabilii evenimentelor)",
"no_matching_members": "Nu există membri corespunzători. Se va reveni la utilizarea membrilor echipei alocați tipului de eveniment.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nu există membri potriviți. Se va reveni la utilizarea membrilor echipei atribuiți tipului de eveniment. Luați în considerare adăugarea unei opțiuni de rezervă sau corectarea logicii de utilizare a membrilor de rezervă",
"hide_calendar_event_details": "Ascunde detaliile evenimentelor din calendar pe calendarele partajate",
@@ -2722,6 +2727,8 @@
"add_new_field": "Adaugă câmp nou",
"you_dont_have_access_to_reroute_this_booking": "Nu ai acces pentru a redirecționa această rezervare",
"form_response_not_found": "Răspunsul formularului nu a fost găsit",
"contact_owner": "Proprietarul contactului",
"contact_owner_not_found": "Nu a fost găsit",
"using_fallback_members": "Se utilizează membrii de rezervă",
"chosen_route": "Ruta aleasă",
"attribute_logic_matched": "Logica atributelor corespunde",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Data de început a rezervării",
"booking_created_date": "Data creării rezervării",
"booking_reassigned_to_host": "Rezervare reatribuită către {{host}}",
"no_contact_owner": "Fără proprietar de contact",
"routing_forms_created": "Formulare de direcționare create",
"routing_forms_total_responses": "Total răspunsuri",
"routing_forms_total_responses_without_booking": "Total răspunsuri fără programare",
"routing_forms_total_responses_with_booking": "Total răspunsuri cu programare",
"routing_form_insights": "Fără programări",
"routing_form_insights_form_name_header": "Nume formular",
"routing_form_insights_booking_status_header": "Programare",
"routing_form_insights_response_no_booking": "Fără programare",
"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)",
"salesforce_route_to_custom_lookup_field": "Redirecționează către un utilizator care se potrivește cu un câmp de căutare pe un cont",
"salesforce_option": "Opțiune Salesforce",
"lookup_field_name": "Nume câmp de căutare",
"rr_distribution_method": "Distribuție",
"rr_distribution_method_description": "Permite optimizarea distribuției pentru disponibilitate maximă sau pentru o alocare mai echilibrată.",
"rr_distribution_method_availability_title": "Maximizează disponibilitatea",
"rr_distribution_method_availability_description": "Permite programatorilor să rezerve întâlniri oricând un host este disponibil. Folosește această opțiune pentru a maximiza numărul potențial de întâlniri programate și când distribuția uniformă a întâlnirilor între hosți este mai puțin importantă.",
"rr_distribution_method_balanced_title": "Echilibrarea încărcării",
"rr_distribution_method_balanced_description": "Vom monitoriza câte rezervări au fost făcute cu fiecare host și vom compara acest lucru cu ceilalți, dezactivând temporar hosții care au prea multe programări, astfel încât rezervările să fie distribuite uniform.",
"exclude_emails_that_contain": "Excludează emailurile care conțin ...",
"exclude_emails_match_found_error_message": "Vă rugăm să introduceți o adresă de email de serviciu validă",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adăugați stringurile noi deasupra acestui rând ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
File diff suppressed because it is too large Load Diff
@@ -410,6 +410,8 @@
"back_to_bookings": "Nazad na rezervacije",
"free_to_pick_another_event_type": "Slobodno izaberite drugi dogadjaj u bilo kom trenutku.",
"cancelled": "Otkazano",
"no_booking": "Nema rezervacije",
"awaiting_host": "Čeka se domaćin",
"cancellation_successful": "Otkazivanje uspešno",
"really_cancel_booking": "Zaista želite da otkažete rezervaciju?",
"cannot_cancel_booking": "Ne možete otkazati ovu rezervaciju",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Rutiranje",
"routing_forms_description": "Kreirajte formulare da biste uputili učesnike na ispravna odredišta",
"routing_forms_send_email_owner": "Pošaljite imejl vlasniku",
"routing_forms_send_email_owner_description": "Pošaljite imejl vlasniku kada pošaljete obrazac",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Ograničenja zakazivanja su uspešno ažurirana",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemate ovlašćenje da izvršite ovu promenu u rezervaciji",
"matching_members": "Одговарајући чланови",
"matching_members_queue_using_attribute_weights": "Red članova za uparivanje (korišćenjem težinskih atributa)",
"matching_members_queue_using_event_assignee_weights": "Red članova za uparivanje (korišćenjem težinskih vrednosti dodeljenih događaju)",
"no_matching_members": "Нема одговарајућих чланова. Систем ће се вратити на коришћење чланова тима додељених типу догађаја.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nema odgovarajućih članova. Koristiće se članovi tima dodeljeni tipu događaja. Razmotrite dodavanje rezervne opcije ili ispravku logike korišćenja rezervnih članova",
"hide_calendar_event_details": "Sakrij detalje događaja u deljenim kalendarima",
@@ -2722,6 +2727,8 @@
"add_new_field": "Dodaj novo polje",
"you_dont_have_access_to_reroute_this_booking": "Nemate pristup za preusmeravanje ove rezervacije",
"form_response_not_found": "Odgovor na formular nije pronađen",
"contact_owner": "Vlasnik kontakta",
"contact_owner_not_found": "Nije pronađen",
"using_fallback_members": "Korišćenje rezervnih članova",
"chosen_route": "Izabrana ruta",
"attribute_logic_matched": "Logika atributa se podudara",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Datum početka rezervacije",
"booking_created_date": "Datum kreiranja rezervacije",
"booking_reassigned_to_host": "Rezervacija dodeljena {{host}}-u",
"no_contact_owner": "Nema vlasnika kontakta",
"routing_forms_created": "Kreirani formulari za rutiranje",
"routing_forms_total_responses": "Ukupno odgovora",
"routing_forms_total_responses_without_booking": "Ukupno odgovora bez rezervacije",
"routing_forms_total_responses_with_booking": "Ukupno odgovora sa rezervacijom",
"routing_form_insights": "Nema rezervacija",
"routing_form_insights_form_name_header": "Naziv formulara",
"routing_form_insights_booking_status_header": "Rezervacija",
"routing_form_insights_response_no_booking": "Nema rezervacije",
"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)",
"salesforce_route_to_custom_lookup_field": "Usmeri ka korisniku koji odgovara polju za pretragu na nalogu",
"salesforce_option": "Salesforce opcija",
"lookup_field_name": "Naziv polja za pretragu",
"rr_distribution_method": "Distribucija",
"rr_distribution_method_description": "Omogućava optimizaciju distribucije za maksimalnu dostupnost ili za balansiranu raspodelu.",
"rr_distribution_method_availability_title": "Maksimalna dostupnost",
"rr_distribution_method_availability_description": "Omogućava zakazivanje sastanaka kad god je domaćin dostupan. Koristite ovo da maksimalno povećate broj potencijalnih sastanaka kada je ravnomerna distribucija sastanaka među domaćinima manje važna.",
"rr_distribution_method_balanced_title": "Balansiranje opterećenja",
"rr_distribution_method_balanced_description": "Pratićemo broj zakazanih sastanaka kod svakog domaćina i upoređivati ga sa ostalima, privremeno onemogućavajući domaćine koji imaju previše sastanaka kako bi distribucija bila ravnomerna.",
"exclude_emails_that_contain": "Isključi e-poruke koje sadrže ...",
"exclude_emails_match_found_error_message": "Molimo unesite važeću poslovnu e-mail adresu",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodajte svoje nove stringove iznad ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Tillbaka till bokningarna",
"free_to_pick_another_event_type": "Känn dig fri att välja en annan händelse när som helst.",
"cancelled": "Avbokad",
"no_booking": "Ingen bokning",
"awaiting_host": "Väntar på värd",
"cancellation_successful": "Avbokning lyckades",
"really_cancel_booking": "Är du säker på att du vill avboka din bokning?",
"cannot_cancel_booking": "Du kan inte avboka denna bokning",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Dirigering",
"routing_forms_description": "Skapa formulär för att dirigera deltagare till rätt mål",
"routing_forms_send_email_owner": "Skicka e-post till ägare",
"routing_forms_send_email_owner_description": "Skickar ett e-postmeddelande till ägaren när formuläret skickas",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Bokningsgränser uppdaterades framgångsrikt",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har inte behörighet att göra denna ändring i bokningen",
"matching_members": "Matchande medlemmar",
"matching_members_queue_using_attribute_weights": "Matchande medlemmars kö (med attributvikter)",
"matching_members_queue_using_event_assignee_weights": "Matchande medlemmars kö (med händelsetilldelningsvikter)",
"no_matching_members": "Inga matchande medlemmar. Det kommer att återgå till att använda de teammedlemmar som är tilldelade till händelsetypen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Inga matchande medlemmar. Det kommer att falla tillbaka på att använda teammedlemmarna som är tilldelade till händelsetypen. Överväg att lägga till en reservplan eller korrigera logiken för using_fallback_members",
"hide_calendar_event_details": "Dölj kalenderhändelsedetaljer på delade kalendrar",
@@ -2722,6 +2727,8 @@
"add_new_field": "Lägg till nytt fält",
"you_dont_have_access_to_reroute_this_booking": "Du har inte behörighet att omdirigera denna bokning",
"form_response_not_found": "Formulärsvar hittades inte",
"contact_owner": "Kontaktägare",
"contact_owner_not_found": "Hittades inte",
"using_fallback_members": "Använder reservmedlemmar",
"chosen_route": "Vald rutt",
"attribute_logic_matched": "Attributlogik matchad",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Bokningens startdatum",
"booking_created_date": "Bokningens skapandedatum",
"booking_reassigned_to_host": "Bokning omtilldelad till {{host}}",
"no_contact_owner": "Ingen kontaktägare",
"routing_forms_created": "Skapade dirigeringsformulär",
"routing_forms_total_responses": "Totalt antal svar",
"routing_forms_total_responses_without_booking": "Totalt antal svar utan bokning",
"routing_forms_total_responses_with_booking": "Totalt antal svar med bokning",
"routing_form_insights": "Inga bokningar",
"routing_form_insights_form_name_header": "Formulärnamn",
"routing_form_insights_booking_status_header": "Bokning",
"routing_form_insights_response_no_booking": "Ingen bokning",
"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)",
"salesforce_route_to_custom_lookup_field": "Rikta till en användare som matchar ett uppslagsfält på ett konto",
"salesforce_option": "Salesforce-alternativ",
"lookup_field_name": "Uppslagsfältsnamn",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Möjliggör optimering av distributionen för maximal tillgänglighet eller för att uppnå en mer balanserad fördelning.",
"rr_distribution_method_availability_title": "Maximera tillgänglighet",
"rr_distribution_method_availability_description": "Låter bokare boka möten när en värd är tillgänglig. Använd detta för att maximera antalet potentiella möten som kan bokas och när jämn fördelning av möten mellan värdar är mindre viktigt.",
"rr_distribution_method_balanced_title": "Lastbalansering",
"rr_distribution_method_balanced_description": "Vi övervakar hur många bokningar som har gjorts med varje värd och jämför detta med andra, och inaktiverar vissa värdar som ligger för långt före så att bokningarna fördelas jämnt.",
"exclude_emails_that_contain": "Uteslut e-postmeddelanden som innehåller ...",
"exclude_emails_match_found_error_message": "Ange en giltig arbets-e-postadress",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Rezervasyonlara dön",
"free_to_pick_another_event_type": "İstediğiniz zaman başka bir etkinlik seçmekten çekinmeyin.",
"cancelled": "İptal edildi",
"no_booking": "Rezervasyon yok",
"awaiting_host": "Ev sahibi bekleniyor",
"cancellation_successful": "İptal işlemi başarılı",
"really_cancel_booking": "Rezervasyonunuz gerçekten iptal edilsin mi?",
"cannot_cancel_booking": "Bu rezervasyonu iptal edemezsiniz",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Yönlendirme",
"routing_forms_description": "Katılımcıları doğru destinasyonlara yönlendirmek için form oluşturun",
"routing_forms_send_email_owner": "Sahibine E-posta Gönderin",
"routing_forms_send_email_owner_description": "Form gönderildiğinde sahibine bir e-posta gönderir",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Rezervasyon sınırları başarıyla güncellendi",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu rezervasyonda değişiklik yapma yetkiniz yok",
"matching_members": "Eşleşen üyeler",
"matching_members_queue_using_attribute_weights": "Üye eşleştirme sırası (özellik ağırlıkları kullanılarak)",
"matching_members_queue_using_event_assignee_weights": "Üye eşleştirme sırası (etkinlik atanan kişi ağırlıkları kullanılarak)",
"no_matching_members": "Eşleşen üye yok. Etkinlik türüne atanan takım üyeleri kullanılacaktır.",
"no_matching_members_will_fallback_to_all_assigned_members": "Eşleşen üye yok. Etkinlik türüne atanmış tüm ekip üyelerine geri dönülecek. Yedek eklemeyi veya using_fallback_members mantığını düzeltmeyi düşünün",
"hide_calendar_event_details": "Paylaşılan takvimlerde etkinlik ayrıntılarını gizle",
@@ -2722,6 +2727,8 @@
"add_new_field": "Yeni alan ekle",
"you_dont_have_access_to_reroute_this_booking": "Bu rezervasyonu yeniden yönlendirme erişiminiz yok",
"form_response_not_found": "Form yanıtı bulunamadı",
"contact_owner": "İletişim sahibi",
"contact_owner_not_found": "Bulunamadı",
"using_fallback_members": "Yedek üyeler kullanılıyor",
"chosen_route": "Seçilen rota",
"attribute_logic_matched": "Özellik mantığı eşleşti",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Rezervasyon başlangıç tarihi",
"booking_created_date": "Rezervasyon oluşturma tarihi",
"booking_reassigned_to_host": "Rezervasyon {{host}} kişisine yeniden atandı",
"no_contact_owner": "İletişim sahibi yok",
"routing_forms_created": "Oluşturulan yönlendirme formları",
"routing_forms_total_responses": "Toplam yanıtlar",
"routing_forms_total_responses_without_booking": "Rezervasyonsuz toplam yanıtlar",
"routing_forms_total_responses_with_booking": "Rezervasyonlu toplam yanıtlar",
"routing_form_insights": "Rezervasyon yok",
"routing_form_insights_form_name_header": "Form adı",
"routing_form_insights_booking_status_header": "Rezervasyon",
"routing_form_insights_response_no_booking": "Rezervasyon yok",
"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)",
"salesforce_route_to_custom_lookup_field": "Bir hesaptaki arama alanıyla eşleşen bir kullanıcıya yönlendir",
"salesforce_option": "Salesforce seçeneği",
"lookup_field_name": "Arama alanı adı",
"rr_distribution_method": "Dağıtım",
"rr_distribution_method_description": "Maksimum kullanılabilirlik için dağıtımı optimize etmeye veya daha dengeli bir atama sağlamaya olanak tanır.",
"rr_distribution_method_availability_title": "Kullanılabilirliği maksimize et",
"rr_distribution_method_availability_description": "Rezervasyon yapanların, bir ev sahibi müsait olduğunda toplantı ayarlamasına olanak tanır. Potansiyel toplantı rezervasyonlarının sayısını en üst düzeye çıkarmak ve ev sahipleri arasında toplantıların eşit dağılımının daha az önemli olduğu durumlarda bunu kullanın.",
"rr_distribution_method_balanced_title": "Yük dengeleme",
"rr_distribution_method_balanced_description": "Her ev sahibiyle yapılan rezervasyon sayısını takip edip diğerleriyle karşılaştıracağız ve rezervasyonların eşit dağılımını sağlamak için çok ileri giden bazı ev sahiplerini devre dışı bırakacağız.",
"exclude_emails_that_contain": "... içeren e-postaları hariç tut",
"exclude_emails_match_found_error_message": "Lütfen geçerli bir iş e-posta adresi girin",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Повернутися до бронювань",
"free_to_pick_another_event_type": "Ви можете будь-коли вибрати інший захід.",
"cancelled": "Скасовано",
"no_booking": "Без бронювання",
"awaiting_host": "Очікування господаря",
"cancellation_successful": "Скасовано",
"really_cancel_booking": "Справді скасувати ваше бронювання?",
"cannot_cancel_booking": "Ви не можете скасувати це бронювання",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Маршрутизація",
"routing_forms_description": "Створюйте форми, щоб правильно скеровувати відвідувачів",
"routing_forms_send_email_owner": "Надіслати власнику електронний лист",
"routing_forms_send_email_owner_description": "Коли надсилається форма, власник отримує електронний лист",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Обмеження бронювання успішно оновлено",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Ви не маєте дозволу на внесення цієї зміни до бронювання",
"matching_members": "Відповідні учасники",
"matching_members_queue_using_attribute_weights": "Черга відповідних учасників (з використанням ваги атрибутів)",
"matching_members_queue_using_event_assignee_weights": "Черга відповідних учасників (з використанням ваги призначених подій)",
"no_matching_members": "Відповідних учасників немає. Буде використано учасників команди, призначених для цього типу події.",
"no_matching_members_will_fallback_to_all_assigned_members": "Немає відповідних учасників. Буде використано всіх учасників команди, призначених для типу події. Розгляньте можливість додавання резервного варіанту або виправлення логіки використання резервних учасників",
"hide_calendar_event_details": "Приховати деталі подій календаря у спільних календарях",
@@ -2722,6 +2727,8 @@
"add_new_field": "Додати нове поле",
"you_dont_have_access_to_reroute_this_booking": "У вас немає доступу для перенаправлення цього бронювання",
"form_response_not_found": "Відповідь форми не знайдено",
"contact_owner": "Власник контакту",
"contact_owner_not_found": "Не знайдено",
"using_fallback_members": "Використання резервних учасників",
"chosen_route": "Обраний маршрут",
"attribute_logic_matched": "Логіка атрибутів співпала",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Дата початку бронювання",
"booking_created_date": "Дата створення бронювання",
"booking_reassigned_to_host": "Бронювання перепризначено {{host}}",
"no_contact_owner": "Немає власника контакту",
"routing_forms_created": "Створено форми маршрутизації",
"routing_forms_total_responses": "Загальна кількість відповідей",
"routing_forms_total_responses_without_booking": "Відповіді без бронювання",
"routing_forms_total_responses_with_booking": "Відповіді з бронюванням",
"routing_form_insights": "Без бронювань",
"routing_form_insights_form_name_header": "Назва форми",
"routing_form_insights_booking_status_header": "Бронювання",
"routing_form_insights_response_no_booking": "Без бронювання",
"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)",
"salesforce_route_to_custom_lookup_field": "Маршрутизація до користувача, який відповідає полю пошуку в обліковому записі",
"salesforce_option": "Опція Salesforce",
"lookup_field_name": "Назва поля пошуку",
"rr_distribution_method": "Розподіл",
"rr_distribution_method_description": "Дозволяє оптимізувати розподіл для максимальної доступності або для більш збалансованого розподілу завдань.",
"rr_distribution_method_availability_title": "Максимізувати доступність",
"rr_distribution_method_availability_description": "Дозволяє учасникам бронювати зустрічі, коли хост доступний. Використовуйте це для максимізації кількості потенційних зустрічей та коли рівномірний розподіл зустрічей між хостами менш важливий.",
"rr_distribution_method_balanced_title": "Балансування навантаження",
"rr_distribution_method_balanced_description": "Ми будемо відстежувати кількість бронювань для кожного хоста та порівнювати їх з іншими, деактивуючи тих хостів, які значно випереджають інших, щоб бронювання розподілялися рівномірно.",
"exclude_emails_that_contain": "Виключити електронні адреси, які містять ...",
"exclude_emails_match_found_error_message": "Будь ласка, введіть дійсну робочу електронну адресу",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "Quay lại các lịch hẹn",
"free_to_pick_another_event_type": "Hãy tuỳ ý chọn một sự kiện khác bất cứ lúc nào.",
"cancelled": "Đã hủy",
"no_booking": "Không có đặt chỗ",
"awaiting_host": "Đang chờ chủ trì",
"cancellation_successful": "Hủy thành công",
"really_cancel_booking": "Thực sự hủy lịch hẹn của bạn?",
"cannot_cancel_booking": "Bạn không thể hủy lịch hẹn này",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Định tuyến",
"routing_forms_description": "Tạo các biểu mẫu để chuyển hướng người tham dự đến đúng địa điểm",
"routing_forms_send_email_owner": "Gửi email đến chủ sở hữu",
"routing_forms_send_email_owner_description": "Gửi một email đến chủ sở hữu khi biểu mẫu được gửi đi",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "Giới hạn đặt lịch đã được cập nhật thành công",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bạn không có quyền thực hiện thay đổi này đối với đặt chỗ",
"matching_members": "Thành viên phù hợp",
"matching_members_queue_using_attribute_weights": "Hàng đợi thành viên phù hợp (sử dụng trọng số thuộc tính)",
"matching_members_queue_using_event_assignee_weights": "Hàng đợi thành viên phù hợp (sử dụng trọng số người được chỉ định sự kiện)",
"no_matching_members": "Không có thành viên phù hợp. Hệ thống sẽ sử dụng các thành viên trong nhóm được chỉ định cho loại sự kiện.",
"no_matching_members_will_fallback_to_all_assigned_members": "Không có thành viên phù hợp. Hệ thống sẽ sử dụng tất cả thành viên nhóm được chỉ định cho loại sự kiện. Hãy cân nhắc thêm phương án dự phòng hoặc điều chỉnh logic sử dụng thành viên dự phòng",
"hide_calendar_event_details": "Ẩn chi tiết sự kiện trên lịch chia sẻ",
@@ -2722,6 +2727,8 @@
"add_new_field": "Thêm trường mới",
"you_dont_have_access_to_reroute_this_booking": "Bạn không có quyền chuyển hướng đặt lịch này",
"form_response_not_found": "Không tìm thấy phản hồi biểu mẫu",
"contact_owner": "Người sở hữu liên hệ",
"contact_owner_not_found": "Không tìm thấy",
"using_fallback_members": "Đang sử dụng thành viên dự phòng",
"chosen_route": "Lộ trình đã chọn",
"attribute_logic_matched": "Logic thuộc tính phù hợp",
@@ -2760,11 +2767,33 @@
"booking_start_date": "Ngày bắt đầu đặt lịch",
"booking_created_date": "Ngày tạo đặt lịch",
"booking_reassigned_to_host": "Đặt lịch được chuyển giao cho {{host}}",
"no_contact_owner": "Không có người sở hữu liên hệ",
"routing_forms_created": "Biểu mẫu định tuyến đã tạo",
"routing_forms_total_responses": "Tổng số phản hồi",
"routing_forms_total_responses_without_booking": "Tổng số phản hồi không có đặt chỗ",
"routing_forms_total_responses_with_booking": "Tổng số phản hồi có đặt chỗ",
"routing_form_insights": "Không có đặt chỗ",
"routing_form_insights_form_name_header": "Tên biểu mẫu",
"routing_form_insights_booking_status_header": "Trạng thái đặt chỗ",
"routing_form_insights_response_no_booking": "Không có đặt chỗ",
"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)",
"salesforce_route_to_custom_lookup_field": "Chuyển đến người dùng phù hợp với trường tra cứu trên tài khoản",
"salesforce_option": "Tùy chọn Salesforce",
"lookup_field_name": "Tên trường tra cứu",
"rr_distribution_method": "Phân phối",
"rr_distribution_method_description": "Cho phép tối ưu hóa việc phân phối để đạt khả năng sẵn sàng tối đa hoặc hướng đến việc phân công cân bằng hơn.",
"rr_distribution_method_availability_title": "Tối đa hóa khả năng sẵn sàng",
"rr_distribution_method_availability_description": "Cho phép người đặt lịch đặt cuộc họp bất cứ khi nào người chủ trì có mặt. Sử dụng tính năng này để tối đa hóa số lượng cuộc họp tiềm năng được đặt và khi việc phân phối cuộc họp đồng đều giữa các chủ trì ít quan trọng hơn.",
"rr_distribution_method_balanced_title": "Cân bằng tải",
"rr_distribution_method_balanced_description": "Chúng tôi sẽ theo dõi số lượng đặt lịch đã được thực hiện với từng chủ trì và so sánh với những người khác, vô hiệu hóa một số chủ trì đã nhận quá nhiều lịch để đảm bảo việc đặt lịch được phân phối đồng đều.",
"exclude_emails_that_contain": "Loại trừ các email chứa ...",
"exclude_emails_match_found_error_message": "Vui lòng nhập địa chỉ email công việc hợp lệ",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "返回预约",
"free_to_pick_another_event_type": "随时可以选择另一个活动。",
"cancelled": "已取消",
"no_booking": "无预订",
"awaiting_host": "等待主持人",
"cancellation_successful": "取消成功",
"really_cancel_booking": "真的要取消您的预约吗?",
"cannot_cancel_booking": "您不能取消此预约",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "路由",
"routing_forms_description": "创建表格以将参与者引导到正确的目标",
"routing_forms_send_email_owner": "向所有者发送电子邮件",
"routing_forms_send_email_owner_description": "提交表单后向所有者发送电子邮件",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "预订限制更新成功",
"you_are_unauthorized_to_make_this_change_to_the_booking": "您无权对此预订进行更改",
"matching_members": "匹配的成员",
"matching_members_queue_using_attribute_weights": "使用属性权重的匹配成员队列",
"matching_members_queue_using_event_assignee_weights": "使用事件分配者权重的匹配成员队列",
"no_matching_members": "没有匹配的成员。将回退到使用分配给事件类型的团队成员。",
"no_matching_members_will_fallback_to_all_assigned_members": "没有匹配的成员。将回退使用分配给该事件类型的团队成员。考虑添加一个回退选项或修正使用回退成员的逻辑",
"hide_calendar_event_details": "隐藏共享日历中的事件详情",
@@ -2722,6 +2727,8 @@
"add_new_field": "添加新字段",
"you_dont_have_access_to_reroute_this_booking": "您无权重新路由此预订",
"form_response_not_found": "未找到表单响应",
"contact_owner": "联系所有者",
"contact_owner_not_found": "未找到",
"using_fallback_members": "正在使用回退成员",
"chosen_route": "选定路线",
"attribute_logic_matched": "属性逻辑匹配",
@@ -2760,11 +2767,33 @@
"booking_start_date": "预约开始日期",
"booking_created_date": "预约创建日期",
"booking_reassigned_to_host": "预约重新分配给 {{host}}",
"no_contact_owner": "没有联系所有者",
"routing_forms_created": "已创建路由表单",
"routing_forms_total_responses": "总响应数",
"routing_forms_total_responses_without_booking": "无预订的总响应数",
"routing_forms_total_responses_with_booking": "有预订的总响应数",
"routing_form_insights": "无预订",
"routing_form_insights_form_name_header": "表单名称",
"routing_form_insights_booking_status_header": "预订",
"routing_form_insights_response_no_booking": "无预订",
"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": "不会强制指定联系人所有者(如果符合属性和轮询标准,仍可成为主持人)",
"salesforce_route_to_custom_lookup_field": "路由至与账户查找字段匹配的用户",
"salesforce_option": "Salesforce选项",
"lookup_field_name": "查找字段名称",
"rr_distribution_method": "分配方式",
"rr_distribution_method_description": "可以优化分配方式以实现最大可用性或更均衡的任务分配。",
"rr_distribution_method_availability_title": "最大化可用性",
"rr_distribution_method_availability_description": "允许预订者在任何主持人可用时预订会议。当会议在主持人之间的均衡分配不太重要时,可使用此选项来最大化潜在会议预订数量。",
"rr_distribution_method_balanced_title": "负载均衡",
"rr_distribution_method_balanced_description": "系统会监控每位主持人已接受的预订数量并与其他人进行比较,如果某些主持人的预订数量远超他人,系统会暂时停用这些主持人,以确保预订均匀分配。",
"exclude_emails_that_contain": "排除包含...的电子邮件",
"exclude_emails_match_found_error_message": "请输入有效的工作电子邮件地址",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 在此上方添加您的新字符串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -410,6 +410,8 @@
"back_to_bookings": "回去預約列表",
"free_to_pick_another_event_type": "請自由挑選其它活動。",
"cancelled": "已取消",
"no_booking": "無預約",
"awaiting_host": "等待主持人",
"cancellation_successful": "成功取消",
"really_cancel_booking": "真的要取消預約嗎?",
"cannot_cancel_booking": "無法取消這次的預約",
@@ -1578,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "路由",
"routing_forms_description": "建立表單以將參與者引導到正確的目的地",
"routing_forms_send_email_owner": "傳送電子郵件給擁有者",
"routing_forms_send_email_owner_description": "表單提交後傳送電子郵件給擁有者",
@@ -2673,6 +2676,8 @@
"booking_limits_updated_successfully": "預約限制更新成功",
"you_are_unauthorized_to_make_this_change_to_the_booking": "您無權更改此預訂",
"matching_members": "符合條件的成員",
"matching_members_queue_using_attribute_weights": "成員匹配隊列(使用屬性權重)",
"matching_members_queue_using_event_assignee_weights": "成員匹配隊列(使用事件指派權重)",
"no_matching_members": "沒有符合條件的成員。將回退使用分配給事件類型的團隊成員。",
"no_matching_members_will_fallback_to_all_assigned_members": "沒有符合條件的成員。將回退使用指派給該事件類型的團隊成員。請考慮添加回退選項或修正使用回退成員的邏輯",
"hide_calendar_event_details": "隱藏共享行事曆中的事件詳情",
@@ -2722,6 +2727,8 @@
"add_new_field": "新增欄位",
"you_dont_have_access_to_reroute_this_booking": "您沒有權限重新安排此預訂",
"form_response_not_found": "找不到表單回應",
"contact_owner": "聯絡人擁有者",
"contact_owner_not_found": "未找到",
"using_fallback_members": "使用回退成員",
"chosen_route": "選定路徑",
"attribute_logic_matched": "屬性邏輯匹配",
@@ -2760,11 +2767,33 @@
"booking_start_date": "預約開始日期",
"booking_created_date": "預約創建日期",
"booking_reassigned_to_host": "預約重新分配給 {{host}}",
"no_contact_owner": "無聯絡人擁有者",
"routing_forms_created": "已建立的路由表單",
"routing_forms_total_responses": "總回應數",
"routing_forms_total_responses_without_booking": "未預約的總回應數",
"routing_forms_total_responses_with_booking": "已預約的總回應數",
"routing_form_insights": "無預約",
"routing_form_insights_form_name_header": "表單名稱",
"routing_form_insights_booking_status_header": "預約狀態",
"routing_form_insights_response_no_booking": "無預約",
"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條件,仍可成為主持人)",
"salesforce_route_to_custom_lookup_field": "路由至符合帳戶查詢欄位的使用者",
"salesforce_option": "Salesforce選項",
"lookup_field_name": "查詢欄位名稱",
"rr_distribution_method": "分配方式",
"rr_distribution_method_description": "可以優化分配方式以達到最大可用性或更平衡的任務分配。",
"rr_distribution_method_availability_title": "最大化可用性",
"rr_distribution_method_availability_description": "讓預約者可以在任何主持人有空的時候預約會議。當會議在主持人之間的平均分配不是那麼重要時,可使用此選項來最大化潛在會議預約數量。",
"rr_distribution_method_balanced_title": "負載平衡",
"rr_distribution_method_balanced_description": "系統會監控每位主持人的預約數量並與其他人進行比較,當某些主持人的預約量遠超過其他人時,系統會暫時停用這些主持人,以確保預約能夠均勻分配。",
"exclude_emails_that_contain": "排除包含...的電子郵件",
"exclude_emails_match_found_error_message": "請輸入有效的工作電子郵件地址",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 請在此處新增您的字串 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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.
+2 -1
View File
@@ -31,7 +31,8 @@
"uk",
"vi",
"zh-CN",
"zh-TW"
"zh-TW",
"sk-SK"
]
},
"buckets": {
+29
View File
@@ -412,6 +412,8 @@ checksums:
back_to_bookings: 31707a41bd8a4488dffe1d54e1b4c177
free_to_pick_another_event_type: adc32eadcc346fe4784782433f0b4767
cancelled: 342a409089d71ce45725add1c62b0ade
no_booking: a55bbf9d47138d264b74c4dca33dae32
awaiting_host: b4e47444d5cf2ffdde05041f39157c8b
cancellation_successful: e65aac18bb95f1d23e9c8390f3126059
really_cancel_booking: d8155209f014a4116151fa2890d2bd48
cannot_cancel_booking: b9fed5d3d04c4e94b9b510d76951dfb0
@@ -1580,6 +1582,7 @@ checksums:
exchange_version_2013_SP1: fe740dec189a7104a23fc564197c1ffa
exchange_version_2015: 50e79a72f00a2753fb9fb573ebd3e805
exchange_version_2016: 2f05e1e406207f418ab99c35364c05c2
routing: 3dd4822bd712b965dcc2a9112264e480
routing_forms_description: ac1f7ea1413defaa500350053495fa4b
routing_forms_send_email_owner: ea60c8ba5274a1895df367b8a98156e3
routing_forms_send_email_owner_description: be57f56ab885aa51dfa1f2a9d133a069
@@ -2675,6 +2678,8 @@ checksums:
booking_limits_updated_successfully: c1dbc8e3ecbbc1c25351806a30ab6368
you_are_unauthorized_to_make_this_change_to_the_booking: c770486d5b4b98a407ff485cbe60e563
matching_members: 8b5c6581fb35a737d20c3ac06b2955b0
matching_members_queue_using_attribute_weights: cee4482ef0fef095ab86d574667dd505
matching_members_queue_using_event_assignee_weights: 87e3f1babccac7a70f5b8d30b2a1827f
no_matching_members: 4d8e678a655a70e9ee5c11104d554bfa
no_matching_members_will_fallback_to_all_assigned_members: ae070f7b0924069ce3571693a7f4fb22
hide_calendar_event_details: 18686c17d852ffc8acf5549587897df5
@@ -2724,6 +2729,8 @@ checksums:
add_new_field: e7b24e3e4404b561e5e81a4f6b1b624c
you_dont_have_access_to_reroute_this_booking: cfb2e5786303f9acf1f32f6a68047ef7
form_response_not_found: d5f6964ba3ec5928e77212e8fe2e822c
contact_owner: 281b97d3844cc0d3b7d35721e4dc9700
contact_owner_not_found: 58efcf2ecdcf18f0cd0253acc60eb812
using_fallback_members: c2e328866abb997e992c917900d21081
chosen_route: e64030b4c8aba409633af4cf95bff98e
attribute_logic_matched: 3a183f5a6db0f695945f6a03c8979d90
@@ -2762,10 +2769,32 @@ checksums:
booking_start_date: ef6f4addaa24a5c7dce41b4d91868ba6
booking_created_date: 902300aaa13a56a2295d561d46073eeb
booking_reassigned_to_host: 9eb99d1201d75ac0366daf0f09bc360a
no_contact_owner: f620730f57bb4a286e91bebfdaf472a7
routing_forms_created: 05d239cae00705c891fb4877949631db
routing_forms_total_responses: 14e8891d4570f994467aeb04d4a13f86
routing_forms_total_responses_without_booking: 1e4f7848cf9bdcd9da190e4905060e0b
routing_forms_total_responses_with_booking: a2c70c857977e82f2b6e00baacb0b280
routing_form_insights: d57b1e71e665222dd8a348c94dba89db
routing_form_insights_form_name_header: b3c1ebbd80d70a6edf7ab37bb84749b0
routing_form_insights_booking_status_header: 15ba10c4735250b49db380f5d93f42d0
routing_form_insights_response_no_booking: 0444e9e998dad620e0f6c777e90f76ce
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
salesforce_route_to_custom_lookup_field: 672a24e4de55eb073559e94b9266da7d
salesforce_option: f09423b2785ee269db428163fc0a58d0
lookup_field_name: 8d81d557128a52661d1ed9e9aae0575c
rr_distribution_method: c3d4633236c01f8314a38a9e9c2361f1
rr_distribution_method_description: e9c1dda0299fbfc979a52a79b57e40c7
rr_distribution_method_availability_title: 3a7c8afaf3ddb8d262e2a03d7fc8779d
rr_distribution_method_availability_description: 0fc05c527e1307e0c20165f0cefe2ed4
rr_distribution_method_balanced_title: f13fe5c1c5e35ad5b2e0139df5c3e62f
rr_distribution_method_balanced_description: 23fd5a95ac9d9f6283883180ba30db32
exclude_emails_that_contain: d43255c873c75c8fe1adcb8f8b7af914
exclude_emails_match_found_error_message: 4ca9429a41d63e53caffeceb0e4229f7
ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS: 18323f2f3fabb169a7cb50ff62433850
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import type { z } from "zod";
import CrmManager from "@calcom/core/crmManager/crmManager";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
@@ -11,14 +12,15 @@ export async function getCRMContactOwnerForRRLeadSkip(
eventTypeMetadata: Prisma.JsonValue
): Promise<string | undefined> {
const parsedEventTypeMetadata = EventTypeMetaDataSchema.safeParse(eventTypeMetadata);
if (!parsedEventTypeMetadata.success || !parsedEventTypeMetadata.data?.apps) return;
const crm = await getCRMManagerWithRRLeadSkip(parsedEventTypeMetadata.data.apps);
if (!crm) return;
const startTime = performance.now();
const contact = await crm.getContacts({ emails: bookerEmail, forRoundRobinSkip: true });
const endTime = performance.now();
logger.info(`Fetching from CRM took ${endTime - startTime}ms`);
if (!contact?.length) return;
return contact[0].ownerEmail;
}
@@ -68,7 +68,7 @@ vi.mock("@calcom/lib/hooks/useLocale", () => ({
}));
let findTeamMembersMatchingAttributeLogicResponse: {
result: { email: string }[] | null;
result: { users: { email: string }[] } | null;
checkedFallback: boolean;
mainWarnings?: string[] | null;
fallbackWarnings?: string[] | null;
@@ -97,7 +97,7 @@ function mockFindTeamMembersMatchingAttributeLogicResponse(
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
appRoutingForms: {
routingForms: {
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
@@ -210,7 +210,9 @@ describe("TestFormDialog", () => {
it("suggests to add fallback when matching members is empty and fallback is not checked", async () => {
mockEventTypeRedirectUrlMatchingRoute();
mockFindTeamMembersMatchingAttributeLogicResponse({
result: [],
result: {
users: [],
},
checkedFallback: false,
});
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
@@ -264,7 +266,9 @@ describe("TestFormDialog", () => {
it("should show No in main and fallback matched", async () => {
mockEventTypeRedirectUrlMatchingRoute();
mockFindTeamMembersMatchingAttributeLogicResponse({
result: [],
result: {
users: [],
},
checkedFallback: true,
mainWarnings: null,
fallbackWarnings: null,
@@ -237,7 +237,16 @@ type SingleFormComponentProps = {
};
type MembersMatchResultType = {
isUsingAttributeWeights: boolean;
eventTypeRedirectUrl: string | null;
contactOwnerEmail: string | null;
teamMembersMatchingAttributeLogic: { id: number; name: string | null; email: string }[] | null;
perUserData: {
bookingsCount: Record<number, number>;
bookingShortfalls: Record<number, number> | null;
calibrations: Record<number, number> | null;
weights: Record<number, number> | null;
} | null;
checkedFallback: boolean;
mainWarnings: string[] | null;
fallbackWarnings: string[] | null;
@@ -273,7 +282,7 @@ const TeamMembersMatchResult = ({
return !membersMatchResult.checkedFallback ? t("yes") : t("no");
};
const renderMatchingMembers = () => {
const renderQueue = () => {
if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) {
if (membersMatchResult.checkedFallback) {
return (
@@ -291,12 +300,48 @@ const TeamMembersMatchResult = ({
);
}
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic.map(
(member) => member.email
);
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic;
if (matchingMembers.length) {
return <span className="font-semibold">{matchingMembers.join(", ")}</span>;
if (matchingMembers.length && membersMatchResult.perUserData) {
const perUserData = membersMatchResult.perUserData;
return (
<span className="font-semibold">
<div className="mt-2 overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">#</th>
<th className="py-2 pr-4">Email</th>
<th className="py-2 pr-4">Bookings</th>
{membersMatchResult.perUserData.weights ? <th className="py-2">Weight</th> : null}
{membersMatchResult.perUserData.calibrations ? <th className="py-2">Calibration</th> : null}
{membersMatchResult.perUserData.bookingShortfalls ? (
<th className="border-l py-2 pl-2">Shortfall</th>
) : null}
</tr>
</thead>
<tbody>
{matchingMembers.map((member, index) => (
<tr key={member.id} className="border-b">
<td className="py-2 pr-4">{index + 1}</td>
<td className="py-2 pr-4">{member.email}</td>
<td className="py-2">{perUserData.bookingsCount[member.id] ?? 0}</td>
{perUserData.weights ? (
<td className="py-2">{perUserData.weights[member.id] ?? 0}</td>
) : null}
{perUserData.calibrations ? (
<td className="py-2">{perUserData.calibrations[member.id] ?? 0}</td>
) : null}
{perUserData.bookingShortfalls ? (
<td className="border-l py-2 pl-2">{perUserData.bookingShortfalls[member.id] ?? 0}</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</span>
);
}
return (
@@ -328,8 +373,23 @@ const TeamMembersMatchResult = ({
/>
)}
</div>
<div data-testid="matching-members">
{t("matching_members")}: {renderMatchingMembers()}
<div className="mt-4">
{membersMatchResult.contactOwnerEmail ? (
<div data-testid="contact-owner-email">
{t("contact_owner")}:{" "}
<span className="font-semibold">{membersMatchResult.contactOwnerEmail}</span>
</div>
) : (
<div data-testid="contact-owner-email">
{t("contact_owner")}: <span className="font-semibold">Not found</span>
</div>
)}
<div className="mt-2" data-testid="matching-members">
{membersMatchResult.isUsingAttributeWeights
? t("matching_members_queue_using_attribute_weights")
: t("matching_members_queue_using_event_assignee_weights")}
{renderQueue()}
</div>
</div>
</div>
);
@@ -362,7 +422,7 @@ export const TestFormDialog = ({
const { t } = useLocale();
const [response, setResponse] = useState<FormResponse>({});
const [chosenRoute, setChosenRoute] = useState<NonRouterRoute | null>(null);
const [eventTypeUrl, setEventTypeUrl] = useState("");
const [eventTypeUrlWithoutParams, setEventTypeUrlWithoutParams] = useState("");
const searchParams = useCompatSearchParams();
const isTeamForm = !!form.teamId;
const [membersMatchResult, setMembersMatchResult] = useState<MembersMatchResultType | null>(null);
@@ -371,10 +431,14 @@ export const TestFormDialog = ({
setMembersMatchResult(null);
};
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setMembersMatchResult({
teamMembersMatchingAttributeLogic: data.result,
isUsingAttributeWeights: data.isUsingAttributeWeights,
eventTypeRedirectUrl: data.eventTypeRedirectUrl,
contactOwnerEmail: data.contactOwnerEmail,
teamMembersMatchingAttributeLogic: data.result ? data.result.users : data.result,
perUserData: data.result ? data.result.perUserData : null,
checkedFallback: data.checkedFallback,
mainWarnings: data.mainWarnings,
fallbackWarnings: data.fallbackWarnings,
@@ -391,27 +455,30 @@ export const TestFormDialog = ({
function testRouting() {
const route = findMatchingRoute({ form, response });
let eventTypeRedirectUrl: string | null = null;
if (route?.action?.type === "eventTypeRedirectUrl") {
setEventTypeUrl(
getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: route.action.value,
form,
allURLSearchParams: new URLSearchParams(),
})
);
eventTypeRedirectUrl = getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: route.action.value,
form,
allURLSearchParams: new URLSearchParams(),
});
setEventTypeUrlWithoutParams(eventTypeRedirectUrl);
}
setChosenRoute(route || null);
if (!route) return;
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response,
route,
isPreview: true,
_enablePerf: searchParams.get("enablePerf") === "true",
});
if (isTeamForm) {
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response,
route,
isPreview: true,
_enablePerf: searchParams.get("enablePerf") === "true",
});
}
}
const renderTestResult = () => {
@@ -461,7 +528,14 @@ export const TestFormDialog = ({
) : (
<div className="flex flex-col space-y-2">
<span className="text-default underline">
<a target="_blank" href={eventTypeUrl} rel="noreferrer" data-testid="test-routing-result">
<a
target="_blank"
className={cn(
findTeamMembersMatchingAttributeLogicMutation.isPending && "pointer-events-none"
)}
href={membersMatchResult?.eventTypeRedirectUrl ?? eventTypeUrlWithoutParams}
rel="noreferrer"
data-testid="test-routing-result">
{chosenRoute.action.value}
</a>
</span>
@@ -486,7 +560,7 @@ export const TestFormDialog = ({
return (
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogContent size="md" enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
@@ -554,7 +628,6 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
const sendUpdatesTo = hookForm.watch("settings.sendUpdatesTo", []) as number[];
const sendToAll = hookForm.watch("settings.sendToAll", false) as boolean;
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast(t("form_updated_successfully"), "success");
@@ -20,7 +20,7 @@ type GetUrlSearchParamsToForwardOptions = {
"id" | "type" | "options" | "identifier" | "label"
>[];
searchParams: URLSearchParams;
formResponseId: number;
formResponseId: number | null;
teamMembersMatchingAttributeLogic: number[] | null;
attributeRoutingConfig: AttributeRoutingConfig | null;
reroutingFormResponses?: FormResponseValueOnly;
@@ -152,3 +152,26 @@ export function getUrlSearchParamsToForwardForReroute({
reroutingFormResponses,
});
}
export function getUrlSearchParamsToForwardForTestPreview({
formResponse,
fields,
attributeRoutingConfig,
teamMembersMatchingAttributeLogic,
}: Pick<
GetUrlSearchParamsToForwardOptions,
"formResponse" | "fields" | "attributeRoutingConfig" | "teamMembersMatchingAttributeLogic"
>) {
// There are no existing query params to forward in test preview. These are available only when doing the actual form submission
const searchParams = new URLSearchParams();
searchParams.set("cal.isTestPreviewLink", "true");
return getUrlSearchParamsToForward({
formResponse,
fields,
attributeRoutingConfig,
teamMembersMatchingAttributeLogic,
// There is no form response being stored in test preview
formResponseId: null,
searchParams,
});
}
@@ -5,7 +5,6 @@ import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { ZDeleteFormInputSchema } from "./deleteForm.schema";
import { ZFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema";
import { ZFormMutationInputSchema } from "./formMutation.schema";
import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
@@ -97,16 +96,6 @@ const appRoutingForms = router({
);
return handler({ ctx, input });
}),
findTeamMembersMatchingAttributeLogic: authedProcedure
.input(ZFindTeamMembersMatchingAttributeLogicInputSchema)
.mutation(async ({ ctx, input }) => {
const handler = await getHandler(
"findTeamMembersMatchingAttributeLogic",
() => import("./findTeamMembersMatchingAttributeLogic.handler")
);
return handler({ ctx, input });
}),
});
export default appRoutingForms;
@@ -1,121 +0,0 @@
import type { ServerResponse } from "http";
import type { NextApiResponse } from "next";
import { entityPrismaWhereClause } from "@calcom/lib/entityPermissionUtils";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema";
interface FindTeamMembersMatchingAttributeLogicHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
res: ServerResponse | NextApiResponse | undefined;
};
input: TFindTeamMembersMatchingAttributeLogicInputSchema;
}
export const findTeamMembersMatchingAttributeLogicHandler = async ({
ctx,
input,
}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => {
const { prisma, user } = ctx;
const { formId, response, route, isPreview, _enablePerf, _concurrency } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
id: formId,
...entityPrismaWhereClause({ userId: user.id }),
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
if (!form.teamId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This form is not associated with a team",
});
}
const serializableForm = await getSerializableForm({ form });
const {
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
timeTaken: teamMembersMatchingAttributeLogicTimeTaken,
troubleshooter,
checkedFallback,
mainAttributeLogicBuildingWarnings: mainWarnings,
fallbackAttributeLogicBuildingWarnings: fallbackWarnings,
} = await findTeamMembersMatchingAttributeLogicOfRoute(
{
response,
route,
form: serializableForm,
teamId: form.teamId,
isPreview: !!isPreview,
},
{
enablePerf: _enablePerf,
// Reuse same flag for enabling troubleshooter. We would normall use them together
enableTroubleshooter: _enablePerf,
concurrency: _concurrency,
}
);
if (!matchingTeamMembersWithResult) {
return {
troubleshooter,
checkedFallback,
mainWarnings,
fallbackWarnings,
result: null,
};
}
const matchingTeamMembersIds = matchingTeamMembersWithResult.map((member) => member.userId);
const matchingTeamMembers = await UserRepository.findByIds({ ids: matchingTeamMembersIds });
console.log("_enablePerf, _concurrency", _enablePerf, _concurrency);
if (_enablePerf) {
const serverTimingHeader = getServerTimingHeader(teamMembersMatchingAttributeLogicTimeTaken);
ctx.res?.setHeader("Server-Timing", serverTimingHeader);
console.log("Server-Timing", serverTimingHeader);
}
return {
troubleshooter,
checkedFallback,
mainWarnings,
fallbackWarnings,
result: matchingTeamMembers.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
})),
};
};
function getServerTimingHeader(timeTaken: Record<string, number | null | undefined>) {
const headerParts = Object.entries(timeTaken)
.map(([key, value]) => {
if (value !== null && value !== undefined) {
return `${key};dur=${value}`;
}
return null;
})
.filter(Boolean);
return headerParts.join(", ");
}
export default findTeamMembersMatchingAttributeLogicHandler;
@@ -8,6 +8,7 @@ import type { PrismaClient } from "@calcom/prisma";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
// import { RoutingFormFieldType } from "../lib/FieldTypes";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { FormResponse } from "../types/types";
@@ -96,13 +97,6 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
});
}
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formId,
response: response,
},
});
const settings = RoutingFormSettings.parse(form.settings);
let userWithEmails: string[] = [];
if (form.teamId && settings?.sendUpdatesTo?.length) {
@@ -154,13 +148,81 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
)
: null;
// const chosenRouteName = `Route ${chosenRouteIndex + 1}`;
// if (input.isPreview) {
// // Detect if response has value for a field that isn't in the field list
// const formFields = serializableFormWithFields.fields.map((field) => field.id);
// const extraFields = Object.keys(response).filter((fieldId) => !formFields.includes(fieldId));
// const attributeRoutingConfig =
// "attributeRoutingConfig" in chosenRoute ? chosenRoute.attributeRoutingConfig ?? null : null;
// let previewData = {
// teamMemberIdsMatchingAttributeLogic,
// chosenRoute: {
// name: chosenRouteName,
// action: "action" in chosenRoute ? chosenRoute.action : null,
// },
// skipContactOwner: attributeRoutingConfig?.skipContactOwner ?? false,
// warnings: [] as string[],
// errors: [] as string[],
// };
// if (extraFields.length > 0) {
// // If response submitted directly through the /response.handler, it is useful to know which fields were non-existent
// // If we reach here through router, all extra fields are already removed from here
// previewData.warnings.push(
// `Response contains values for non-existent fields: ${extraFields.join(", ")}`
// );
// }
// // Check for values not present in options for SINGLE_SELECT and MULTISELECT fields
// serializableFormWithFields.fields.forEach((field) => {
// if (
// field.type !== RoutingFormFieldType.SINGLE_SELECT &&
// field.type !== RoutingFormFieldType.MULTI_SELECT
// ) {
// return;
// }
// const fieldResponse = response[field.id];
// if (fieldResponse && fieldResponse.value) {
// const values = Array.isArray(fieldResponse.value) ? fieldResponse.value : [fieldResponse.value];
// const invalidValues = values.filter(
// (value) => !field.options?.some((option) => option.id === value || option.label === value)
// );
// if (invalidValues.length > 0) {
// previewData.errors.push(`Invalid value(s) for ${field.label}: ${invalidValues.join(", ")}`);
// }
// }
// });
// return {
// isPreview: true,
// previewData,
// formResponse: null,
// teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
// };
// }
const dbFormResponse = await prisma.app_RoutingForms_FormResponse.create({
data: {
formId,
response: response,
chosenRouteId,
},
});
await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as FormResponse,
dbFormResponse.id,
"action" in chosenRoute ? chosenRoute.action : undefined
);
return {
isPreview: false,
formResponse: dbFormResponse,
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
attributeRoutingConfig:
@@ -11,6 +11,7 @@ export const ZResponseInputSchema = z.object({
),
// TODO: There could be existing forms loaded that will not send chosenRouteId. Make it required later.
chosenRouteId: z.string().optional(),
isPreview: z.boolean().optional(),
});
export type TResponseInputSchema = z.infer<typeof ZResponseInputSchema>;
@@ -112,6 +112,7 @@ export async function onFormSubmission(
throw new Error(`Field with id ${fieldId} not found`);
}
// Use the label lowercased as the key to identify a field.
// TODO: We seem to be using label from the response, Can we not use the field.label
const key =
form.fields.find((f) => f.id === fieldId)?.identifier ||
(fieldResponse.label as keyof typeof fieldResponsesByIdentifier);
+59 -58
View File
@@ -64,70 +64,70 @@ export enum RouteActionType {
}
export const routeActionTypeSchema = z.nativeEnum(RouteActionType);
export const children1Schema = z.object({
children1: z
.record(
z.object({
type: z.string().optional(),
properties: z
.object({
field: z.any().optional(),
operator: z.any().optional(),
value: z.any().optional(),
valueSrc: z.any().optional(),
})
.optional(),
})
)
.optional()
// Be very careful and lenient here. Just ensure that the rule isn't invalid without breaking anything
.superRefine((children1, ctx) => {
if (!children1) return;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
Object.entries(children1).forEach(([, _rule]) => {
const rule = _rule as unknown;
if (!isObject(rule) || rule.type !== "rule") return;
if (!isObject(rule.properties)) return;
const value = rule.properties.value || [];
const valueSrc = rule.properties.valueSrc;
if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
return;
}
if (!valueSrc.length) {
// If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
return;
}
// MultiSelect array can be 2D array
const flattenedValues = value.flat();
const validValues = flattenedValues.filter((value: unknown) => {
// Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field
// Also, it is possible that RAQB has some requirements to support null or empty string values.
if (value === undefined) return false;
return true;
});
if (!validValues.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Looks like you are trying to create a rule with no value",
});
}
});
}),
});
/**
* Stricter schema for validating before saving to DB
* It doesn't decide what will be saved, it is just to validate the data before saving
*/
export const queryValueSaveValidationSchema = queryValueSchema
.omit({ children1: true })
.merge(
z.object({
children1: z
.record(
z.object({
type: z.string().optional(),
properties: z
.object({
field: z.any().optional(),
operator: z.any().optional(),
value: z.any().optional(),
valueSrc: z.any().optional(),
})
.optional(),
})
)
.optional()
// Be very careful and lenient here. Just ensure that the rule isn't invalid without breaking anything
.superRefine((children1, ctx) => {
if (!children1) return;
const isObject = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null;
Object.entries(children1).forEach(([, _rule]) => {
const rule = _rule as unknown;
if (!isObject(rule) || rule.type !== "rule") return;
if (!isObject(rule.properties)) return;
const value = rule.properties.value || [];
const valueSrc = rule.properties.valueSrc;
if (!(value instanceof Array) || !(valueSrc instanceof Array)) {
return;
}
if (!valueSrc.length) {
// If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty
return;
}
// MultiSelect array can be 2D array
const flattenedValues = value.flat();
const validValues = flattenedValues.filter((value: unknown) => {
// Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field
// Also, it is possible that RAQB has some requirements to support null or empty string values.
if (value === undefined) return false;
return true;
});
if (!validValues.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Looks like you are trying to create a rule with no value",
});
}
});
}),
})
)
.merge(children1Schema)
.nullish();
export const attributeRoutingConfigSchema = z
@@ -200,6 +200,7 @@ export const appDataSchema = z.any();
export const appKeysSchema = z.object({});
// This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted
// Record key is formFieldId
export const routingFormResponseInDbSchema = z.record(
z.object({
label: z.string().optional(),
+1 -1
View File
@@ -29,7 +29,7 @@ export default class CrmManager {
public async createEvent(event: CalendarEvent, appOptions?: any) {
const crmService = await this.getCrmService(this.credential);
const { skipContactCreation } = crmService?.getAppOptions();
const { skipContactCreation } = crmService?.getAppOptions() || {};
// First see if the attendees already exist in the crm
let contacts = (await this.getContacts({ emails: event.attendees.map((a) => a.email) })) || [];
// Ensure that all attendees are in the crm
@@ -80,12 +80,13 @@ export const PeopleFilter = () => {
icon={<Avatar alt={`${member?.id} avatar`} imageSrc={member.avatarUrl} size="xs" />}
/>
))}
<div className="text-default text-center" ref={observerRef}>
<div className="text-default text-center" ref={observerRef} data-testid="people-filter">
<Button
color="minimal"
loading={queryMembers.isFetchingNextPage}
disabled={!queryMembers.hasNextPage}
onClick={() => queryMembers.fetchNextPage()}>
onClick={() => queryMembers.fetchNextPage()}
data-testid="people-filter">
{queryMembers.hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
@@ -11,6 +11,7 @@ const CUSTOM_REQUIRED_FIELD_ERROR_MSG = "error_required_field";
const CUSTOM_PHONE_VALIDATION_ERROR_MSG = "invalid_number";
const CUSTOM_EMAIL_VALIDATION_ERROR_MSG = "email_validation_error";
const CUSTOM_URL_VALIDATION_ERROR_MSG = "url_validation_error";
const CUSTOM_EMAIL_EXCLUDED_ERROR_MSG = "exclude_emails_match_found_error_message";
const ZOD_REQUIRED_FIELD_ERROR_MSG = "Required";
describe("getBookingResponsesSchema", () => {
@@ -1241,3 +1242,79 @@ describe("getBookingResponsesPartialSchema - Prefill validation", () => {
);
});
});
describe("excluded email/domain validation", () => {
test("should fail if the email is present in excluded emails", async () => {
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo, @gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "harry@gmail.com",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.error.issues[0]).toEqual(
expect.objectContaining({
code: "custom",
message: `{email}${CUSTOM_EMAIL_EXCLUDED_ERROR_MSG}`,
})
);
});
test("should pass if the email is not present in excluded emails", async () => {
const excludedEmails = "spammer@cal.com, hotmail.com, yahoo, @gmail.com";
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
excludeEmails: excludedEmails,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
const parsedResponses = await schema.safeParseAsync({
name: "test",
email: "harry@workmail.com",
});
expect(parsedResponses.success).toBe(true);
if (!parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.data).toEqual({
name: "test",
email: "harry@workmail.com",
});
});
});
@@ -162,13 +162,29 @@ function preprocess<T extends z.ZodType>({
}
if (bookingField.type === "email") {
// Email RegExp to validate if the input is a valid email
if (!bookingField.hidden && !emailSchema.safeParse(value).success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("email_validation_error"),
});
if (!bookingField.hidden) {
// Email RegExp to validate if the input is a valid email
if (!emailSchema.safeParse(value).success) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("email_validation_error"),
});
}
// validate the excluded emails
const bookerEmail = value;
const excludedEmails =
bookingField.excludeEmails?.split(",").map((domain) => domain.trim()) || [];
const match = excludedEmails.find((email) => bookerEmail.includes(email));
if (match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("exclude_emails_match_found_error_message"),
});
}
}
continue;
}
@@ -57,7 +57,7 @@ import logger from "@calcom/lib/logger";
import { handlePayment } from "@calcom/lib/payment/handlePayment";
import { getPiiFreeCalendarEvent, getPiiFreeEventType } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { DistributionMethod, getLuckyUser } from "@calcom/lib/server/getLuckyUser";
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
import { getTranslation } from "@calcom/lib/server/i18n";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
@@ -467,13 +467,38 @@ async function handler(
eventType.schedulingType === SchedulingType.ROUND_ROBIN &&
eventType.rescheduleWithSameRoundRobinHost;
const userIdsSet = new Set(users.map((user) => user.id));
let routingFormResponse;
if (routedTeamMemberIds) {
routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
where: {
id: routingFormResponseId,
},
select: {
response: true,
form: {
select: {
routes: true,
fields: true,
},
},
chosenRouteId: true,
},
});
}
const newLuckyUser = isSameRoundRobinHost
? freeUsers.find((user) => user.id === originalRescheduledBookingUserId)
: await getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
: await getLuckyUser({
// find a lucky user that is not already in the luckyUsers array
availableUsers: freeUsers,
allRRHosts: eventTypeWithUsers.hosts.filter((host) => !host.isFixed),
allRRHosts: eventTypeWithUsers.hosts.filter(
(host) => !host.isFixed && userIdsSet.has(host.user.id)
), // users part of virtual queue
eventType,
routingFormResponse: routingFormResponse ?? null,
});
if (!newLuckyUser) {
break; // prevent infinite loop
@@ -53,6 +53,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
requiresBookerEmailVerification: true,
maxLeadThreshold: true,
minimumBookingNotice: true,
userId: true,
price: true,
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
import type { IncomingMessage } from "http";
import type { Logger } from "tslog";
import { filterHostsByLeadThreshold } from "@calcom/lib/bookings/filterHostsByLeadThreshold";
import { HttpError } from "@calcom/lib/http-error";
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
@@ -16,9 +17,13 @@ import type { NewBookingEventType } from "./types";
type Users = (Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
metadata?: Prisma.JsonValue;
createdAt?: Date;
})[];
type EventType = Pick<NewBookingEventType, "hosts" | "users" | "id" | "userId" | "schedulingType">;
type EventType = Pick<
NewBookingEventType,
"hosts" | "users" | "id" | "userId" | "schedulingType" | "maxLeadThreshold"
>;
type InputProps = {
req: IncomingMessage;
@@ -79,7 +84,7 @@ export async function loadAndValidateUsers({
}
if (!users) throw new HttpError({ statusCode: 404, message: "eventTypeUser.notFound" });
// map fixed users
users = users.map((user) => ({
...user,
isFixed:
@@ -88,6 +93,22 @@ export async function loadAndValidateUsers({
: user.isFixed || eventType.schedulingType !== SchedulingType.ROUND_ROBIN,
}));
const qualifiedHosts = await filterHostsByLeadThreshold({
eventTypeId: eventType.id,
hosts: eventType.hosts.map((host) => ({
isFixed: host.isFixed,
createdAt: host.createdAt,
email: host.user.email,
user: host.user,
})),
maxLeadThreshold: eventType.maxLeadThreshold,
});
if (qualifiedHosts.length) {
// remove users that are not in the qualified hosts array
const qualifiedHostIds = new Set(qualifiedHosts.map((qualifiedHost) => qualifiedHost.user.id));
users = users.filter((user) => qualifiedHostIds.has(user.id));
}
logger.debug(
"Concerned users",
safeStringify({
@@ -46,13 +46,19 @@ export const loadUsers = async ({
const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEventType["users"]> => {
const hosts = eventType.hosts || [];
const users = hosts.map(({ user, isFixed, priority, weight }) => ({
const users = hosts.map(({ user, isFixed, priority, weight, createdAt }) => ({
...user,
isFixed,
priority,
weight,
createdAt,
}));
return users.length ? users : eventType.users;
return users.length
? users
: eventType.users.map((user) => ({
...user,
createdAt: null,
}));
};
const loadDynamicUsers = async (dynamicUserList: string[], currentOrgDomain: string | null) => {
@@ -225,7 +225,6 @@ export default async function handleChildrenEventTypes({
const updatePayloadFiltered = Object.entries(updatePayload)
.filter(([key, _]) => key !== "children")
.reduce((newObj, [key, value]) => ({ ...newObj, [key]: value }), {});
console.log({ unlockedFieldProps });
// Update event types for old users
const oldEventTypes = await prisma.$transaction(
oldUserIds.map((userId) => {
@@ -21,7 +21,6 @@ import { SENDER_NAME } from "@calcom/lib/constants";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import logger from "@calcom/lib/logger";
import { getLuckyUser } from "@calcom/lib/server";
import { DistributionMethod } from "@calcom/lib/server/getLuckyUser";
import { getTranslation } from "@calcom/lib/server/i18n";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
@@ -126,13 +125,11 @@ export const roundRobinReassignment = async ({
roundRobinReassignLogger
);
const reassignedRRHost = await getLuckyUser(DistributionMethod.PRIORITIZE_AVAILABILITY, {
const reassignedRRHost = await getLuckyUser({
availableUsers,
eventType: {
id: eventType.id,
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
},
allRRHosts: eventType.hosts.filter((host) => !host.isFixed),
eventType,
allRRHosts: eventType.hosts.filter((host) => !host.isFixed), // todo: only use hosts from virtual queue
routingFormResponse: null,
});
const hasOrganizerChanged = !previousRRHost || booking.userId === previousRRHost?.id;
@@ -12,9 +12,8 @@ import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
import CreateEventTypeForm from "./CreateEventTypeForm";
import usePostHog from "../../ee/event-tracking/lib/posthog/userPostHog";
import CreateEventTypeForm from "./CreateEventTypeForm";
// this describes the uniform data needed to create a new event type on Profile or Team
export interface EventTypeParent {
@@ -19,7 +19,7 @@ import type {
} from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/enums";
import { Label, Select, SettingsToggle } from "@calcom/ui";
import { Label, Select, SettingsToggle, RadioGroup as RadioArea } from "@calcom/ui";
export type EventTeamAssignmentTabBaseProps = Pick<EventTypeSetupProps, "teamMembers" | "team" | "eventType">;
@@ -333,7 +333,6 @@ const Hosts = ({
assignAllTeamMembers: boolean;
setAssignAllTeamMembers: Dispatch<SetStateAction<boolean>>;
}) => {
const { t } = useLocale();
const {
control,
setValue,
@@ -498,6 +497,44 @@ export const EventTeamAssignmentTab = ({ team, teamMembers, eventType }: EventTe
/>
</div>
</div>
<div className="border-subtle mt-4 flex flex-col rounded-md">
<div className="border-subtle rounded-t-md border p-6 pb-5">
<Label className="mb-1 text-sm font-semibold">{t("rr_distribution_method")}</Label>
<p className="text-subtle max-w-full break-words text-sm leading-tight">
{t("rr_distribution_method_description")}
</p>
</div>
<div className="border-subtle rounded-b-md border border-t-0 p-6">
<Controller
name="maxLeadThreshold"
render={({ field: { value, onChange } }) => (
<RadioArea.Group
onValueChange={(val) => {
if (val === "loadBalancing") onChange(3);
else onChange(null);
}}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="maximizeAvailability"
checked={value === null}
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_availability_title")}</strong>
<p>{t("rr_distribution_method_availability_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="loadBalancing"
checked={value !== null}
className="text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("rr_distribution_method_balanced_title")}</strong>
<p>{t("rr_distribution_method_balanced_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
)}
/>
</div>
</div>
<Hosts
assignAllTeamMembers={assignAllTeamMembers}
setAssignAllTeamMembers={setAssignAllTeamMembers}
@@ -44,7 +44,6 @@ const userSelect = Prisma.validator<Prisma.UserSelect>()({
id: true,
name: true,
slug: true,
calVideoLogo: true,
bannerUrl: true,
},
},
@@ -137,6 +137,7 @@ export type FormValues = {
forwardParamsSuccessRedirect: boolean | null;
secondaryEmailId?: number;
isRRWeightsEnabled: boolean;
maxLeadThreshold?: number;
};
export type LocationFormValues = Pick<FormValues, "id" | "locations" | "bookingFields" | "seatsPerTimeSlot">;
+12 -2
View File
@@ -1,4 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import type { SubmitHandler, UseFormReturn } from "react-hook-form";
import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-form";
@@ -32,7 +33,7 @@ import {
import { fieldTypesConfigMap } from "./fieldTypes";
import { fieldsThatSupportLabelAsSafeHtml } from "./fieldsThatSupportLabelAsSafeHtml";
import type { fieldsSchema } from "./schema";
import { fieldSchema, type fieldsSchema } from "./schema";
import { getFieldIdentifier } from "./utils/getFieldIdentifier";
import { getConfig as getVariantsConfig } from "./utils/variantsConfig";
@@ -459,7 +460,7 @@ function FieldEditDialog({
const { t } = useLocale();
const fieldForm = useForm<RhfFormField>({
defaultValues: dialog.data || {},
// resolver: zodResolver(fieldSchema),
resolver: zodResolver(fieldSchema),
});
const formFieldType = fieldForm.getValues("type");
@@ -573,6 +574,15 @@ function FieldEditDialog({
<FieldWithLengthCheckSupport containerClassName="mt-6" fieldForm={fieldForm} />
) : null}
{fieldType.value === "email" && fieldForm.getValues("name") === "email" && (
<InputField
{...fieldForm.register("excludeEmails")}
containerClassName="mt-6"
label={t("exclude_emails_that_contain")}
placeholder="gmail.com, hotmail.com, ..."
/>
)}
<Controller
name="required"
control={fieldForm.control}
+24
View File
@@ -99,6 +99,30 @@ const baseFieldSchema = z.object({
* @requires supportsLengthCheck = true
*/
maxLength: z.number().optional(),
// Emails that needs to be excluded
excludeEmails: z
.string()
.superRefine((val, ctx) => {
const allDomains = val.split(",").map((dom) => dom.trim());
const regex = /^(?:@?[a-z0-9-]+(?:\.[a-z]{2,})?)?(?:@[a-z0-9-]+\.[a-z]{2,})?$/;
/*
Valid patterns - [ example, example.anything, anyone@example.anything ]
Invalid patterns - Patterns involving capital letter [ Example, Example.anything, Anyone@example.anything ]
*/
const isValid = !allDomains.some((domain) => !regex.test(domain));
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Enter valid domain or email",
});
}
})
.optional(),
});
export const variantsConfigSchema = z.object({
@@ -0,0 +1,89 @@
import { useState } from "react";
import { classNames } from "@calcom/lib";
import { trpc } from "@calcom/trpc";
import { ToggleGroup } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
import { BarList } from "./tremor/BarList";
interface FormCardProps {
formName: string;
fields: Record<string, { optionId: string; count: number; optionLabel: string }[]>;
}
function FormCard({ formName, fields }: FormCardProps) {
const fieldNames = Object.keys(fields);
const [selectedField, setSelectedField] = useState(fieldNames[0]);
const toggleOptions = fieldNames.map((fieldName) => ({
value: fieldName,
label: fieldName.charAt(0).toUpperCase() + fieldName.slice(1),
}));
const selectedFieldData = fields[selectedField]?.map((option) => ({
name: option.optionLabel,
value: option.count,
}));
return (
<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>
</div>
<div className="p-4">
<ToggleGroup
options={toggleOptions}
value={selectedField}
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"
/>
</div>
)}
</div>
</div>
</div>
);
}
export function FailedBookingsByField() {
const { filter } = useFilterContext();
const { selectedTeamId, isAll, initialConfig, selectedRoutingFormId } = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const { data } = trpc.viewer.insights.failedBookingsByField.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
if (!data) return null;
return (
<div className="w-full text-sm">
<div className="flex h-12 items-center">
<h2 className="text-emphasis text-md font-semibold">Failed Bookings By Field</h2>
</div>
<div className="">
<div className={classNames("grid grid-cols-1 gap-6 lg:grid-cols-2")}>
{Object.entries(data).map(([formName, fields]) => (
<FormCard key={formName} formName={formName} fields={fields} />
))}
</div>
</div>
</div>
);
}
@@ -0,0 +1,449 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import {
useReactTable,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
import Link from "next/link";
import { useRef, useMemo, useId } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
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,
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";
type RoutingFormResponse = RouterOutputs["viewer"]["insights"]["routingFormResponses"]["data"][number];
type RoutingFormTableRow = {
id: number;
formName: string;
createdAt: Date;
routedToBooking: RoutingFormResponse["routedToBooking"];
[key: string]: any;
};
function CellWithOverflowX({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<div className={classNames("group relative max-w-[200px]", className)}>
<div
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;
const nextElement = el.nextElementSibling;
if (!nextElement) return;
if (el.scrollWidth > 200) {
nextElement.classList.remove("hidden");
} else {
nextElement.classList.add("hidden");
}
}}>
{children}
</div>
<div className="from-default absolute right-0 top-0 hidden h-full w-8 bg-gradient-to-l to-transparent " />
</div>
);
}
// Upper case the first letter of each word and replace underscores with spaces
function bookingStatusToText(status: BookingStatus) {
return status
.toLowerCase()
.replace(/_/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function BookedByCell({
attendees,
rowId,
}: {
attendees: { email: string; timeZone: string }[] | undefined;
rowId: number;
}) {
const cellId = useId();
if (!attendees || attendees.length === 0) return null;
return (
<div className="flex w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Badge variant="gray" className="whitespace-nowrap">
{attendee.email}
</Badge>
</CellWithOverflowX>
))}
</div>
);
}
function ResponseValueCell({ value, rowId }: { value: string[]; rowId: number }) {
const cellId = useId();
if (value.length === 0) return <div className="h-6 w-[200px]" />;
return (
<CellWithOverflowX className="flex w-[200px] gap-1">
{value.length > 2 ? (
<>
{value.slice(0, 2).map((v: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
</Badge>
))}
<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-default text-sm">
{v}
</span>
))}
</div>
</HoverCardContent>
</HoverCard>
</>
) : (
value.map((v: string, i: number) => (
<Badge key={`${cellId}-${i}-${rowId}`} variant="gray">
{v}
</Badge>
))
)}
</CellWithOverflowX>
);
}
function BookingStatusCell({
booking,
rowId,
copyToClipboard,
t,
}: {
booking: RoutingFormResponse["routedToBooking"];
rowId: number;
copyToClipboard: (text: string) => void;
t: (key: string) => string;
}) {
const cellId = useId();
if (!booking || !booking.user) {
return (
<div className="w-[250px]">
<Badge variant="error" className="ml-6" key={`${cellId}-no-booking-${rowId}`}>
{t("no_booking")}
</Badge>
</div>
);
}
let badgeVariant: BadgeProps["variant"] = "success";
switch (booking.status) {
case BookingStatus.REJECTED:
case BookingStatus.AWAITING_HOST:
case BookingStatus.PENDING:
case BookingStatus.CANCELLED:
badgeVariant = "warning";
break;
}
return (
<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 ?? ""} />
<div>
<p className="text-sm font-medium">{booking.user.name}</p>
<p className="group/booking_status_email text-subtle flex items-center text-xs">
<span className="truncate">{booking.user.email}</span>
<button
className="invisible ml-2 group-hover/booking_status_email:visible"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
copyToClipboard(booking.user?.email ?? "");
}}>
<Icon name="copy" />
</button>
</p>
</div>
</div>
<div className="text-emphasis mt-4 flex items-center gap-2 text-xs">
<span>Status:</span>
<Badge variant={badgeVariant}>{bookingStatusToText(booking.status)}</Badge>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
}
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable() {
const { t } = useLocale();
const { filter } = useFilterContext();
const tableContainerRef = useRef<HTMLDivElement>(null);
const { copyToClipboard, isCopied } = useCopy();
const {
dateRange,
selectedTeamId,
isAll,
initialConfig,
selectedRoutingFormId,
selectedMemberUserId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
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(
{
teamId: selectedTeamId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
userId: selectedMemberUserId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
limit: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
placeholderData: keepPreviousData,
trpc: {
context: { skipBatch: true },
},
enabled: initialConfigIsReady,
}
);
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.data) ?? [], [data]);
const totalDBRowCount = data?.pages?.[0]?.total ?? 0;
const totalFetched = flatData.length;
const processedData = useMemo(() => {
if (isHeadersLoading) return [];
return flatData.map((response) => {
const row: RoutingFormTableRow = {
id: response.id,
formName: response.form.name,
formId: response.form.id,
createdAt: response.createdAt,
routedToBooking: response.routedToBooking,
};
Object.entries(response.response).forEach(([fieldId, field]) => {
const header = headers?.find((h) => h.id === fieldId);
if (header?.options) {
if (Array.isArray(field.value)) {
// Map the IDs to their corresponding labels for array values
const labels = field.value.map((id) => {
const option = header.options?.find((opt) => opt.id === id);
return option?.label ?? id;
});
row[fieldId] = labels;
} else {
// Handle single value case
const option = header.options?.find((opt) => opt.id === field.value);
row[fieldId] = option?.label ?? field.value;
}
} else {
row[fieldId] = field.value;
}
});
return row;
});
}, [flatData, headers, isHeadersLoading]);
const columnHelper = createColumnHelper<RoutingFormTableRow>();
const columns = useMemo(
() => [
columnHelper.accessor("bookedAttendees", {
id: "bookedBy",
header: t("routing_form_insights_booked_by"),
size: 200,
cell: (info) => {
const row = info.row.original;
return <BookedByCell attendees={row.routedToBooking?.attendees || []} rowId={row.id} />;
},
}),
...(headers?.map((header) => {
return columnHelper.accessor(header.id, {
id: header.id,
header: header.label,
size: 200,
cell: (info) => {
let value = info.getValue();
value = Array.isArray(value) ? value : [value];
return (
<div className="max-w-[200px]">
<ResponseValueCell value={value} rowId={info.row.original.id} />
</div>
);
},
});
}) ?? []),
columnHelper.accessor("routedToBooking", {
id: "bookingStatus",
header: t("routing_form_insights_booking_at"),
size: 250,
cell: (info) => (
<div className="max-w-[250px]">
<BookingStatusCell
booking={info.getValue()}
rowId={info.row.original.id}
copyToClipboard={copyToClipboard}
t={t}
/>
</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]
);
const table = useReactTable<RoutingFormTableRow>({
data: processedData,
columns,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
defaultColumn: {
size: 200,
},
});
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
tableContainerRef,
fetchNextPage,
isFetching,
totalFetched,
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
table={table}
tableContainerRef={tableContainerRef}
onScroll={(e) => {
if (hasNextPage) {
fetchMoreOnBottomReached(e.target as HTMLDivElement);
}
}}
isPending={isFetching && !data}
/>
</div>
);
}
@@ -0,0 +1,109 @@
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 { useFilterContext } from "../context/provider";
import { valueFormatter } from "../lib";
import { CardInsights } from "./Card";
export const RoutingKPICards = () => {
const { t } = useLocale();
const { filter } = useFilterContext();
const {
dateRange,
selectedEventTypeId,
selectedUserId,
selectedMemberUserId,
isAll,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
} = filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
const [startDate, endDate] = dateRange;
const { selectedTeamId: teamId } = filter;
const { data, isSuccess, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
{
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
teamId,
eventTypeId: selectedEventTypeId ?? undefined,
isAll,
routingFormId: selectedRoutingFormId ?? undefined,
userId: selectedMemberUserId ?? undefined,
bookingStatus: selectedBookingStatus ?? undefined,
fieldFilter: selectedRoutingFormFilter ?? undefined,
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
enabled: initialConfigIsReady,
}
);
const categories: {
title: string;
index: "active" | "total_responses" | "total_responses_without_booking" | "total_responses_with_booking";
}[] = [
{
title: t("routing_forms_total_responses"),
index: "total_responses",
},
{
title: t("routing_forms_total_responses_without_booking"),
index: "total_responses_without_booking",
},
{
title: t("routing_forms_total_responses_with_booking"),
index: "total_responses_with_booking",
},
];
if (isPending) {
return <LoadingKPICards categories={categories} />;
}
if (!isSuccess || !startDate || !endDate || (!teamId && !selectedUserId)) return null;
return (
<>
<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>
<Flex className="items-baseline justify-start space-x-3 truncate">
{/* @ts-expect-error - theyre actually dynamic fields that we know the index of - but TS doesnt know that */}
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
</Flex>
</CardInsights>
))}
</Grid>
</>
);
};
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
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" />
</div>
</div>
</CardInsights>
))}
</Grid>
);
};
@@ -1,5 +1,6 @@
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
export { RoutingKPICards } from "./RoutingKPICards";
export { BookingStatusLineChart } from "./BookingStatusLineChart";
export { LeastBookedTeamMembersTable } from "./LeastBookedTeamMembersTable";
export { MostBookedTeamMembersTable } from "./MostBookedTeamMembersTable";
@@ -8,3 +9,5 @@ export { RecentFeedbackTable } from "./RecentFeedbackTable";
export { HighestNoShowHostTable } from "./HighestNoShowHostTable";
export { HighestRatedMembersTable } from "./HighestRatedMembersTable";
export { LowestRatedMembersTable } from "./LowestRatedMembersTable";
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 };
@@ -4,8 +4,10 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { BookingStatus } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { useDefaultRoutingForm } from "../hooks/useDefaultRoutingForm";
import type { FilterContextType } from "./provider";
import { FilterProvider } from "./provider";
@@ -16,9 +18,14 @@ const querySchema = z.object({
userId: z.coerce.number().nullable(),
memberUserId: z.coerce.number().nullable(),
eventTypeId: z.coerce.number().nullable(),
filter: z.enum(["event-type", "user"]).nullable(),
filter: z
.union([z.enum(["event-type", "user", "routing_forms", "booking_status"]), z.string().regex(/^rf_.*$/)])
.nullable(),
routingFormId: z.string().nullable(),
bookingStatus: z.enum(["NO_BOOKING", ...Object.values(BookingStatus)]).nullable(),
});
// TODO(SEAN): We can have a big refactor here and move this all out of context and into query state. This will make it easier to create shareable links
export function FiltersProvider({ children }: { children: React.ReactNode }) {
// searchParams to get initial values from query params
const utils = trpc.useUtils();
@@ -32,6 +39,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
userIdParsed,
eventTypeIdParsed,
filterParsed,
routingFormIdParsed,
bookingStatusParsed,
memberUserIdParsed;
const safe = querySchema.safeParse({
@@ -42,6 +51,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
eventTypeId: searchParams?.get("eventTypeId") ?? null,
filter: searchParams?.get("filter") ?? null,
memberUserId: searchParams?.get("memberUserId") ?? null,
routingFormId: searchParams?.get("routingFormId") ?? null,
bookingStatus: searchParams?.get("bookingStatus") ?? null,
});
if (!safe.success) {
@@ -53,7 +64,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
userIdParsed = safe.data.userId;
eventTypeIdParsed = safe.data.eventTypeId;
filterParsed = safe.data.filter;
routingFormIdParsed = safe.data.routingFormId;
memberUserIdParsed = safe.data.memberUserId;
bookingStatusParsed = safe.data.bookingStatus;
}
const [configFilters, setConfigFilters] = useState<FilterContextType["filter"]>({
@@ -68,7 +81,11 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedTeamId: teamIdParsed || null,
selectedTeamName: null,
selectedEventTypeId: eventTypeIdParsed || null,
selectedFilter: filterParsed ? [filterParsed] : null,
selectedFilter: filterParsed
? [filterParsed as "event-type" | "user" | "routing_forms" | `rf_${string}`]
: null,
selectedRoutingFormId: routingFormIdParsed || null,
selectedBookingStatus: bookingStatusParsed || null,
isAll: false,
initialConfig: {
userId: null,
@@ -77,6 +94,20 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
},
});
// Use the custom hook
const { mostPopularForm } = useDefaultRoutingForm({
teamId: teamIdParsed,
isAll: safe.success ? !!safe.data.teamId : false,
routingFormId: routingFormIdParsed,
onRoutingFormChange: (formId) => {
setConfigFilters((prev) => ({
...prev,
selectedFilter: prev.selectedFilter ? [...prev.selectedFilter, "routing_forms"] : ["routing_forms"],
selectedRoutingFormId: formId,
}));
},
});
const {
dateRange,
selectedTimeView,
@@ -86,6 +117,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedEventTypeId,
selectedFilter,
selectedTeamName,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
isAll,
initialConfig,
} = configFilters;
@@ -103,6 +137,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
selectedFilter,
isAll,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
selectedRoutingFormFilter,
},
setConfigFilters: (newConfigFilters) => {
setConfigFilters({
@@ -119,6 +156,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
isAll,
dateRange,
initialConfig,
selectedRoutingFormId,
selectedBookingStatus,
} = newConfigFilters;
const [startTime, endTime] = dateRange || [null, null];
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
@@ -134,6 +173,8 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
setParamsIfDefined("startTime", startTime?.format("YYYY-MM-DD"));
setParamsIfDefined("endTime", endTime?.format("YYYY-MM-DD"));
setParamsIfDefined("filter", selectedFilter?.[0]);
setParamsIfDefined("routingFormId", selectedRoutingFormId);
setParamsIfDefined("bookingStatus", selectedBookingStatus);
router.push(`${pathname}?${newSearchParams.toString()}`);
},
clearFilters: () => {
@@ -152,6 +193,9 @@ export function FiltersProvider({ children }: { children: React.ReactNode }) {
isAll: !!initialConfig?.isAll,
dateRange: [dayjs().subtract(1, "week"), dayjs(), "w"],
initialConfig,
selectedRoutingFormId: mostPopularForm?.id ?? null, // Set the most popular form as default when clearing filters
selectedBookingStatus: null,
selectedRoutingFormFilter: null,
});
const newSearchParams = new URLSearchParams();
@@ -1,3 +1,4 @@
import type { BookingStatus } from "@prisma/client";
import * as React from "react";
import type { Dayjs } from "@calcom/dayjs";
@@ -5,12 +6,18 @@ import type { Dayjs } from "@calcom/dayjs";
interface IFilter {
dateRange: [Dayjs, Dayjs, null | string];
selectedTimeView?: "year" | "week" | "month";
selectedFilter?: Array<"user" | "event-type"> | null;
selectedFilter?: Array<"user" | "event-type" | "routing_forms" | "booking_status" | `rf_${string}`> | null;
selectedTeamId?: number | null;
selectedTeamName?: string | null;
selectedUserId?: number | null;
selectedMemberUserId?: number | null;
selectedEventTypeId?: number | null;
selectedRoutingFormId?: string | null;
selectedBookingStatus?: BookingStatus | "NO_BOOKING" | null;
selectedRoutingFormFilter?: {
fieldId: string;
optionId: string;
} | null;
isAll?: boolean;
initialConfig?: {
teamId?: number | null;
@@ -0,0 +1,49 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookingStatus } from "@calcom/prisma/enums";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type BookingStatusOption = {
value: BookingStatus | "NO_BOOKING";
label: string;
};
export const BookingStatusFilter = () => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedBookingStatus, selectedFilter } = filter;
const bookingStatusOptions = useMemo(() => {
const options: BookingStatusOption[] = Object.values(BookingStatus).map((status) => ({
value: status,
label: t(`${status.toLowerCase()}`).charAt(0).toUpperCase() + t(`${status.toLowerCase()}`).slice(1),
}));
options.push({
value: "NO_BOOKING",
label: t("no_booking"),
});
return options;
}, [t]);
if (!selectedFilter?.includes("booking_status")) {
return null;
}
return (
<FilterSelect
title={t("routing_form_insights_booking_status")}
options={bookingStatusOptions}
selectedValue={selectedBookingStatus}
onChange={(value) => {
setConfigFilters({ selectedBookingStatus: value as BookingStatus | "NO_BOOKING" });
}}
buttonIcon={<Icon name="circle" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
/>
);
};
@@ -0,0 +1,57 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
const Download = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const { data, isPending } = trpc.viewer.insights.rawData.useQuery(
{
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId,
userId: filter.selectedUserId,
eventTypeId: filter.selectedEventTypeId,
memberUserId: filter.selectedMemberUserId,
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: Infinity,
trpc: {
context: { skipBatch: true },
},
}
);
type RawData = RouterOutputs["viewer"]["insights"]["rawData"] | undefined;
const handleDownloadClick = async (data: RawData) => {
if (!data) return;
const { data: csvRaw, filename } = data;
downloadAsCsv(csvRaw, filename);
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
{...(isPending && { loading: isPending })}
className="self-end sm:self-baseline">
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={() => handleDownloadClick(data)}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { Download };
@@ -0,0 +1,87 @@
import { useState } from "react";
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
import { showToast } from "@calcom/ui";
type RawRoutingData = RouterOutputs["viewer"]["insights"]["rawRoutingData"]["data"][number] | undefined;
interface BatchResult {
data: RawRoutingData[];
nextCursor: number | null;
hasMore: boolean;
}
const RoutingDownload = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const [isDownloading, setIsDownloading] = useState(false);
const utils = trpc.useContext();
const fetchBatch = async (cursor: number | null = null) => {
const result = await utils.viewer.insights.rawRoutingData.fetch({
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId ?? undefined,
userId: filter.selectedUserId ?? undefined,
routingFormId: filter.selectedRoutingFormId ?? undefined,
bookingStatus: filter.selectedBookingStatus ?? undefined,
fieldFilter: filter.selectedRoutingFormFilter ?? undefined,
isAll: !!filter.isAll,
cursor: cursor ?? undefined,
});
return result;
};
const handleDownloadClick = async () => {
try {
setIsDownloading(true);
let allData: RawRoutingData[] = [];
let hasMore = true;
let cursor: number | null = null;
// Fetch data in batches until there's no more data
while (hasMore) {
const result: BatchResult = await fetchBatch(cursor);
allData = [...allData, ...result.data];
hasMore = result.hasMore;
cursor = result.nextCursor;
}
if (allData.length > 0) {
const filename = `RoutingInsights-${filter.dateRange[0].format(
"YYYY-MM-DD"
)}-${filter.dateRange[1].format("YYYY-MM-DD")}.csv`;
downloadAsCsv(allData as Record<string, unknown>[], filename);
}
} catch (error) {
showToast(t("error_downloading_data"), "error");
} finally {
setIsDownloading(false);
}
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
className="self-end sm:self-baseline"
loading={isDownloading}>
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={handleDownloadClick}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { RoutingDownload };
@@ -1,58 +1,2 @@
import { useFilterContext } from "@calcom/features/insights/context/provider";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Button, Dropdown, DropdownItem, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui";
const Download = () => {
const { filter } = useFilterContext();
const { t } = useLocale();
const { data, isPending } = trpc.viewer.insights.rawData.useQuery(
{
startDate: filter.dateRange[0].toISOString(),
endDate: filter.dateRange[1].toISOString(),
teamId: filter.selectedTeamId,
userId: filter.selectedUserId,
eventTypeId: filter.selectedEventTypeId,
memberUserId: filter.selectedMemberUserId,
},
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
retry: false,
staleTime: Infinity,
trpc: {
context: { skipBatch: true },
},
}
);
type RawData = RouterOutputs["viewer"]["insights"]["rawData"] | undefined;
const handleDownloadClick = async (data: RawData) => {
if (!data) return;
const { data: csvRaw, filename } = data;
downloadAsCsv(csvRaw, filename);
};
return (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button
EndIcon="file-down"
color="secondary"
{...(isPending && { loading: isPending })}
className="self-end sm:self-baseline">
{t("download")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownItem onClick={() => handleDownloadClick(data)}>{t("as_csv")}</DropdownItem>
</DropdownMenuContent>
</Dropdown>
);
};
export { Download };
export { Download } from "./Download";
export { RoutingDownload } from "./RoutingDownload";
@@ -1,18 +1,16 @@
import { memo } from "react";
import { FilterCheckboxFieldsContainer } from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, CheckboxField } from "@calcom/ui";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type EventType = RouterOutputs["viewer"]["insights"]["eventTypeList"][number];
type Option = { value: string; label: string };
const mapEventTypeToOption = (eventType: EventType): Option => ({
value: eventType.id.toString(),
const mapEventTypeToOption = (eventType: EventType) => ({
value: eventType.id,
label: eventType.teamId ? `${eventType.title} (${eventType.team?.name})` : eventType.title,
});
@@ -21,6 +19,7 @@ export const EventTypeList = memo(() => {
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedEventTypeId, selectedUserId, isAll } = filter;
const { selectedFilter } = filter;
const { data, isSuccess } = trpc.viewer.insights.eventTypeList.useQuery({
teamId: selectedTeamId ?? undefined,
userId: selectedUserId ?? undefined,
@@ -29,52 +28,19 @@ export const EventTypeList = memo(() => {
if (!selectedFilter?.includes("event-type")) return null;
if (!selectedTeamId && !selectedUserId) return null;
const filterOptions = data?.map(mapEventTypeToOption);
const selectedEventType = data?.find((item) => item.id === selectedEventTypeId);
const eventTypeValue = selectedEventType ? mapEventTypeToOption(selectedEventType) : null;
if (!isSuccess || !data || !Array.isArray(data)) return null;
const getPopoverText = () => {
if (eventTypeValue?.label) {
return `${t("event_type")}: ${eventTypeValue?.label}`;
}
return t("event_type");
};
const filterOptions = data.map(mapEventTypeToOption);
return (
<AnimatedPopover text={getPopoverText()}>
<FilterCheckboxFieldsContainer>
{filterOptions?.map((eventType) => (
<div
key={eventType.value}
className="item-center hover:bg-muted flex cursor-pointer px-4 py-2 transition">
<CheckboxField
checked={eventTypeValue?.value === eventType.value}
onChange={(e) => {
if (e.target.checked) {
const selectedEventTypeId = data.find((item) => item.id.toString() === eventType.value)?.id;
!!selectedEventTypeId &&
setConfigFilters({
selectedEventTypeId,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedEventTypeId: null,
});
}
}}
description={eventType.label}
/>
</div>
))}
{filterOptions?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
<FilterSelect
title={t("event_type")}
options={filterOptions}
selectedValue={selectedEventTypeId}
onChange={(value) => setConfigFilters({ selectedEventTypeId: value ? Number(value) : null })}
buttonIcon={<Icon name="calendar" className="mr-2 h-4 w-4" />}
emptyText={t("no_options_available")}
/>
);
});
@@ -1,4 +1,7 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { IconName } from "@calcom/ui";
import {
Dropdown,
@@ -12,38 +15,88 @@ import {
import { useFilterContext } from "../context/provider";
type FilterType = "event-type" | "user" | "routing_forms" | `rf_${string}` | "booking_status";
type Option = {
value: "event-type" | "user";
value: FilterType;
label: string;
StartIcon: IconName;
};
export const FilterType = () => {
export const FilterType = ({ showRoutingFilters = false }: { showRoutingFilters?: boolean }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedUserId } = filter;
const { selectedFilter, selectedUserId, selectedTeamId, selectedRoutingFormId, isAll, initialConfig } =
filter;
const initialConfigIsReady = !!(initialConfig?.teamId || initialConfig?.userId || initialConfig?.isAll);
let filterOptions: Option[] = [
// Dynamically load filters if showRoutingFilters is set to true
// Query routing form field options when showRoutingFilters is true
const { data: routingFormFieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
label: t("event_type"),
value: "event-type",
StartIcon: "link",
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
label: t("user"),
value: "user",
StartIcon: "user",
},
];
enabled: showRoutingFilters && initialConfigIsReady,
trpc: {
context: {
skipBatch: true,
},
},
}
);
if (selectedUserId) {
// remove user option from filterOptions
filterOptions = filterOptions.filter((option) => option.value !== "user");
}
const filterOptions = useMemo(() => {
let options: Option[] = [
{
label: t("user"),
value: "user",
StartIcon: "users" as IconName,
},
];
const filterValue = selectedFilter
? filterOptions.find((option) => option.value === selectedFilter[0])
: null;
// Add routing forms filter options
if (showRoutingFilters) {
options.push({
label: t("routing_forms"),
value: "routing_forms" as FilterType,
StartIcon: "calendar-check-2" as IconName,
});
options.push({
label: t("booking_status"),
value: "booking_status" as FilterType,
StartIcon: "circle" as IconName,
});
// Add dynamic routing form field options
if (routingFormFieldOptions?.length) {
options = [
...options,
...routingFormFieldOptions.map((option) => ({
label: option.label,
value: `rf_${option.id}` as FilterType,
StartIcon: "layers" as IconName,
})),
];
}
} else {
options.push({
label: t("event_type"),
value: "event-type",
StartIcon: "link",
});
}
if (selectedUserId) {
// remove user option from filterOptions
options = options.filter((option) => option.value !== "user");
}
return options;
}, [t, showRoutingFilters, routingFormFieldOptions, selectedUserId]);
return (
<Dropdown>
@@ -51,26 +104,31 @@ export const FilterType = () => {
<div className="hover:border-emphasis border-default text-default hover:text-emphasis focus:border-subtle mb-4 flex h-9 max-h-72 items-center justify-between whitespace-nowrap rounded-md border px-3 py-2 text-sm hover:cursor-pointer focus:outline-none focus:ring-2 focus:ring-neutral-800 focus:ring-offset-1">
<Icon name="plus" className="mr-2 h-4 w-4" />
<Tooltip content={t("add_filter")}>
<div>{t("add_filter")}</div>
<div>{selectedFilter?.length ? `${selectedFilter.length} ${t("filters")}` : t("add_filter")}</div>
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent className="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}
onClick={() => {
// This can multiple values, but for now we only want to have one filter active at a time
setConfigFilters({
selectedFilter: [option.value],
selectedFilter: selectedFilter
? selectedFilter.includes(option.value)
? selectedFilter.filter((f) => f !== option.value)
: [...selectedFilter, option.value]
: [option.value],
});
}}
childrenClassName="w-full">
<div className="flex w-full items-center justify-between">
{t(option.label)}
{filterValue?.value === 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>
@@ -0,0 +1,63 @@
import { useMemo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const RoutingFormFieldFilter = ({ fieldId }: { fieldId: string }) => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, isAll, selectedRoutingFormId, selectedRoutingFormFilter } = filter;
const { data: fieldOptions } = trpc.viewer.insights.getRoutingFormFieldOptions.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: !!isAll,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: !!selectedTeamId || !!isAll,
}
);
const currentField = useMemo(() => {
return fieldOptions?.find((option) => `${option.id}` === fieldId);
}, [fieldOptions]);
if (!currentField) return null;
const options =
currentField.options?.map((option) => ({
value: option.id ?? "",
label: option.label,
})) ?? [];
return (
<>
<FilterSelect
title={currentField.label}
options={options}
selectedValue={selectedRoutingFormFilter?.optionId}
onChange={(value) => {
console.log("value", value);
if (!value) {
setConfigFilters({
selectedRoutingFormFilter: null,
});
} else {
setConfigFilters({
selectedRoutingFormFilter: {
fieldId,
optionId: value as string,
},
});
}
}}
buttonIcon={<Icon name="layers" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
/>
</>
);
};
@@ -0,0 +1,50 @@
import { memo } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { Icon, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type Form = RouterOutputs["viewer"]["insights"]["getRoutingFormsForFilters"][number];
function buildFilterOptions(forms: Form[]) {
return forms.map((form) => ({
value: form.id,
label: form.name,
}));
}
export const RoutingFormFilterList = memo(() => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedRoutingFormId, isAll } = filter;
const { selectedFilter } = filter;
const { data: allForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
},
{
enabled: selectedFilter?.includes("routing_forms"),
}
);
if (!selectedFilter?.includes("routing_forms")) return null;
const filterOptions = buildFilterOptions(allForms || []);
return (
<FilterSelect
title={t("routing_forms")}
options={filterOptions}
selectedValue={selectedRoutingFormId}
onChange={(value) => setConfigFilters({ selectedRoutingFormId: value as string })}
buttonIcon={<Icon name="filter" className="mr-2 h-4 w-4" />}
/>
);
});
RoutingFormFilterList.displayName = "RoutingFormFilterList";
@@ -12,10 +12,10 @@ import { AnimatedPopover, Avatar, Divider, Icon } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
export const TeamAndSelfList = () => {
export const TeamAndSelfList = ({ omitOrg = false }: { omitOrg?: boolean }) => {
const { t } = useLocale();
const session = useSession();
const currentOrgId = session.data?.user.org?.id;
const { filter, setConfigFilters } = useFilterContext();
const { selectedTeamId, selectedUserId, isAll } = filter;
const { data, isSuccess } = trpc.viewer.insights.teamListForUser.useQuery(undefined, {
@@ -101,41 +101,44 @@ export const TeamAndSelfList = () => {
)}
<Divider />
{data?.map((team) => (
<FilterCheckboxField
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && !isAll}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedTeamId: team.id,
selectedUserId: null,
selectedTeamName: team.name,
isAll: false,
// Setting these to null to reset the filters
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: null,
selectedTeamName: null,
isAll: false,
});
{data?.map((team) => {
if (omitOrg && team.id === currentOrgId) return null;
return (
<FilterCheckboxField
key={team.id}
id={team.name || ""}
label={team.name || ""}
checked={selectedTeamId === team.id && !isAll}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedTeamId: team.id,
selectedUserId: null,
selectedTeamName: team.name,
isAll: false,
// Setting these to null to reset the filters
selectedEventTypeId: null,
selectedMemberUserId: null,
selectedFilter: null,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedTeamId: null,
selectedTeamName: null,
isAll: true,
});
}
}}
icon={
<Avatar
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
size="xs"
/>
}
}}
icon={
<Avatar
alt={team.name || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)}
size="xs"
/>
}
/>
))}
/>
);
})}
<Divider />
<FilterCheckboxField
@@ -1,89 +1,43 @@
import { useState } from "react";
import {
FilterCheckboxField,
FilterCheckboxFieldsContainer,
} from "@calcom/features/filters/components/TeamsFilter";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, Avatar, FilterSearchField } from "@calcom/ui";
import { Icon, Avatar, FilterSelect } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
type User = RouterOutputs["viewer"]["insights"]["userList"][number];
type Option = { value: number; label: string; username: string | null; avatarUrl: string | null };
const mapUserToOption = (user: User): Option => ({
const mapUserToOption = (user: User) => ({
value: user.id,
label: user.name ?? user.email, // every user should have at least email
username: user.username,
avatarUrl: user.avatarUrl,
label: user.name ?? user.email,
icon: <Avatar alt={`${user.name} avatar`} imageSrc={user.avatarUrl} size="sm" className="mr-2" />,
});
export const UserListInTeam = () => {
const { t } = useLocale();
const { filter, setConfigFilters } = useFilterContext();
const { selectedFilter, selectedTeamId, selectedMemberUserId, isAll } = filter;
const [searchText, setSearchText] = useState("");
const { data, isSuccess } = trpc.viewer.insights.userList.useQuery({
teamId: selectedTeamId ?? -1,
isAll: !!isAll,
});
if (!selectedFilter?.includes("user")) return null;
if (!selectedTeamId) return null;
if (!selectedFilter?.includes("user") || !selectedTeamId || !isSuccess || data?.length === 0) {
return null;
}
const userListOptions = data?.map(mapUserToOption);
const selectedTeamUser = data?.find((item) => item.id === selectedMemberUserId);
const userValue = selectedTeamUser ? mapUserToOption(selectedTeamUser) : null;
const filteredUserListOptions = userListOptions?.filter((member) => {
if (searchText.trim() === "") return true;
const searchLower = searchText.toLowerCase();
const labelMatch = member.label.toLowerCase().includes(searchLower);
const usernameMatch = member.username?.toLowerCase().includes(searchLower);
return labelMatch || usernameMatch;
});
if (!isSuccess || data?.length === 0) return null;
const getTextForPopover = () => {
if (userValue?.label) {
return `${t("people")}: ${userValue.label}`;
}
return t("people");
};
const userListOptions = data.map(mapUserToOption);
return (
<AnimatedPopover text={getTextForPopover()}>
<FilterCheckboxFieldsContainer>
<FilterSearchField onChange={(e) => setSearchText(e.target.value)} placeholder={t("search")} />
{filteredUserListOptions?.map((member) => (
<FilterCheckboxField
key={member.value}
id={member?.value?.toString()}
label={member?.label ?? member.username ?? "No Name"}
checked={userValue?.value === member?.value}
onChange={(e) => {
if (e.target.checked) {
setConfigFilters({
selectedMemberUserId: member.value,
});
} else if (!e.target.checked) {
setConfigFilters({
selectedMemberUserId: undefined,
});
}
}}
icon={<Avatar alt={`${member?.value} avatar`} imageSrc={member.avatarUrl} size="xs" />}
/>
))}
{filteredUserListOptions?.length === 0 && (
<h2 className="text-default px-4 py-2 text-sm font-medium">{t("no_options_available")}</h2>
)}
</FilterCheckboxFieldsContainer>
</AnimatedPopover>
<FilterSelect
title={t("people")}
options={userListOptions}
selectedValue={selectedMemberUserId}
onChange={(value) => setConfigFilters({ selectedMemberUserId: Number(value) })}
buttonIcon={<Icon name="users" className="mr-2 h-4 w-4" />}
placeholder={t("search")}
testId="people-filter"
/>
);
};
+29 -6
View File
@@ -2,10 +2,13 @@ import { useFilterContext } from "@calcom/features/insights/context/provider";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Icon, Tooltip } from "@calcom/ui";
import { BookingStatusFilter } from "./BookingStatusFilter";
import { DateSelect } from "./DateSelect";
import { Download } from "./Download/index";
import { Download, RoutingDownload } from "./Download";
import { EventTypeList } from "./EventTypeList";
import { FilterType } from "./FilterType";
import { RoutingFormFieldFilter } from "./RoutingFormFieldFilter";
import { RoutingFormFilterList } from "./RoutingFormFilterList";
import { TeamAndSelfList } from "./TeamAndSelfList";
import { UserListInTeam } from "./UsersListInTeam";
@@ -23,7 +26,7 @@ const ClearFilters = () => {
color="secondary"
target="_blank"
rel="noreferrer"
className="h-[38px] min-w-24 border-0"
className="min-w-24 h-[38px] border-0"
onClick={() => {
clearFilters();
}}>
@@ -34,17 +37,37 @@ const ClearFilters = () => {
);
};
export const Filters = () => {
export const Filters = ({ showRoutingFilters = false }: { showRoutingFilters?: boolean }) => {
const { filter } = useFilterContext();
const { selectedFilter } = filter;
// Get all filters that relate to the routing form
const routingFormFieldIds = selectedFilter
? selectedFilter.map((filter) => {
if (filter.startsWith("rf_")) return filter.substring(3);
})
: [];
return (
<div className="ml-auto mt-6 flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:justify-between">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-nowrap sm:justify-start">
<TeamAndSelfList />
<TeamAndSelfList omitOrg={showRoutingFilters} />
<UserListInTeam />
<EventTypeList />
<FilterType />
{showRoutingFilters ? (
<>
<RoutingFormFilterList />
<BookingStatusFilter />
{routingFormFieldIds.map((fieldId) => {
if (fieldId) return <RoutingFormFieldFilter fieldId={fieldId} />;
})}
</>
) : null}
<FilterType showRoutingFilters={showRoutingFilters} />
<ClearFilters />
</div>
@@ -73,7 +96,7 @@ export const Filters = () => {
</Tooltip>
</ButtonGroup> */}
<div className="flex flex-col-reverse sm:flex-row sm:flex-nowrap sm:justify-between">
<Download />
{showRoutingFilters ? <RoutingDownload /> : <Download />}
<DateSelect />
</div>
</div>
@@ -0,0 +1,60 @@
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef } from "react";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { trpc } from "@calcom/trpc";
export function useDefaultRoutingForm({
teamId,
isAll,
routingFormId,
onRoutingFormChange,
}: {
teamId: number | null | undefined;
isAll: boolean;
routingFormId: string | null | undefined;
onRoutingFormChange: (formId: string) => void;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const isRoutingInsights = pathname?.includes("/insights/routing");
const hasSetDefault = useRef(false);
// Query to get routing forms list
const { data: routingForms } = trpc.viewer.insights.getRoutingFormsForFilters.useQuery(
{
teamId: teamId ?? undefined,
isAll,
},
{
enabled: isRoutingInsights,
}
);
// Get the most popular form
const mostPopularForm = routingForms?.length
? [...routingForms].sort((a, b) => (b._count?.responses ?? 0) - (a._count?.responses ?? 0))[0]
: null;
// Effect to set default routing form only once when the page loads
useEffect(() => {
if (isRoutingInsights && !routingFormId && mostPopularForm && !hasSetDefault.current) {
hasSetDefault.current = true;
const newSearchParams = new URLSearchParams(searchParams?.toString() ?? undefined);
newSearchParams.set("routingFormId", mostPopularForm.id);
router.push(`${pathname}?${newSearchParams.toString()}`);
onRoutingFormChange(mostPopularForm.id);
}
}, [
isRoutingInsights,
routingFormId,
mostPopularForm,
pathname,
searchParams,
router,
onRoutingFormChange,
]);
return { routingForms, mostPopularForm };
}
@@ -0,0 +1,640 @@
import { Prisma } from "@prisma/client";
import {
zodFields as routingFormFieldsSchema,
routingFormResponseInDbSchema,
} from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
type RoutingFormInsightsTeamFilter = {
teamId?: number | null;
isAll: boolean;
organizationId?: number | null;
routingFormId?: string | null;
};
type RoutingFormInsightsFilter = RoutingFormInsightsTeamFilter & {
startDate?: string;
endDate?: string;
userId?: number | null;
bookingStatus?: BookingStatus | "NO_BOOKING" | null;
fieldFilter?: {
fieldId: string;
optionId: string;
} | null;
};
class RoutingEventsInsights {
private static async getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
// Get team IDs based on organization if applicable
let teamIds: number[] = [];
if (isAll && organizationId) {
const teamsFromOrg = await prisma.team.findMany({
where: {
parentId: organizationId,
},
select: {
id: true,
},
});
teamIds = [organizationId, ...teamsFromOrg.map((t) => t.id)];
} else if (teamId) {
teamIds = [teamId];
}
// Base where condition for forms
const formsWhereCondition: Prisma.App_RoutingForms_FormWhereInput = {
...(teamIds.length > 0 && {
teamId: {
in: teamIds,
},
}),
...(routingFormId && {
id: routingFormId,
}),
};
return formsWhereCondition;
}
static async getRoutingFormStats({
teamId,
startDate,
endDate,
isAll = false,
organizationId,
routingFormId,
userId,
bookingStatus,
fieldFilter,
}: RoutingFormInsightsFilter) {
// Get team IDs based on organization if applicable
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
// Base where condition for responses
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
form: formsWhereCondition,
};
// Get total forms count
const totalFormsPromise = prisma.app_RoutingForms_Form.count({
where: formsWhereCondition,
});
// Get total responses
const totalResponsesPromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
// Get responses without booking
const responsesWithoutBookingPromise = prisma.app_RoutingForms_FormResponse.count({
where: {
...responsesWhereCondition,
routedToBookingUid: null,
},
});
const [totalForms, totalResponses, responsesWithoutBooking] = await Promise.all([
totalFormsPromise,
totalResponsesPromise,
responsesWithoutBookingPromise,
]);
return {
created: totalForms,
total_responses: totalResponses,
total_responses_without_booking: responsesWithoutBooking,
total_responses_with_booking: totalResponses - responsesWithoutBooking,
};
}
static async getRoutingFormsForFilters({
teamId,
isAll,
organizationId,
}: {
teamId?: number;
isAll: boolean;
organizationId?: number | undefined;
routingFormId?: string | undefined;
}) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({ teamId, isAll, organizationId });
return await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
_count: {
select: {
responses: true,
},
},
},
});
}
static async getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
cursor,
limit,
userId,
fieldFilter,
bookingStatus,
}: RoutingFormInsightsFilter & { cursor?: number; limit?: number }) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
form: formsTeamWhereCondition,
};
const totalResponsePromise = prisma.app_RoutingForms_FormResponse.count({
where: responsesWhereCondition,
});
const responsesPromise = prisma.app_RoutingForms_FormResponse.findMany({
select: {
id: true,
response: true,
form: {
select: {
id: true,
name: true,
},
},
routedToBooking: {
select: {
uid: true,
status: true,
createdAt: true,
attendees: {
select: {
timeZone: true,
email: true,
},
},
user: {
select: { id: true, name: true, email: true, avatarUrl: true },
},
},
},
createdAt: true,
},
where: responsesWhereCondition,
orderBy: {
createdAt: "desc",
},
take: limit ? limit + 1 : undefined, // Get one extra item to check if there are more pages
cursor: cursor ? { id: cursor } : undefined,
});
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
// Parse response data
const parsedResponses = responses.map((r) => {
const responseData = routingFormResponseInDbSchema.parse(r.response);
return { ...r, response: responseData };
});
return {
total: totalResponses,
data: parsedResponses,
nextCursor: responses.length > (limit ?? 0) ? responses[responses.length - 1].id : undefined,
};
}
static async getRoutingFormFieldOptions({
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
routingFormId,
organizationId,
});
const routingForms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
fields: true,
},
});
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
return fields;
}
static async getFailedBookingsByRoutingFormGroup({
teamId,
isAll,
routingFormId,
organizationId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
const teamConditions = [];
// @ts-expect-error it doest exist but TS isnt smart enough when its unmber or int filter
if (formsWhereCondition.teamId?.in) {
// @ts-expect-error it doest exist but TS isnt smart enough when its unmber or int filter
teamConditions.push(`f."teamId" IN (${formsWhereCondition.teamId.in.join(",")})`);
}
if (routingFormId) {
teamConditions.push(`f.id = '${routingFormId}'`);
}
const whereClause = teamConditions.length
? Prisma.sql`AND ${Prisma.raw(teamConditions.join(" AND "))}`
: Prisma.sql``;
// If youre at this point wondering what this does. This groups the responses by form and field and counts the number of responses for each option that don't have a booking.
const result = await prisma.$queryRaw<
{
formId: string;
formName: string;
fieldId: string;
fieldLabel: string;
optionId: string;
optionLabel: string;
count: number;
}[]
>`
WITH form_fields AS (
SELECT
f.id as form_id,
f.name as form_name,
field->>'id' as field_id,
field->>'label' as field_label,
opt->>'id' as option_id,
opt->>'label' as option_label
FROM "App_RoutingForms_Form" f,
LATERAL jsonb_array_elements(f.fields) as field
LEFT JOIN LATERAL jsonb_array_elements(field->'options') as opt ON true
WHERE true
${whereClause}
),
response_stats AS (
SELECT
r."formId",
key as field_id,
CASE
WHEN jsonb_typeof(value->'value') = 'array' THEN
v.value_item
ELSE
value->>'value'
END as selected_option,
COUNT(DISTINCT r.id) as response_count
FROM "App_RoutingForms_FormResponse" r
CROSS JOIN jsonb_each(r.response::jsonb) as fields(key, value)
LEFT JOIN LATERAL jsonb_array_elements_text(
CASE
WHEN jsonb_typeof(value->'value') = 'array'
THEN value->'value'
ELSE NULL
END
) as v(value_item) ON true
WHERE r."routedToBookingUid" IS NULL
GROUP BY r."formId", key, selected_option
)
SELECT
ff.form_id as "formId",
ff.form_name as "formName",
ff.field_id as "fieldId",
ff.field_label as "fieldLabel",
ff.option_id as "optionId",
ff.option_label as "optionLabel",
COALESCE(rs.response_count, 0)::integer as count
FROM form_fields ff
LEFT JOIN response_stats rs ON
rs."formId" = ff.form_id AND
rs.field_id = ff.field_id AND
rs.selected_option = ff.option_id
WHERE ff.option_id IS NOT NULL
ORDER BY count DESC`;
// First group by form and field
const groupedByFormAndField = result.reduce((acc, curr) => {
const formKey = curr.formName;
acc[formKey] = acc[formKey] || {};
const labelKey = curr.fieldLabel;
acc[formKey][labelKey] = acc[formKey][labelKey] || [];
acc[formKey][labelKey].push({
optionId: curr.optionId,
count: curr.count,
optionLabel: curr.optionLabel,
});
return acc;
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
// NOTE: totalCount represents the sum of all response counts across all fields and options for a form
// For example, if a form has 2 fields with 2 options each:
// Field1: Option1 (5 responses), Option2 (3 responses)
// Field2: Option1 (2 responses), Option2 (4 responses)
// Then totalCount = 5 + 3 + 2 + 4 = 14 total responses
const sortedEntries = Object.entries(groupedByFormAndField)
.map(([formName, fields]) => ({
formName,
fields,
totalCount: Object.values(fields)
.flat()
.reduce((sum, item) => sum + item.count, 0),
}))
.sort((a, b) => b.totalCount - a.totalCount);
// Convert back to original format
const sortedGroupedByFormAndField = sortedEntries.reduce((acc, { formName, fields }) => {
acc[formName] = fields;
return acc;
}, {} as Record<string, Record<string, { optionId: string; count: number; optionLabel: string }[]>>);
return sortedGroupedByFormAndField;
}
static async getRoutingFormHeaders({
teamId,
isAll,
organizationId,
routingFormId,
}: RoutingFormInsightsTeamFilter) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
const routingForms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
fields: true,
},
});
const fields = routingFormFieldsSchema.parse(routingForms.map((f) => f.fields).flat());
const headers = fields?.map((f) => {
return {
id: f.id,
label: f.label,
options: f.options,
};
});
return headers;
}
static async getRawData({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
bookingStatus,
fieldFilter,
take,
skip,
}: RoutingFormInsightsFilter & { take?: number; skip?: number }) {
const formsWhereCondition = await this.getWhereForTeamOrAllTeams({
teamId,
isAll,
organizationId,
routingFormId,
});
// First get all forms and their fields to build a mapping
const forms = await prisma.app_RoutingForms_Form.findMany({
where: formsWhereCondition,
select: {
id: true,
name: true,
fields: true,
},
});
// Create a mapping of form ID to fields
type FormFieldOption = {
label: string;
type: string;
options: Record<string, string>;
};
type FormFieldsMap = Record<string, Record<string, FormFieldOption>>;
const formFieldsMap = forms.reduce((acc, form) => {
const fields = routingFormFieldsSchema.parse(form.fields);
acc[form.id] =
fields?.reduce((fieldMap: Record<string, FormFieldOption>, field) => {
fieldMap[field.id] = {
label: field.label,
type: field.type,
options:
field.options?.reduce((optMap, opt) => {
if (opt.id !== null) {
optMap[opt.id] = opt.label;
}
return optMap;
}, {} as Record<string, string>) ?? {},
};
return fieldMap;
}, {}) || {};
return acc;
}, {} as FormFieldsMap);
const responsesWhereCondition: Prisma.App_RoutingForms_FormResponseWhereInput = {
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
...(userId || bookingStatus
? {
...(bookingStatus === "NO_BOOKING"
? { routedToBooking: null }
: {
routedToBooking: {
...(userId && { userId }),
...(bookingStatus && { status: bookingStatus }),
},
}),
}
: {}),
...(fieldFilter && {
response: {
path: [fieldFilter.fieldId, "value"],
array_contains: [fieldFilter.optionId],
},
}),
form: formsWhereCondition,
};
const responses = await prisma.app_RoutingForms_FormResponse.findMany({
select: {
id: true,
response: true,
createdAt: true,
form: {
select: {
id: true,
name: true,
},
},
routedToBooking: {
select: {
uid: true,
status: true,
createdAt: true,
startTime: true,
endTime: true,
attendees: {
select: {
email: true,
name: true,
timeZone: true,
},
},
user: {
select: {
name: true,
email: true,
},
},
},
},
},
where: responsesWhereCondition,
orderBy: {
createdAt: "desc",
},
take: take,
skip: skip,
});
// Transform the data into a flat structure suitable for CSV
return responses.map((response) => {
const parsedResponse = routingFormResponseInDbSchema.parse(response.response);
const formFields = formFieldsMap[response.form.id];
const flatResponse: Record<string, unknown> = {
"Response ID": response.id,
"Form Name": response.form.name,
"Submitted At": response.createdAt.toISOString(),
"Has Booking": !!response.routedToBooking,
"Booking Status": response.routedToBooking?.status || "NO_BOOKING",
"Booking Created At": response.routedToBooking?.createdAt?.toISOString() || "",
"Booking Start Time": response.routedToBooking?.startTime?.toISOString() || "",
"Booking End Time": response.routedToBooking?.endTime?.toISOString() || "",
"Attendee Name": response.routedToBooking?.attendees[0]?.name || "",
"Attendee Email": response.routedToBooking?.attendees[0]?.email || "",
"Attendee Timezone": response.routedToBooking?.attendees[0]?.timeZone || "",
"Routed To Name": response.routedToBooking?.user?.name || "",
"Routed To Email": response.routedToBooking?.user?.email || "",
};
// Add form fields as columns with their labels
Object.entries(parsedResponse).forEach(([fieldId, field]) => {
const fieldInfo = formFields[fieldId];
if (fieldInfo) {
const fieldLabel = fieldInfo.label;
if (Array.isArray(field.value)) {
// For multi-select fields, map the IDs to labels
const values = field.value.map((val) => fieldInfo.options[val] || val);
flatResponse[fieldLabel] = values.join(", ");
} else {
// For single-select fields, map the ID to label
flatResponse[fieldLabel] = fieldInfo.options[field.value] || field.value;
}
}
});
return flatResponse;
});
}
}
export { RoutingEventsInsights };
File diff suppressed because it is too large Load Diff
@@ -86,6 +86,19 @@ const navigation: NavigationItemType[] = [
name: "insights",
href: "/insights",
icon: "chart-bar",
isCurrent: ({ pathname: path, item }) => path?.startsWith(item.href) ?? false,
child: [
{
name: "bookings",
href: "/insights",
isCurrent: ({ pathname: path }) => path == "/insights" ?? false,
},
{
name: "routing",
href: "/insights/routing",
isCurrent: ({ pathname: path }) => path?.startsWith("/insights/routing") ?? false,
},
],
},
];
+3 -3
View File
@@ -23,7 +23,7 @@ export function UpgradeTip({
features: Array<{ icon: JSX.Element; title: string; description: string }>;
buttons?: JSX.Element;
/**Chldren renders when the user is in a team */
children: JSX.Element;
children: ReactNode;
isParentLoading?: ReactNode;
plan: "team" | "enterprise";
}) {
@@ -38,9 +38,9 @@ export function UpgradeTip({
const hasUnpublishedTeam = !!data?.[0];
if (plan === "team" && (hasTeamPlan || hasUnpublishedTeam)) return children;
if (plan === "team" && (hasTeamPlan || hasUnpublishedTeam)) return <>{children}</>;
if (plan === "enterprise" && hasEnterprisePlan) return children;
if (plan === "enterprise" && hasEnterprisePlan) return <>{children}</>;
if (isPending) return <>{isParentLoading}</>;
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownItem,
DropdownMenuSeparator,
showToast,
@@ -74,50 +75,125 @@ export function TableActions({
StartIcon="ellipsis"
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{permissionsForUser.canEdit && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canImpersonate && (
<>
<DropdownMenuPortal>
<DropdownMenuContent>
{permissionsForUser.canEdit && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canImpersonate && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="lock">
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
color="destructive"
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResendInvitation && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
resendInvitationMutation.mutate({
teamId: orgId,
language: i18n.language,
email: user.email,
isOrg: true,
});
}}
StartIcon="send">
{t("resend_invitation")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
)}
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" StartIcon="external-link">
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
{permissionsForUser.canEdit && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
@@ -127,84 +203,13 @@ export function TableActions({
},
})
}
color="destructive"
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canResendInvitation && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() => {
resendInvitationMutation.mutate({
teamId: orgId,
language: i18n.language,
email: user.email,
isOrg: true,
});
}}
StartIcon="send">
{t("resend_invitation")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
)}
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" StartIcon="external-link">
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
{permissionsForUser.canEdit && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "EDIT_USER_SHEET",
payload: {
user,
showModal: true,
},
})
}
StartIcon="pencil">
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon="user-x">
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
</div>
</>
@@ -0,0 +1,151 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
import {
filterHostsByLeadThreshold,
errorCodes,
_filterHostByLeadThreshold,
} from "./filterHostsByLeadThreshold";
// Import the original Prisma client
// Mocking setup
const prismaMock = {
booking: {
groupBy: vi.fn(), // Mock the groupBy method
},
};
// Use `vi.spyOn` to make `prisma.booking.groupBy` call the mock instead
vi.spyOn(prisma.booking, "groupBy").mockImplementation(prismaMock.booking.groupBy);
afterEach(() => {
// Clear call history before each test to avoid cross-test interference
prismaMock.booking.groupBy.mockClear();
});
describe("filterHostByLeadThreshold", () => {
it("skips filter if host is fixed", async () => {
const hosts = [{ isFixed: true, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } }];
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventTypeId: 1,
})
).resolves.toStrictEqual(hosts);
});
it("skips filter if lead threshold is null", async () => {
const hosts = [{ isFixed: false, createdAt: new Date(), user: { id: 1 } }];
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: null,
eventTypeId: 1,
})
).resolves.toStrictEqual(hosts);
});
it("throws error when maxLeadThreshold = 0, 0 ahead makes no sense.", () => {
expect(() =>
_filterHostByLeadThreshold({
host: { leadOffset: 3 },
maxLeadThreshold: 0,
})
).toThrow(errorCodes.MAX_LEAD_THRESHOLD_FALSY);
});
it("correctly disqualifies a host when the lead offset is exceeding the threshold", async () => {
prismaMock.booking.groupBy.mockResolvedValue([
{ userId: 1, _count: { _all: 5 } },
{ userId: 2, _count: { _all: 10 } },
]);
const hosts = [
{ isFixed: false, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
{ isFixed: false, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
];
// host is not disqualified as the threshold of 11 is not exceeded.
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 11,
eventTypeId: 1,
})
).resolves.toStrictEqual(hosts);
// with a reduced threshold of 3 the second host (t=10) is disqualified
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventTypeId: 1,
})
).resolves.toStrictEqual([hosts.find(({ user: { id: userId } }) => userId === 1)]);
// double check that lead thresholds are disabled when maxLeadThreshold=null as I'm paranoid.
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: null,
eventTypeId: 1,
})
).resolves.toStrictEqual(hosts);
});
it("ignores fixed users towards fairness disqualification", async () => {
prismaMock.booking.groupBy.mockResolvedValue([
{ userId: 1, _count: { _all: 5 } },
{ userId: 2, _count: { _all: 10 } },
]);
const hosts = [
// fixed users do not count towards disqualification.
{ isFixed: true, createdAt: new Date(), user: { id: 1, email: "example1@acme.com" } },
{ isFixed: false, createdAt: new Date(), user: { id: 2, email: "example2@acme.com" } },
];
// with a reduced threshold of 3 the second host (t=10) is disqualified
expect(
filterHostsByLeadThreshold({
hosts,
maxLeadThreshold: 3,
eventTypeId: 1,
})
).resolves.toStrictEqual([hosts.find(({ user: { id: userId } }) => userId === 1)]);
expect(prismaMock.booking.groupBy).toHaveBeenCalledWith({
by: ["userId"],
where: {
OR: [
{
user: {
id: {
in: [2],
},
},
OR: [
{
noShowHost: false,
},
{
noShowHost: null,
},
],
},
{
attendees: {
some: {
email: {
in: ["example2@acme.com"],
},
},
},
},
],
attendees: { some: { noShow: false } },
status: BookingStatus.ACCEPTED,
eventTypeId: 1,
createdAt: {
gte: hosts[1].createdAt,
},
},
_count: { _all: true },
});
});
});

Some files were not shown because too many files have changed in this diff Show More