diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 5e4accb1db..197e317a60 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" -} \ No newline at end of file +} diff --git a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx index 863ec394d2..46274d6fa4 100644 --- a/packages/features/eventtypes/components/AddMembersWithSwitch.tsx +++ b/packages/features/eventtypes/components/AddMembersWithSwitch.tsx @@ -283,6 +283,7 @@ export function AddMembersWithSwitch({ onInactive={onAssignAllTeamMembersInactive} customClassNames={customClassNames?.assingAllTeamMembers} /> + {assignmentState !== AssignmentState.ALL_TEAM_MEMBERS_ENABLED_AND_SEGMENT_NOT_APPLICABLE && (
void; +}; + +const TeamMemberItem = ({ member, onWeightChange }: TeamMemberItemProps) => { + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + + return ( +
+ + {member.label} +
+ {isEditing ? ( +
+
+ { + 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); + } + }} + /> + + % + +
+
+ ) : ( + + )} +
+
+ ); +}; + +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>((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>(localWeightsInitialValues); + const [uploadErrors, setUploadErrors] = useState>([]); + 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) => { + 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 = { ...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 ( + <> + + +
+ + + {t("edit_team_member_weights")} +
+ + Weights determine how meetings are distributed among hosts. + + Learn more + + +
+
+ + +
+ + +
+ setSearchQuery(e.target.value.toLowerCase())} + addOnLeading={ +
+ + + + + + + +
+
+
+ + ); +}; diff --git a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx index 6d34eb1ec4..2cd833a8cc 100644 --- a/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx +++ b/packages/features/eventtypes/components/tabs/assignment/EventTeamAssignmentTab.tsx @@ -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(); + const { setValue, getValues, control, formState } = useFormContext(); const assignRRMembersUsingSegment = getValues("assignRRMembersUsingSegment"); const isRRWeightsEnabled = useWatch({ control, @@ -317,26 +319,39 @@ const RoundRobinHosts = ({

- {!assignAllTeamMembers && !assignRRMembersUsingSegment && ( - - name="isRRWeightsEnabled" - render={({ field: { value, onChange } }) => ( - { - onChange(active); - const rrHosts = getValues("hosts").filter((host) => !host.isFixed); - const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active)); - setValue("hosts", sortedRRHosts); - }} - /> - )} - /> + {!assignRRMembersUsingSegment && ( + <> + + name="isRRWeightsEnabled" + render={({ field: { value: isRRWeightsEnabled, onChange } }) => ( + { + onChange(active); + const rrHosts = getValues("hosts").filter((host) => !host.isFixed); + const sortedRRHosts = rrHosts.sort((a, b) => sortHosts(a, b, active)); + setValue("hosts", sortedRRHosts); + }}> + {!assignRRMembersUsingSegment ? ( + { + const sortedRRHosts = hosts.sort((a, b) => sortHosts(a, b, true)); + setValue("hosts", sortedRRHosts, { shouldDirty: true }); + }} + assignAllTeamMembers={assignAllTeamMembers} + /> + ) : null} + + )} + /> + )}