feat: Implement comprehensive supplier management and enhance user modals to support supplier users.
This commit is contained in:
parent
8b9ac59c3f
commit
d242bbf708
@ -80,6 +80,12 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
path: "/tenant/workflow-definitions",
|
path: "/tenant/workflow-definitions",
|
||||||
requiredPermission: { resource: "workflow" },
|
requiredPermission: { resource: "workflow" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
label: "Suppliers",
|
||||||
|
path: "/tenant/suppliers",
|
||||||
|
requiredPermission: { resource: "supplier" },
|
||||||
|
},
|
||||||
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ import { roleService } from "@/services/role-service";
|
|||||||
import { departmentService } from "@/services/department-service";
|
import { departmentService } from "@/services/department-service";
|
||||||
import { designationService } from "@/services/designation-service";
|
import { designationService } from "@/services/designation-service";
|
||||||
import { moduleService } from "@/services/module-service";
|
import { moduleService } from "@/services/module-service";
|
||||||
|
import { supplierService } from "@/services/supplier-service";
|
||||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||||
import type { User } from "@/types/user";
|
import type { User } from "@/types/user";
|
||||||
|
|
||||||
@ -33,6 +34,8 @@ const editUserSchema = z.object({
|
|||||||
department_id: z.string().optional(),
|
department_id: z.string().optional(),
|
||||||
designation_id: z.string().optional(),
|
designation_id: z.string().optional(),
|
||||||
module_ids: z.array(z.string()).optional(),
|
module_ids: z.array(z.string()).optional(),
|
||||||
|
category: z.enum(["tenant_user", "supplier_user"]).default("tenant_user"),
|
||||||
|
supplier_id: z.string().optional().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type EditUserFormData = z.infer<typeof editUserSchema>;
|
type EditUserFormData = z.infer<typeof editUserSchema>;
|
||||||
@ -76,7 +79,8 @@ export const EditUserModal = ({
|
|||||||
clearErrors,
|
clearErrors,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<EditUserFormData>({
|
} = useForm<EditUserFormData>({
|
||||||
resolver: zodResolver(editUserSchema),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
resolver: zodResolver(editUserSchema) as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusValue = watch("status");
|
const statusValue = watch("status");
|
||||||
@ -84,6 +88,8 @@ export const EditUserModal = ({
|
|||||||
const departmentIdValue = watch("department_id");
|
const departmentIdValue = watch("department_id");
|
||||||
const designationIdValue = watch("designation_id");
|
const designationIdValue = watch("designation_id");
|
||||||
const moduleIdsValue = watch("module_ids");
|
const moduleIdsValue = watch("module_ids");
|
||||||
|
const categoryValue = watch("category");
|
||||||
|
const supplierIdValue = watch("supplier_id");
|
||||||
|
|
||||||
const rolesFromAuth = useAppSelector((state) => state.auth.roles);
|
const rolesFromAuth = useAppSelector((state) => state.auth.roles);
|
||||||
const isSuperAdmin = rolesFromAuth.includes("super_admin");
|
const isSuperAdmin = rolesFromAuth.includes("super_admin");
|
||||||
@ -103,6 +109,10 @@ export const EditUserModal = ({
|
|||||||
const [initialModuleOptions, setInitialModuleOptions] = useState<
|
const [initialModuleOptions, setInitialModuleOptions] = useState<
|
||||||
{ value: string; label: string }[]
|
{ value: string; label: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const [initialSupplierOption, setInitialSupplierOption] = useState<{
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Load roles for dropdown - ensure selected role is included
|
// Load roles for dropdown - ensure selected role is included
|
||||||
const loadRoles = async (page: number, limit: number) => {
|
const loadRoles = async (page: number, limit: number) => {
|
||||||
@ -168,6 +178,28 @@ export const EditUserModal = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Load user data when modal opens
|
// Load user data when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && userId) {
|
if (isOpen && userId) {
|
||||||
@ -224,8 +256,35 @@ export const EditUserModal = ({
|
|||||||
department_id: departmentId,
|
department_id: departmentId,
|
||||||
designation_id: designationId,
|
designation_id: designationId,
|
||||||
module_ids: user.modules?.map((m) => m.id) || [],
|
module_ids: user.modules?.map((m) => m.id) || [],
|
||||||
|
category:
|
||||||
|
(user.category as "tenant_user" | "supplier_user") ||
|
||||||
|
"tenant_user",
|
||||||
|
supplier_id: user.supplier_id || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set initial supplier option if user is a supplier_user
|
||||||
|
if (user.supplier_id && user.category === "supplier_user") {
|
||||||
|
// Fetch supplier name to display in the dropdown
|
||||||
|
try {
|
||||||
|
const supplierResp = await supplierService.getById(
|
||||||
|
user.supplier_id,
|
||||||
|
defaultTenantId,
|
||||||
|
);
|
||||||
|
if (supplierResp.data) {
|
||||||
|
setInitialSupplierOption({
|
||||||
|
value: supplierResp.data.id,
|
||||||
|
label: supplierResp.data.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If fetch fails, still set a minimal option so the value shows
|
||||||
|
setInitialSupplierOption({
|
||||||
|
value: user.supplier_id,
|
||||||
|
label: user.supplier_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (user.modules) {
|
if (user.modules) {
|
||||||
setInitialModuleOptions(
|
setInitialModuleOptions(
|
||||||
user.modules.map((m) => ({ value: m.id, label: m.name })),
|
user.modules.map((m) => ({ value: m.id, label: m.name })),
|
||||||
@ -251,6 +310,7 @@ export const EditUserModal = ({
|
|||||||
setInitialRoleOptions([]);
|
setInitialRoleOptions([]);
|
||||||
setInitialDepartmentOption(null);
|
setInitialDepartmentOption(null);
|
||||||
setInitialDesignationOption(null);
|
setInitialDesignationOption(null);
|
||||||
|
setInitialSupplierOption(null);
|
||||||
reset({
|
reset({
|
||||||
email: "",
|
email: "",
|
||||||
first_name: "",
|
first_name: "",
|
||||||
@ -261,6 +321,8 @@ export const EditUserModal = ({
|
|||||||
department_id: "",
|
department_id: "",
|
||||||
designation_id: "",
|
designation_id: "",
|
||||||
module_ids: [],
|
module_ids: [],
|
||||||
|
category: "tenant_user",
|
||||||
|
supplier_id: null,
|
||||||
});
|
});
|
||||||
setInitialModuleOptions([]);
|
setInitialModuleOptions([]);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
@ -340,7 +402,7 @@ export const EditUserModal = ({
|
|||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit(handleFormSubmit)}
|
onClick={handleSubmit(handleFormSubmit as any)}
|
||||||
disabled={isLoading || isLoadingUser}
|
disabled={isLoading || isLoadingUser}
|
||||||
size="default"
|
size="default"
|
||||||
className="px-4 py-2.5 text-sm"
|
className="px-4 py-2.5 text-sm"
|
||||||
@ -350,7 +412,7 @@ export const EditUserModal = ({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
<form onSubmit={handleSubmit(handleFormSubmit as any)} className="p-5">
|
||||||
{isLoadingUser && (
|
{isLoadingUser && (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||||
@ -418,6 +480,41 @@ export const EditUserModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) =>
|
||||||
|
setValue(
|
||||||
|
"category",
|
||||||
|
value as "tenant_user" | "supplier_user",
|
||||||
|
{ shouldValidate: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
initialOption={initialSupplierOption || undefined}
|
||||||
|
error={errors.supplier_id?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||||
<MultiselectPaginatedSelect
|
<MultiselectPaginatedSelect
|
||||||
label="Assign Role"
|
label="Assign Role"
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { roleService } from "@/services/role-service";
|
|||||||
import { departmentService } from "@/services/department-service";
|
import { departmentService } from "@/services/department-service";
|
||||||
import { designationService } from "@/services/designation-service";
|
import { designationService } from "@/services/designation-service";
|
||||||
import { moduleService } from "@/services/module-service";
|
import { moduleService } from "@/services/module-service";
|
||||||
|
import { supplierService } from "@/services/supplier-service";
|
||||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||||
|
|
||||||
// Validation schema
|
// Validation schema
|
||||||
@ -39,6 +40,8 @@ const newUserSchema = z
|
|||||||
department_id: z.string().optional(),
|
department_id: z.string().optional(),
|
||||||
designation_id: z.string().optional(),
|
designation_id: z.string().optional(),
|
||||||
module_ids: z.array(z.string()).optional(),
|
module_ids: z.array(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, {
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
message: "Passwords don't match",
|
message: "Passwords don't match",
|
||||||
@ -78,12 +81,15 @@ export const NewUserModal = ({
|
|||||||
clearErrors,
|
clearErrors,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<NewUserFormData>({
|
} = useForm<NewUserFormData>({
|
||||||
resolver: zodResolver(newUserSchema),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
resolver: zodResolver(newUserSchema) as any,
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
role_ids: [],
|
role_ids: [],
|
||||||
department_id: "",
|
department_id: "",
|
||||||
designation_id: "",
|
designation_id: "",
|
||||||
module_ids: [],
|
module_ids: [],
|
||||||
|
category: "tenant_user" as const,
|
||||||
|
supplier_id: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -92,6 +98,8 @@ export const NewUserModal = ({
|
|||||||
const departmentIdValue = watch("department_id");
|
const departmentIdValue = watch("department_id");
|
||||||
const designationIdValue = watch("designation_id");
|
const designationIdValue = watch("designation_id");
|
||||||
const moduleIdsValue = watch("module_ids");
|
const moduleIdsValue = watch("module_ids");
|
||||||
|
const categoryValue = watch("category");
|
||||||
|
const supplierIdValue = watch("supplier_id");
|
||||||
|
|
||||||
const roles = useAppSelector((state) => state.auth.roles);
|
const roles = useAppSelector((state) => state.auth.roles);
|
||||||
const isSuperAdmin = roles.includes("super_admin");
|
const isSuperAdmin = roles.includes("super_admin");
|
||||||
@ -111,6 +119,8 @@ export const NewUserModal = ({
|
|||||||
department_id: "",
|
department_id: "",
|
||||||
designation_id: "",
|
designation_id: "",
|
||||||
module_ids: [],
|
module_ids: [],
|
||||||
|
category: "tenant_user",
|
||||||
|
supplier_id: null,
|
||||||
});
|
});
|
||||||
clearErrors();
|
clearErrors();
|
||||||
}
|
}
|
||||||
@ -180,6 +190,28 @@ export const NewUserModal = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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> => {
|
const handleFormSubmit = async (data: NewUserFormData): Promise<void> => {
|
||||||
clearErrors();
|
clearErrors();
|
||||||
try {
|
try {
|
||||||
@ -255,7 +287,7 @@ export const NewUserModal = ({
|
|||||||
</SecondaryButton>
|
</SecondaryButton>
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit(handleFormSubmit)}
|
onClick={handleSubmit(handleFormSubmit as any)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
size="default"
|
size="default"
|
||||||
className="px-4 py-2.5 text-sm"
|
className="px-4 py-2.5 text-sm"
|
||||||
@ -265,7 +297,7 @@ export const NewUserModal = ({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-5">
|
<form onSubmit={handleSubmit(handleFormSubmit as any)} className="p-5">
|
||||||
{/* General Error Display */}
|
{/* General Error Display */}
|
||||||
{errors.root && (
|
{errors.root && (
|
||||||
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md mb-4">
|
||||||
@ -344,6 +376,38 @@ export const NewUserModal = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) =>
|
||||||
|
setValue("category", value as "tenant_user" | "supplier_user", {
|
||||||
|
shouldValidate: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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>
|
||||||
|
|
||||||
{/* 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">
|
||||||
<MultiselectPaginatedSelect
|
<MultiselectPaginatedSelect
|
||||||
|
|||||||
346
src/components/shared/SupplierModal.tsx
Normal file
346
src/components/shared/SupplierModal.tsx
Normal file
@ -0,0 +1,346 @@
|
|||||||
|
import { useState, useEffect, type ReactElement } from "react";
|
||||||
|
import { useForm, Controller } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import * as z from "zod";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
FormField,
|
||||||
|
FormSelect,
|
||||||
|
PrimaryButton,
|
||||||
|
SecondaryButton,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { supplierService } from "@/services/supplier-service";
|
||||||
|
import type { CreateSupplierData } from "@/types/supplier";
|
||||||
|
import { showToast } from "@/utils/toast";
|
||||||
|
|
||||||
|
const supplierSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
|
code: z.string().optional(),
|
||||||
|
supplier_type: z.string().min(1, "Supplier type is required"),
|
||||||
|
category: z.string().min(1, "Category is required"),
|
||||||
|
status: z.string().optional(),
|
||||||
|
risk_level: z.string().optional(),
|
||||||
|
website: z.string().url("Invalid URL").optional().or(z.literal("")),
|
||||||
|
description: z.string().optional(),
|
||||||
|
address: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
state: z.string().optional(),
|
||||||
|
country: z.string().optional(),
|
||||||
|
zip_code: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type SupplierFormData = z.infer<typeof supplierSchema>;
|
||||||
|
|
||||||
|
interface SupplierModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
mode: "create" | "edit" | "view";
|
||||||
|
supplierId?: string | null;
|
||||||
|
tenantId?: string | null;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SupplierModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
mode,
|
||||||
|
supplierId,
|
||||||
|
tenantId,
|
||||||
|
onSuccess,
|
||||||
|
}: SupplierModalProps): ReactElement => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [metadata, setMetadata] = useState<{
|
||||||
|
types: { code: string; name: string }[];
|
||||||
|
categories: { code: string; name: string }[];
|
||||||
|
statuses: { code: string; name: string }[];
|
||||||
|
}>({
|
||||||
|
types: [],
|
||||||
|
categories: [],
|
||||||
|
statuses: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<SupplierFormData>({
|
||||||
|
resolver: zodResolver(supplierSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
supplier_type: "",
|
||||||
|
category: "",
|
||||||
|
status: "pending",
|
||||||
|
risk_level: "low",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMetadata = async () => {
|
||||||
|
try {
|
||||||
|
const [typesRes, catsRes, statsRes] = await Promise.all([
|
||||||
|
supplierService.getTypes(),
|
||||||
|
supplierService.getCategories(),
|
||||||
|
supplierService.getStatuses(),
|
||||||
|
]);
|
||||||
|
setMetadata({
|
||||||
|
types: typesRes.data,
|
||||||
|
categories: catsRes.data,
|
||||||
|
statuses: statsRes.data,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch metadata", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
fetchMetadata();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSupplier = async () => {
|
||||||
|
if (mode !== "create" && supplierId && isOpen) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await supplierService.getById(supplierId, tenantId);
|
||||||
|
if (response.success) {
|
||||||
|
reset(response.data as any);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast.error("Failed to load supplier", error?.message);
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} else if (mode === "create" && isOpen) {
|
||||||
|
reset({
|
||||||
|
name: "",
|
||||||
|
supplier_type: "",
|
||||||
|
category: "",
|
||||||
|
status: "pending",
|
||||||
|
risk_level: "low",
|
||||||
|
code: "",
|
||||||
|
website: "",
|
||||||
|
description: "",
|
||||||
|
address: "",
|
||||||
|
city: "",
|
||||||
|
state: "",
|
||||||
|
country: "",
|
||||||
|
zip_code: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSupplier();
|
||||||
|
}, [mode, supplierId, isOpen, reset, tenantId]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: SupplierFormData) => {
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
if (mode === "create") {
|
||||||
|
await supplierService.create(data as CreateSupplierData, tenantId);
|
||||||
|
showToast.success("Supplier created successfully");
|
||||||
|
} else if (mode === "edit" && supplierId) {
|
||||||
|
await supplierService.update(supplierId, data, tenantId);
|
||||||
|
showToast.success("Supplier updated successfully");
|
||||||
|
}
|
||||||
|
onSuccess();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
showToast.error(
|
||||||
|
mode === "create"
|
||||||
|
? "Failed to create supplier"
|
||||||
|
: "Failed to update supplier",
|
||||||
|
error?.message,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={mode === "create" ? "Add New Supplier" : "Edit Supplier"}
|
||||||
|
maxWidth="lg"
|
||||||
|
footer={
|
||||||
|
<div className="p-3 flex justify-end gap-3">
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting || isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit(onSubmit)}
|
||||||
|
disabled={isSubmitting || isLoading}
|
||||||
|
>
|
||||||
|
{mode === "create" ? "Create Supplier" : "Save Changes"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 p-5">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-10 text-center text-[#6b7280]">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
label="Supplier Name"
|
||||||
|
placeholder="Enter supplier name"
|
||||||
|
required
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("name")}
|
||||||
|
error={errors.name?.message}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Supplier Code"
|
||||||
|
placeholder="e.g. SUP-001 (Optional)"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("code")}
|
||||||
|
error={errors.code?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="supplier_type"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Supplier Type"
|
||||||
|
required
|
||||||
|
options={metadata.types.map((t) => ({
|
||||||
|
value: t.code,
|
||||||
|
label: t.name,
|
||||||
|
}))}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
error={errors.supplier_type?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="category"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Category (Criticaility)"
|
||||||
|
required
|
||||||
|
options={metadata.categories.map((c) => ({
|
||||||
|
value: c.code,
|
||||||
|
label: c.name,
|
||||||
|
}))}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
error={errors.category?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Controller
|
||||||
|
name="status"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Status"
|
||||||
|
options={metadata.statuses.map((s) => ({
|
||||||
|
value: s.code,
|
||||||
|
label: s.name,
|
||||||
|
}))}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
error={errors.status?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="risk_level"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormSelect
|
||||||
|
label="Risk Level"
|
||||||
|
options={[
|
||||||
|
{ value: "low", label: "Low" },
|
||||||
|
{ value: "medium", label: "Medium" },
|
||||||
|
{ value: "high", label: "High" },
|
||||||
|
]}
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
error={errors.risk_level?.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="Website"
|
||||||
|
placeholder="https://example.com"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("website")}
|
||||||
|
error={errors.website?.message}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<FormField
|
||||||
|
label="Description"
|
||||||
|
placeholder="Brief description of products/services..."
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("description")}
|
||||||
|
error={errors.description?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-[rgba(0,0,0,0.08)] pt-4 mt-4">
|
||||||
|
<h4 className="text-sm font-medium mb-4">Contact Information</h4>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<FormField
|
||||||
|
label="Address"
|
||||||
|
placeholder="Street address"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("address")}
|
||||||
|
error={errors.address?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<FormField
|
||||||
|
label="City"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("city")}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="State/Province"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("state")}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Country"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("country")}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Zip/Postal Code"
|
||||||
|
disabled={mode === "view" || isSubmitting}
|
||||||
|
{...register("zip_code")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
330
src/components/shared/SuppliersTable.tsx
Normal file
330
src/components/shared/SuppliersTable.tsx
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import { useState, useEffect, type ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
PrimaryButton,
|
||||||
|
StatusBadge,
|
||||||
|
ActionDropdown,
|
||||||
|
DataTable,
|
||||||
|
Pagination,
|
||||||
|
FilterDropdown,
|
||||||
|
type Column,
|
||||||
|
SupplierModal,
|
||||||
|
ViewSupplierModal,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { Plus, Building2 } from "lucide-react";
|
||||||
|
import { supplierService } from "@/services/supplier-service";
|
||||||
|
import type { Supplier } from "@/types/supplier";
|
||||||
|
import { formatDate } from "@/utils/format-date";
|
||||||
|
|
||||||
|
interface SuppliersTableProps {
|
||||||
|
tenantId?: string | null;
|
||||||
|
showHeader?: boolean;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusVariant = (
|
||||||
|
status: string,
|
||||||
|
): "success" | "failure" | "process" | "info" => {
|
||||||
|
switch (status?.toLowerCase()) {
|
||||||
|
case "approved":
|
||||||
|
case "qualified":
|
||||||
|
return "success";
|
||||||
|
case "pending":
|
||||||
|
case "conditional":
|
||||||
|
return "process";
|
||||||
|
case "suspended":
|
||||||
|
case "disqualified":
|
||||||
|
return "failure";
|
||||||
|
default:
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
switch (category?.toLowerCase()) {
|
||||||
|
case "critical":
|
||||||
|
return "bg-red-100 text-red-700";
|
||||||
|
case "major":
|
||||||
|
return "bg-orange-100 text-orange-700";
|
||||||
|
case "minor":
|
||||||
|
return "bg-blue-100 text-blue-700";
|
||||||
|
default:
|
||||||
|
return "bg-gray-100 text-gray-700";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SuppliersTable = ({
|
||||||
|
tenantId,
|
||||||
|
showHeader = true,
|
||||||
|
compact = false,
|
||||||
|
}: SuppliersTableProps): ReactElement => {
|
||||||
|
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||||
|
const [limit, setLimit] = useState<number>(compact ? 5 : 10);
|
||||||
|
const [total, setTotal] = useState<number>(0);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<"create" | "edit" | "view">(
|
||||||
|
"create",
|
||||||
|
);
|
||||||
|
const [selectedSupplierId, setSelectedSupplierId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [viewModalOpen, setViewModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const fetchSuppliers = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await supplierService.list({
|
||||||
|
tenantId,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
limit,
|
||||||
|
offset: (currentPage - 1) * limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
setSuppliers(response.data);
|
||||||
|
setTotal(response.pagination.total);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load suppliers");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(
|
||||||
|
err?.response?.data?.error?.message || "Failed to load suppliers",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSuppliers();
|
||||||
|
}, [tenantId, currentPage, limit, statusFilter, searchQuery]);
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setModalMode("create");
|
||||||
|
setSelectedSupplierId(null);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleView = (id: string) => {
|
||||||
|
setSelectedSupplierId(id);
|
||||||
|
setViewModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (id: string) => {
|
||||||
|
setModalMode("edit");
|
||||||
|
setSelectedSupplierId(id);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: Column<Supplier>[] = [
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
label: "Supplier",
|
||||||
|
render: (supplier) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
||||||
|
<Building2 className="w-4 h-4 text-[#9aa6b2]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-sm font-medium text-[#0f1724]">
|
||||||
|
{supplier.name}
|
||||||
|
</span>
|
||||||
|
{supplier.code && (
|
||||||
|
<span className="text-[10px] text-[#6b7280] font-mono">
|
||||||
|
{supplier.code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "supplier_type",
|
||||||
|
label: "Type",
|
||||||
|
render: (supplier) => (
|
||||||
|
<span className="text-sm text-[#4b5563] capitalize">
|
||||||
|
{supplier.supplier_type.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "category",
|
||||||
|
label: "Category",
|
||||||
|
render: (supplier) => (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] font-medium capitalize ${getCategoryColor(supplier.category)}`}
|
||||||
|
>
|
||||||
|
{supplier.category}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "Status",
|
||||||
|
render: (supplier) => (
|
||||||
|
<StatusBadge variant={getStatusVariant(supplier.status)}>
|
||||||
|
{supplier.status}
|
||||||
|
</StatusBadge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "location",
|
||||||
|
label: "Location",
|
||||||
|
render: (supplier) => (
|
||||||
|
<span className="text-sm text-[#6b7280]">
|
||||||
|
{supplier.city
|
||||||
|
? `${supplier.city}, ${supplier.country}`
|
||||||
|
: supplier.country || "-"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "created_at",
|
||||||
|
label: "Dated",
|
||||||
|
render: (supplier) => (
|
||||||
|
<span className="text-sm text-[#6b7280]">
|
||||||
|
{formatDate(supplier.created_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "Actions",
|
||||||
|
align: "right",
|
||||||
|
render: (supplier) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ActionDropdown
|
||||||
|
onView={() => handleView(supplier.id)}
|
||||||
|
onEdit={() => handleEdit(supplier.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const mobileCardRenderer = (supplier: Supplier) => (
|
||||||
|
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center">
|
||||||
|
<Building2 className="w-5 h-5 text-[#9aa6b2]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-[#0f1724]">
|
||||||
|
{supplier.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-[#6b7280]">{supplier.supplier_type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionDropdown
|
||||||
|
onView={() => handleView(supplier.id)}
|
||||||
|
onEdit={() => handleEdit(supplier.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<StatusBadge variant={getStatusVariant(supplier.status)}>
|
||||||
|
{supplier.status}
|
||||||
|
</StatusBadge>
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded text-[10px] font-medium capitalize ${getCategoryColor(supplier.category)}`}
|
||||||
|
>
|
||||||
|
{supplier.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{showHeader && (
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 px-4 py-3 bg-white border-b border-[rgba(0,0,0,0.08)]">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<FilterDropdown
|
||||||
|
label="Status"
|
||||||
|
options={[
|
||||||
|
{ value: "", label: "All Status" },
|
||||||
|
{ value: "approved", label: "Approved" },
|
||||||
|
{ value: "qualified", label: "Qualified" },
|
||||||
|
{ value: "pending", label: "Pending" },
|
||||||
|
{ value: "suspended", label: "Suspended" },
|
||||||
|
{ value: "disqualified", label: "Disqualified" },
|
||||||
|
]}
|
||||||
|
value={statusFilter || ""}
|
||||||
|
onChange={(val) => {
|
||||||
|
setStatusFilter(val as string);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search suppliers..."
|
||||||
|
className="pl-3 pr-10 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-xs focus:outline-none focus:ring-1 focus:ring-[#112868] w-full sm:w-64"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span className="text-xs">New Supplier</span>
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={suppliers}
|
||||||
|
keyExtractor={(s) => s.id}
|
||||||
|
isLoading={isLoading}
|
||||||
|
error={error}
|
||||||
|
mobileCardRenderer={mobileCardRenderer}
|
||||||
|
emptyMessage="No suppliers found"
|
||||||
|
/>
|
||||||
|
{total > 0 && (
|
||||||
|
<div className="p-4 border-t border-[rgba(0,0,0,0.08)]">
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={Math.ceil(total / limit)}
|
||||||
|
totalItems={total}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
onLimitChange={setLimit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SupplierModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
mode={modalMode}
|
||||||
|
supplierId={selectedSupplierId}
|
||||||
|
tenantId={tenantId}
|
||||||
|
onSuccess={fetchSuppliers}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ViewSupplierModal
|
||||||
|
isOpen={viewModalOpen}
|
||||||
|
onClose={() => setViewModalOpen(false)}
|
||||||
|
supplierId={selectedSupplierId}
|
||||||
|
tenantId={tenantId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
394
src/components/shared/ViewSupplierModal.tsx
Normal file
394
src/components/shared/ViewSupplierModal.tsx
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import {
|
||||||
|
Loader2,
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
Building2,
|
||||||
|
Tag,
|
||||||
|
ShieldCheck,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Modal, SecondaryButton, StatusBadge } from "@/components/shared";
|
||||||
|
import { supplierService } from "@/services/supplier-service";
|
||||||
|
import type { Supplier } from "@/types/supplier";
|
||||||
|
|
||||||
|
// Helper function to get status badge variant
|
||||||
|
const getStatusVariant = (
|
||||||
|
status: string,
|
||||||
|
): "success" | "failure" | "process" | "info" => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "approved":
|
||||||
|
case "active":
|
||||||
|
return "success";
|
||||||
|
case "rejected":
|
||||||
|
case "inactive":
|
||||||
|
return "failure";
|
||||||
|
case "pending":
|
||||||
|
return "process";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to format date
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ViewSupplierModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
supplierId: string | null;
|
||||||
|
tenantId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRiskBadge = (level?: string) => {
|
||||||
|
const l = level?.toLowerCase();
|
||||||
|
if (l === "high")
|
||||||
|
return (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-red-100 text-red-700 border border-red-200">
|
||||||
|
HIGH RISK
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (l === "medium")
|
||||||
|
return (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-100 text-amber-700 border border-amber-200">
|
||||||
|
MEDIUM RISK
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-100 text-emerald-700 border border-emerald-200">
|
||||||
|
LOW RISK
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ViewSupplierModal = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
supplierId,
|
||||||
|
tenantId,
|
||||||
|
}: ViewSupplierModalProps): ReactElement | null => {
|
||||||
|
const [supplier, setSupplier] = useState<Supplier | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load supplier data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && supplierId) {
|
||||||
|
const loadSupplier = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await supplierService.getById(supplierId, tenantId);
|
||||||
|
if (response.success) {
|
||||||
|
setSupplier(response.data);
|
||||||
|
} else {
|
||||||
|
setError("Failed to load supplier details");
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(
|
||||||
|
err?.response?.data?.error?.message ||
|
||||||
|
"Failed to load supplier details",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSupplier();
|
||||||
|
} else {
|
||||||
|
setSupplier(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [isOpen, supplierId, tenantId]);
|
||||||
|
|
||||||
|
const InfoRow = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string | ReactElement | null;
|
||||||
|
icon?: any;
|
||||||
|
}) => (
|
||||||
|
<div className="flex flex-col gap-1.5 p-3 rounded-xl hover:bg-gray-50 transition-all duration-300">
|
||||||
|
<label className="text-[10px] font-bold text-[#94a3b8] uppercase tracking-widest flex items-center gap-2">
|
||||||
|
{Icon && <Icon className="w-3.5 h-3.5 text-[#64748b]" />}
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="text-sm text-[#334155] font-semibold break-words">
|
||||||
|
{value || (
|
||||||
|
<span className="text-[#cbd5e1] font-normal italic lowercase">
|
||||||
|
not specified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Supplier Profile"
|
||||||
|
maxWidth="lg"
|
||||||
|
footer={
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-8 py-2.5 rounded-lg border-[rgba(0,0,0,0.1)] hover:bg-gray-50 font-semibold"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</SecondaryButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="p-0 overflow-hidden">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||||
|
<Loader2 className="w-10 h-10 text-[#112868] animate-spin opacity-25" />
|
||||||
|
<span className="text-xs font-bold text-[#94a3b8] tracking-widest uppercase animate-pulse">
|
||||||
|
Retrieving Profile...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="p-5 bg-red-50 border border-red-100 rounded-2xl flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center text-red-600 shrink-0">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-black text-red-800 uppercase tracking-tight">
|
||||||
|
Access Error
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-red-600/80 mt-1 font-medium leading-relaxed">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !error && supplier && (
|
||||||
|
<div className="flex flex-col animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||||
|
{/* Legend Header */}
|
||||||
|
<div className="relative px-8 py-12 bg-gradient-to-br from-[#112868] via-[#1a365d] to-[#1e3a8a] text-white">
|
||||||
|
{/* Mesh Gradient Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(35,220,225,0.15),transparent)] pointer-events-none" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-px bg-white/10" />
|
||||||
|
|
||||||
|
<div className="relative flex flex-col md:flex-row items-center md:items-start gap-8">
|
||||||
|
{/* Visual Identity */}
|
||||||
|
<div className="relative group">
|
||||||
|
<div className="absolute -inset-1 bg-gradient-to-r from-[#23dce1] to-[#112868] rounded-2xl blur opacity-25 group-hover:opacity-40 transition duration-1000 group-hover:duration-200" />
|
||||||
|
<div className="relative w-24 h-24 rounded-2xl bg-white/10 backdrop-blur-xl border border-white/20 flex items-center justify-center shadow-2xl shrink-0 ring-8 ring-white/5">
|
||||||
|
<Building2 className="w-12 h-12 text-[#23dce1]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4 text-center md:text-left">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-4">
|
||||||
|
<h2 className="text-3xl font-black tracking-tighter uppercase">
|
||||||
|
{supplier.name}
|
||||||
|
</h2>
|
||||||
|
{getRiskBadge(supplier.risk_level)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center md:justify-start gap-2">
|
||||||
|
<span className="px-2 py-0.5 bg-white/10 rounded font-mono text-xs text-[#23dce1] border border-white/10 lowercase tracking-widest">
|
||||||
|
ref: {supplier.code || "unassigned"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-center md:justify-start gap-5">
|
||||||
|
<div className="flex items-center gap-2 text-white/80">
|
||||||
|
<Tag className="w-4 h-4 text-[#23dce1]" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest whitespace-nowrap">
|
||||||
|
{supplier.supplier_type.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-1 rounded-full bg-white/20 hidden md:block" />
|
||||||
|
<div className="flex items-center gap-2 text-white/80">
|
||||||
|
<Globe className="w-4 h-4 text-[#23dce1]" />
|
||||||
|
<span className="text-xs font-black uppercase tracking-widest whitespace-nowrap">
|
||||||
|
{supplier.category} level
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 pt-2">
|
||||||
|
<StatusBadge
|
||||||
|
variant={getStatusVariant(supplier.status)}
|
||||||
|
className="shadow-2xl !px-6 !py-2 !text-[12px] font-black border border-white/10"
|
||||||
|
>
|
||||||
|
{supplier.status}
|
||||||
|
</StatusBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core Registry Layout */}
|
||||||
|
<div className="p-8 space-y-10 bg-white">
|
||||||
|
{/* Detailed Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
|
||||||
|
{/* Information Cluster */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-blue-50 flex items-center justify-center text-[#112868]">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||||
|
Profile Data
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<InfoRow
|
||||||
|
label="Criticality Rating"
|
||||||
|
value={supplier.category}
|
||||||
|
/>
|
||||||
|
<InfoRow
|
||||||
|
label="Digital Presence"
|
||||||
|
icon={Globe}
|
||||||
|
value={
|
||||||
|
supplier.website ? (
|
||||||
|
<a
|
||||||
|
href={supplier.website}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-[#112868] hover:text-[#23dce1] hover:underline flex items-center gap-2 transition-all font-bold group"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{supplier.website.replace(/^https?:\/\//, "")}
|
||||||
|
</span>
|
||||||
|
<Globe className="w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</a>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logistics Cluster */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-cyan-50 flex items-center justify-center text-[#23dce1]">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||||
|
Regional presence
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 bg-gray-50/50 rounded-2xl border border-gray-100 flex items-start gap-4 hover:bg-gray-50 transition-colors duration-300">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-white shadow-sm flex items-center justify-center text-[#94a3b8] shrink-0">
|
||||||
|
<MapPin className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{(() => {
|
||||||
|
const addr: any = supplier.address;
|
||||||
|
const isObj = typeof addr === "object" && addr !== null;
|
||||||
|
|
||||||
|
const street = isObj
|
||||||
|
? [addr.line1, addr.line2].filter(Boolean).join(", ")
|
||||||
|
: (addr as string);
|
||||||
|
|
||||||
|
const city = isObj ? addr.city : supplier.city;
|
||||||
|
const state = isObj ? addr.state : supplier.state;
|
||||||
|
const country = isObj ? addr.country : supplier.country;
|
||||||
|
const zip = isObj
|
||||||
|
? addr.postal_code
|
||||||
|
: supplier.zip_code;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-black text-[#334155] leading-tight">
|
||||||
|
{street || "Street address not provided"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-[#64748b]">
|
||||||
|
{city && `${city}, `}
|
||||||
|
{state && `${state} `}
|
||||||
|
{zip && `[${zip}]`}
|
||||||
|
</p>
|
||||||
|
{country && (
|
||||||
|
<span className="mt-1 text-[10px] font-black text-[#475569] uppercase tracking-tighter opacity-60">
|
||||||
|
Primary HQ: {country}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Narrative Section */}
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-gray-50 flex items-center justify-center text-[#94a3b8]">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-xs font-black text-[#1e293b] uppercase tracking-[0.2em]">
|
||||||
|
Operational Overview
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="relative group p-8 bg-[#fdfdfd] rounded-3xl border border-gray-100/80 shadow-[0_8px_30px_rgb(0,0,0,0.02)]">
|
||||||
|
<div className="absolute top-0 left-0 p-4 -mt-3 -ml-3 text-4xl text-gray-100 font-serif select-none pointer-events-none">
|
||||||
|
"
|
||||||
|
</div>
|
||||||
|
<p className="relative text-[15px] font-medium text-[#475569] leading-relaxed italic pr-4">
|
||||||
|
{supplier.description ||
|
||||||
|
"Detailed operational description is currently unavailable for this record. Please contact the administrator for further details regarding this supplier's service scope."}
|
||||||
|
</p>
|
||||||
|
<div className="absolute bottom-0 right-0 p-4 -mb-3 -mr-3 text-4xl text-gray-100 font-serif rotate-180 select-none pointer-events-none">
|
||||||
|
"
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Security & Lifecycle */}
|
||||||
|
<div className="pt-2 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="flex items-center gap-4 p-5 rounded-2xl bg-white border border-gray-100 shadow-sm group hover:border-[#112868]/20 transition-all duration-300">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#112868]/5 flex items-center justify-center text-[#112868] group-hover:bg-[#112868] group-hover:text-white transition-all">
|
||||||
|
<Clock className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black text-[#94a3b8] uppercase tracking-widest">
|
||||||
|
System Ingestion
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-black text-[#1e293b] tracking-tight">
|
||||||
|
{formatDate(supplier.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 p-5 rounded-2xl bg-white border border-gray-100 shadow-sm group hover:border-[#23dce1]/20 transition-all duration-300">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-[#23dce1]/5 flex items-center justify-center text-[#23dce1] group-hover:bg-[#23dce1] group-hover:text-white transition-all">
|
||||||
|
<ShieldCheck className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[10px] font-black text-[#94a3b8] uppercase tracking-widest">
|
||||||
|
Last Compliance Check
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-black text-[#1e293b] tracking-tight">
|
||||||
|
{formatDate(supplier.updated_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -99,7 +99,10 @@ const workflowSchema = z
|
|||||||
name: z.string().min(1, "Name is required"),
|
name: z.string().min(1, "Name is required"),
|
||||||
code: z.string().min(1, "Code is required"),
|
code: z.string().min(1, "Code is required"),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
entity_type: z.string().min(1, "entity_type is required").max(100, "entity_type must be at most 100 characters"),
|
entity_type: z
|
||||||
|
.string()
|
||||||
|
.min(1, "entity_type is required")
|
||||||
|
.max(100, "entity_type must be at most 100 characters"),
|
||||||
status: z.enum(["draft", "active", "deprecated", "archived"]),
|
status: z.enum(["draft", "active", "deprecated", "archived"]),
|
||||||
source_module: z.array(z.string()).optional(),
|
source_module: z.array(z.string()).optional(),
|
||||||
source_module_id: z
|
source_module_id: z
|
||||||
@ -141,6 +144,7 @@ interface WorkflowDefinitionModalProps {
|
|||||||
definition?: WorkflowDefinition | null;
|
definition?: WorkflowDefinition | null;
|
||||||
tenantId?: string | null;
|
tenantId?: string | null;
|
||||||
onSuccess: () => void;
|
onSuccess: () => void;
|
||||||
|
initialEntityType?: string;
|
||||||
}
|
}
|
||||||
const StepAssigneeFields = ({
|
const StepAssigneeFields = ({
|
||||||
index,
|
index,
|
||||||
@ -210,6 +214,7 @@ export const WorkflowDefinitionModal = ({
|
|||||||
definition,
|
definition,
|
||||||
tenantId,
|
tenantId,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
|
initialEntityType,
|
||||||
}: WorkflowDefinitionModalProps) => {
|
}: WorkflowDefinitionModalProps) => {
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
"general" | "steps" | "transitions"
|
"general" | "steps" | "transitions"
|
||||||
@ -350,7 +355,7 @@ export const WorkflowDefinitionModal = ({
|
|||||||
name: "",
|
name: "",
|
||||||
code: "",
|
code: "",
|
||||||
description: "",
|
description: "",
|
||||||
entity_type: "",
|
entity_type: initialEntityType || "",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
source_module: [],
|
source_module: [],
|
||||||
source_module_id: [],
|
source_module_id: [],
|
||||||
@ -373,7 +378,7 @@ export const WorkflowDefinitionModal = ({
|
|||||||
}
|
}
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
}
|
}
|
||||||
}, [isOpen, definition, reset]);
|
}, [isOpen, definition, reset, initialEntityType]);
|
||||||
|
|
||||||
const onFormSubmit = async (data: WorkflowSchemaType) => {
|
const onFormSubmit = async (data: WorkflowSchemaType) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -21,12 +21,14 @@ interface WorkflowDefinitionsTableProps {
|
|||||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||||
compact?: boolean; // Compact mode for tabs
|
compact?: boolean; // Compact mode for tabs
|
||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
|
entityType?: string; // Filter by entity type
|
||||||
}
|
}
|
||||||
|
|
||||||
const WorkflowDefinitionsTable = ({
|
const WorkflowDefinitionsTable = ({
|
||||||
tenantId: tenantId,
|
tenantId: tenantId,
|
||||||
compact = false,
|
compact = false,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
|
entityType,
|
||||||
}: WorkflowDefinitionsTableProps): ReactElement => {
|
}: WorkflowDefinitionsTableProps): ReactElement => {
|
||||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||||
const effectiveTenantId = tenantId || reduxTenantId || undefined;
|
const effectiveTenantId = tenantId || reduxTenantId || undefined;
|
||||||
@ -58,6 +60,7 @@ const WorkflowDefinitionsTable = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
const response = await workflowService.listDefinitions({
|
const response = await workflowService.listDefinitions({
|
||||||
tenantId: effectiveTenantId,
|
tenantId: effectiveTenantId,
|
||||||
|
entity_type: entityType,
|
||||||
status: statusFilter || undefined,
|
status: statusFilter || undefined,
|
||||||
limit,
|
limit,
|
||||||
offset: (currentPage - 1) * limit,
|
offset: (currentPage - 1) * limit,
|
||||||
@ -200,9 +203,7 @@ const WorkflowDefinitionsTable = ({
|
|||||||
key: "entity_type",
|
key: "entity_type",
|
||||||
label: "Entity Type",
|
label: "Entity Type",
|
||||||
render: (wf) => (
|
render: (wf) => (
|
||||||
<span className="text-sm text-[#6b7280]">
|
<span className="text-sm text-[#6b7280]">{wf.entity_type}</span>
|
||||||
{wf.entity_type}
|
|
||||||
</span>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -407,6 +408,7 @@ const WorkflowDefinitionsTable = ({
|
|||||||
definition={selectedDefinition}
|
definition={selectedDefinition}
|
||||||
tenantId={effectiveTenantId}
|
tenantId={effectiveTenantId}
|
||||||
onSuccess={fetchDefinitions}
|
onSuccess={fetchDefinitions}
|
||||||
|
initialEntityType={entityType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -26,4 +26,7 @@ export { AuthenticatedImage } from './AuthenticatedImage';
|
|||||||
export * from './DepartmentModals';
|
export * from './DepartmentModals';
|
||||||
export * from './DesignationModals';
|
export * from './DesignationModals';
|
||||||
export { default as WorkflowDefinitionsTable } from './WorkflowDefinitionsTable';
|
export { default as WorkflowDefinitionsTable } from './WorkflowDefinitionsTable';
|
||||||
export { WorkflowDefinitionModal } from './WorkflowDefinitionModal';
|
export { WorkflowDefinitionModal } from './WorkflowDefinitionModal';
|
||||||
|
export { SuppliersTable } from './SuppliersTable';
|
||||||
|
export { SupplierModal } from './SupplierModal';
|
||||||
|
export { ViewSupplierModal } from './ViewSupplierModal';
|
||||||
30
src/pages/superadmin/Suppliers.tsx
Normal file
30
src/pages/superadmin/Suppliers.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { type ReactElement } from "react";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { SuppliersTable } from "@/components/shared";
|
||||||
|
|
||||||
|
const Suppliers = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Suppliers"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Supplier Management (Global)",
|
||||||
|
description: "Manage global suppliers.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white overflow-hidden p-4 md:p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-[#0f1724]">
|
||||||
|
Global Suppliers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<SuppliersTable showHeader={true} compact={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Suppliers;
|
||||||
@ -23,6 +23,7 @@ import {
|
|||||||
DataTable,
|
DataTable,
|
||||||
Pagination,
|
Pagination,
|
||||||
WorkflowDefinitionsTable,
|
WorkflowDefinitionsTable,
|
||||||
|
SuppliersTable,
|
||||||
type Column,
|
type Column,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { UsersTable, RolesTable } from "@/components/superadmin";
|
import { UsersTable, RolesTable } from "@/components/superadmin";
|
||||||
@ -45,6 +46,7 @@ type TabType =
|
|||||||
| "designations"
|
| "designations"
|
||||||
| "user-categories"
|
| "user-categories"
|
||||||
| "workflow-definitions"
|
| "workflow-definitions"
|
||||||
|
| "suppliers"
|
||||||
| "modules"
|
| "modules"
|
||||||
| "settings"
|
| "settings"
|
||||||
| "license"
|
| "license"
|
||||||
@ -75,6 +77,7 @@ const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
|
|||||||
label: "Workflow Definitions",
|
label: "Workflow Definitions",
|
||||||
icon: <GitBranch className="w-4 h-4" />,
|
icon: <GitBranch className="w-4 h-4" />,
|
||||||
},
|
},
|
||||||
|
{ id: "suppliers", label: "Suppliers", icon: <Users className="w-4 h-4" /> },
|
||||||
{ id: "modules", label: "Modules", icon: <Package className="w-4 h-4" /> },
|
{ id: "modules", label: "Modules", icon: <Package className="w-4 h-4" /> },
|
||||||
{ id: "settings", label: "Settings", icon: <Settings className="w-4 h-4" /> },
|
{ id: "settings", label: "Settings", icon: <Settings className="w-4 h-4" /> },
|
||||||
{ id: "license", label: "License", icon: <FileText className="w-4 h-4" /> },
|
{ id: "license", label: "License", icon: <FileText className="w-4 h-4" /> },
|
||||||
@ -354,6 +357,20 @@ const TenantDetails = (): ReactElement => {
|
|||||||
{activeTab === "workflow-definitions" && id && (
|
{activeTab === "workflow-definitions" && id && (
|
||||||
<WorkflowDefinitionsTable tenantId={id} compact={true} />
|
<WorkflowDefinitionsTable tenantId={id} compact={true} />
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "suppliers" && id && (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-[#0f1724]">
|
||||||
|
List of Suppliers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<SuppliersTable
|
||||||
|
tenantId={id}
|
||||||
|
showHeader={true}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{activeTab === "modules" && id && <ModulesTab tenantId={id} />}
|
{activeTab === "modules" && id && <ModulesTab tenantId={id} />}
|
||||||
{activeTab === "settings" && tenant && (
|
{activeTab === "settings" && tenant && (
|
||||||
<SettingsTab tenant={tenant} />
|
<SettingsTab tenant={tenant} />
|
||||||
|
|||||||
30
src/pages/tenant/Suppliers.tsx
Normal file
30
src/pages/tenant/Suppliers.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { type ReactElement } from "react";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
import { SuppliersTable } from "@/components/shared";
|
||||||
|
|
||||||
|
const Suppliers = (): ReactElement => {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
currentPage="Suppliers"
|
||||||
|
pageHeader={{
|
||||||
|
title: "Supplier Management",
|
||||||
|
description: "Manage suppliers and their details.",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg bg-white overflow-hidden p-4 md:p-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-[#0f1724]">
|
||||||
|
Suppliers
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<SuppliersTable showHeader={true} compact={false} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Suppliers;
|
||||||
@ -1,14 +1,17 @@
|
|||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from "react";
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
// Lazy load route components for code splitting
|
// Lazy load route components for code splitting
|
||||||
const Dashboard = lazy(() => import('@/pages/superadmin/Dashboard'));
|
const Dashboard = lazy(() => import("@/pages/superadmin/Dashboard"));
|
||||||
const Tenants = lazy(() => import('@/pages/superadmin/Tenants'));
|
const Tenants = lazy(() => import("@/pages/superadmin/Tenants"));
|
||||||
const CreateTenantWizard = lazy(() => import('@/pages/superadmin/CreateTenantWizard'));
|
const CreateTenantWizard = lazy(
|
||||||
const EditTenant = lazy(() => import('@/pages/superadmin/EditTenant'));
|
() => import("@/pages/superadmin/CreateTenantWizard"),
|
||||||
const TenantDetails = lazy(() => import('@/pages/superadmin/TenantDetails'));
|
);
|
||||||
const Modules = lazy(() => import('@/pages/superadmin/Modules'));
|
const EditTenant = lazy(() => import("@/pages/superadmin/EditTenant"));
|
||||||
const AuditLogs = lazy(() => import('@/pages/superadmin/AuditLogs'));
|
const TenantDetails = lazy(() => import("@/pages/superadmin/TenantDetails"));
|
||||||
|
const Modules = lazy(() => import("@/pages/superadmin/Modules"));
|
||||||
|
const AuditLogs = lazy(() => import("@/pages/superadmin/AuditLogs"));
|
||||||
|
const Suppliers = lazy(() => import("@/pages/superadmin/Suppliers"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -18,7 +21,11 @@ const RouteLoader = (): ReactElement => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Wrapper component with Suspense
|
// Wrapper component with Suspense
|
||||||
const LazyRoute = ({ component: Component }: { component: React.ComponentType }): ReactElement => (
|
const LazyRoute = ({
|
||||||
|
component: Component,
|
||||||
|
}: {
|
||||||
|
component: React.ComponentType;
|
||||||
|
}): ReactElement => (
|
||||||
<Suspense fallback={<RouteLoader />}>
|
<Suspense fallback={<RouteLoader />}>
|
||||||
<Component />
|
<Component />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@ -32,31 +39,35 @@ export interface RouteConfig {
|
|||||||
// Super Admin routes (requires super_admin role)
|
// Super Admin routes (requires super_admin role)
|
||||||
export const superAdminRoutes: RouteConfig[] = [
|
export const superAdminRoutes: RouteConfig[] = [
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: "/dashboard",
|
||||||
element: <LazyRoute component={Dashboard} />,
|
element: <LazyRoute component={Dashboard} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tenants',
|
path: "/tenants",
|
||||||
element: <LazyRoute component={Tenants} />,
|
element: <LazyRoute component={Tenants} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tenants/create-wizard',
|
path: "/tenants/create-wizard",
|
||||||
element: <LazyRoute component={CreateTenantWizard} />,
|
element: <LazyRoute component={CreateTenantWizard} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tenants/:id/edit',
|
path: "/tenants/:id/edit",
|
||||||
element: <LazyRoute component={EditTenant} />,
|
element: <LazyRoute component={EditTenant} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/tenants/:id',
|
path: "/tenants/:id",
|
||||||
element: <LazyRoute component={TenantDetails} />,
|
element: <LazyRoute component={TenantDetails} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/modules',
|
path: "/modules",
|
||||||
element: <LazyRoute component={Modules} />,
|
element: <LazyRoute component={Modules} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/audit-logs',
|
path: "/audit-logs",
|
||||||
element: <LazyRoute component={AuditLogs} />,
|
element: <LazyRoute component={AuditLogs} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/suppliers",
|
||||||
|
element: <LazyRoute component={Suppliers} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -13,6 +13,7 @@ const Designations = lazy(() => import("@/pages/tenant/Designations"));
|
|||||||
const WorkflowDefination = lazy(
|
const WorkflowDefination = lazy(
|
||||||
() => import("@/pages/tenant/WorkflowDefination"),
|
() => import("@/pages/tenant/WorkflowDefination"),
|
||||||
);
|
);
|
||||||
|
const Suppliers = lazy(() => import("@/pages/tenant/Suppliers"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -75,4 +76,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/workflow-definitions",
|
path: "/tenant/workflow-definitions",
|
||||||
element: <LazyRoute component={WorkflowDefination} />,
|
element: <LazyRoute component={WorkflowDefination} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/suppliers",
|
||||||
|
element: <LazyRoute component={Suppliers} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
73
src/services/supplier-service.ts
Normal file
73
src/services/supplier-service.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import apiClient from './api-client';
|
||||||
|
import type {
|
||||||
|
SuppliersResponse,
|
||||||
|
SupplierResponse,
|
||||||
|
SupplierContactsResponse,
|
||||||
|
CreateSupplierData,
|
||||||
|
UpdateSupplierData
|
||||||
|
} from '@/types/supplier';
|
||||||
|
|
||||||
|
export const supplierService = {
|
||||||
|
list: async (params: {
|
||||||
|
tenantId?: string | null;
|
||||||
|
status?: string;
|
||||||
|
category?: string;
|
||||||
|
supplier_type?: string;
|
||||||
|
risk_level?: string;
|
||||||
|
search?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}): Promise<SuppliersResponse> => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params.tenantId) queryParams.append('tenantId', params.tenantId);
|
||||||
|
if (params.status) queryParams.append('status', params.status);
|
||||||
|
if (params.category) queryParams.append('category', params.category);
|
||||||
|
if (params.supplier_type) queryParams.append('supplier_type', params.supplier_type);
|
||||||
|
if (params.risk_level) queryParams.append('risk_level', params.risk_level);
|
||||||
|
if (params.search) queryParams.append('search', params.search);
|
||||||
|
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||||
|
if (params.offset) queryParams.append('offset', params.offset.toString());
|
||||||
|
|
||||||
|
const response = await apiClient.get<SuppliersResponse>(`/suppliers?${queryParams.toString()}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getById: async (id: string, tenantId?: string | null): Promise<SupplierResponse> => {
|
||||||
|
const url = tenantId ? `/suppliers/${id}?tenantId=${tenantId}` : `/suppliers/${id}`;
|
||||||
|
const response = await apiClient.get<SupplierResponse>(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (data: CreateSupplierData, tenantId?: string | null): Promise<SupplierResponse> => {
|
||||||
|
const url = tenantId ? `/suppliers?tenantId=${tenantId}` : '/suppliers';
|
||||||
|
const response = await apiClient.post<SupplierResponse>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, data: UpdateSupplierData, tenantId?: string | null): Promise<SupplierResponse> => {
|
||||||
|
const url = tenantId ? `/suppliers/${id}?tenantId=${tenantId}` : `/suppliers/${id}`;
|
||||||
|
const response = await apiClient.put<SupplierResponse>(url, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getContacts: async (id: string, tenantId?: string | null): Promise<SupplierContactsResponse> => {
|
||||||
|
const url = tenantId ? `/suppliers/${id}/contacts?tenantId=${tenantId}` : `/suppliers/${id}/contacts`;
|
||||||
|
const response = await apiClient.get<SupplierContactsResponse>(url);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTypes: async (): Promise<{ success: boolean, data: { code: string, name: string }[] }> => {
|
||||||
|
const response = await apiClient.get('/suppliers/types');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getCategories: async (): Promise<{ success: boolean, data: { code: string, name: string, description?: string }[] }> => {
|
||||||
|
const response = await apiClient.get('/suppliers/categories');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getStatuses: async (): Promise<{ success: boolean, data: { code: string, name: string }[] }> => {
|
||||||
|
const response = await apiClient.get('/suppliers/statuses');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/types/supplier.ts
Normal file
70
src/types/supplier.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
export interface Supplier {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
supplier_type: string;
|
||||||
|
category: string;
|
||||||
|
status: string;
|
||||||
|
risk_level?: string;
|
||||||
|
website?: string;
|
||||||
|
description?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
zip_code?: string;
|
||||||
|
tenant_id: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierContact {
|
||||||
|
id: string;
|
||||||
|
supplier_id: string;
|
||||||
|
name: string;
|
||||||
|
title?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
mobile?: string;
|
||||||
|
is_primary: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SuppliersResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: Supplier[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: Supplier;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SupplierContactsResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: SupplierContact[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSupplierData {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
supplier_type: string;
|
||||||
|
category: string;
|
||||||
|
status?: string;
|
||||||
|
risk_level?: string;
|
||||||
|
website?: string;
|
||||||
|
description?: string;
|
||||||
|
address?: string;
|
||||||
|
city?: string;
|
||||||
|
state?: string;
|
||||||
|
country?: string;
|
||||||
|
zip_code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSupplierData extends Partial<CreateSupplierData> {}
|
||||||
@ -33,6 +33,8 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}[];
|
}[];
|
||||||
|
category?: 'tenant_user' | 'supplier_user';
|
||||||
|
supplier_id?: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@ -64,6 +66,8 @@ export interface CreateUserRequest {
|
|||||||
department_id?: string;
|
department_id?: string;
|
||||||
designation_id?: string;
|
designation_id?: string;
|
||||||
module_ids?: string[];
|
module_ids?: string[];
|
||||||
|
category?: 'tenant_user' | 'supplier_user';
|
||||||
|
supplier_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserResponse {
|
export interface CreateUserResponse {
|
||||||
@ -89,6 +93,8 @@ export interface UpdateUserRequest {
|
|||||||
department_id?: string;
|
department_id?: string;
|
||||||
designation_id?: string;
|
designation_id?: string;
|
||||||
module_ids?: string[];
|
module_ids?: string[];
|
||||||
|
category?: 'tenant_user' | 'supplier_user';
|
||||||
|
supplier_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserResponse {
|
export interface UpdateUserResponse {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user