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",
|
||||
requiredPermission: { resource: "workflow" },
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
label: "Suppliers",
|
||||
path: "/tenant/suppliers",
|
||||
requiredPermission: { resource: "supplier" },
|
||||
},
|
||||
{ icon: Package, label: "Modules", path: "/tenant/modules" },
|
||||
];
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ import { roleService } from "@/services/role-service";
|
||||
import { departmentService } from "@/services/department-service";
|
||||
import { designationService } from "@/services/designation-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
import type { User } from "@/types/user";
|
||||
|
||||
@ -33,6 +34,8 @@ const editUserSchema = z.object({
|
||||
department_id: z.string().optional(),
|
||||
designation_id: 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>;
|
||||
@ -76,7 +79,8 @@ export const EditUserModal = ({
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<EditUserFormData>({
|
||||
resolver: zodResolver(editUserSchema),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(editUserSchema) as any,
|
||||
});
|
||||
|
||||
const statusValue = watch("status");
|
||||
@ -84,6 +88,8 @@ export const EditUserModal = ({
|
||||
const departmentIdValue = watch("department_id");
|
||||
const designationIdValue = watch("designation_id");
|
||||
const moduleIdsValue = watch("module_ids");
|
||||
const categoryValue = watch("category");
|
||||
const supplierIdValue = watch("supplier_id");
|
||||
|
||||
const rolesFromAuth = useAppSelector((state) => state.auth.roles);
|
||||
const isSuperAdmin = rolesFromAuth.includes("super_admin");
|
||||
@ -103,6 +109,10 @@ export const EditUserModal = ({
|
||||
const [initialModuleOptions, setInitialModuleOptions] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([]);
|
||||
const [initialSupplierOption, setInitialSupplierOption] = useState<{
|
||||
value: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
// Load roles for dropdown - ensure selected role is included
|
||||
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
|
||||
useEffect(() => {
|
||||
if (isOpen && userId) {
|
||||
@ -224,8 +256,35 @@ export const EditUserModal = ({
|
||||
department_id: departmentId,
|
||||
designation_id: designationId,
|
||||
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) {
|
||||
setInitialModuleOptions(
|
||||
user.modules.map((m) => ({ value: m.id, label: m.name })),
|
||||
@ -251,6 +310,7 @@ export const EditUserModal = ({
|
||||
setInitialRoleOptions([]);
|
||||
setInitialDepartmentOption(null);
|
||||
setInitialDesignationOption(null);
|
||||
setInitialSupplierOption(null);
|
||||
reset({
|
||||
email: "",
|
||||
first_name: "",
|
||||
@ -261,6 +321,8 @@ export const EditUserModal = ({
|
||||
department_id: "",
|
||||
designation_id: "",
|
||||
module_ids: [],
|
||||
category: "tenant_user",
|
||||
supplier_id: null,
|
||||
});
|
||||
setInitialModuleOptions([]);
|
||||
setLoadError(null);
|
||||
@ -340,7 +402,7 @@ export const EditUserModal = ({
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
onClick={handleSubmit(handleFormSubmit as any)}
|
||||
disabled={isLoading || isLoadingUser}
|
||||
size="default"
|
||||
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 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
|
||||
@ -418,6 +480,41 @@ export const EditUserModal = ({
|
||||
/>
|
||||
</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">
|
||||
<MultiselectPaginatedSelect
|
||||
label="Assign Role"
|
||||
|
||||
@ -16,6 +16,7 @@ import { roleService } from "@/services/role-service";
|
||||
import { departmentService } from "@/services/department-service";
|
||||
import { designationService } from "@/services/designation-service";
|
||||
import { moduleService } from "@/services/module-service";
|
||||
import { supplierService } from "@/services/supplier-service";
|
||||
import { useAppSelector } from "@/hooks/redux-hooks";
|
||||
|
||||
// Validation schema
|
||||
@ -39,6 +40,8 @@ const newUserSchema = z
|
||||
department_id: z.string().optional(),
|
||||
designation_id: 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, {
|
||||
message: "Passwords don't match",
|
||||
@ -78,12 +81,15 @@ export const NewUserModal = ({
|
||||
clearErrors,
|
||||
formState: { errors },
|
||||
} = useForm<NewUserFormData>({
|
||||
resolver: zodResolver(newUserSchema),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
resolver: zodResolver(newUserSchema) as any,
|
||||
defaultValues: {
|
||||
role_ids: [],
|
||||
department_id: "",
|
||||
designation_id: "",
|
||||
module_ids: [],
|
||||
category: "tenant_user" as const,
|
||||
supplier_id: null,
|
||||
},
|
||||
});
|
||||
|
||||
@ -92,6 +98,8 @@ export const NewUserModal = ({
|
||||
const departmentIdValue = watch("department_id");
|
||||
const designationIdValue = watch("designation_id");
|
||||
const moduleIdsValue = watch("module_ids");
|
||||
const categoryValue = watch("category");
|
||||
const supplierIdValue = watch("supplier_id");
|
||||
|
||||
const roles = useAppSelector((state) => state.auth.roles);
|
||||
const isSuperAdmin = roles.includes("super_admin");
|
||||
@ -111,6 +119,8 @@ export const NewUserModal = ({
|
||||
department_id: "",
|
||||
designation_id: "",
|
||||
module_ids: [],
|
||||
category: "tenant_user",
|
||||
supplier_id: null,
|
||||
});
|
||||
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> => {
|
||||
clearErrors();
|
||||
try {
|
||||
@ -255,7 +287,7 @@ export const NewUserModal = ({
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleSubmit(handleFormSubmit)}
|
||||
onClick={handleSubmit(handleFormSubmit as any)}
|
||||
disabled={isLoading}
|
||||
size="default"
|
||||
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 */}
|
||||
{errors.root && (
|
||||
<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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="grid grid-cols-2 gap-5 pb-4">
|
||||
<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"),
|
||||
code: z.string().min(1, "Code is required"),
|
||||
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"]),
|
||||
source_module: z.array(z.string()).optional(),
|
||||
source_module_id: z
|
||||
@ -141,6 +144,7 @@ interface WorkflowDefinitionModalProps {
|
||||
definition?: WorkflowDefinition | null;
|
||||
tenantId?: string | null;
|
||||
onSuccess: () => void;
|
||||
initialEntityType?: string;
|
||||
}
|
||||
const StepAssigneeFields = ({
|
||||
index,
|
||||
@ -210,6 +214,7 @@ export const WorkflowDefinitionModal = ({
|
||||
definition,
|
||||
tenantId,
|
||||
onSuccess,
|
||||
initialEntityType,
|
||||
}: WorkflowDefinitionModalProps) => {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"general" | "steps" | "transitions"
|
||||
@ -350,7 +355,7 @@ export const WorkflowDefinitionModal = ({
|
||||
name: "",
|
||||
code: "",
|
||||
description: "",
|
||||
entity_type: "",
|
||||
entity_type: initialEntityType || "",
|
||||
status: "draft",
|
||||
source_module: [],
|
||||
source_module_id: [],
|
||||
@ -373,7 +378,7 @@ export const WorkflowDefinitionModal = ({
|
||||
}
|
||||
setActiveTab("general");
|
||||
}
|
||||
}, [isOpen, definition, reset]);
|
||||
}, [isOpen, definition, reset, initialEntityType]);
|
||||
|
||||
const onFormSubmit = async (data: WorkflowSchemaType) => {
|
||||
try {
|
||||
|
||||
@ -21,12 +21,14 @@ interface WorkflowDefinitionsTableProps {
|
||||
tenantId?: string | null; // If provided, use this tenantId (Super Admin mode)
|
||||
compact?: boolean; // Compact mode for tabs
|
||||
showHeader?: boolean;
|
||||
entityType?: string; // Filter by entity type
|
||||
}
|
||||
|
||||
const WorkflowDefinitionsTable = ({
|
||||
tenantId: tenantId,
|
||||
compact = false,
|
||||
showHeader = true,
|
||||
entityType,
|
||||
}: WorkflowDefinitionsTableProps): ReactElement => {
|
||||
const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId);
|
||||
const effectiveTenantId = tenantId || reduxTenantId || undefined;
|
||||
@ -58,6 +60,7 @@ const WorkflowDefinitionsTable = ({
|
||||
setError(null);
|
||||
const response = await workflowService.listDefinitions({
|
||||
tenantId: effectiveTenantId,
|
||||
entity_type: entityType,
|
||||
status: statusFilter || undefined,
|
||||
limit,
|
||||
offset: (currentPage - 1) * limit,
|
||||
@ -200,9 +203,7 @@ const WorkflowDefinitionsTable = ({
|
||||
key: "entity_type",
|
||||
label: "Entity Type",
|
||||
render: (wf) => (
|
||||
<span className="text-sm text-[#6b7280]">
|
||||
{wf.entity_type}
|
||||
</span>
|
||||
<span className="text-sm text-[#6b7280]">{wf.entity_type}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
@ -407,6 +408,7 @@ const WorkflowDefinitionsTable = ({
|
||||
definition={selectedDefinition}
|
||||
tenantId={effectiveTenantId}
|
||||
onSuccess={fetchDefinitions}
|
||||
initialEntityType={entityType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -26,4 +26,7 @@ export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export * from './DepartmentModals';
|
||||
export * from './DesignationModals';
|
||||
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,
|
||||
Pagination,
|
||||
WorkflowDefinitionsTable,
|
||||
SuppliersTable,
|
||||
type Column,
|
||||
} from "@/components/shared";
|
||||
import { UsersTable, RolesTable } from "@/components/superadmin";
|
||||
@ -45,6 +46,7 @@ type TabType =
|
||||
| "designations"
|
||||
| "user-categories"
|
||||
| "workflow-definitions"
|
||||
| "suppliers"
|
||||
| "modules"
|
||||
| "settings"
|
||||
| "license"
|
||||
@ -75,6 +77,7 @@ const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
|
||||
label: "Workflow Definitions",
|
||||
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: "settings", label: "Settings", icon: <Settings 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 && (
|
||||
<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 === "settings" && 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 type { ReactElement } from 'react';
|
||||
import { lazy, Suspense } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
// Lazy load route components for code splitting
|
||||
const Dashboard = lazy(() => import('@/pages/superadmin/Dashboard'));
|
||||
const Tenants = lazy(() => import('@/pages/superadmin/Tenants'));
|
||||
const CreateTenantWizard = lazy(() => import('@/pages/superadmin/CreateTenantWizard'));
|
||||
const EditTenant = lazy(() => import('@/pages/superadmin/EditTenant'));
|
||||
const TenantDetails = lazy(() => import('@/pages/superadmin/TenantDetails'));
|
||||
const Modules = lazy(() => import('@/pages/superadmin/Modules'));
|
||||
const AuditLogs = lazy(() => import('@/pages/superadmin/AuditLogs'));
|
||||
const Dashboard = lazy(() => import("@/pages/superadmin/Dashboard"));
|
||||
const Tenants = lazy(() => import("@/pages/superadmin/Tenants"));
|
||||
const CreateTenantWizard = lazy(
|
||||
() => import("@/pages/superadmin/CreateTenantWizard"),
|
||||
);
|
||||
const EditTenant = lazy(() => import("@/pages/superadmin/EditTenant"));
|
||||
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
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -18,7 +21,11 @@ const RouteLoader = (): ReactElement => (
|
||||
);
|
||||
|
||||
// Wrapper component with Suspense
|
||||
const LazyRoute = ({ component: Component }: { component: React.ComponentType }): ReactElement => (
|
||||
const LazyRoute = ({
|
||||
component: Component,
|
||||
}: {
|
||||
component: React.ComponentType;
|
||||
}): ReactElement => (
|
||||
<Suspense fallback={<RouteLoader />}>
|
||||
<Component />
|
||||
</Suspense>
|
||||
@ -32,31 +39,35 @@ export interface RouteConfig {
|
||||
// Super Admin routes (requires super_admin role)
|
||||
export const superAdminRoutes: RouteConfig[] = [
|
||||
{
|
||||
path: '/dashboard',
|
||||
path: "/dashboard",
|
||||
element: <LazyRoute component={Dashboard} />,
|
||||
},
|
||||
{
|
||||
path: '/tenants',
|
||||
path: "/tenants",
|
||||
element: <LazyRoute component={Tenants} />,
|
||||
},
|
||||
{
|
||||
path: '/tenants/create-wizard',
|
||||
path: "/tenants/create-wizard",
|
||||
element: <LazyRoute component={CreateTenantWizard} />,
|
||||
},
|
||||
{
|
||||
path: '/tenants/:id/edit',
|
||||
path: "/tenants/:id/edit",
|
||||
element: <LazyRoute component={EditTenant} />,
|
||||
},
|
||||
{
|
||||
path: '/tenants/:id',
|
||||
path: "/tenants/:id",
|
||||
element: <LazyRoute component={TenantDetails} />,
|
||||
},
|
||||
{
|
||||
path: '/modules',
|
||||
path: "/modules",
|
||||
element: <LazyRoute component={Modules} />,
|
||||
},
|
||||
{
|
||||
path: '/audit-logs',
|
||||
path: "/audit-logs",
|
||||
element: <LazyRoute component={AuditLogs} />,
|
||||
},
|
||||
{
|
||||
path: "/suppliers",
|
||||
element: <LazyRoute component={Suppliers} />,
|
||||
},
|
||||
];
|
||||
|
||||
@ -13,6 +13,7 @@ const Designations = lazy(() => import("@/pages/tenant/Designations"));
|
||||
const WorkflowDefination = lazy(
|
||||
() => import("@/pages/tenant/WorkflowDefination"),
|
||||
);
|
||||
const Suppliers = lazy(() => import("@/pages/tenant/Suppliers"));
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -75,4 +76,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/workflow-definitions",
|
||||
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;
|
||||
name: string;
|
||||
}[];
|
||||
category?: 'tenant_user' | 'supplier_user';
|
||||
supplier_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -64,6 +66,8 @@ export interface CreateUserRequest {
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
module_ids?: string[];
|
||||
category?: 'tenant_user' | 'supplier_user';
|
||||
supplier_id?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateUserResponse {
|
||||
@ -89,6 +93,8 @@ export interface UpdateUserRequest {
|
||||
department_id?: string;
|
||||
designation_id?: string;
|
||||
module_ids?: string[];
|
||||
category?: 'tenant_user' | 'supplier_user';
|
||||
supplier_id?: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserResponse {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user