From 939bd4ddc9fcb15e3b855cd6eaf55371c11b66b8 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Thu, 12 Mar 2026 19:05:50 +0530 Subject: [PATCH] feat: Implement support for multiple user roles, department, and designation fields across user management pages and modals. --- src/components/shared/EditUserModal.tsx | 119 +++----- .../shared/MultiselectPaginatedSelect.tsx | 116 ++++--- src/components/shared/NewUserModal.tsx | 21 +- src/components/shared/ViewUserModal.tsx | 21 +- src/components/superadmin/UsersTable.tsx | 23 +- src/pages/tenant/Users.tsx | 284 ++++++++++++------ src/types/user.ts | 10 +- 7 files changed, 358 insertions(+), 236 deletions(-) diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 21e1e10..4f4e4a0 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -9,6 +9,7 @@ import { FormField, FormSelect, PaginatedSelect, + MultiselectPaginatedSelect, PrimaryButton, SecondaryButton, } from "@/components/shared"; @@ -26,7 +27,7 @@ const editUserSchema = z.object({ message: "Status is required", }), tenant_id: z.string().min(1, "Tenant is required"), - role_id: z.string().min(1, "Role is required"), + role_ids: z.array(z.string()).min(1, "At least one role is required"), department_id: z.string().optional(), designation_id: z.string().optional(), }); @@ -76,16 +77,13 @@ export const EditUserModal = ({ }); const statusValue = watch("status"); - const roleIdValue = watch("role_id"); + const roleIdsValue = watch("role_ids"); const departmentIdValue = watch("department_id"); const designationIdValue = watch("designation_id"); - const [currentRoleName, setCurrentRoleName] = useState(""); - - const [initialRoleOption, setInitialRoleOption] = useState<{ - value: string; - label: string; - } | null>(null); + const [initialRoleOptions, setInitialRoleOptions] = useState< + { value: string; label: string }[] + >([]); const [initialDepartmentOption, setInitialDepartmentOption] = useState<{ value: string; label: string; @@ -100,22 +98,11 @@ export const EditUserModal = ({ const response = defaultTenantId ? await roleService.getByTenant(defaultTenantId, page, limit) : await roleService.getAll(page, limit); - let options = response.data.map((role) => ({ - value: role.id, - label: role.name, - })); - - if (initialRoleOption && page === 1) { - const exists = options.find( - (opt) => opt.value === initialRoleOption.value, - ); - if (!exists) { - options = [initialRoleOption, ...options]; - } - } - return { - options, + options: response.data.map((role) => ({ + value: role.id, + label: role.name, + })), pagination: response.pagination, }; }; @@ -124,22 +111,11 @@ export const EditUserModal = ({ const response = await departmentService.list(defaultTenantId, { active_only: true, }); - let options = response.data.map((dept) => ({ - value: dept.id, - label: dept.name, - })); - - if (initialDepartmentOption) { - const exists = options.find( - (opt) => opt.value === initialDepartmentOption.value, - ); - if (!exists) { - options = [initialDepartmentOption, ...options]; - } - } - return { - options, + options: response.data.map((dept) => ({ + value: dept.id, + label: dept.name, + })), pagination: { page: 1, limit: response.data.length, @@ -154,22 +130,11 @@ export const EditUserModal = ({ const response = await designationService.list(defaultTenantId, { active_only: true, }); - let options = response.data.map((desig) => ({ - value: desig.id, - label: desig.name, - })); - - if (initialDesignationOption) { - const exists = options.find( - (opt) => opt.value === initialDesignationOption.value, - ); - if (!exists) { - options = [initialDesignationOption, ...options]; - } - } - return { - options, + options: response.data.map((desig) => ({ + value: desig.id, + label: desig.name, + })), pagination: { page: 1, limit: response.data.length, @@ -193,20 +158,25 @@ export const EditUserModal = ({ loadedUserIdRef.current = userId; const tenantId = user.tenant?.id || user.tenant_id || ""; - const roleId = user.role?.id || user.role_id || ""; + const roleIds = + user.roles?.map((r) => r.id) || + (user.role_id ? [user.role_id] : []); + const roleOptions = + user.roles?.map((r) => ({ value: r.id, label: r.name })) || + (user.role?.id + ? [{ value: user.role.id, label: user.role.name }] + : []); + const departmentId = user.department?.id || user.department_id || ""; const designationId = user.designation?.id || user.designation_id || ""; - const roleName = user.role?.name || ""; const departmentName = user.department?.name || ""; const designationName = user.designation?.name || ""; - setCurrentRoleName(roleName); - - if (roleId && roleName) { - setInitialRoleOption({ value: roleId, label: roleName }); + if (roleOptions.length > 0) { + setInitialRoleOptions(roleOptions); } if (departmentId && departmentName) { setInitialDepartmentOption({ @@ -227,7 +197,7 @@ export const EditUserModal = ({ last_name: user.last_name, status: user.status, tenant_id: defaultTenantId || tenantId, - role_id: roleId, + role_ids: roleIds, department_id: departmentId, designation_id: designationId, }); @@ -248,8 +218,7 @@ export const EditUserModal = ({ } } else if (!isOpen) { loadedUserIdRef.current = null; - setCurrentRoleName(""); - setInitialRoleOption(null); + setInitialRoleOptions([]); setInitialDepartmentOption(null); setInitialDesignationOption(null); reset({ @@ -258,7 +227,7 @@ export const EditUserModal = ({ last_name: "", status: "active", tenant_id: defaultTenantId || "", - role_id: "", + role_ids: [], department_id: "", designation_id: "", }); @@ -417,19 +386,17 @@ export const EditUserModal = ({ /> -
- {currentRoleName !== "Tenant Admin" && ( - setValue("role_id", value)} - onLoadOptions={loadRoles} - initialOption={initialRoleOption || undefined} - error={errors.role_id?.message} - /> - )} +
+ setValue("role_ids", value)} + onLoadOptions={loadRoles} + initialOptions={initialRoleOptions} + error={errors.role_ids?.message} + /> void; - onLoadOptions: (page: number, limit: number) => Promise<{ + onLoadOptions: ( + page: number, + limit: number, + ) => Promise<{ options: MultiselectPaginatedSelectOption[]; pagination: { page: number; @@ -37,7 +40,7 @@ export const MultiselectPaginatedSelect = ({ required = false, error, helperText, - placeholder = 'Select Items', + placeholder = "Select Items", value, onValueChange, onLoadOptions, @@ -46,7 +49,9 @@ export const MultiselectPaginatedSelect = ({ id, }: MultiselectPaginatedSelectProps): ReactElement => { const [isOpen, setIsOpen] = useState(false); - const [options, setOptions] = useState([]); + const [options, setOptions] = useState( + [], + ); const [isLoading, setIsLoading] = useState(false); const [isLoadingMore, setIsLoadingMore] = useState(false); const [pagination, setPagination] = useState<{ @@ -72,7 +77,7 @@ export const MultiselectPaginatedSelect = ({ bottom?: string; left: string; width: string; - }>({ left: '0', width: '0' }); + }>({ left: "0", width: "0" }); // Load initial options const loadOptions = useCallback( @@ -85,22 +90,22 @@ export const MultiselectPaginatedSelect = ({ } const result = await onLoadOptions(page, pagination.limit); - + if (append) { setOptions((prev) => [...prev, ...result.options]); } else { setOptions(result.options); } - + setPagination(result.pagination); } catch (err) { - console.error('Error loading options:', err); + console.error("Error loading options:", err); } finally { setIsLoading(false); setIsLoadingMore(false); } }, - [onLoadOptions, pagination.limit] + [onLoadOptions, pagination.limit], ); // Load options when dropdown opens @@ -126,9 +131,9 @@ export const MultiselectPaginatedSelect = ({ } }; - scrollContainer.addEventListener('scroll', handleScroll); + scrollContainer.addEventListener("scroll", handleScroll); return () => { - scrollContainer.removeEventListener('scroll', handleScroll); + scrollContainer.removeEventListener("scroll", handleScroll); }; }, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]); @@ -149,26 +154,35 @@ export const MultiselectPaginatedSelect = ({ const handleScroll = (event: Event) => { // Don't close if scrolling inside the dropdown's internal scroll container const target = event.target as HTMLElement; - if (scrollContainerRef.current && (scrollContainerRef.current === target || scrollContainerRef.current.contains(target))) { + if ( + scrollContainerRef.current && + (scrollContainerRef.current === target || + scrollContainerRef.current.contains(target)) + ) { return; } // Don't close if scrolling inside the dropdown menu itself - if (dropdownMenuRef.current && (dropdownMenuRef.current === target || dropdownMenuRef.current.contains(target))) { + if ( + dropdownMenuRef.current && + (dropdownMenuRef.current === target || + dropdownMenuRef.current.contains(target)) + ) { return; } setIsOpen(false); }; if (isOpen && buttonRef.current) { - document.addEventListener('mousedown', handleClickOutside); - window.addEventListener('scroll', handleScroll, true); + document.addEventListener("mousedown", handleClickOutside); + window.addEventListener("scroll", handleScroll, true); const rect = buttonRef.current.getBoundingClientRect(); const spaceBelow = window.innerHeight - rect.bottom; const spaceAbove = rect.top; const dropdownHeight = Math.min(240, 240); - const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; + const shouldOpenUp = + spaceBelow < dropdownHeight && spaceAbove > spaceBelow; if (shouldOpenUp) { setDropdownStyle({ @@ -186,16 +200,25 @@ export const MultiselectPaginatedSelect = ({ } return () => { - document.removeEventListener('mousedown', handleClickOutside); - window.removeEventListener('scroll', handleScroll, true); + document.removeEventListener("mousedown", handleClickOutside); + window.removeEventListener("scroll", handleScroll, true); }; }, [isOpen]); - const fieldId = id || `multiselect-${label.toLowerCase().replace(/\s+/g, '-')}`; + const fieldId = + id || `multiselect-${label.toLowerCase().replace(/\s+/g, "-")}`; const hasError = Boolean(error); // Combine loaded options with initial options, prioritizing loaded options - const allOptions = [...initialOptions, ...options.filter(opt => !initialOptions.some(init => init.value === opt.value))]; - const selectedOptions = allOptions.filter((opt) => value.includes(opt.value)); + const allOptions = [ + ...initialOptions, + ...options.filter( + (opt) => !initialOptions.some((init) => init.value === opt.value), + ), + ]; + const selectedOptions = value.map((val) => { + const found = allOptions.find((opt) => opt.value === val); + return found || { value: val, label: val }; + }); const handleToggle = (optionValue: string) => { if (value.includes(optionValue)) { @@ -226,13 +249,13 @@ export const MultiselectPaginatedSelect = ({ id={fieldId} onClick={() => setIsOpen(!isOpen)} className={cn( - 'min-h-10 w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors', - 'flex items-center justify-between gap-2', + "min-h-10 w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors", + "flex items-center justify-between gap-2", hasError - ? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20' - : 'border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20', - 'focus-visible:outline-none focus-visible:ring-2', - className + ? "border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20" + : "border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20", + "focus-visible:outline-none focus-visible:ring-2", + className, )} aria-invalid={hasError} aria-expanded={isOpen} @@ -268,7 +291,10 @@ export const MultiselectPaginatedSelect = ({ )}
@@ -299,22 +325,26 @@ export const MultiselectPaginatedSelect = ({ {allOptions.map((option) => { const isSelected = value.includes(option.value); return ( -
  • +
  • {error && ( - )} diff --git a/src/components/shared/NewUserModal.tsx b/src/components/shared/NewUserModal.tsx index ef427ef..89becfa 100644 --- a/src/components/shared/NewUserModal.tsx +++ b/src/components/shared/NewUserModal.tsx @@ -8,6 +8,7 @@ import { FormField, FormSelect, PaginatedSelect, + MultiselectPaginatedSelect, PrimaryButton, SecondaryButton, } from "@/components/shared"; @@ -32,7 +33,7 @@ const newUserSchema = z auth_provider: z.enum(["local"], { message: "Auth provider is required", }), - role_id: z.string().min(1, "Role is required"), + role_ids: z.array(z.string()).min(1, "At least one role is required"), department_id: z.string().optional(), designation_id: z.string().optional(), }) @@ -76,14 +77,14 @@ export const NewUserModal = ({ } = useForm({ resolver: zodResolver(newUserSchema), defaultValues: { - role_id: "", + role_ids: [], department_id: "", designation_id: "", }, }); const statusValue = watch("status"); - const roleIdValue = watch("role_id"); + const roleIdsValue = watch("role_ids"); const departmentIdValue = watch("department_id"); const designationIdValue = watch("designation_id"); @@ -98,7 +99,7 @@ export const NewUserModal = ({ last_name: "", status: "active", auth_provider: "local", - role_id: "", + role_ids: [], department_id: "", designation_id: "", }); @@ -179,7 +180,7 @@ export const NewUserModal = ({ detail.path === "last_name" || detail.path === "status" || detail.path === "auth_provider" || - detail.path === "role_id" + detail.path === "role_ids" ) { setError(detail.path as keyof NewUserFormData, { type: "server", @@ -323,14 +324,14 @@ export const NewUserModal = ({ {/* Role and Status Row */}
    - setValue("role_id", value)} + placeholder="Select Roles" + value={roleIdsValue || []} + onValueChange={(value) => setValue("role_ids", value)} onLoadOptions={loadRoles} - error={errors.role_id?.message} + error={errors.role_ids?.message} />
    -

    - {user.role?.name || "-"} -

    +
    + {user.roles && user.roles.length > 0 ? ( + user.roles.map((role) => ( + + {role.name} + + )) + ) : ( +

    + {user.role?.name || "-"} +

    + )} +
    handleViewUser(user.id)} - onEdit={canUpdate('users') ? () => handleEditUser(user.id, `${user.first_name} ${user.last_name}`) : undefined} - onDelete={canDelete('users') ? () => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`) : undefined} + onEdit={ + canUpdate("users") + ? () => + handleEditUser( + user.id, + `${user.first_name} ${user.last_name}`, + ) + : undefined + } + onDelete={ + canDelete("users") + ? () => + handleDeleteUser( + user.id, + `${user.first_name} ${user.last_name}`, + ) + : undefined + } />
    Status:
    - {user.status} + + {user.status} +
    Auth Provider: -

    {user.auth_provider}

    +

    + {user.auth_provider} +

    Joined: -

    {formatDate(user.created_at)}

    +

    + {formatDate(user.created_at)} +

    @@ -323,8 +407,9 @@ const Users = (): ReactElement => { {/* Table Container */} @@ -337,11 +422,14 @@ const Users = (): ReactElement => { { @@ -355,16 +443,16 @@ const Users = (): ReactElement => { { @@ -389,15 +477,15 @@ const Users = (): ReactElement => { {/* New User Button */} - {canCreate('users') && ( - setIsModalOpen(true)} - > - - New User - + {canCreate("users") && ( + setIsModalOpen(true)} + > + + New User + )} @@ -456,7 +544,7 @@ const Users = (): ReactElement => { onClose={() => { setEditModalOpen(false); setSelectedUserId(null); - setSelectedUserName(''); + setSelectedUserName(""); }} userId={selectedUserId} onLoadUser={loadUser} @@ -470,7 +558,7 @@ const Users = (): ReactElement => { onClose={() => { setDeleteModalOpen(false); setSelectedUserId(null); - setSelectedUserName(''); + setSelectedUserName(""); }} onConfirm={handleConfirmDelete} title="Delete User" diff --git a/src/types/user.ts b/src/types/user.ts index 4abb319..f61edb9 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -15,6 +15,10 @@ export interface User { id: string; name: string; }; + roles?: { + id: string; + name: string; + }[]; department_id?: string; designation_id?: string; department?: { @@ -51,7 +55,8 @@ export interface CreateUserRequest { status: 'active' | 'suspended' | 'deleted'; auth_provider: 'local'; tenant_id?: string; - role_id: string; + role_id?: string; + role_ids?: string[]; department_id?: string; designation_id?: string; } @@ -74,7 +79,8 @@ export interface UpdateUserRequest { status: 'active' | 'suspended' | 'deleted'; auth_provider?: string; tenant_id: string; - role_id: string; + role_id?: string; + role_ids?: string[]; department_id?: string; designation_id?: string; }