feat: Implement comprehensive supplier management and enhance user modals to support supplier users.

This commit is contained in:
Yashwin 2026-03-16 20:21:42 +05:30
parent 8b9ac59c3f
commit d242bbf708
17 changed files with 1519 additions and 30 deletions

View File

@ -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" },
];

View File

@ -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"

View File

@ -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

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -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 {

View File

@ -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>
);

View File

@ -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';

View 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;

View File

@ -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} />

View 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;

View File

@ -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} />,
},
];

View File

@ -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} />,
},
];

View 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
View 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> {}

View File

@ -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 {