feat: Implement support for multiple user roles, department, and designation fields across user management pages and modals.

This commit is contained in:
Yashwin 2026-03-12 19:05:50 +05:30
parent b1ac65b345
commit 939bd4ddc9
7 changed files with 358 additions and 236 deletions

View File

@ -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<string>("");
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 = ({
/>
</div>
<div className={`grid grid-cols-2 gap-5 pb-4`}>
{currentRoleName !== "Tenant Admin" && (
<PaginatedSelect
label="Assign Role"
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => setValue("role_id", value)}
onLoadOptions={loadRoles}
initialOption={initialRoleOption || undefined}
error={errors.role_id?.message}
/>
)}
<div className="grid grid-cols-2 gap-5 pb-4">
<MultiselectPaginatedSelect
label="Assign Role"
required
placeholder="Select Roles"
value={roleIdsValue || []}
onValueChange={(value) => setValue("role_ids", value)}
onLoadOptions={loadRoles}
initialOptions={initialRoleOptions}
error={errors.role_ids?.message}
/>
<FormSelect
label="Status"
required

View File

@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { ReactElement } from 'react';
import { ChevronDown, Loader2, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import type { ReactElement } from "react";
import { ChevronDown, Loader2, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface MultiselectPaginatedSelectOption {
value: string;
@ -17,7 +17,10 @@ interface MultiselectPaginatedSelectProps {
placeholder?: string;
value: string[];
onValueChange: (value: string[]) => 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<boolean>(false);
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>([]);
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>(
[],
);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(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 = ({
)}
</div>
<ChevronDown
className={cn('w-4 h-4 transition-transform flex-shrink-0', isOpen && 'rotate-180')}
className={cn(
"w-4 h-4 transition-transform flex-shrink-0",
isOpen && "rotate-180",
)}
/>
</button>
@ -299,22 +325,26 @@ export const MultiselectPaginatedSelect = ({
{allOptions.map((option) => {
const isSelected = value.includes(option.value);
return (
<li key={option.value} role="option" aria-selected={isSelected}>
<li
key={option.value}
role="option"
aria-selected={isSelected}
>
<button
type="button"
onClick={() => handleToggle(option.value)}
className={cn(
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors',
'flex items-center gap-2',
isSelected && 'bg-gray-50'
"w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors",
"flex items-center gap-2",
isSelected && "bg-gray-50",
)}
>
<div
className={cn(
'w-4 h-4 border rounded flex items-center justify-center flex-shrink-0',
"w-4 h-4 border rounded flex items-center justify-center flex-shrink-0",
isSelected
? 'bg-[#112868] border-[#112868]'
: 'border-[rgba(0,0,0,0.2)]'
? "bg-[#112868] border-[#112868]"
: "border-[rgba(0,0,0,0.2)]",
)}
>
{isSelected && (
@ -345,11 +375,15 @@ export const MultiselectPaginatedSelect = ({
</>
)}
</div>,
document.body
document.body,
)}
</div>
{error && (
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert">
<p
id={`${fieldId}-error`}
className="text-sm text-[#ef4444]"
role="alert"
>
{error}
</p>
)}

View File

@ -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<NewUserFormData>({
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 */}
<div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect
<MultiselectPaginatedSelect
label="Assign Role"
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => 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}
/>
<FormSelect

View File

@ -138,11 +138,24 @@ export const ViewUserModal = ({
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">
Role
Roles
</label>
<p className="text-sm text-[#0e1b2a]">
{user.role?.name || "-"}
</p>
<div className="flex flex-wrap gap-1">
{user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
>
{role.name}
</span>
))
) : (
<p className="text-sm text-[#0e1b2a]">
{user.role?.name || "-"}
</p>
)}
</div>
</div>
<div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block">

View File

@ -132,7 +132,7 @@ export const UsersTable = ({
last_name: string;
status: "active" | "suspended" | "deleted";
auth_provider: "local";
role_id: string;
role_ids: string[];
department_id?: string;
designation_id?: string;
}): Promise<void> => {
@ -178,7 +178,7 @@ export const UsersTable = ({
status: "active" | "suspended" | "deleted";
auth_provider?: string;
tenant_id: string;
role_id: string;
role_ids: string[];
department_id?: string;
designation_id?: string;
},
@ -263,9 +263,22 @@ export const UsersTable = ({
key: "role",
label: "Role",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">
{user.role?.name || "-"}
</span>
<div className="flex flex-wrap gap-1">
{user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
>
{role.name}
</span>
))
) : (
<span className="text-sm font-normal text-[#0f1724]">
{user.role?.name || "-"}
</span>
)}
</div>
),
},
{

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import {
PrimaryButton,
StatusBadge,
@ -13,12 +13,12 @@ import {
Pagination,
FilterDropdown,
type Column,
} from '@/components/shared';
import { Plus, Download, ArrowUpDown } from 'lucide-react';
import { userService } from '@/services/user-service';
import type { User } from '@/types/user';
import { showToast } from '@/utils/toast';
import { usePermissions } from '@/hooks/usePermissions';
} from "@/components/shared";
import { Plus, Download, ArrowUpDown } from "lucide-react";
import { userService } from "@/services/user-service";
import type { User } from "@/types/user";
import { showToast } from "@/utils/toast";
import { usePermissions } from "@/hooks/usePermissions";
// Helper function to get user initials
const getUserInitials = (firstName: string, lastName: string): string => {
@ -28,24 +28,30 @@ const getUserInitials = (firstName: string, lastName: string): string => {
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
// Helper function to get status badge variant
const getStatusVariant = (status: string): 'success' | 'failure' | 'process' => {
const getStatusVariant = (
status: string,
): "success" | "failure" | "process" => {
switch (status.toLowerCase()) {
case 'active':
return 'success';
case 'pending_verification':
return 'process';
case 'inactive':
return 'failure';
case 'deleted':
return 'failure';
case 'suspended':
return 'process';
case "active":
return "success";
case "pending_verification":
return "process";
case "inactive":
return "failure";
case "deleted":
return "failure";
case "suspended":
return "process";
default:
return 'success';
return "success";
}
};
@ -83,7 +89,7 @@ const Users = (): ReactElement => {
const [editModalOpen, setEditModalOpen] = useState<boolean>(false);
const [deleteModalOpen, setDeleteModalOpen] = useState<boolean>(false);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [selectedUserName, setSelectedUserName] = useState<string>('');
const [selectedUserName, setSelectedUserName] = useState<string>("");
const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
@ -91,20 +97,25 @@ const Users = (): ReactElement => {
page: number,
itemsPerPage: number,
status: string | null = null,
sortBy: string[] | null = null
sortBy: string[] | null = null,
): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const response = await userService.getAll(page, itemsPerPage, status, sortBy);
const response = await userService.getAll(
page,
itemsPerPage,
status,
sortBy,
);
if (response.success) {
setUsers(response.data);
setPagination(response.pagination);
} else {
setError('Failed to load users');
setError("Failed to load users");
}
} catch (err: any) {
setError(err?.response?.data?.error?.message || 'Failed to load users');
setError(err?.response?.data?.error?.message || "Failed to load users");
} finally {
setIsLoading(false);
}
@ -119,15 +130,19 @@ const Users = (): ReactElement => {
password: string;
first_name: string;
last_name: string;
status: 'active' | 'suspended' | 'deleted';
auth_provider: 'local';
role_id: string;
status: "active" | "suspended" | "deleted";
auth_provider: "local";
role_ids: string[];
department_id?: string;
designation_id?: string;
}): Promise<void> => {
try {
setIsCreating(true);
const response = await userService.create(data);
const message = response.message || `User created successfully`;
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been added`;
const description = response.message
? undefined
: `${data.first_name} ${data.last_name} has been added`;
showToast.success(message, description);
setIsModalOpen(false);
await fetchUsers(currentPage, limit, statusFilter, orderBy);
@ -158,16 +173,20 @@ const Users = (): ReactElement => {
email: string;
first_name: string;
last_name: string;
status: 'active' | 'suspended' | 'deleted';
status: "active" | "suspended" | "deleted";
tenant_id: string;
role_id: string;
}
role_ids: string[];
department_id?: string;
designation_id?: string;
},
): Promise<void> => {
try {
setIsUpdating(true);
const response = await userService.update(id, data);
const message = response.message || `User updated successfully`;
const description = response.message ? undefined : `${data.first_name} ${data.last_name} has been updated`;
const description = response.message
? undefined
: `${data.first_name} ${data.last_name} has been updated`;
showToast.success(message, description);
setEditModalOpen(false);
setSelectedUserId(null);
@ -195,7 +214,7 @@ const Users = (): ReactElement => {
await userService.delete(selectedUserId);
setDeleteModalOpen(false);
setSelectedUserId(null);
setSelectedUserName('');
setSelectedUserName("");
await fetchUsers(currentPage, limit, statusFilter, orderBy);
} catch (err: any) {
throw err;
@ -213,8 +232,8 @@ const Users = (): ReactElement => {
// Define table columns
const columns: Column<User>[] = [
{
key: 'name',
label: 'User Name',
key: "name",
label: "User Name",
render: (user) => (
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
@ -227,50 +246,91 @@ const Users = (): ReactElement => {
</span>
</div>
),
mobileLabel: 'Name',
mobileLabel: "Name",
},
{
key: 'email',
label: 'Email',
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.email}</span>,
},
{
key: 'role',
label: 'role',
render: (user) => <span className="text-sm font-normal text-[#0f1724]">{user.role?.name}</span>,
},
{
key: 'status',
label: 'Status',
key: "email",
label: "Email",
render: (user) => (
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
<span className="text-sm font-normal text-[#0f1724]">{user.email}</span>
),
},
{
key: 'auth_provider',
label: 'Auth Provider',
key: "role",
label: "Role",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">{user.auth_provider}</span>
<div className="flex flex-wrap gap-1">
{user.roles && user.roles.length > 0 ? (
user.roles.map((role) => (
<span
key={role.id}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium bg-[#112868]/10 text-[#112868]"
>
{role.name}
</span>
))
) : (
<span className="text-sm font-normal text-[#0f1724]">
{user.role?.name || "-"}
</span>
)}
</div>
),
},
{
key: 'created_at',
label: 'Joined Date',
key: "status",
label: "Status",
render: (user) => (
<span className="text-sm font-normal text-[#6b7280]">{formatDate(user.created_at)}</span>
<StatusBadge variant={getStatusVariant(user.status)}>
{user.status}
</StatusBadge>
),
mobileLabel: 'Joined',
},
{
key: 'actions',
label: 'Actions',
align: 'right',
key: "auth_provider",
label: "Auth Provider",
render: (user) => (
<span className="text-sm font-normal text-[#0f1724]">
{user.auth_provider}
</span>
),
},
{
key: "created_at",
label: "Joined Date",
render: (user) => (
<span className="text-sm font-normal text-[#6b7280]">
{formatDate(user.created_at)}
</span>
),
mobileLabel: "Joined",
},
{
key: "actions",
label: "Actions",
align: "right",
render: (user) => (
<div className="flex justify-end">
<ActionDropdown
onView={() => 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
}
/>
</div>
),
@ -291,29 +351,53 @@ const Users = (): ReactElement => {
<h3 className="text-sm font-medium text-[#0f1724] truncate">
{user.first_name} {user.last_name}
</h3>
<p className="text-xs text-[#6b7280] mt-0.5 truncate">{user.email}</p>
<p className="text-xs text-[#6b7280] mt-0.5 truncate">
{user.email}
</p>
</div>
</div>
<ActionDropdown
onView={() => 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
}
/>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<span className="text-[#9aa6b2]">Status:</span>
<div className="mt-1">
<StatusBadge variant={getStatusVariant(user.status)}>{user.status}</StatusBadge>
<StatusBadge variant={getStatusVariant(user.status)}>
{user.status}
</StatusBadge>
</div>
</div>
<div>
<span className="text-[#9aa6b2]">Auth Provider:</span>
<p className="text-[#0f1724] font-normal mt-1">{user.auth_provider}</p>
<p className="text-[#0f1724] font-normal mt-1">
{user.auth_provider}
</p>
</div>
<div>
<span className="text-[#9aa6b2]">Joined:</span>
<p className="text-[#6b7280] font-normal mt-1">{formatDate(user.created_at)}</p>
<p className="text-[#6b7280] font-normal mt-1">
{formatDate(user.created_at)}
</p>
</div>
</div>
</div>
@ -323,8 +407,9 @@ const Users = (): ReactElement => {
<Layout
currentPage="Users"
pageHeader={{
title: 'User List',
description: 'View and manage all users in your QAssure platform from a single place.',
title: "User List",
description:
"View and manage all users in your QAssure platform from a single place.",
}}
>
{/* Table Container */}
@ -337,11 +422,14 @@ const Users = (): ReactElement => {
<FilterDropdown
label="Status"
options={[
{ value: 'active', label: 'Active' },
{ value: 'pending_verification', label: 'Pending Verification' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' },
{ value: "active", label: "Active" },
{
value: "pending_verification",
label: "Pending Verification",
},
{ value: "inactive", label: "Inactive" },
{ value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" },
]}
value={statusFilter}
onChange={(value) => {
@ -355,16 +443,16 @@ const Users = (): ReactElement => {
<FilterDropdown
label="Sort by"
options={[
{ value: ['first_name', 'asc'], label: 'First Name (A-Z)' },
{ value: ['first_name', 'desc'], label: 'First Name (Z-A)' },
{ value: ['last_name', 'asc'], label: 'Last Name (A-Z)' },
{ value: ['last_name', 'desc'], label: 'Last Name (Z-A)' },
{ value: ['email', 'asc'], label: 'Email (A-Z)' },
{ value: ['email', 'desc'], label: 'Email (Z-A)' },
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
{ value: ["first_name", "asc"], label: "First Name (A-Z)" },
{ value: ["first_name", "desc"], label: "First Name (Z-A)" },
{ value: ["last_name", "asc"], label: "Last Name (A-Z)" },
{ value: ["last_name", "desc"], label: "Last Name (Z-A)" },
{ value: ["email", "asc"], label: "Email (A-Z)" },
{ value: ["email", "desc"], label: "Email (Z-A)" },
{ value: ["created_at", "asc"], label: "Created (Oldest)" },
{ value: ["created_at", "desc"], label: "Created (Newest)" },
{ value: ["updated_at", "asc"], label: "Updated (Oldest)" },
{ value: ["updated_at", "desc"], label: "Updated (Newest)" },
]}
value={orderBy}
onChange={(value) => {
@ -389,15 +477,15 @@ const Users = (): ReactElement => {
</button>
{/* New User Button */}
{canCreate('users') && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
{canCreate("users") && (
<PrimaryButton
size="default"
className="flex items-center gap-2"
onClick={() => setIsModalOpen(true)}
>
<Plus className="w-3.5 h-3.5" />
<span className="text-xs">New User</span>
</PrimaryButton>
)}
</div>
</div>
@ -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"

View File

@ -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;
}