diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 3d09a429cc..1e0f45dda9 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2871,6 +2871,8 @@ "lock_attribute_for_assignment_description": "Locking would only allow assignments from Directory Sync", "attribute_edited_successfully": "Attribute edited successfully", "new_group_option": "New group option", + "attribute_weight_enabled":"Weights enabled", + "attribute_weight_enabled_description":"By enabling weights, it would be possible to assign higher priority to certain attributes per user. The higher the weight, the higher the priority.", "routed": "Routed", "reassigned": "Reassigned", "rerouted": "Rerouted", diff --git a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx index 0b4509da2d..96abe8d37c 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/AttributesForm.tsx @@ -21,6 +21,7 @@ import { const AttributeFormSchema = z.object({ attrName: z.string().min(1), isLocked: z.boolean().optional(), + isWeightsEnabled: z.boolean().optional(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), options: z.array( z.object({ @@ -193,6 +194,23 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm ); }} /> + {["SINGLE_SELECT", "MULTI_SELECT"].includes(watchedType) && ( + { + return ( + { + onChange(checked); + }} + /> + ); + }} + /> + )} } onSubmit={(values) => { diff --git a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx index d75e0dbe2a..6db2c34c67 100644 --- a/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx +++ b/packages/features/ee/organizations/pages/settings/attributes/attributes-list-view.tsx @@ -17,6 +17,7 @@ import { DropdownMenuItem, Icon, showToast, + Badge, } from "@calcom/ui"; import { DeleteAttributeModal } from "./DeleteAttributeModal"; @@ -75,6 +76,12 @@ function AttributeItem({ {t("number_of_options", { count: attribute.options.length })} )} + {attribute.isWeightsEnabled && ( + <> + • + {t("attribute_weight_enabled")} + + )}

diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx index f8812fd16f..9dac7a88e3 100644 --- a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx +++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx @@ -285,6 +285,7 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) { slug: value.slug, contains: value.contains, isGroup: value.isGroup, + weight: 100, })); newAttributes.push(...newAttributeValues); } else { @@ -296,6 +297,7 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) { slug: slugify(selectedAttributeOptions[0]), contains: [], isGroup: false, + weight: 100, }); } diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx index 3f40dfbf6b..5aa9c0590a 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx @@ -45,6 +45,7 @@ const attributeSchema = z.object({ z.object({ label: z.string().optional(), value: stringOrNumber.optional(), + weight: z.number().optional(), createdByDSyncId: z.string().nullable().optional(), }) ) @@ -192,7 +193,7 @@ export function EditForm({
- + @@ -247,11 +248,11 @@ function AttributesList(props: { selectedUserId: number }) { trpc.viewer.attributes.getByUserId.useQuery({ userId: props.selectedUserId, }); - const { data: attributes, isPending: attributesPending } = trpc.viewer.attributes.list.useQuery(); + const { data: attributes } = trpc.viewer.attributes.list.useQuery(); const enabledAttributes = attributes?.filter((attr) => attr.enabled); const { t } = useLocale(); - const { control, watch, getFieldState, setValue } = useFormContext(); + const { control, getFieldState } = useFormContext(); const getOptionsByAttributeId = (attributeId: string) => { const attribute = attributes?.find((attr) => attr.id === attributeId); @@ -278,7 +279,8 @@ function AttributesList(props: { selectedUserId: number }) { options: attr.options.map((option) => ({ label: option.value, value: option.id, - createdByDSyncId: option.createdByDSyncId, + createdByDSyncId: option.createdByDSyncId ?? null, + weight: option.weight ?? 100, })), }; } else if (attr.type === "SINGLE_SELECT") { @@ -289,10 +291,11 @@ function AttributesList(props: { selectedUserId: number }) { label: attr.options[0]?.value, value: attr.options[0]?.id, createdByDSyncId: attr.options[0]?.createdByDSyncId ?? null, + weight: attr.options[0]?.weight ?? 100, }, ], }; - } else { + } else if (attr.type === "TEXT") { acc[key] = { id: attr.id, value: attr.options[0]?.value || "", @@ -308,7 +311,7 @@ function AttributesList(props: { selectedUserId: number }) { return (
- + {attributeFieldState.error && (

{JSON.stringify(attributeFieldState.error)} @@ -323,7 +326,7 @@ function AttributesList(props: { selectedUserId: number }) { render={({ field: { value, ...field } }) => { const fieldValue = value as Attribute | undefined | null; return ( -

+
{["TEXT", "NUMBER"].includes(attr.type) && ( )} {["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && ( - { - if (!value) return; - const valueAsArray = value instanceof Array ? value : [value]; +
+ { + if (!value) return; + const valueAsArray = value instanceof Array ? value : [value]; - const updatedOptions = - attr.type === "MULTI_SELECT" - ? valueAsArray.map((v) => ({ label: v.label, value: v.value })) - : [{ label: valueAsArray[0].label, value: valueAsArray[0].value }]; + const updatedOptions = + attr.type === "MULTI_SELECT" + ? valueAsArray.map((v) => ({ + label: v.label, + value: v.value, + weight: v.weight || 100, + })) + : [ + { + label: valueAsArray[0].label, + value: valueAsArray[0].value, + weight: valueAsArray[0].weight || 100, + }, + ]; - field.onChange({ - id: attr.id, - // It is also ensured in the backend that the options not owned by cal.com are not removed - options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({ - earlierOptions: fieldValue?.options || [], - updatedOptions, - }), - }); - }} - /> + field.onChange({ + id: attr.id, + options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({ + earlierOptions: fieldValue?.options || [], + updatedOptions, + }), + }); + }} + /> + {attr.isWeightsEnabled && fieldValue?.options && ( +
+ +
+ {fieldValue.options.map((option, idx) => { + return ( + <> +
+ + { + const newWeight = parseFloat(e.target.value) || 1; + const newOptions = fieldValue?.options?.map((opt, i) => + i === idx ? { ...opt, weight: newWeight } : opt + ); + field.onChange({ + id: attr.id, + options: newOptions, + }); + }} + addOnSuffix={%} + /> +
+ + ); + })} +
+
+ )} +
)}
); diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx index 672a2f7b17..b5fe1f7de9 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx @@ -62,7 +62,7 @@ export function EditUserSheet({ setEditMode(false); dispatch({ type: "CLOSE_MODAL" }); }}> - + {!isPending && loadedUser ? ( <> {!editMode ? ( @@ -71,12 +71,7 @@ export function EditUserSheet({
- +

{loadedUser?.name || "Nameless User"} diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 53ed8296c6..963ea49a05 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -178,13 +178,32 @@ export function UserListTable() { ); if (attributeValues.length === 0) return null; return ( -
+
{attributeValues.map((attributeValue, index) => { const isAGroupOption = attributeValue.contains?.length > 0; + const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` : undefined; return ( - - {attributeValue.value} - +
+ + {attributeValue.value} + + + {suffix ? ( + + {suffix} + + ) : null} +
); })}
diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts index 37291a9c8b..785e061ca5 100644 --- a/packages/features/users/components/UserTable/types.ts +++ b/packages/features/users/components/UserTable/types.ts @@ -21,6 +21,7 @@ export interface UserTableUser { attributeId: string; value: string; slug: string; + weight?: number | null; contains: string[]; }[]; } diff --git a/packages/lib/csvUtils.ts b/packages/lib/csvUtils.ts index 8e1c7487a7..529a4dbd70 100644 --- a/packages/lib/csvUtils.ts +++ b/packages/lib/csvUtils.ts @@ -121,9 +121,12 @@ export const generateCsvRawForMembersTable = ( if (!acc[attr.attributeId]) { acc[attr.attributeId] = []; } - acc[attr.attributeId].push(attr.value); + acc[attr.attributeId].push({ + value: attr.value, + weight: attr.weight ?? undefined, + }); return acc; - }, {} as Record); + }, {} as Record); const requiredColumns = [ email, // Members column @@ -133,9 +136,14 @@ export const generateCsvRawForMembersTable = ( ]; // Add attribute columns - const attributeColumns = ATTRIBUTE_IDS.map((attrId) => - attributeMap[attrId] ? sanitizeValue(attributeMap[attrId].join(",")) : "" - ); + const attributeColumns = ATTRIBUTE_IDS.map((attrId) => { + const attributes = attributeMap[attrId]; + if (!attributes?.length) return ""; + + return sanitizeValue( + attributes.map((attr) => (attr.weight ? `${attr.value} (${attr.weight}%)` : attr.value)).join(",") + ); + }); return [...requiredColumns, ...attributeColumns]; }); diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts index 73b0f2c05e..e63d7e215c 100644 --- a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.handler.ts @@ -191,6 +191,7 @@ const assignUserToAttributeHandler = async ({ input, ctx }: GetOptions) => { create: { memberId: membership.id, attributeOptionId: option.value, + weight: option.weight, }, update: {}, // No update needed if it already exists select: { diff --git a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts index 462f2815b8..f75ec35d03 100644 --- a/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts +++ b/packages/trpc/server/routers/viewer/attributes/assignUserToAttribute.schema.ts @@ -2,7 +2,9 @@ import { z } from "zod"; const attributeSchema = z.object({ id: z.string(), - options: z.array(z.object({ label: z.string().optional(), value: z.string() })).optional(), + options: z + .array(z.object({ label: z.string().optional(), value: z.string(), weight: z.number().optional() })) + .optional(), value: z.string().optional(), }); diff --git a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts index 9551a874c7..3a601e68cf 100644 --- a/packages/trpc/server/routers/viewer/attributes/edit.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/edit.handler.ts @@ -52,6 +52,7 @@ const editAttributesHandler = async ({ input, ctx }: GetOptions) => { type: input.type, teamId: org.id, isLocked: input.isLocked, + isWeightsEnabled: input.isWeightsEnabled, }, select: { id: true, diff --git a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts index d82934c5d4..b5f2f3bb0d 100644 --- a/packages/trpc/server/routers/viewer/attributes/edit.schema.ts +++ b/packages/trpc/server/routers/viewer/attributes/edit.schema.ts @@ -5,6 +5,7 @@ export const editAttributeSchema = z.object({ name: z.string(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), isLocked: z.boolean().optional(), + isWeightsEnabled: z.boolean().optional(), options: z.array( z.object({ value: z.string(), diff --git a/packages/trpc/server/routers/viewer/attributes/get.handler.ts b/packages/trpc/server/routers/viewer/attributes/get.handler.ts index 60e16ce6ac..0c964fbe79 100644 --- a/packages/trpc/server/routers/viewer/attributes/get.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/get.handler.ts @@ -34,6 +34,7 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => { name: true, type: true, isLocked: true, + isWeightsEnabled: true, options: { select: { id: true, @@ -65,6 +66,7 @@ const getAttributeHandler = async ({ input, ctx }: GetOptions) => { id: z.string(), name: z.string(), isLocked: z.boolean().optional(), + isWeightsEnabled: z.boolean().optional(), type: z.enum(["TEXT", "NUMBER", "SINGLE_SELECT", "MULTI_SELECT"]), options: z.array( z.object({ diff --git a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts index 2075c9e2db..a6f68199f7 100644 --- a/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts +++ b/packages/trpc/server/routers/viewer/attributes/getByUserId.handler.ts @@ -21,6 +21,7 @@ type GroupedAttribute = { id: string; slug: string; value: string; + weight: number | null; createdByDSyncId: string | null; }[]; }; @@ -77,13 +78,14 @@ const getByUserIdHandler = async ({ input, ctx }: GetOptions) => { }, }, createdByDSyncId: true, + weight: true, }, }); const groupedAttributes = userAttributes.reduce((acc, assignment) => { - const { attributeOption, createdByDSyncId } = assignment; + const { attributeOption, createdByDSyncId, weight } = assignment; const { attribute: attrInfo, ...optionInfo } = attributeOption; - const optionInfoWithCreatedByDSyncId = { ...optionInfo, createdByDSyncId }; + const optionInfoWithCreatedByDSyncId = { ...optionInfo, createdByDSyncId, weight }; const existingGroup = acc.find((group) => group.id === attrInfo.id); if (existingGroup) { diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index fae14baeeb..1f94304062 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -177,20 +177,29 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { let attributes; if (expand?.includes("attributes")) { - attributes = await prisma.attributeOption.findMany({ - where: { - assignedUsers: { - some: { - memberId: membership.id, + attributes = await prisma.attributeToUser + .findMany({ + where: { + memberId: membership.id, + }, + select: { + attributeOption: true, + weight: true, + }, + orderBy: { + attributeOption: { + attribute: { + name: "asc", + }, }, }, - }, - orderBy: { - attribute: { - name: "asc", - }, - }, - }); + }) + .then((assignedUsers) => + assignedUsers.map((au) => ({ + ...au.attributeOption, + weight: au.weight ?? 100, + })) + ); } return { diff --git a/packages/ui/components/icon/icon-list.mjs b/packages/ui/components/icon/icon-list.mjs index c8513f1ed7..5d15657c5f 100644 --- a/packages/ui/components/icon/icon-list.mjs +++ b/packages/ui/components/icon/icon-list.mjs @@ -19,6 +19,7 @@ export const lucideIconList = new Set([ "book-open", "book-user", "building", + "chevrons-down-up", "calendar-check-2", "calendar-days", "calendar-heart",