feat: Round Robin weights future members toggle (#17782)
Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
1 parent
a29407349d
commit
2fd2578504
4 files changed
+355
-23
No files matched your search
@@ -2874,6 +2874,8 @@
|
||||
"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.",
|
||||
"edit_team_member_weights": "Edit Weights",
|
||||
"weights_updated_successfully": "Weights updated successfully",
|
||||
"require_confirmation_for_free_email": "Only require confirmation for free email providers (Ex. @gmail.com, @outlook.com)",
|
||||
"exclude_emails_that_contain": "Exclude emails that contain ...",
|
||||
"require_emails_that_contain": "Require emails that contain ...",
|
||||
@@ -2924,4 +2926,4 @@
|
||||
"managed_users_description": "See all the managed users created by your OAuth client",
|
||||
"select_oAuth_client": "Select Oauth Client",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
}
|
||||
@@ -283,6 +283,7 @@ export function AddMembersWithSwitch({
|
||||
onInactive={onAssignAllTeamMembersInactive}
|
||||
customClassNames={customClassNames?.assingAllTeamMembers}
|
||||
/>
|
||||
|
||||
{assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
|
||||
<div className="mt-2">
|
||||
<MembersSegmentWithToggle
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import type { Host, TeamMember } from "@calcom/features/eventtypes/lib/types";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import "@calcom/ui";
|
||||
import {
|
||||
Avatar,
|
||||
Button,
|
||||
buttonClasses,
|
||||
TextField,
|
||||
Icon,
|
||||
Sheet,
|
||||
SheetBody,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
type TeamMemberItemProps = {
|
||||
member: TeamMember & { weight?: number };
|
||||
onWeightChange: (memberId: string, weight: number) => void;
|
||||
};
|
||||
|
||||
const TeamMemberItem = ({ member, onWeightChange }: TeamMemberItemProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
return (
|
||||
<div className="border-subtle flex h-12 items-center border-b px-3 py-1 last:border-b-0">
|
||||
<Avatar size="sm" imageSrc={member.avatar} alt={member.label} className="min-w-10" />
|
||||
<span className="text-emphasis ml-3 flex-grow text-sm">{member.label}</span>
|
||||
<div className="ml-auto flex h-full items-center">
|
||||
{isEditing ? (
|
||||
<div className="flex h-full items-center">
|
||||
<div className="relative flex h-full items-center">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
min="0"
|
||||
inputMode="numeric"
|
||||
className="bg-muted border-default text-emphasis h-7 w-12 rounded-l-sm border px-2 text-sm [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
defaultValue={member.weight ?? 100}
|
||||
onBlur={(e) => {
|
||||
const newWeight = parseInt(e.target.value);
|
||||
if (!isNaN(newWeight)) {
|
||||
onWeightChange(member.value, newWeight);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const newWeight = parseInt(input.value);
|
||||
if (!isNaN(newWeight)) {
|
||||
onWeightChange(member.value, newWeight);
|
||||
}
|
||||
setIsEditing(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-default border-default bg-muted flex h-7 items-center rounded-r-sm border border-l-0 px-2 text-sm">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="text-emphasis hover:bg-subtle decoration-emphasis flex h-7 items-center rounded-sm px-2 text-sm underline underline-offset-4"
|
||||
onClick={() => setIsEditing(true)}>
|
||||
{member.weight ?? 100}%
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
teamMembers: TeamMember[];
|
||||
value: Host[];
|
||||
onChange: (hosts: Host[]) => void;
|
||||
assignAllTeamMembers: boolean;
|
||||
}
|
||||
|
||||
export const EditWeightsForAllTeamMembers = ({
|
||||
teamMembers,
|
||||
value,
|
||||
onChange,
|
||||
assignAllTeamMembers,
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const localWeightsInitialValues = teamMembers.reduce<Record<string, number>>((acc, member) => {
|
||||
// When assignAllTeamMembers is false, only include members that exist in value array
|
||||
// Find the member in the value array and use its weight if it exists
|
||||
const memberInValue = value.find((host) => host.userId === parseInt(member.value, 10));
|
||||
acc[member.value] = memberInValue?.weight ?? 100;
|
||||
return acc;
|
||||
}, {});
|
||||
const [localWeights, setLocalWeights] = useState<Record<string, number>>(localWeightsInitialValues);
|
||||
const [uploadErrors, setUploadErrors] = useState<Array<{ email: string; error: string }>>([]);
|
||||
const [isErrorsExpanded, setIsErrorsExpanded] = useState(true);
|
||||
|
||||
const handleWeightChange = (memberId: string, weight: number) => {
|
||||
setLocalWeights((prev) => ({ ...prev, [memberId]: weight }));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedValue = value.map((host) => ({
|
||||
...host,
|
||||
weight: localWeights[host.userId.toString()] ?? host.weight ?? 100,
|
||||
}));
|
||||
onChange(updatedValue);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDownloadCsv = () => {
|
||||
const csvData = teamMembers.map((member) => ({
|
||||
id: member.value,
|
||||
name: member.label,
|
||||
email: member.email,
|
||||
weight: localWeights[member.value] ?? 100,
|
||||
}));
|
||||
downloadAsCsv(csvData, "team-members-weights.csv");
|
||||
};
|
||||
|
||||
const handleUploadCsv = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files?.length) return;
|
||||
|
||||
const file = e.target.files[0];
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = async (event) => {
|
||||
if (!event.target?.result) return;
|
||||
|
||||
const csvData = event.target.result as string;
|
||||
const lines = csvData.split("\n");
|
||||
const newWeights: Record<string, number> = { ...localWeights };
|
||||
const newErrors: Array<{ email: string; error: string }> = [];
|
||||
|
||||
// Skip header row
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
const [, , email, weightStr] = line.split(",");
|
||||
if (!email || !weightStr) continue;
|
||||
|
||||
const member = teamMembers.find((m) => m.email === email);
|
||||
if (!member) {
|
||||
newErrors.push({ email, error: t("member_not_found") });
|
||||
continue;
|
||||
}
|
||||
|
||||
const weight = parseInt(weightStr);
|
||||
if (isNaN(weight) || weight <= 0) {
|
||||
newErrors.push({ email, error: t("invalid_weight") });
|
||||
continue;
|
||||
}
|
||||
|
||||
newWeights[member.value] = weight;
|
||||
}
|
||||
|
||||
setLocalWeights(newWeights);
|
||||
setUploadErrors(newErrors);
|
||||
|
||||
if (newErrors.length > 0) {
|
||||
showToast(t("weights_updated_with_errors", { count: newErrors.length }), "warning");
|
||||
} else {
|
||||
showToast(t("weights_updated_from_csv"), "success");
|
||||
}
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
return teamMembers
|
||||
.map((member) => ({
|
||||
...member,
|
||||
weight: localWeights[member.value],
|
||||
}))
|
||||
.filter(
|
||||
(member) =>
|
||||
member.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
member.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
.filter((member) => {
|
||||
// When assignAllTeamMembers is false, only include members that exist in value array
|
||||
return assignAllTeamMembers || value.some((host) => host.userId === parseInt(member.value, 10));
|
||||
});
|
||||
}, [teamMembers, localWeights, searchQuery, assignAllTeamMembers, value]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="-ml-2 -mt-2 mb-2 w-fit"
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
}}>
|
||||
{t("edit_team_member_weights")}
|
||||
</Button>
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<form className="flex h-full flex-col">
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{t("edit_team_member_weights")}</SheetTitle>
|
||||
<div className="text-subtle text-sm">
|
||||
<Trans i18nKey="weights_description">
|
||||
Weights determine how meetings are distributed among hosts.
|
||||
<Link
|
||||
className="underline underline-offset-2"
|
||||
target="_blank"
|
||||
href="https://cal.com/docs/enterprise-features/teams/round-robin-scheduling#weights">
|
||||
Learn more
|
||||
</Link>
|
||||
</Trans>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<SheetBody className="mt-4 flex h-full flex-col space-y-6 p-1">
|
||||
<div className="flex justify-start gap-2">
|
||||
<label className={buttonClasses({ color: "secondary" })}>
|
||||
<Icon name="upload" className="mr-2 h-4 w-4" />
|
||||
<input type="file" accept=".csv" className="hidden" onChange={handleUploadCsv} />
|
||||
{t("upload")}
|
||||
</label>
|
||||
<Button color="secondary" StartIcon="download" onClick={handleDownloadCsv}>
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
<TextField
|
||||
placeholder={t("search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value.toLowerCase())}
|
||||
addOnLeading={
|
||||
<Icon name="search" className="text-subtle h-4 w-4" aria-hidden="true" focusable="false" />
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex max-h-[80dvh] flex-col overflow-y-auto rounded-md border">
|
||||
{filteredMembers.map((member) => (
|
||||
<TeamMemberItem key={member.value} member={member} onWeightChange={handleWeightChange} />
|
||||
))}
|
||||
{filteredMembers.length === 0 && (
|
||||
<div className="text-subtle py-4 text-center text-sm">{t("no_members_found")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
className="flex w-full items-center justify-between rounded-md border bg-red-50 p-3 text-sm text-red-900"
|
||||
onClick={() => setIsErrorsExpanded(!isErrorsExpanded)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon name="info" className="h-4 w-4" />
|
||||
<span>{t("csv_upload_errors", { count: uploadErrors.length })}</span>
|
||||
</div>
|
||||
<Icon name="chevron-down" className="h-4 w-4" />
|
||||
</button>
|
||||
{isErrorsExpanded && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{uploadErrors.map((error, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-md border border-red-100 bg-red-50 p-3 text-sm text-red-900">
|
||||
<strong>{error.email}:</strong> {error.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SheetBody>
|
||||
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
// Restore to default weights from the original value
|
||||
setLocalWeights(localWeightsInitialValues);
|
||||
setSearchQuery("");
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button onClick={handleSave}>{t("done")}</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</form>
|
||||
</Sheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+36
-22
@@ -26,6 +26,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { Label, Select, SettingsToggle, RadioGroup as RadioArea } from "@calcom/ui";
|
||||
|
||||
import { EditWeightsForAllTeamMembers } from "../../EditWeightsForAllTeamMembers";
|
||||
|
||||
export type EventTeamAssignmentTabCustomClassNames = {
|
||||
assignmentType?: {
|
||||
container?: string;
|
||||
@@ -291,7 +293,7 @@ const RoundRobinHosts = ({
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { setValue, getValues, control } = useFormContext<FormValues>();
|
||||
const { setValue, getValues, control, formState } = useFormContext<FormValues>();
|
||||
const assignRRMembersUsingSegment = getValues("assignRRMembersUsingSegment");
|
||||
const isRRWeightsEnabled = useWatch({
|
||||
control,
|
||||
@@ -317,26 +319,39 @@ const RoundRobinHosts = ({
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-md border border-t-0 px-6 pt-4">
|
||||
{!assignAllTeamMembers && !assignRRMembersUsingSegment && (
|
||||
<Controller<FormValues>
|
||||
name="isRRWeightsEnabled"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("enable_weights")}
|
||||
description={weightDescription}
|
||||
checked={value}
|
||||
switchContainerClassName={customClassNames?.enableWeights?.container}
|
||||
labelClassName={customClassNames?.enableWeights?.label}
|
||||
descriptionClassName={customClassNames?.enableWeights?.description}
|
||||
onCheckedChange={(active) => {
|
||||
onChange(active);
|
||||
const rrHosts = getValues("hosts").filter((host) => !host.isFixed);
|
||||
const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active));
|
||||
setValue("hosts", sortedRRHosts);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{!assignRRMembersUsingSegment && (
|
||||
<>
|
||||
<Controller<FormValues>
|
||||
name="isRRWeightsEnabled"
|
||||
render={({ field: { value: isRRWeightsEnabled, onChange } }) => (
|
||||
<SettingsToggle
|
||||
title={t("enable_weights")}
|
||||
description={weightDescription}
|
||||
checked={isRRWeightsEnabled}
|
||||
switchContainerClassName={customClassNames?.enableWeights?.container}
|
||||
labelClassName={customClassNames?.enableWeights?.label}
|
||||
descriptionClassName={customClassNames?.enableWeights?.description}
|
||||
onCheckedChange={(active) => {
|
||||
onChange(active);
|
||||
const rrHosts = getValues("hosts").filter((host) => !host.isFixed);
|
||||
const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active));
|
||||
setValue("hosts", sortedRRHosts);
|
||||
}}>
|
||||
{!assignRRMembersUsingSegment ? (
|
||||
<EditWeightsForAllTeamMembers
|
||||
teamMembers={teamMembers}
|
||||
value={value}
|
||||
onChange={(hosts) => {
|
||||
const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true));
|
||||
setValue("hosts", sortedRRHosts, { shouldDirty: true });
|
||||
}}
|
||||
assignAllTeamMembers={assignAllTeamMembers}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsToggle>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AddMembersWithSwitch
|
||||
teamId={teamId}
|
||||
@@ -367,7 +382,6 @@ const RoundRobinHosts = ({
|
||||
}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
setValue("isRRWeightsEnabled", false);
|
||||
}}
|
||||
customClassNames={customClassNames?.addMembers}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user