feat: Refactor user role assignments in modals to use role_module_assignments with improved validation and module association handling.

This commit is contained in:
Yashwin 2026-03-24 13:02:19 +05:30
parent 083d10fdff
commit 320277b536
2 changed files with 276 additions and 396 deletions

View File

@ -9,6 +9,7 @@ import {
FormField,
FormSelect,
PaginatedSelect,
MultiselectPaginatedSelect,
PrimaryButton,
SecondaryButton,
} from "@/components/shared";
@ -20,6 +21,11 @@ import { supplierService } from "@/services/supplier-service";
import { useAppSelector } from "@/hooks/redux-hooks";
import type { User } from "@/types/user";
const assignmentSchema = z.object({
role_id: z.string().min(1, "Role is required"),
module_ids: z.array(z.string().min(1)).min(1, "At least one module is required"),
});
// Validation schema
const editUserSchema = z.object({
email: z.email({ message: "Please enter a valid email address" }),
@ -29,24 +35,22 @@ const editUserSchema = z.object({
message: "Status is required",
}),
tenant_id: z.string().min(1, "Tenant is required"),
role_module_combinations: z.array(
z.object({
role_id: z.string().min(1, "Role is required"),
module_id: z.string().nullable().optional(),
})
).min(1, "At least one role assignment is required")
.refine(
(combinations) => {
const seen = new Set();
for (const combo of combinations) {
const key = `${combo.role_id}-${combo.module_id || "global"}`;
if (seen.has(key)) return false;
seen.add(key);
}
return true;
},
{ message: "Duplicate role-module combinations are not allowed" }
),
role_module_assignments: z.array(assignmentSchema).min(1, "At least one role assignment is required")
.superRefine((assignments, ctx) => {
const seenModules = new Set<string>();
assignments.forEach((assignment, rowIndex) => {
assignment.module_ids.forEach((moduleId) => {
if (seenModules.has(moduleId)) {
ctx.addIssue({
code: "custom",
path: [rowIndex, "module_ids"],
message: "A module can only be assigned to one role",
});
}
seenModules.add(moduleId);
});
});
}),
department_id: z.string().optional(),
designation_id: z.string().optional(),
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
@ -54,13 +58,16 @@ const editUserSchema = z.object({
});
type EditUserFormData = z.infer<typeof editUserSchema>;
type UpdateUserPayload = Omit<EditUserFormData, "role_module_assignments"> & {
role_module_combinations: { role_id: string; module_id: string }[];
};
interface EditUserModalProps {
isOpen: boolean;
onClose: () => void;
userId: string | null;
onLoadUser: (id: string) => Promise<User>;
onSubmit: (id: string, data: EditUserFormData) => Promise<void>;
onSubmit: (id: string, data: UpdateUserPayload) => Promise<void>;
isLoading?: boolean;
defaultTenantId?: string; // If provided, automatically set tenant_id and hide tenant field
}
@ -101,7 +108,7 @@ export const EditUserModal = ({
const { fields, append, remove } = useFieldArray({
control,
name: "role_module_combinations",
name: "role_module_assignments",
});
const statusValue = watch("status");
@ -236,23 +243,32 @@ export const EditUserModal = ({
const roleOptions: { value: string; label: string }[] = [];
const moduleOptions: { value: string; label: string }[] = [];
let initialCombinations = [{ role_id: "", module_id: null as string | null }];
let initialAssignments = [{ role_id: "", module_ids: [] as string[] }];
if (user.role_module_combinations && user.role_module_combinations.length > 0) {
initialCombinations = user.role_module_combinations.map(c => {
const grouped = new Map<string, string[]>();
user.role_module_combinations.forEach((c) => {
if (c.role_id && c.role_name && !roleOptions.some(o => o.value === c.role_id)) {
roleOptions.push({ value: c.role_id, label: c.role_name });
}
if (c.module_id && c.module_name && !moduleOptions.some(o => o.value === c.module_id)) {
moduleOptions.push({ value: c.module_id, label: c.module_name });
}
return { role_id: c.role_id, module_id: c.module_id || null };
if (c.module_id) {
const existing = grouped.get(c.role_id) || [];
existing.push(c.module_id);
grouped.set(c.role_id, existing);
}
});
initialAssignments = Array.from(grouped.entries()).map(([role_id, module_ids]) => ({
role_id,
module_ids,
}));
} else {
// Fallback for older format
const r_ids = user.roles?.map(r => r.id) || (user.role_id ? [user.role_id] : []);
if (r_ids.length > 0) {
initialCombinations = r_ids.map(id => ({ role_id: id, module_id: null }));
initialAssignments = r_ids.map(id => ({ role_id: id, module_ids: [] }));
}
if (user.roles?.length) {
user.roles.forEach(r => roleOptions.push({ value: r.id, label: r.name }));
@ -294,7 +310,7 @@ export const EditUserModal = ({
last_name: user.last_name,
status: user.status,
tenant_id: defaultTenantId || tenantId,
role_module_combinations: initialCombinations,
role_module_assignments: initialAssignments,
department_id: departmentId,
designation_id: designationId,
category:
@ -356,7 +372,7 @@ export const EditUserModal = ({
last_name: "",
status: "active",
tenant_id: defaultTenantId || "",
role_module_combinations: [{ role_id: "", module_id: null }],
role_module_assignments: [{ role_id: "", module_ids: [] }],
department_id: "",
designation_id: "",
category: "tenant_user",
@ -394,13 +410,21 @@ export const EditUserModal = ({
data.supplier_id = null;
}
await onSubmit(userId, data);
const { role_module_assignments, ...submitData } = data;
const role_module_combinations = role_module_assignments.flatMap((row) =>
row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })),
);
await onSubmit(userId, { ...submitData, role_module_combinations });
} catch (error: any) {
if (
error?.response?.data?.details &&
Array.isArray(error.response.data.details)
) {
const validationErrors = error.response.data.details;
setError("root", {
type: "server",
message: validationErrors[0]?.message || "Validation failed",
});
validationErrors.forEach(
(detail: { path: string; message: string }) => {
if (
@ -409,6 +433,7 @@ export const EditUserModal = ({
detail.path === "last_name" ||
detail.path === "status" ||
detail.path === "tenant_id" ||
detail.path === "role_module_assignments" ||
detail.path === "role_module_combinations"
) {
setError(detail.path as keyof EditUserFormData, {
@ -441,6 +466,17 @@ export const EditUserModal = ({
}
};
const getModuleError = (index: number): string | undefined => {
const fieldError: any = errors.role_module_assignments?.[index]?.module_ids;
if (!fieldError) return undefined;
if (typeof fieldError.message === "string") return fieldError.message;
if (Array.isArray(fieldError)) {
const firstChild = fieldError.find((e: any) => e?.message);
return firstChild?.message;
}
return undefined;
};
return (
<Modal
isOpen={isOpen}
@ -595,23 +631,23 @@ export const EditUserModal = ({
<label className="text-sm font-medium text-gray-700">Role & Module Assignments</label>
<button
type="button"
onClick={() => append({ role_id: "", module_id: null })}
onClick={() => append({ role_id: "", module_ids: [] })}
className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Add Assignment
</button>
</div>
{(errors.role_module_combinations as any)?.message && (
{(errors.role_module_assignments as any)?.message && (
<p className="text-sm text-red-500 mb-2">
{(errors.role_module_combinations as any).message}
{(errors.role_module_assignments as any).message}
</p>
)}
<div className="space-y-3">
{fields.map((field, index) => {
const roleIdValue = watch(`role_module_combinations.${index}.role_id`);
const moduleIdValue = watch(`role_module_combinations.${index}.module_id`);
const roleIdValue = watch(`role_module_assignments.${index}.role_id`);
const moduleIdsValue = watch(`role_module_assignments.${index}.module_ids`) || [];
// Extract specific label if available from initial options
const getRoleLabel = (val: string) => {
@ -619,32 +655,35 @@ export const EditUserModal = ({
return opt ? opt.label : undefined;
};
const getModuleLabel = (val: string) => {
const opt = initialModuleOptions.find(o => o.value === val);
return opt ? opt.label : undefined;
};
const initialSelectedModules = moduleIdsValue
.map((val) => {
const opt = initialModuleOptions.find((o) => o.value === val);
return opt ? { value: opt.value, label: opt.label } : null;
})
.filter((opt): opt is { value: string; label: string } => Boolean(opt));
return (
<div key={field.id} className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative">
<div className="flex-1 grid grid-cols-2 gap-3">
<PaginatedSelect
label={`Role ${index + 1}`}
label="Select Role"
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => setValue(`role_module_combinations.${index}.role_id`, value, { shouldValidate: true })}
onValueChange={(value) => setValue(`role_module_assignments.${index}.role_id`, value, { shouldValidate: true })}
onLoadOptions={loadRoles}
initialOption={roleIdValue ? { value: roleIdValue, label: getRoleLabel(roleIdValue) || roleIdValue } : undefined}
error={errors.role_module_combinations?.[index]?.role_id?.message}
error={errors.role_module_assignments?.[index]?.role_id?.message}
/>
<PaginatedSelect
label="Module (Optional)"
placeholder="Select Module"
value={moduleIdValue || ""}
onValueChange={(value) => setValue(`role_module_combinations.${index}.module_id`, value, { shouldValidate: true })}
<MultiselectPaginatedSelect
label="Select Modules"
required
placeholder="Select Modules"
value={moduleIdsValue}
onValueChange={(value) => setValue(`role_module_assignments.${index}.module_ids`, value, { shouldValidate: true })}
onLoadOptions={loadModules}
initialOption={moduleIdValue ? { value: moduleIdValue, label: getModuleLabel(moduleIdValue) || moduleIdValue } : undefined}
error={errors.role_module_combinations?.[index]?.module_id?.message}
initialOptions={initialSelectedModules}
error={getModuleError(index)}
/>
</div>
{fields.length > 1 && (

View File

@ -9,6 +9,7 @@ import {
FormField,
FormSelect,
PaginatedSelect,
MultiselectPaginatedSelect,
PrimaryButton,
SecondaryButton,
} from "@/components/shared";
@ -19,41 +20,36 @@ import { moduleService } from "@/services/module-service";
import { supplierService } from "@/services/supplier-service";
import { useAppSelector } from "@/hooks/redux-hooks";
// Validation schema
const assignmentSchema = z.object({
role_id: z.string().min(1, "Role is required"),
module_ids: z.array(z.string().min(1)).min(1, "At least one module is required"),
});
const newUserSchema = z
.object({
email: z.email({ message: "Please enter a valid email address" }),
password: z
.string()
.min(1, "Password is required")
.min(6, "Password must be at least 6 characters"),
password: z.string().min(1, "Password is required").min(6, "Password must be at least 6 characters"),
confirmPassword: z.string().min(1, "Confirm password is required"),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
status: z.enum(["active", "suspended", "deleted"], {
message: "Status is required",
}),
auth_provider: z.enum(["local"], {
message: "Auth provider is required",
}),
role_module_combinations: z.array(
z.object({
role_id: z.string().min(1, "Role is required"),
module_id: z.string().nullable().optional(),
})
).min(1, "At least one role assignment is required")
.refine(
(combinations) => {
const seen = new Set();
for (const combo of combinations) {
const key = `${combo.role_id}-${combo.module_id || "global"}`;
if (seen.has(key)) return false;
seen.add(key);
}
return true;
},
{ message: "Duplicate role-module combinations are not allowed" }
),
status: z.enum(["active", "suspended", "deleted"], { message: "Status is required" }),
auth_provider: z.enum(["local"], { message: "Auth provider is required" }),
role_module_assignments: z.array(assignmentSchema).min(1, "At least one role assignment is required")
.superRefine((assignments, ctx) => {
const used = new Set<string>();
assignments.forEach((assignment, rowIndex) => {
assignment.module_ids.forEach((moduleId) => {
if (used.has(moduleId)) {
ctx.addIssue({
code: "custom",
path: [rowIndex, "module_ids"],
message: "A module can only be assigned to one role",
});
}
used.add(moduleId);
});
});
}),
department_id: z.string().optional(),
designation_id: z.string().optional(),
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
@ -65,13 +61,16 @@ const newUserSchema = z
});
type NewUserFormData = z.infer<typeof newUserSchema>;
type CreateUserPayload = Omit<NewUserFormData, "confirmPassword" | "role_module_assignments"> & {
role_module_combinations: { role_id: string; module_id: string }[];
};
interface NewUserModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: Omit<NewUserFormData, "confirmPassword">) => Promise<void>;
onSubmit: (data: CreateUserPayload) => Promise<void>;
isLoading?: boolean;
defaultTenantId?: string; // If provided, filter roles by tenant_id
defaultTenantId?: string;
}
const statusOptions = [
@ -87,31 +86,21 @@ export const NewUserModal = ({
isLoading = false,
defaultTenantId,
}: NewUserModalProps): ReactElement | null => {
const {
control,
register,
handleSubmit,
setValue,
watch,
reset,
setError,
clearErrors,
formState: { errors },
} = useForm<NewUserFormData>({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
resolver: zodResolver(newUserSchema) as any,
defaultValues: {
role_module_combinations: [{ role_id: "", module_id: null }],
department_id: "",
designation_id: "",
category: "tenant_user" as const,
supplier_id: null,
},
});
const { control, register, handleSubmit, setValue, watch, reset, setError, clearErrors, formState: { errors } } =
useForm<NewUserFormData>({
resolver: zodResolver(newUserSchema) as any,
defaultValues: {
role_module_assignments: [{ role_id: "", module_ids: [] }],
department_id: "",
designation_id: "",
category: "tenant_user",
supplier_id: null,
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "role_module_combinations",
name: "role_module_assignments",
});
const statusValue = watch("status");
@ -123,7 +112,6 @@ export const NewUserModal = ({
const roles = useAppSelector((state) => state.auth.roles);
const isSuperAdmin = roles.includes("super_admin");
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
reset({
@ -134,7 +122,7 @@ export const NewUserModal = ({
last_name: "",
status: "active",
auth_provider: "local",
role_module_combinations: [{ role_id: "", module_id: null }],
role_module_assignments: [{ role_id: "", module_ids: [] }],
department_id: "",
designation_id: "",
category: "tenant_user",
@ -144,158 +132,90 @@ export const NewUserModal = ({
}
}, [isOpen, reset, clearErrors]);
// Load roles for dropdown
const loadRoles = async (page: number, limit: number) => {
const response = defaultTenantId
? await roleService.getByTenant(defaultTenantId, page, limit)
: await roleService.getAll(page, limit);
return {
options: response.data.map((role) => ({
value: role.id,
label: role.name,
})),
options: response.data.map((role) => ({ value: role.id, label: role.name })),
pagination: response.pagination,
};
};
const loadDepartments = async () => {
const response = await departmentService.list(defaultTenantId, {
active_only: true,
});
return {
options: response.data.map((dept) => ({
value: dept.id,
label: dept.name,
})),
pagination: {
page: 1,
limit: response.data.length,
total: response.data.length,
totalPages: 1,
hasMore: false,
},
};
};
const loadDesignations = async () => {
const response = await designationService.list(defaultTenantId, {
active_only: true,
});
return {
options: response.data.map((desig) => ({
value: desig.id,
label: desig.name,
})),
pagination: {
page: 1,
limit: response.data.length,
total: response.data.length,
totalPages: 1,
hasMore: false,
},
};
};
const loadModules = async (page: number, limit: number) => {
const tenantId = isSuperAdmin ? defaultTenantId : undefined;
const response = await moduleService.getAvailable(page, limit, tenantId);
return {
options: response.data.map((module) => ({
value: module.id,
label: module.name,
})),
options: response.data.map((module) => ({ value: module.id, label: module.name })),
pagination: response.pagination,
};
};
const loadDepartments = async () => {
const response = await departmentService.list(defaultTenantId, { active_only: true });
return {
options: response.data.map((dept) => ({ value: dept.id, label: dept.name })),
pagination: { page: 1, limit: response.data.length, total: response.data.length, totalPages: 1, hasMore: false },
};
};
const loadDesignations = async () => {
const response = await designationService.list(defaultTenantId, { active_only: true });
return {
options: response.data.map((desig) => ({ value: desig.id, label: desig.name })),
pagination: { page: 1, limit: response.data.length, total: response.data.length, totalPages: 1, hasMore: false },
};
};
const loadSuppliers = async (page: number, limit: number) => {
const response = await supplierService.list({
tenantId: defaultTenantId,
limit,
offset: (page - 1) * limit,
});
const response = await supplierService.list({ tenantId: defaultTenantId, limit, offset: (page - 1) * limit });
const total = response.pagination?.total ?? response.data?.length ?? 0;
return {
options: (response.data || []).map((supplier: any) => ({
value: supplier.id,
label: supplier.name,
})),
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit) || 1,
hasMore: page * limit < total,
},
options: (response.data || []).map((supplier: any) => ({ value: supplier.id, label: supplier.name })),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) || 1, hasMore: page * limit < total },
};
};
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
clearErrors();
try {
const { confirmPassword, ...submitData } = data;
// Normalize empty strings to null for optional UUID fields
const { confirmPassword, role_module_assignments, ...submitData } = data;
const role_module_combinations = role_module_assignments.flatMap((row) =>
row.module_ids.map((module_id) => ({ role_id: row.role_id, module_id })),
);
if (!submitData.department_id) submitData.department_id = undefined;
if (!submitData.designation_id) submitData.designation_id = undefined;
// If category is tenant_user, supplier_id should always be null
if (submitData.category === "tenant_user") {
submitData.supplier_id = null;
} else if (!submitData.supplier_id) {
submitData.supplier_id = null;
}
await onSubmit(submitData);
if (submitData.category === "tenant_user") submitData.supplier_id = null;
else if (!submitData.supplier_id) submitData.supplier_id = null;
await onSubmit({ ...submitData, role_module_combinations });
} catch (error: any) {
// Handle validation errors from API
if (
error?.response?.data?.details &&
Array.isArray(error.response.data.details)
) {
const validationErrors = error.response.data.details;
validationErrors.forEach(
(detail: { path: string; message: string }) => {
if (
detail.path === "email" ||
detail.path === "password" ||
detail.path === "first_name" ||
detail.path === "last_name" ||
detail.path === "status" ||
detail.path === "auth_provider" ||
detail.path === "role_module_combinations"
) {
setError(detail.path as keyof NewUserFormData, {
type: "server",
message: detail.message,
});
}
},
);
} else {
// Handle general errors
// Check for nested error object with message property
const errorObj = error?.response?.data?.error;
const errorMessage =
(typeof errorObj === "object" &&
errorObj !== null &&
"message" in errorObj
? errorObj.message
: null) ||
(typeof errorObj === "string" ? errorObj : null) ||
error?.response?.data?.message ||
error?.message ||
"Failed to create user. Please try again.";
setError("root", {
type: "server",
message:
typeof errorMessage === "string"
? errorMessage
: "Failed to create user. Please try again.",
});
if (error?.response?.data?.details && Array.isArray(error.response.data.details)) {
const first = error.response.data.details[0];
setError("root", { type: "server", message: first?.message || "Validation failed" });
return;
}
const message =
error?.response?.data?.error?.message ||
error?.response?.data?.message ||
error?.message ||
"Failed to create user. Please try again.";
setError("root", { type: "server", message });
}
};
const getModuleError = (index: number): string | undefined => {
const fieldError: any = errors.role_module_assignments?.[index]?.module_ids;
if (!fieldError) return undefined;
if (typeof fieldError.message === "string") return fieldError.message;
if (Array.isArray(fieldError)) {
const firstChild = fieldError.find((e: any) => e?.message);
return firstChild?.message;
}
return undefined;
};
return (
<Modal
isOpen={isOpen}
@ -305,12 +225,7 @@ export const NewUserModal = ({
maxWidth="md"
footer={
<>
<SecondaryButton
type="button"
onClick={onClose}
disabled={isLoading}
className="px-4 py-2.5 text-sm"
>
<SecondaryButton type="button" onClick={onClose} disabled={isLoading} className="px-4 py-2.5 text-sm">
Cancel
</SecondaryButton>
<PrimaryButton
@ -326,190 +241,116 @@ export const NewUserModal = ({
}
>
<form onSubmit={handleSubmit(handleFormSubmit as any)} className="p-5">
{/* General Error Display */}
{errors.root && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
<p className="text-sm text-[#ef4444]">{errors.root.message}</p>
</div>
)}
<div className="flex flex-col gap-0">
{/* Email */}
<FormField
label="Email"
type="email"
<FormField label="Email" type="email" required placeholder="Enter email address" error={errors.email?.message} {...register("email")} />
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField label="First Name" required placeholder="Enter first name" error={errors.first_name?.message} {...register("first_name")} />
<FormField label="Last Name" required placeholder="Enter last name" error={errors.last_name?.message} {...register("last_name")} />
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField label="Password" type="password" required placeholder="Enter password" error={errors.password?.message} {...register("password")} />
<FormField label="Confirm Password" type="password" required placeholder="Confirm password" error={errors.confirmPassword?.message} {...register("confirmPassword")} />
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
<PaginatedSelect label="Department" placeholder="Select Department" value={departmentIdValue || ""} onValueChange={(value) => setValue("department_id", value)} onLoadOptions={loadDepartments} error={errors.department_id?.message} />
<PaginatedSelect label="Designation" placeholder="Select Designation" value={designationIdValue || ""} onValueChange={(value) => setValue("designation_id", value)} onLoadOptions={loadDesignations} error={errors.designation_id?.message} />
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
<FormSelect
label="User Category"
required
placeholder="Enter email address"
error={errors.email?.message}
{...register("email")}
placeholder="Select Category"
options={[{ value: "tenant_user", label: "Tenant User" }, { value: "supplier_user", label: "Supplier User" }]}
value={categoryValue || "tenant_user"}
onValueChange={(value) => {
const category = value as "tenant_user" | "supplier_user";
setValue("category", category, { shouldValidate: true });
if (category === "tenant_user") setValue("supplier_id", null);
}}
error={errors.category?.message}
/>
{/* First Name and Last Name Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField
label="First Name"
required
placeholder="Enter first name"
error={errors.first_name?.message}
{...register("first_name")}
/>
<FormField
label="Last Name"
required
placeholder="Enter last name"
error={errors.last_name?.message}
{...register("last_name")}
/>
</div>
{/* Password and Confirm Password Row */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormField
label="Password"
type="password"
required
placeholder="Enter password"
error={errors.password?.message}
{...register("password")}
/>
<FormField
label="Confirm Password"
type="password"
required
placeholder="Confirm password"
error={errors.confirmPassword?.message}
{...register("confirmPassword")}
/>
</div>
<div className="grid grid-cols-2 gap-5 pb-4">
{categoryValue === "supplier_user" && (
<PaginatedSelect
label="Department"
placeholder="Select Department"
value={departmentIdValue || ""}
onValueChange={(value) => setValue("department_id", value)}
onLoadOptions={loadDepartments}
error={errors.department_id?.message}
/>
<PaginatedSelect
label="Designation"
placeholder="Select Designation"
value={designationIdValue || ""}
onValueChange={(value) => setValue("designation_id", value)}
onLoadOptions={loadDesignations}
error={errors.designation_id?.message}
/>
</div>
{/* User Category */}
<div className="grid grid-cols-2 gap-5 pb-4">
<FormSelect
label="User Category"
label="Linked Supplier"
required
placeholder="Select Category"
options={[
{ value: "tenant_user", label: "Tenant User" },
{ value: "supplier_user", label: "Supplier User" },
]}
value={categoryValue || "tenant_user"}
onValueChange={(value) => {
const category = value as "tenant_user" | "supplier_user";
setValue("category", category, { shouldValidate: true });
if (category === "tenant_user") {
setValue("supplier_id", null);
}
}}
error={errors.category?.message}
placeholder="Select Supplier"
value={supplierIdValue || ""}
onValueChange={(value) => setValue("supplier_id", value)}
onLoadOptions={loadSuppliers}
error={errors.supplier_id?.message}
/>
)}
</div>
<div className="pb-4">
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) => setValue("status", value as "active" | "suspended" | "deleted")}
error={errors.status?.message}
/>
</div>
{categoryValue === "supplier_user" && (
<PaginatedSelect
label="Linked Supplier"
required
placeholder="Select Supplier"
value={supplierIdValue || ""}
onValueChange={(value) => setValue("supplier_id", value)}
onLoadOptions={loadSuppliers}
error={errors.supplier_id?.message}
/>
)}
<div className="mt-2 mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">Role & Module Assignments</label>
<button
type="button"
onClick={() => append({ role_id: "", module_ids: [] })}
className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Add Assignment
</button>
</div>
<div className="pb-4">
<FormSelect
label="Status"
required
placeholder="Select Status"
options={statusOptions}
value={statusValue}
onValueChange={(value) =>
setValue("status", value as "active" | "suspended" | "deleted")
}
error={errors.status?.message}
/>
</div>
<div className="mt-2 mb-4">
<div className="flex justify-between items-center mb-2">
<label className="text-sm font-medium text-gray-700">Role & Module Assignments</label>
<button
type="button"
onClick={() => append({ role_id: "", module_id: null })}
className="text-sm font-medium text-[#112868] hover:text-[#0b1c4a] flex items-center gap-1"
>
<Plus className="w-4 h-4" /> Add Assignment
</button>
</div>
{(errors.role_module_combinations as any)?.message && (
<p className="text-sm text-red-500 mb-2">
{(errors.role_module_combinations as any).message}
</p>
)}
<div className="space-y-3">
{fields.map((field, index) => {
const roleIdValue = watch(`role_module_combinations.${index}.role_id`);
const moduleIdValue = watch(`role_module_combinations.${index}.module_id`);
return (
<div key={field.id} className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative">
<div className="flex-1 grid grid-cols-2 gap-3">
<PaginatedSelect
label={`Role ${index + 1}`}
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => setValue(`role_module_combinations.${index}.role_id`, value, { shouldValidate: true })}
onLoadOptions={loadRoles}
error={errors.role_module_combinations?.[index]?.role_id?.message}
/>
<PaginatedSelect
label="Module (Optional)"
placeholder="Select Module"
value={moduleIdValue || ""}
onValueChange={(value) => setValue(`role_module_combinations.${index}.module_id`, value, { shouldValidate: true })}
onLoadOptions={loadModules}
error={errors.role_module_combinations?.[index]?.module_id?.message}
/>
</div>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="mt-8 p-2 text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Remove Assignment"
>
<Trash2 className="w-4 h-4" />
</button>
)}
{(errors.role_module_assignments as any)?.message && (
<p className="text-sm text-red-500 mb-2">{(errors.role_module_assignments as any).message}</p>
)}
<div className="space-y-3">
{fields.map((field, index) => {
const roleIdValue = watch(`role_module_assignments.${index}.role_id`);
const moduleIdsValue = watch(`role_module_assignments.${index}.module_ids`) || [];
return (
<div key={field.id} className="flex gap-2 items-start border p-3 rounded-md bg-gray-50 relative">
<div className="flex-1 grid grid-cols-2 gap-3">
<PaginatedSelect
label="Select Role"
required
placeholder="Select Role"
value={roleIdValue || ""}
onValueChange={(value) => setValue(`role_module_assignments.${index}.role_id`, value, { shouldValidate: true })}
onLoadOptions={loadRoles}
error={errors.role_module_assignments?.[index]?.role_id?.message}
/>
<MultiselectPaginatedSelect
label="Select Modules"
required
placeholder="Select Modules"
value={moduleIdsValue}
onValueChange={(value) => setValue(`role_module_assignments.${index}.module_ids`, value, { shouldValidate: true })}
onLoadOptions={loadModules}
error={getModuleError(index)}
/>
</div>
);
})}
</div>
{fields.length > 1 && (
<button
type="button"
onClick={() => remove(index)}
className="mt-8 p-2 text-red-500 hover:bg-red-50 rounded-md transition-colors"
title="Remove Assignment"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
);
})}
</div>
</div>
</form>