Qassure-frontend/src/components/shared/NewUserModal.tsx

519 lines
17 KiB
TypeScript

import { useEffect } from "react";
import type { ReactElement } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Plus, Trash2 } from "lucide-react";
import {
Modal,
FormField,
FormSelect,
PaginatedSelect,
PrimaryButton,
SecondaryButton,
} from "@/components/shared";
import { roleService } from "@/services/role-service";
import { departmentService } from "@/services/department-service";
import { designationService } from "@/services/designation-service";
import { moduleService } from "@/services/module-service";
import { supplierService } from "@/services/supplier-service";
import { useAppSelector } from "@/hooks/redux-hooks";
// Validation schema
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"),
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" }
),
department_id: z.string().optional(),
designation_id: z.string().optional(),
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
supplier_id: z.string().optional().nullable(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
});
type NewUserFormData = z.infer<typeof newUserSchema>;
interface NewUserModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: Omit<NewUserFormData, "confirmPassword">) => Promise<void>;
isLoading?: boolean;
defaultTenantId?: string; // If provided, filter roles by tenant_id
}
const statusOptions = [
{ value: "active", label: "Active" },
{ value: "suspended", label: "Suspended" },
{ value: "deleted", label: "Deleted" },
];
export const NewUserModal = ({
isOpen,
onClose,
onSubmit,
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 { fields, append, remove } = useFieldArray({
control,
name: "role_module_combinations",
});
const statusValue = watch("status");
const departmentIdValue = watch("department_id");
const designationIdValue = watch("designation_id");
const categoryValue = watch("category");
const supplierIdValue = watch("supplier_id");
const roles = useAppSelector((state) => state.auth.roles);
const isSuperAdmin = roles.includes("super_admin");
// Reset form when modal closes
useEffect(() => {
if (!isOpen) {
reset({
email: "",
password: "",
confirmPassword: "",
first_name: "",
last_name: "",
status: "active",
auth_provider: "local",
role_module_combinations: [{ role_id: "", module_id: null }],
department_id: "",
designation_id: "",
category: "tenant_user",
supplier_id: null,
});
clearErrors();
}
}, [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,
})),
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,
})),
pagination: response.pagination,
};
};
const loadSuppliers = async (page: number, limit: number) => {
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,
},
};
};
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
clearErrors();
try {
const { confirmPassword, ...submitData } = data;
// Normalize empty strings to null for optional UUID fields
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);
} 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.",
});
}
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Create New User"
description="Invite a new user to this tenant"
maxWidth="md"
footer={
<>
<SecondaryButton
type="button"
onClick={onClose}
disabled={isLoading}
className="px-4 py-2.5 text-sm"
>
Cancel
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleSubmit(handleFormSubmit as any)}
disabled={isLoading}
size="default"
className="px-4 py-2.5 text-sm"
>
{isLoading ? "Creating..." : "Create User"}
</PrimaryButton>
</>
}
>
<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"
required
placeholder="Enter email address"
error={errors.email?.message}
{...register("email")}
/>
{/* 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">
<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"
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}
/>
{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>
<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>
)}
</div>
);
})}
</div>
</div>
</div>
</form>
</Modal>
);
};