Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const CheckedHostField = ({
weight: option.weight ?? 100,
scheduleId: option.defaultScheduleId,
groupId: option.groupId,
minimumBookingNotice: option.minimumBookingNotice ?? null,
}))
);
}}
Expand All @@ -106,6 +107,7 @@ const CheckedHostField = ({
isFixed,
weight: host.weight ?? 100,
groupId: host.groupId,
minimumBookingNotice: host.minimumBookingNotice,
});

return acc;
Expand Down
36 changes: 34 additions & 2 deletions apps/web/modules/event-types/components/CheckedTeamSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import { Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";

import type { PriorityDialogCustomClassNames, WeightDialogCustomClassNames } from "./HostEditDialogs";
import { PriorityDialog, WeightDialog } from "./HostEditDialogs";
import type {
MinimumNoticeDialogCustomClassNames,
PriorityDialogCustomClassNames,
WeightDialogCustomClassNames,
} from "./HostEditDialogs";
import { MinimumNoticeDialog, PriorityDialog, WeightDialog } from "./HostEditDialogs";

export type CheckedSelectOption = {
avatar: string;
Expand All @@ -28,6 +32,7 @@ export type CheckedSelectOption = {
disabled?: boolean;
defaultScheduleId?: number | null;
groupId: string | null;
minimumBookingNotice?: number | null;
};

export type CheckedTeamSelectCustomClassNames = {
Expand All @@ -40,11 +45,13 @@ export type CheckedTeamSelectCustomClassNames = {
name?: string;
changePriorityButton?: string;
changeWeightButton?: string;
changeMinimumNoticeButton?: string;
removeButton?: string;
};
};
priorityDialog?: PriorityDialogCustomClassNames;
weightDialog?: WeightDialogCustomClassNames;
minimumNoticeDialog?: MinimumNoticeDialogCustomClassNames;
};
export const CheckedTeamSelect = ({
options = [],
Expand All @@ -64,6 +71,7 @@ export const CheckedTeamSelect = ({
const isPlatform = useIsPlatform();
const [priorityDialogOpen, setPriorityDialogOpen] = useState(false);
const [weightDialogOpen, setWeightDialogOpen] = useState(false);
const [minimumNoticeDialogOpen, setMinimumNoticeDialogOpen] = useState(false);

const [currentOption, setCurrentOption] = useState(value[0] ?? null);

Expand Down Expand Up @@ -164,6 +172,22 @@ export const CheckedTeamSelect = ({
) : (
<></>
)}
<Tooltip content={t("set_minimum_notice")}>
<Button
color="minimal"
className={classNames(
"mr-6 h-2 p-0 text-sm hover:bg-transparent",
customClassNames?.selectedHostList?.listItem?.changeMinimumNoticeButton
)}
onClick={() => {
setMinimumNoticeDialogOpen(true);
setCurrentOption(option);
}}>
{option.minimumBookingNotice != null
? `${option.minimumBookingNotice}${t("minutes_short")}`
: t("set_notice")}
</Button>
</Tooltip>
</>
) : (
<></>
Expand Down Expand Up @@ -200,6 +224,14 @@ export const CheckedTeamSelect = ({
onChange={props.onChange}
customClassNames={customClassNames?.weightDialog}
/>
<MinimumNoticeDialog
isOpenDialog={minimumNoticeDialogOpen}
setIsOpenDialog={setMinimumNoticeDialogOpen}
option={currentOption}
options={options}
onChange={props.onChange}
customClassNames={customClassNames?.minimumNoticeDialog}
/>
</>
) : (
<></>
Expand Down
141 changes: 129 additions & 12 deletions apps/web/modules/event-types/components/HostEditDialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { Options } from "react-select";

import { Dialog } from "@calcom/features/components/controlled-dialog";
import type {
FormValues,
Host,
InputClassNames,
SelectClassNames,
} from "@calcom/features/eventtypes/lib/types";
import { groupHostsByGroupId, getHostsFromOtherGroups, sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import { getHostsFromOtherGroups, groupHostsByGroupId, sortHosts } from "@calcom/lib/bookings/hostGroupUtils";
import { DEFAULT_GROUP_ID } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { Label } from "@calcom/ui/components/form";
import { Select } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { DialogClose, DialogContent, DialogFooter } from "@calcom/ui/components/dialog";
import { Label, Select, TextField } from "@calcom/ui/components/form";
import type { Dispatch, SetStateAction } from "react";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import type { Options } from "react-select";

import type { CheckedSelectOption } from "./CheckedTeamSelect";
import WeightDescription from "./WeightDescription";
Expand Down Expand Up @@ -54,7 +52,7 @@ export const PriorityDialog = (

const [newPriority, setNewPriority] = useState<{ label: string; value: number }>();
const setPriority = () => {
if (!!newPriority) {
if (newPriority) {
const hosts: Host[] = getValues("hosts");
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
const hostGroups = getValues("hostGroups");
Expand Down Expand Up @@ -141,7 +139,7 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC
const [newWeight, setNewWeight] = useState<number | undefined>();

const setWeight = () => {
if (!!newWeight) {
if (newWeight) {
const hosts: Host[] = getValues("hosts");
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
const hostGroups = getValues("hostGroups");
Expand Down Expand Up @@ -186,6 +184,7 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
minimumBookingNotice: host.minimumBookingNotice,
}));

// Preserve hosts from other groups
Expand All @@ -201,6 +200,7 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
minimumBookingNotice: host.minimumBookingNotice,
};
});
const newFullValue = [...otherGroupsOptions, ...updatedOptions];
Expand Down Expand Up @@ -242,3 +242,120 @@ export const WeightDialog = (props: IDialog & { customClassNames?: WeightDialogC
</Dialog>
);
};

export type MinimumNoticeDialogCustomClassNames = {
container?: string;
label?: string;
confirmButton?: string;
noticeInput?: InputClassNames;
};

export const MinimumNoticeDialog = (
props: IDialog & { customClassNames?: MinimumNoticeDialogCustomClassNames }
) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, option, options, onChange, customClassNames } = props;
const { getValues } = useFormContext<FormValues>();
const [newMinimumNotice, setNewMinimumNotice] = useState<number | undefined>();

const setMinimumNotice = () => {
const hosts: Host[] = getValues("hosts");
const isRRWeightsEnabled = getValues("isRRWeightsEnabled");
const hostGroups = getValues("hostGroups");
const rrHosts = hosts.filter((host) => !host.isFixed);

const groupedHosts = groupHostsByGroupId({ hosts: rrHosts, hostGroups });

const updateHostNotice = (host: Host) => {
if (host.userId === parseInt(option.value, 10)) {
return { ...host, minimumBookingNotice: newMinimumNotice ?? null };
}
return host;
};

let sortedHostGroup: (Host & {
avatar: string;
label: string;
})[] = [];

const hostGroupToSort = groupedHosts[option.groupId ?? DEFAULT_GROUP_ID];

if (hostGroupToSort) {
sortedHostGroup = hostGroupToSort
.map((host) => {
const userOption = options.find((opt) => opt.value === host.userId.toString());
const updatedHost = updateHostNotice(host);
return {
...updatedHost,
avatar: userOption?.avatar ?? "",
label: userOption?.label ?? host.userId.toString(),
};
})
.sort((a, b) => sortHosts(a, b, isRRWeightsEnabled));
}

const updatedOptions = sortedHostGroup.map((host) => ({
avatar: host.avatar,
label: host.label,
value: host.userId.toString(),
priority: host.priority,
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
minimumBookingNotice: host.minimumBookingNotice,
}));

const otherGroupsHosts = getHostsFromOtherGroups(rrHosts, option.groupId);

const otherGroupsOptions = otherGroupsHosts.map((host) => {
const userOption = options.find((opt) => opt.value === host.userId.toString());
return {
avatar: userOption?.avatar ?? "",
label: userOption?.label ?? host.userId.toString(),
value: host.userId.toString(),
priority: host.priority,
weight: host.weight,
isFixed: host.isFixed,
groupId: host.groupId,
minimumBookingNotice: host.minimumBookingNotice,
};
});
const newFullValue = [...otherGroupsOptions, ...updatedOptions];
onChange(newFullValue);
setIsOpenDialog(false);
};

return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow title={t("set_minimum_notice")}>
<Alert severity="neutral" message={t("minimum_notice_default_info")} className="mb-4" />
<div className={classNames("mb-4", customClassNames?.container)}>
<Label className={customClassNames?.label}>
{t("minimum_notice_for_user", { userName: option.label })}
</Label>
<div className={classNames(customClassNames?.noticeInput?.container)}>
<TextField
min={0}
className={customClassNames?.noticeInput?.input}
labelClassName={customClassNames?.noticeInput?.label}
addOnClassname={customClassNames?.noticeInput?.addOn}
label={t("minutes")}
value={newMinimumNotice}
defaultValue={option.minimumBookingNotice ?? ""}
placeholder={t("use_event_default")}
type="number"
onChange={(e) => setNewMinimumNotice(e.target.value ? parseInt(e.target.value) : undefined)}
addOnSuffix={<>{t("minutes_short")}</>}
/>
</div>
</div>
<DialogFooter>
<DialogClose onClick={() => setNewMinimumNotice(undefined)} />
<Button onClick={setMinimumNotice} className={customClassNames?.confirmButton}>
{t("confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
7 changes: 7 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -2943,10 +2943,17 @@
"send_booker_to": "Send Booker to",
"set_priority": "Set priority",
"set_weight": "Set weight",
"set_minimum_notice": "Set minimum notice",
"enable_weights": "Enable weights",
"priority_for_user": "Priority for {{userName}}",
"weights_description": "Weights determine how meetings are distributed among hosts. <0>Learn more</0>",
"weight_for_user": "Weight for {{userName}}",
"minimum_notice_for_user": "Minimum notice for {{userName}}",
"use_event_default": "Use event default",
"minimum_notice_default_info": "By default, the event type's minimum notice time is used. Set a custom value to override it for this host.",
"from_event": "From event",
"set_notice": "Set notice",
"minutes_short": "min",
"change_priority": "change priority",
"field_identifiers_as_variables": "Use field identifiers as variables for your custom event redirect",
"field_identifiers_as_variables_with_example": "Use field identifiers as variables for your custom event redirect (e.g. {{variable}})",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const getEventTypesFromDBSelect = {
weight: true,
createdAt: true,
groupId: true,
minimumBookingNotice: true,
user: {
select: {
credentials: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,14 @@ const loadUsersByEventType = async (eventType: EventType): Promise<NewBookingEve
eventType,
hosts: hosts ?? fallbackHosts,
});
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt, groupId }) => ({
return matchingHosts.map(({ user, isFixed, priority, weight, createdAt, groupId, minimumBookingNotice }) => ({
...user,
isFixed,
priority,
weight,
createdAt,
groupId,
minimumBookingNotice,
}));
};

Expand Down
1 change: 1 addition & 0 deletions packages/features/bookings/lib/handleNewBooking/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type IsFixedAwareUser = User & {
organization?: { slug: string };
priority?: number;
weight?: number;
minimumBookingNotice?: number | null;
userLevelSelectedCalendars: SelectedCalendar[];
allSelectedCalendars: SelectedCalendar[];
groupId?: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type Host<T> = {
priority?: number | null;
weight?: number | null;
groupId: string | null;
minimumBookingNotice?: number | null;
} & {
user: T;
};
Expand Down Expand Up @@ -112,13 +113,17 @@ export class QualifiedHostsService {
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId: string | null;
minimumBookingNotice?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
fixedHosts: {
isFixed: boolean;
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId: string | null;
minimumBookingNotice?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
// all hosts we want to fallback to including the qualifiedRRHosts (fairness + crm contact owner)
Expand All @@ -127,6 +132,8 @@ export class QualifiedHostsService {
createdAt: Date | null;
priority?: number | null;
weight?: number | null;
groupId: string | null;
minimumBookingNotice?: number | null;
user: Omit<T, "credentials"> & { credentials: CredentialForCalendarService[] };
}[];
}> {
Expand Down
14 changes: 11 additions & 3 deletions packages/features/bookings/lib/service/RegularBookingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,9 +1138,17 @@ async function handler(
for (const [groupId, luckyUserPool] of Object.entries(luckyUserPools)) {
let luckUserFound = false;
while (luckyUserPool.length > 0 && !luckUserFound) {
const freeUsers = luckyUserPool.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
);
const bookingStartTime = new Date(reqBody.start);
const now = new Date();
const freeUsers = luckyUserPool
.filter(
(user) => !luckyUsers.concat(notAvailableLuckyUsers).find((existing) => existing.id === user.id)
)
.filter((user) => {
const notice = user.minimumBookingNotice ?? eventType.minimumBookingNotice;
const earliestBookableTime = new Date(now.getTime() + notice * 60 * 1000);
return bookingStartTime >= earliestBookableTime;
});
// no more freeUsers after subtracting notAvailableLuckyUsers from luckyUsers :(
if (freeUsers.length === 0) break;
assertNonEmptyArray(freeUsers); // make sure TypeScript knows it too with an assertion; the error will never be thrown.
Expand Down
Loading
Loading