refactor: enhance user validation schemas, improve code formatting, and debug storage statistics retrieval.

This commit is contained in:
Yashwin 2026-05-18 16:55:18 +05:30
parent 793fa23c1b
commit 53c4048ad5
7 changed files with 225 additions and 85 deletions

View File

@ -23,19 +23,29 @@ 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"),
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" }),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
first_name: z
.string()
.min(1, "First name is required")
.max(255, "Maximum 255 characters allowed"),
last_name: z
.string()
.min(1, "Last name is required")
.max(255, "Maximum 255 characters allowed"),
status: z.enum(["active", "suspended", "deleted"], {
message: "Status is required",
}),
tenant_id: z.string().min(1, "Tenant is required"),
role_module_assignments: z.array(assignmentSchema).min(1, "At least one role assignment is required")
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) => {
@ -239,20 +249,36 @@ export const EditUserModal = ({
loadedUserIdRef.current = userId;
const tenantId = user.tenant?.id || user.tenant_id || "";
const roleOptions: { value: string; label: string }[] = [];
const moduleOptions: { value: string; label: string }[] = [];
let initialAssignments = [{ role_id: "", module_ids: [] as string[] }];
if (user.role_module_combinations && user.role_module_combinations.length > 0) {
let initialAssignments = [
{ role_id: "", module_ids: [] as string[] },
];
if (
user.role_module_combinations &&
user.role_module_combinations.length > 0
) {
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)) {
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 });
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,
});
}
if (c.module_id) {
const existing = grouped.get(c.role_id) || [];
@ -260,23 +286,37 @@ export const EditUserModal = ({
grouped.set(c.role_id, existing);
}
});
initialAssignments = Array.from(grouped.entries()).map(([role_id, module_ids]) => ({
role_id,
module_ids,
}));
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] : []);
const r_ids =
user.roles?.map((r) => r.id) ||
(user.role_id ? [user.role_id] : []);
if (r_ids.length > 0) {
initialAssignments = r_ids.map(id => ({ role_id: id, module_ids: [] }));
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 }));
user.roles.forEach((r) =>
roleOptions.push({ value: r.id, label: r.name }),
);
} else if (user.role?.id) {
roleOptions.push({ value: user.role.id, label: user.role.name });
roleOptions.push({
value: user.role.id,
label: user.role.name,
});
}
if (user.modules?.length) {
user.modules.forEach(m => moduleOptions.push({ value: m.id, label: m.name }));
user.modules.forEach((m) =>
moduleOptions.push({ value: m.id, label: m.name }),
);
}
}
@ -406,10 +446,13 @@ export const EditUserModal = ({
} else if (!data.supplier_id) {
data.supplier_id = null;
}
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 })),
row.module_ids.map((module_id) => ({
role_id: row.role_id,
module_id,
})),
);
await onSubmit(userId, { ...submitData, role_module_combinations });
} catch (error: any) {
@ -556,7 +599,9 @@ export const EditUserModal = ({
required
placeholder="Select Department"
value={departmentIdValue || ""}
onValueChange={(value) => setValue("department_id", value, { shouldValidate: true })}
onValueChange={(value) =>
setValue("department_id", value, { shouldValidate: true })
}
onLoadOptions={loadDepartments}
initialOption={initialDepartmentOption || undefined}
error={errors.department_id?.message}
@ -566,7 +611,9 @@ export const EditUserModal = ({
required
placeholder="Select Designation"
value={designationIdValue || ""}
onValueChange={(value) => setValue("designation_id", value, { shouldValidate: true })}
onValueChange={(value) =>
setValue("designation_id", value, { shouldValidate: true })
}
onLoadOptions={loadDesignations}
initialOption={initialDesignationOption || undefined}
error={errors.designation_id?.message}
@ -627,7 +674,9 @@ export const EditUserModal = ({
<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>
<label className="text-sm font-medium text-gray-700">
Role & Module Assignments
</label>
<button
type="button"
onClick={() => append({ role_id: "", module_ids: [] })}
@ -636,7 +685,7 @@ export const EditUserModal = ({
<Plus className="w-4 h-4" /> Add Assignment
</button>
</div>
{(errors.role_module_assignments as any)?.message && (
<p className="text-sm text-red-500 mb-2">
{(errors.role_module_assignments as any).message}
@ -645,41 +694,76 @@ export const EditUserModal = ({
<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`) || [];
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) => {
const opt = initialRoleOptions.find(o => o.value === val);
const opt = initialRoleOptions.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;
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));
.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
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 })}
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_assignments?.[index]?.role_id?.message}
initialOption={
roleIdValue
? {
value: roleIdValue,
label:
getRoleLabel(roleIdValue) || roleIdValue,
}
: undefined
}
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 })}
onValueChange={(value) =>
setValue(
`role_module_assignments.${index}.module_ids`,
value,
{ shouldValidate: true },
)
}
onLoadOptions={loadModules}
initialOptions={initialSelectedModules}
error={getModuleError(index)}

View File

@ -43,6 +43,7 @@ import { z } from "zod";
import { Input } from "@/components/ui/input";
import fileAttachmentService, {
type CategoriesFilterOptions,
type StorageQuota,
} from "@/services/file-attachment-service";
// ─────────────────────────────────────────────────────────────────────────────
@ -61,9 +62,9 @@ function getExt(name: string) {
return name.slice(((name.lastIndexOf(".") - 1) >>> 0) + 1).toLowerCase();
}
function isBlocked(name: string) {
return BLOCKED_EXTENSIONS.includes(getExt(name) ? `.${getExt(name)}` : "");
}
// function isBlocked(name: string) {
// return BLOCKED_EXTENSIONS.includes(getExt(name) ? `.${getExt(name)}` : "");
// }
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
@ -204,6 +205,23 @@ export const FileUploadModal = ({
// File entries with upload progress (internal state kept separate from zod files array for progress tracking)
const [fileEntries, setFileEntries] = useState<FileEntry[]>([]);
// Storage quota settings fetched dynamically from backend
const [quota, setQuota] = useState<StorageQuota | null>(null);
useEffect(() => {
if (isOpen) {
fileAttachmentService.getQuota()
.then((res) => {
if (res?.data) {
setQuota(res.data);
}
})
.catch((err) => {
console.error("Failed to load quota settings in FileUploadModal:", err);
});
}
}, [isOpen]);
// ── Auto-generate Entity ID ──
useEffect(() => {
@ -242,15 +260,38 @@ export const FileUploadModal = ({
// ── Add files ──
const addFiles = useCallback((incoming: File[]) => {
const activeBlocked = quota?.blocked_extensions || BLOCKED_EXTENSIONS;
const activeMaxSizeBytes = quota?.max_file_size_bytes || (MAX_SIZE_MB * 1024 * 1024);
const newEntries: FileEntry[] = incoming
.slice(0, MAX_FILES)
.map((file) => ({
file,
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
status: isBlocked(file.name) ? "blocked" : "idle",
progress: 0,
error: isBlocked(file.name) ? "Blocked file type" : undefined,
}));
.map((file) => {
const ext = getExt(file.name);
const fileExt = ext ? `.${ext}` : "";
const isBlockedType = activeBlocked.some(
(blockedExt) => blockedExt.toLowerCase() === fileExt.toLowerCase()
);
const isTooLarge = file.size > activeMaxSizeBytes;
let status: FileStatus = "idle";
let error: string | undefined;
if (isBlockedType) {
status = "blocked";
error = "Blocked file type";
} else if (isTooLarge) {
status = "blocked";
error = `Exceeds limit of ${formatBytes(activeMaxSizeBytes)}`;
}
return {
file,
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
status,
progress: 0,
error,
};
});
setFileEntries((prev) => {
const combined = [...prev, ...newEntries];
@ -259,7 +300,7 @@ export const FileUploadModal = ({
setValue("files", limited.map(e => e.file), { shouldValidate: true });
return limited;
});
}, [setValue]);
}, [setValue, quota]);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
@ -433,7 +474,9 @@ export const FileUploadModal = ({
<p className="text-xs text-[#9aa6b2] mt-0.5">
Attach supporting source files via File Attachment Service
</p>
<p className="text-xs text-[#9aa6b2]">PDF, DOCX, XLSX up to {MAX_SIZE_MB}MB</p>
<p className="text-xs text-[#9aa6b2]">
Allowed formats up to {quota ? formatBytes(quota.max_file_size_bytes) : `${MAX_SIZE_MB}MB`}
</p>
</div>
</div>
) : (

View File

@ -28,8 +28,8 @@ const assignmentSchema = z.object({
const newUserSchema = z
.object({
email: z.email({ message: "Please enter a valid email address" }),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
first_name: z.string().min(1, "First name is required").max(255, "Maximum 255 characters allowed"),
last_name: z.string().min(1, "Last name is required").max(255, "Maximum 255 characters 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")

View File

@ -145,7 +145,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<Button
variant="ghost"
size="sm"
className="text-[15px] font-bold gap-1 h-7"
className="text-[15px] font-bold gap-1 h-7 cursor-pointer"
style={{ color: primaryColor }}
onClick={() => navigate(auditLogPath)}
>
@ -214,7 +214,7 @@ export const RecentActivity = ({ variant }: RecentActivityProps) => {
<Button
variant="outline"
size="sm"
className="h-8 text-[11px] gap-1.5 border-[rgba(0,0,0,0.08)] hover:bg-gray-50"
className="h-8 text-[11px] gap-1.5 border-[rgba(0,0,0,0.08)] hover:bg-gray-50 cursor-pointer"
onClick={() => navigate(auditLogPath)}
>
View All <ArrowRight className="w-3 h-3" />

View File

@ -59,33 +59,38 @@ const tenantDetailsSchema = z.object({
});
// Step 2: Contact Details Schema - user creation + organization address
const contactDetailsSchema = z
.object({
email: z.email({ message: "Please enter a valid email address" }),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
contact_phone: z
.string()
.optional()
.nullable()
.refine(
(val) => {
if (!val || val.trim() === "") return true; // Optional field, empty is valid
return /^\d{10}$/.test(val);
},
{
message: "Phone number must be exactly 10 digits",
},
),
address_line1: z.string().min(1, "Address is required"),
address_line2: z.string().optional().nullable(),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
postal_code: z
.string()
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
country: z.string().min(1, "Country is required"),
});
const contactDetailsSchema = z.object({
email: z.email({ message: "Please enter a valid email address" }),
first_name: z
.string()
.min(1, "First name is required")
.max(255, "Maximum 255 characters allowed"),
last_name: z
.string()
.min(1, "Last name is required")
.max(255, "Maximum 255 characters allowed"),
contact_phone: z
.string()
.optional()
.nullable()
.refine(
(val) => {
if (!val || val.trim() === "") return true; // Optional field, empty is valid
return /^\d{10}$/.test(val);
},
{
message: "Phone number must be exactly 10 digits",
},
),
address_line1: z.string().min(1, "Address is required"),
address_line2: z.string().optional().nullable(),
city: z.string().min(1, "City is required"),
state: z.string().min(1, "State is required"),
postal_code: z
.string()
.regex(/^[1-9]\d{5}$/, "Postal code must be a valid 6-digit PIN code"),
country: z.string().min(1, "Country is required"),
});
// Step 3: Settings Schema
const settingsSchema = z.object({

View File

@ -68,8 +68,14 @@ const tenantDetailsSchema = z.object({
const contactDetailsSchema = z.object({
id: z.string().uuid().optional().nullable(),
email: z.string().email({ message: "Please enter a valid email address" }),
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
first_name: z
.string()
.min(1, "First name is required")
.max(255, "Maximum 255 characters allowed"),
last_name: z
.string()
.min(1, "Last name is required")
.max(255, "Maximum 255 characters allowed"),
contact_phone: z
.string()
.optional()

View File

@ -149,6 +149,8 @@ const StorageDashboard = (): ReactElement => {
fileAttachmentService.getStorageStats(),
fileAttachmentService.getQuota(),
]);
console.log(quotaRes.data);
setStats(statsRes.data);
setQuota(quotaRes.data);
} catch (err: any) {