From d242bbf7086eab49e9dc514110008d26d237c3db Mon Sep 17 00:00:00 2001 From: Yashwin Date: Mon, 16 Mar 2026 20:21:42 +0530 Subject: [PATCH] feat: Implement comprehensive supplier management and enhance user modals to support supplier users. --- src/components/layout/Sidebar.tsx | 6 + src/components/shared/EditUserModal.tsx | 103 ++++- src/components/shared/NewUserModal.tsx | 70 +++- src/components/shared/SupplierModal.tsx | 346 +++++++++++++++ src/components/shared/SuppliersTable.tsx | 330 +++++++++++++++ src/components/shared/ViewSupplierModal.tsx | 394 ++++++++++++++++++ .../shared/WorkflowDefinitionModal.tsx | 11 +- .../shared/WorkflowDefinitionsTable.tsx | 8 +- src/components/shared/index.ts | 5 +- src/pages/superadmin/Suppliers.tsx | 30 ++ src/pages/superadmin/TenantDetails.tsx | 17 + src/pages/tenant/Suppliers.tsx | 30 ++ src/routes/super-admin-routes.tsx | 45 +- src/routes/tenant-admin-routes.tsx | 5 + src/services/supplier-service.ts | 73 ++++ src/types/supplier.ts | 70 ++++ src/types/user.ts | 6 + 17 files changed, 1519 insertions(+), 30 deletions(-) create mode 100644 src/components/shared/SupplierModal.tsx create mode 100644 src/components/shared/SuppliersTable.tsx create mode 100644 src/components/shared/ViewSupplierModal.tsx create mode 100644 src/pages/superadmin/Suppliers.tsx create mode 100644 src/pages/tenant/Suppliers.tsx create mode 100644 src/services/supplier-service.ts create mode 100644 src/types/supplier.ts diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 7692cd6..4d66fd2 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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" }, ]; diff --git a/src/components/shared/EditUserModal.tsx b/src/components/shared/EditUserModal.tsx index 0488c2c..6130219 100644 --- a/src/components/shared/EditUserModal.tsx +++ b/src/components/shared/EditUserModal.tsx @@ -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; @@ -76,7 +79,8 @@ export const EditUserModal = ({ clearErrors, formState: { errors }, } = useForm({ - 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 = ({ } > -
+ {isLoadingUser && (
@@ -418,6 +480,41 @@ export const EditUserModal = ({ />
+ {/* User Category */} +
+ + setValue( + "category", + value as "tenant_user" | "supplier_user", + { shouldValidate: true }, + ) + } + error={errors.category?.message} + /> + + {categoryValue === "supplier_user" && ( + setValue("supplier_id", value)} + onLoadOptions={loadSuppliers} + initialOption={initialSupplierOption || undefined} + error={errors.supplier_id?.message} + /> + )} +
+
data.password === data.confirmPassword, { message: "Passwords don't match", @@ -78,12 +81,15 @@ export const NewUserModal = ({ clearErrors, formState: { errors }, } = useForm({ - 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 => { clearErrors(); try { @@ -255,7 +287,7 @@ export const NewUserModal = ({ } > - + {/* General Error Display */} {errors.root && (
@@ -344,6 +376,38 @@ export const NewUserModal = ({ />
+ {/* User Category */} +
+ + setValue("category", value as "tenant_user" | "supplier_user", { + shouldValidate: true, + }) + } + error={errors.category?.message} + /> + + {categoryValue === "supplier_user" && ( + setValue("supplier_id", value)} + onLoadOptions={loadSuppliers} + error={errors.supplier_id?.message} + /> + )} +
+ {/* Role and Status Row */}
; + +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({ + 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 ( + + + Cancel + + + {mode === "create" ? "Create Supplier" : "Save Changes"} + +
+ } + > + + {isLoading ? ( +
Loading...
+ ) : ( + <> +
+ + +
+ +
+ ( + ({ + value: t.code, + label: t.name, + }))} + value={field.value} + onValueChange={field.onChange} + disabled={mode === "view" || isSubmitting} + error={errors.supplier_type?.message} + /> + )} + /> + ( + ({ + value: c.code, + label: c.name, + }))} + value={field.value} + onValueChange={field.onChange} + disabled={mode === "view" || isSubmitting} + error={errors.category?.message} + /> + )} + /> +
+ +
+ ( + ({ + value: s.code, + label: s.name, + }))} + value={field.value} + onValueChange={field.onChange} + disabled={mode === "view" || isSubmitting} + error={errors.status?.message} + /> + )} + /> + ( + + )} + /> +
+ + + +
+ +
+ +
+

Contact Information

+
+
+ +
+ + + + +
+
+ + )} + + + ); +}; diff --git a/src/components/shared/SuppliersTable.tsx b/src/components/shared/SuppliersTable.tsx new file mode 100644 index 0000000..9d61799 --- /dev/null +++ b/src/components/shared/SuppliersTable.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const [limit, setLimit] = useState(compact ? 5 : 10); + const [total, setTotal] = useState(0); + const [statusFilter, setStatusFilter] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + + // Modal state + const [modalOpen, setModalOpen] = useState(false); + const [modalMode, setModalMode] = useState<"create" | "edit" | "view">( + "create", + ); + const [selectedSupplierId, setSelectedSupplierId] = useState( + 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[] = [ + { + key: "name", + label: "Supplier", + render: (supplier) => ( +
+
+ +
+
+ + {supplier.name} + + {supplier.code && ( + + {supplier.code} + + )} +
+
+ ), + }, + { + key: "supplier_type", + label: "Type", + render: (supplier) => ( + + {supplier.supplier_type.replace(/_/g, " ")} + + ), + }, + { + key: "category", + label: "Category", + render: (supplier) => ( + + {supplier.category} + + ), + }, + { + key: "status", + label: "Status", + render: (supplier) => ( + + {supplier.status} + + ), + }, + { + key: "location", + label: "Location", + render: (supplier) => ( + + {supplier.city + ? `${supplier.city}, ${supplier.country}` + : supplier.country || "-"} + + ), + }, + { + key: "created_at", + label: "Dated", + render: (supplier) => ( + + {formatDate(supplier.created_at)} + + ), + }, + { + key: "actions", + label: "Actions", + align: "right", + render: (supplier) => ( +
+ handleView(supplier.id)} + onEdit={() => handleEdit(supplier.id)} + /> +
+ ), + }, + ]; + + const mobileCardRenderer = (supplier: Supplier) => ( +
+
+
+
+ +
+
+

+ {supplier.name} +

+

{supplier.supplier_type}

+
+
+ handleView(supplier.id)} + onEdit={() => handleEdit(supplier.id)} + /> +
+
+ + {supplier.status} + + + {supplier.category} + +
+
+ ); + + return ( +
+ {showHeader && ( +
+
+ { + setStatusFilter(val as string); + setCurrentPage(1); + }} + /> +
+ { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + /> +
+
+ + + New Supplier + +
+ )} + +
+ s.id} + isLoading={isLoading} + error={error} + mobileCardRenderer={mobileCardRenderer} + emptyMessage="No suppliers found" + /> + {total > 0 && ( +
+ +
+ )} +
+ + setModalOpen(false)} + mode={modalMode} + supplierId={selectedSupplierId} + tenantId={tenantId} + onSuccess={fetchSuppliers} + /> + + setViewModalOpen(false)} + supplierId={selectedSupplierId} + tenantId={tenantId} + /> +
+ ); +}; diff --git a/src/components/shared/ViewSupplierModal.tsx b/src/components/shared/ViewSupplierModal.tsx new file mode 100644 index 0000000..b1935a1 --- /dev/null +++ b/src/components/shared/ViewSupplierModal.tsx @@ -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 ( + + HIGH RISK + + ); + if (l === "medium") + return ( + + MEDIUM RISK + + ); + return ( + + LOW RISK + + ); +}; + +export const ViewSupplierModal = ({ + isOpen, + onClose, + supplierId, + tenantId, +}: ViewSupplierModalProps): ReactElement | null => { + const [supplier, setSupplier] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // Load supplier data when modal opens + useEffect(() => { + if (isOpen && supplierId) { + const loadSupplier = async (): Promise => { + 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; + }) => ( +
+ +
+ {value || ( + + not specified + + )} +
+
+ ); + + return ( + + Dismiss + + } + > +
+ {isLoading && ( +
+ + + Retrieving Profile... + +
+ )} + + {error && ( +
+
+
+ +
+
+

+ Access Error +

+

+ {error} +

+
+
+
+ )} + + {!isLoading && !error && supplier && ( +
+ {/* Legend Header */} +
+ {/* Mesh Gradient Overlay */} +
+
+ +
+ {/* Visual Identity */} +
+
+
+ +
+
+ +
+
+
+

+ {supplier.name} +

+ {getRiskBadge(supplier.risk_level)} +
+
+ + ref: {supplier.code || "unassigned"} + +
+
+ +
+
+ + + {supplier.supplier_type.replace(/_/g, " ")} + +
+
+
+ + + {supplier.category} level + +
+
+
+ +
+ + {supplier.status} + +
+
+
+ + {/* Core Registry Layout */} +
+ {/* Detailed Grid */} +
+ {/* Information Cluster */} +
+
+
+ +
+

+ Profile Data +

+
+
+ + + + {supplier.website.replace(/^https?:\/\//, "")} + + + + ) : null + } + /> +
+
+ + {/* Logistics Cluster */} +
+
+
+ +
+

+ Regional presence +

+
+
+
+ +
+
+ {(() => { + 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 ( + <> +

+ {street || "Street address not provided"} +

+

+ {city && `${city}, `} + {state && `${state} `} + {zip && `[${zip}]`} +

+ {country && ( + + Primary HQ: {country} + + )} + + ); + })()} +
+
+
+
+ + {/* Narrative Section */} +
+
+
+ +
+

+ Operational Overview +

+
+
+
+ " +
+

+ {supplier.description || + "Detailed operational description is currently unavailable for this record. Please contact the administrator for further details regarding this supplier's service scope."} +

+
+ " +
+
+
+ + {/* Security & Lifecycle */} +
+
+
+ +
+
+ + System Ingestion + + + {formatDate(supplier.created_at)} + +
+
+
+
+ +
+
+ + Last Compliance Check + + + {formatDate(supplier.updated_at)} + +
+
+
+
+
+ )} +
+ + ); +}; diff --git a/src/components/shared/WorkflowDefinitionModal.tsx b/src/components/shared/WorkflowDefinitionModal.tsx index 30c72c7..e3ff33c 100644 --- a/src/components/shared/WorkflowDefinitionModal.tsx +++ b/src/components/shared/WorkflowDefinitionModal.tsx @@ -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 { diff --git a/src/components/shared/WorkflowDefinitionsTable.tsx b/src/components/shared/WorkflowDefinitionsTable.tsx index dc49552..94d7125 100644 --- a/src/components/shared/WorkflowDefinitionsTable.tsx +++ b/src/components/shared/WorkflowDefinitionsTable.tsx @@ -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) => ( - - {wf.entity_type} - + {wf.entity_type} ), }, { @@ -407,6 +408,7 @@ const WorkflowDefinitionsTable = ({ definition={selectedDefinition} tenantId={effectiveTenantId} onSuccess={fetchDefinitions} + initialEntityType={entityType} />
); diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 65b872f..6971bd0 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -26,4 +26,7 @@ export { AuthenticatedImage } from './AuthenticatedImage'; export * from './DepartmentModals'; export * from './DesignationModals'; export { default as WorkflowDefinitionsTable } from './WorkflowDefinitionsTable'; -export { WorkflowDefinitionModal } from './WorkflowDefinitionModal'; \ No newline at end of file +export { WorkflowDefinitionModal } from './WorkflowDefinitionModal'; +export { SuppliersTable } from './SuppliersTable'; +export { SupplierModal } from './SupplierModal'; +export { ViewSupplierModal } from './ViewSupplierModal'; \ No newline at end of file diff --git a/src/pages/superadmin/Suppliers.tsx b/src/pages/superadmin/Suppliers.tsx new file mode 100644 index 0000000..3f74bbf --- /dev/null +++ b/src/pages/superadmin/Suppliers.tsx @@ -0,0 +1,30 @@ +import { type ReactElement } from "react"; +import { Layout } from "@/components/layout/Layout"; +import { SuppliersTable } from "@/components/shared"; + +const Suppliers = (): ReactElement => { + return ( + +
+
+
+
+

+ Global Suppliers +

+
+ +
+
+
+
+ ); +}; + +export default Suppliers; diff --git a/src/pages/superadmin/TenantDetails.tsx b/src/pages/superadmin/TenantDetails.tsx index b4d875a..a187325 100644 --- a/src/pages/superadmin/TenantDetails.tsx +++ b/src/pages/superadmin/TenantDetails.tsx @@ -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: , }, + { id: "suppliers", label: "Suppliers", icon: }, { id: "modules", label: "Modules", icon: }, { id: "settings", label: "Settings", icon: }, { id: "license", label: "License", icon: }, @@ -354,6 +357,20 @@ const TenantDetails = (): ReactElement => { {activeTab === "workflow-definitions" && id && ( )} + {activeTab === "suppliers" && id && ( +
+
+

+ List of Suppliers +

+
+ +
+ )} {activeTab === "modules" && id && } {activeTab === "settings" && tenant && ( diff --git a/src/pages/tenant/Suppliers.tsx b/src/pages/tenant/Suppliers.tsx new file mode 100644 index 0000000..073aa5a --- /dev/null +++ b/src/pages/tenant/Suppliers.tsx @@ -0,0 +1,30 @@ +import { type ReactElement } from "react"; +import { Layout } from "@/components/layout/Layout"; +import { SuppliersTable } from "@/components/shared"; + +const Suppliers = (): ReactElement => { + return ( + +
+
+
+
+

+ Suppliers +

+
+ +
+
+
+
+ ); +}; + +export default Suppliers; diff --git a/src/routes/super-admin-routes.tsx b/src/routes/super-admin-routes.tsx index 85a717e..3ebff87 100644 --- a/src/routes/super-admin-routes.tsx +++ b/src/routes/super-admin-routes.tsx @@ -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 => ( }> @@ -32,31 +39,35 @@ export interface RouteConfig { // Super Admin routes (requires super_admin role) export const superAdminRoutes: RouteConfig[] = [ { - path: '/dashboard', + path: "/dashboard", element: , }, { - path: '/tenants', + path: "/tenants", element: , }, { - path: '/tenants/create-wizard', + path: "/tenants/create-wizard", element: , }, { - path: '/tenants/:id/edit', + path: "/tenants/:id/edit", element: , }, { - path: '/tenants/:id', + path: "/tenants/:id", element: , }, { - path: '/modules', + path: "/modules", element: , }, { - path: '/audit-logs', + path: "/audit-logs", element: , }, + { + path: "/suppliers", + element: , + }, ]; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 5d70c55..04fff4d 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -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: , }, + { + path: "/tenant/suppliers", + element: , + }, ]; diff --git a/src/services/supplier-service.ts b/src/services/supplier-service.ts new file mode 100644 index 0000000..a1b57bd --- /dev/null +++ b/src/services/supplier-service.ts @@ -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 => { + 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(`/suppliers?${queryParams.toString()}`); + return response.data; + }, + + getById: async (id: string, tenantId?: string | null): Promise => { + const url = tenantId ? `/suppliers/${id}?tenantId=${tenantId}` : `/suppliers/${id}`; + const response = await apiClient.get(url); + return response.data; + }, + + create: async (data: CreateSupplierData, tenantId?: string | null): Promise => { + const url = tenantId ? `/suppliers?tenantId=${tenantId}` : '/suppliers'; + const response = await apiClient.post(url, data); + return response.data; + }, + + update: async (id: string, data: UpdateSupplierData, tenantId?: string | null): Promise => { + const url = tenantId ? `/suppliers/${id}?tenantId=${tenantId}` : `/suppliers/${id}`; + const response = await apiClient.put(url, data); + return response.data; + }, + + getContacts: async (id: string, tenantId?: string | null): Promise => { + const url = tenantId ? `/suppliers/${id}/contacts?tenantId=${tenantId}` : `/suppliers/${id}/contacts`; + const response = await apiClient.get(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; + } +}; diff --git a/src/types/supplier.ts b/src/types/supplier.ts new file mode 100644 index 0000000..31b5d0e --- /dev/null +++ b/src/types/supplier.ts @@ -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 {} diff --git a/src/types/user.ts b/src/types/user.ts index 5026798..d415897 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -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 {