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, FormField,
FormSelect, FormSelect,
PaginatedSelect, PaginatedSelect,
MultiselectPaginatedSelect,
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
} from "@/components/shared"; } from "@/components/shared";
@ -26,7 +27,7 @@ const editUserSchema = z.object({
message: "Status is required", message: "Status is required",
}), }),
tenant_id: z.string().min(1, "Tenant 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(), department_id: z.string().optional(),
designation_id: z.string().optional(), designation_id: z.string().optional(),
}); });
@ -76,16 +77,13 @@ export const EditUserModal = ({
}); });
const statusValue = watch("status"); const statusValue = watch("status");
const roleIdValue = watch("role_id"); const roleIdsValue = watch("role_ids");
const departmentIdValue = watch("department_id"); const departmentIdValue = watch("department_id");
const designationIdValue = watch("designation_id"); const designationIdValue = watch("designation_id");
const [currentRoleName, setCurrentRoleName] = useState<string>(""); const [initialRoleOptions, setInitialRoleOptions] = useState<
{ value: string; label: string }[]
const [initialRoleOption, setInitialRoleOption] = useState<{ >([]);
value: string;
label: string;
} | null>(null);
const [initialDepartmentOption, setInitialDepartmentOption] = useState<{ const [initialDepartmentOption, setInitialDepartmentOption] = useState<{
value: string; value: string;
label: string; label: string;
@ -100,22 +98,11 @@ export const EditUserModal = ({
const response = defaultTenantId const response = defaultTenantId
? await roleService.getByTenant(defaultTenantId, page, limit) ? await roleService.getByTenant(defaultTenantId, page, limit)
: await roleService.getAll(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 { return {
options, options: response.data.map((role) => ({
value: role.id,
label: role.name,
})),
pagination: response.pagination, pagination: response.pagination,
}; };
}; };
@ -124,22 +111,11 @@ export const EditUserModal = ({
const response = await departmentService.list(defaultTenantId, { const response = await departmentService.list(defaultTenantId, {
active_only: true, 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 { return {
options, options: response.data.map((dept) => ({
value: dept.id,
label: dept.name,
})),
pagination: { pagination: {
page: 1, page: 1,
limit: response.data.length, limit: response.data.length,
@ -154,22 +130,11 @@ export const EditUserModal = ({
const response = await designationService.list(defaultTenantId, { const response = await designationService.list(defaultTenantId, {
active_only: true, 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 { return {
options, options: response.data.map((desig) => ({
value: desig.id,
label: desig.name,
})),
pagination: { pagination: {
page: 1, page: 1,
limit: response.data.length, limit: response.data.length,
@ -193,20 +158,25 @@ export const EditUserModal = ({
loadedUserIdRef.current = userId; loadedUserIdRef.current = userId;
const tenantId = user.tenant?.id || user.tenant_id || ""; 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 = const departmentId =
user.department?.id || user.department_id || ""; user.department?.id || user.department_id || "";
const designationId = const designationId =
user.designation?.id || user.designation_id || ""; user.designation?.id || user.designation_id || "";
const roleName = user.role?.name || "";
const departmentName = user.department?.name || ""; const departmentName = user.department?.name || "";
const designationName = user.designation?.name || ""; const designationName = user.designation?.name || "";
setCurrentRoleName(roleName); if (roleOptions.length > 0) {
setInitialRoleOptions(roleOptions);
if (roleId && roleName) {
setInitialRoleOption({ value: roleId, label: roleName });
} }
if (departmentId && departmentName) { if (departmentId && departmentName) {
setInitialDepartmentOption({ setInitialDepartmentOption({
@ -227,7 +197,7 @@ export const EditUserModal = ({
last_name: user.last_name, last_name: user.last_name,
status: user.status, status: user.status,
tenant_id: defaultTenantId || tenantId, tenant_id: defaultTenantId || tenantId,
role_id: roleId, role_ids: roleIds,
department_id: departmentId, department_id: departmentId,
designation_id: designationId, designation_id: designationId,
}); });
@ -248,8 +218,7 @@ export const EditUserModal = ({
} }
} else if (!isOpen) { } else if (!isOpen) {
loadedUserIdRef.current = null; loadedUserIdRef.current = null;
setCurrentRoleName(""); setInitialRoleOptions([]);
setInitialRoleOption(null);
setInitialDepartmentOption(null); setInitialDepartmentOption(null);
setInitialDesignationOption(null); setInitialDesignationOption(null);
reset({ reset({
@ -258,7 +227,7 @@ export const EditUserModal = ({
last_name: "", last_name: "",
status: "active", status: "active",
tenant_id: defaultTenantId || "", tenant_id: defaultTenantId || "",
role_id: "", role_ids: [],
department_id: "", department_id: "",
designation_id: "", designation_id: "",
}); });
@ -417,19 +386,17 @@ export const EditUserModal = ({
/> />
</div> </div>
<div className={`grid grid-cols-2 gap-5 pb-4`}> <div className="grid grid-cols-2 gap-5 pb-4">
{currentRoleName !== "Tenant Admin" && ( <MultiselectPaginatedSelect
<PaginatedSelect label="Assign Role"
label="Assign Role" required
required placeholder="Select Roles"
placeholder="Select Role" value={roleIdsValue || []}
value={roleIdValue || ""} onValueChange={(value) => setValue("role_ids", value)}
onValueChange={(value) => setValue("role_id", value)} onLoadOptions={loadRoles}
onLoadOptions={loadRoles} initialOptions={initialRoleOptions}
initialOption={initialRoleOption || undefined} error={errors.role_ids?.message}
error={errors.role_id?.message} />
/>
)}
<FormSelect <FormSelect
label="Status" label="Status"
required required

View File

@ -1,8 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react'; import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from 'react-dom'; import { createPortal } from "react-dom";
import type { ReactElement } from 'react'; import type { ReactElement } from "react";
import { ChevronDown, Loader2, X } from 'lucide-react'; import { ChevronDown, Loader2, X } from "lucide-react";
import { cn } from '@/lib/utils'; import { cn } from "@/lib/utils";
interface MultiselectPaginatedSelectOption { interface MultiselectPaginatedSelectOption {
value: string; value: string;
@ -17,7 +17,10 @@ interface MultiselectPaginatedSelectProps {
placeholder?: string; placeholder?: string;
value: string[]; value: string[];
onValueChange: (value: string[]) => void; onValueChange: (value: string[]) => void;
onLoadOptions: (page: number, limit: number) => Promise<{ onLoadOptions: (
page: number,
limit: number,
) => Promise<{
options: MultiselectPaginatedSelectOption[]; options: MultiselectPaginatedSelectOption[];
pagination: { pagination: {
page: number; page: number;
@ -37,7 +40,7 @@ export const MultiselectPaginatedSelect = ({
required = false, required = false,
error, error,
helperText, helperText,
placeholder = 'Select Items', placeholder = "Select Items",
value, value,
onValueChange, onValueChange,
onLoadOptions, onLoadOptions,
@ -46,7 +49,9 @@ export const MultiselectPaginatedSelect = ({
id, id,
}: MultiselectPaginatedSelectProps): ReactElement => { }: MultiselectPaginatedSelectProps): ReactElement => {
const [isOpen, setIsOpen] = useState<boolean>(false); const [isOpen, setIsOpen] = useState<boolean>(false);
const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>([]); const [options, setOptions] = useState<MultiselectPaginatedSelectOption[]>(
[],
);
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false); const [isLoadingMore, setIsLoadingMore] = useState<boolean>(false);
const [pagination, setPagination] = useState<{ const [pagination, setPagination] = useState<{
@ -72,7 +77,7 @@ export const MultiselectPaginatedSelect = ({
bottom?: string; bottom?: string;
left: string; left: string;
width: string; width: string;
}>({ left: '0', width: '0' }); }>({ left: "0", width: "0" });
// Load initial options // Load initial options
const loadOptions = useCallback( const loadOptions = useCallback(
@ -85,22 +90,22 @@ export const MultiselectPaginatedSelect = ({
} }
const result = await onLoadOptions(page, pagination.limit); const result = await onLoadOptions(page, pagination.limit);
if (append) { if (append) {
setOptions((prev) => [...prev, ...result.options]); setOptions((prev) => [...prev, ...result.options]);
} else { } else {
setOptions(result.options); setOptions(result.options);
} }
setPagination(result.pagination); setPagination(result.pagination);
} catch (err) { } catch (err) {
console.error('Error loading options:', err); console.error("Error loading options:", err);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setIsLoadingMore(false); setIsLoadingMore(false);
} }
}, },
[onLoadOptions, pagination.limit] [onLoadOptions, pagination.limit],
); );
// Load options when dropdown opens // Load options when dropdown opens
@ -126,9 +131,9 @@ export const MultiselectPaginatedSelect = ({
} }
}; };
scrollContainer.addEventListener('scroll', handleScroll); scrollContainer.addEventListener("scroll", handleScroll);
return () => { return () => {
scrollContainer.removeEventListener('scroll', handleScroll); scrollContainer.removeEventListener("scroll", handleScroll);
}; };
}, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]); }, [isOpen, pagination, isLoadingMore, isLoading, loadOptions]);
@ -149,26 +154,35 @@ export const MultiselectPaginatedSelect = ({
const handleScroll = (event: Event) => { const handleScroll = (event: Event) => {
// Don't close if scrolling inside the dropdown's internal scroll container // Don't close if scrolling inside the dropdown's internal scroll container
const target = event.target as HTMLElement; 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; return;
} }
// Don't close if scrolling inside the dropdown menu itself // 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; return;
} }
setIsOpen(false); setIsOpen(false);
}; };
if (isOpen && buttonRef.current) { if (isOpen && buttonRef.current) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
window.addEventListener('scroll', handleScroll, true); window.addEventListener("scroll", handleScroll, true);
const rect = buttonRef.current.getBoundingClientRect(); const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom; const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top; const spaceAbove = rect.top;
const dropdownHeight = Math.min(240, 240); const dropdownHeight = Math.min(240, 240);
const shouldOpenUp = spaceBelow < dropdownHeight && spaceAbove > spaceBelow; const shouldOpenUp =
spaceBelow < dropdownHeight && spaceAbove > spaceBelow;
if (shouldOpenUp) { if (shouldOpenUp) {
setDropdownStyle({ setDropdownStyle({
@ -186,16 +200,25 @@ export const MultiselectPaginatedSelect = ({
} }
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener('scroll', handleScroll, true); window.removeEventListener("scroll", handleScroll, true);
}; };
}, [isOpen]); }, [isOpen]);
const fieldId = id || `multiselect-${label.toLowerCase().replace(/\s+/g, '-')}`; const fieldId =
id || `multiselect-${label.toLowerCase().replace(/\s+/g, "-")}`;
const hasError = Boolean(error); const hasError = Boolean(error);
// Combine loaded options with initial options, prioritizing loaded options // Combine loaded options with initial options, prioritizing loaded options
const allOptions = [...initialOptions, ...options.filter(opt => !initialOptions.some(init => init.value === opt.value))]; const allOptions = [
const selectedOptions = allOptions.filter((opt) => value.includes(opt.value)); ...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) => { const handleToggle = (optionValue: string) => {
if (value.includes(optionValue)) { if (value.includes(optionValue)) {
@ -226,13 +249,13 @@ export const MultiselectPaginatedSelect = ({
id={fieldId} id={fieldId}
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className={cn( className={cn(
'min-h-10 w-full px-3.5 py-2 bg-white border rounded-md text-sm transition-colors', "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', "flex items-center justify-between gap-2",
hasError hasError
? 'border-[#ef4444] focus-visible:border-[#ef4444] focus-visible:ring-[#ef4444]/20' ? "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', : "border-[rgba(0,0,0,0.08)] focus-visible:border-[#112868] focus-visible:ring-[#112868]/20",
'focus-visible:outline-none focus-visible:ring-2', "focus-visible:outline-none focus-visible:ring-2",
className className,
)} )}
aria-invalid={hasError} aria-invalid={hasError}
aria-expanded={isOpen} aria-expanded={isOpen}
@ -268,7 +291,10 @@ export const MultiselectPaginatedSelect = ({
)} )}
</div> </div>
<ChevronDown <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> </button>
@ -299,22 +325,26 @@ export const MultiselectPaginatedSelect = ({
{allOptions.map((option) => { {allOptions.map((option) => {
const isSelected = value.includes(option.value); const isSelected = value.includes(option.value);
return ( return (
<li key={option.value} role="option" aria-selected={isSelected}> <li
key={option.value}
role="option"
aria-selected={isSelected}
>
<button <button
type="button" type="button"
onClick={() => handleToggle(option.value)} onClick={() => handleToggle(option.value)}
className={cn( className={cn(
'w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors', "w-full px-3 py-2 text-left text-sm text-[#0e1b2a] hover:bg-gray-50 transition-colors",
'flex items-center gap-2', "flex items-center gap-2",
isSelected && 'bg-gray-50' isSelected && "bg-gray-50",
)} )}
> >
<div <div
className={cn( 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 isSelected
? 'bg-[#112868] border-[#112868]' ? "bg-[#112868] border-[#112868]"
: 'border-[rgba(0,0,0,0.2)]' : "border-[rgba(0,0,0,0.2)]",
)} )}
> >
{isSelected && ( {isSelected && (
@ -345,11 +375,15 @@ export const MultiselectPaginatedSelect = ({
</> </>
)} )}
</div>, </div>,
document.body document.body,
)} )}
</div> </div>
{error && ( {error && (
<p id={`${fieldId}-error`} className="text-sm text-[#ef4444]" role="alert"> <p
id={`${fieldId}-error`}
className="text-sm text-[#ef4444]"
role="alert"
>
{error} {error}
</p> </p>
)} )}

View File

@ -8,6 +8,7 @@ import {
FormField, FormField,
FormSelect, FormSelect,
PaginatedSelect, PaginatedSelect,
MultiselectPaginatedSelect,
PrimaryButton, PrimaryButton,
SecondaryButton, SecondaryButton,
} from "@/components/shared"; } from "@/components/shared";
@ -32,7 +33,7 @@ const newUserSchema = z
auth_provider: z.enum(["local"], { auth_provider: z.enum(["local"], {
message: "Auth provider is required", 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(), department_id: z.string().optional(),
designation_id: z.string().optional(), designation_id: z.string().optional(),
}) })
@ -76,14 +77,14 @@ export const NewUserModal = ({
} = useForm<NewUserFormData>({ } = useForm<NewUserFormData>({
resolver: zodResolver(newUserSchema), resolver: zodResolver(newUserSchema),
defaultValues: { defaultValues: {
role_id: "", role_ids: [],
department_id: "", department_id: "",
designation_id: "", designation_id: "",
}, },
}); });
const statusValue = watch("status"); const statusValue = watch("status");
const roleIdValue = watch("role_id"); const roleIdsValue = watch("role_ids");
const departmentIdValue = watch("department_id"); const departmentIdValue = watch("department_id");
const designationIdValue = watch("designation_id"); const designationIdValue = watch("designation_id");
@ -98,7 +99,7 @@ export const NewUserModal = ({
last_name: "", last_name: "",
status: "active", status: "active",
auth_provider: "local", auth_provider: "local",
role_id: "", role_ids: [],
department_id: "", department_id: "",
designation_id: "", designation_id: "",
}); });
@ -179,7 +180,7 @@ export const NewUserModal = ({
detail.path === "last_name" || detail.path === "last_name" ||
detail.path === "status" || detail.path === "status" ||
detail.path === "auth_provider" || detail.path === "auth_provider" ||
detail.path === "role_id" detail.path === "role_ids"
) { ) {
setError(detail.path as keyof NewUserFormData, { setError(detail.path as keyof NewUserFormData, {
type: "server", type: "server",
@ -323,14 +324,14 @@ export const NewUserModal = ({
{/* Role and Status Row */} {/* Role and Status Row */}
<div className="grid grid-cols-2 gap-5 pb-4"> <div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect <MultiselectPaginatedSelect
label="Assign Role" label="Assign Role"
required required
placeholder="Select Role" placeholder="Select Roles"
value={roleIdValue || ""} value={roleIdsValue || []}
onValueChange={(value) => setValue("role_id", value)} onValueChange={(value) => setValue("role_ids", value)}
onLoadOptions={loadRoles} onLoadOptions={loadRoles}
error={errors.role_id?.message} error={errors.role_ids?.message}
/> />
<FormSelect <FormSelect

View File

@ -138,11 +138,24 @@ export const ViewUserModal = ({
</div> </div>
<div> <div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block"> <label className="text-xs font-medium text-[#6b7280] mb-1 block">
Role Roles
</label> </label>
<p className="text-sm text-[#0e1b2a]"> <div className="flex flex-wrap gap-1">
{user.role?.name || "-"} {user.roles && user.roles.length > 0 ? (
</p> 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>
<div> <div>
<label className="text-xs font-medium text-[#6b7280] mb-1 block"> <label className="text-xs font-medium text-[#6b7280] mb-1 block">

View File

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

View File

@ -15,6 +15,10 @@ export interface User {
id: string; id: string;
name: string; name: string;
}; };
roles?: {
id: string;
name: string;
}[];
department_id?: string; department_id?: string;
designation_id?: string; designation_id?: string;
department?: { department?: {
@ -51,7 +55,8 @@ export interface CreateUserRequest {
status: 'active' | 'suspended' | 'deleted'; status: 'active' | 'suspended' | 'deleted';
auth_provider: 'local'; auth_provider: 'local';
tenant_id?: string; tenant_id?: string;
role_id: string; role_id?: string;
role_ids?: string[];
department_id?: string; department_id?: string;
designation_id?: string; designation_id?: string;
} }
@ -74,7 +79,8 @@ export interface UpdateUserRequest {
status: 'active' | 'suspended' | 'deleted'; status: 'active' | 'suspended' | 'deleted';
auth_provider?: string; auth_provider?: string;
tenant_id: string; tenant_id: string;
role_id: string; role_id?: string;
role_ids?: string[];
department_id?: string; department_id?: string;
designation_id?: string; designation_id?: string;
} }