feat: Implement support for multiple user roles, department, and designation fields across user management pages and modals.
This commit is contained in:
parent
b1ac65b345
commit
939bd4ddc9
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user