From 901dde336217488829fc746f7f35f39b005be852 Mon Sep 17 00:00:00 2001 From: Yashwin Date: Thu, 30 Apr 2026 16:02:04 +0530 Subject: [PATCH] refactor: standardize UI components with SearchBox, ActiveOnlyToggle, CodeBadge, and FormTextArea, while updating associated services and pages. --- src/components/shared/ActiveOnlyToggle.tsx | 36 +++ src/components/shared/CodeBadge.tsx | 19 ++ src/components/shared/DepartmentModals.tsx | 19 +- src/components/shared/DesignationModals.tsx | 19 +- src/components/shared/EditRoleModal.tsx | 11 +- src/components/shared/NewRoleModal.tsx | 11 +- src/components/shared/SupplierModal.tsx | 208 +++++++++--- src/components/shared/SuppliersTable.tsx | 74 ++--- src/components/shared/ViewSupplierModal.tsx | 62 ++-- .../shared/WorkflowDefinitionsTable.tsx | 3 +- src/components/shared/index.ts | 4 +- .../superadmin/DepartmentListView.tsx | 53 +++ .../superadmin/DepartmentTreeView.tsx | 210 ++++++++++++ .../superadmin/DepartmentsTable.tsx | 191 ++++++----- .../superadmin/DesignationsTable.tsx | 57 ++-- src/components/superadmin/EditModuleModal.tsx | 14 +- src/components/superadmin/NewModuleModal.tsx | 302 ++++++++++++------ src/components/superadmin/UsersTable.tsx | 98 +++++- src/components/superadmin/index.ts | 2 +- src/pages/superadmin/CreateTenantWizard.tsx | 6 +- src/pages/superadmin/EditTenant.tsx | 4 +- src/pages/superadmin/Modules.tsx | 28 +- src/pages/superadmin/TenantDetails.tsx | 25 +- src/pages/superadmin/Tenants.tsx | 173 ++++++---- src/pages/tenant/Departments.tsx | 19 +- src/pages/tenant/Roles.tsx | 45 ++- src/pages/tenant/Suppliers.tsx | 2 +- src/pages/tenant/Users.tsx | 67 +++- src/services/module-service.ts | 6 +- src/services/role-service.ts | 20 +- src/services/user-service.ts | 26 +- src/types/role.ts | 1 + 32 files changed, 1352 insertions(+), 463 deletions(-) create mode 100644 src/components/shared/ActiveOnlyToggle.tsx create mode 100644 src/components/shared/CodeBadge.tsx create mode 100644 src/components/superadmin/DepartmentListView.tsx create mode 100644 src/components/superadmin/DepartmentTreeView.tsx diff --git a/src/components/shared/ActiveOnlyToggle.tsx b/src/components/shared/ActiveOnlyToggle.tsx new file mode 100644 index 0000000..9f2df3a --- /dev/null +++ b/src/components/shared/ActiveOnlyToggle.tsx @@ -0,0 +1,36 @@ +import type { ReactElement } from 'react'; +import { useAppTheme } from '@/hooks/useAppTheme'; + +interface ActiveOnlyToggleProps { + activeOnly: boolean; + onChange: (value: boolean) => void; + label?: string; + className?: string; +} + +export const ActiveOnlyToggle = ({ + activeOnly, + onChange, + label = 'Active Only', + className = '' +}: ActiveOnlyToggleProps): ReactElement => { + const { primaryColor } = useAppTheme(); + + return ( +
+ {label} + +
+ ); +}; diff --git a/src/components/shared/CodeBadge.tsx b/src/components/shared/CodeBadge.tsx new file mode 100644 index 0000000..a23347e --- /dev/null +++ b/src/components/shared/CodeBadge.tsx @@ -0,0 +1,19 @@ +import { cn } from "@/lib/utils"; + +interface CodeBadgeProps { + label: string; + className?: string; +} + +export default function CodeBadge({ label, className }: CodeBadgeProps) { + return ( + + {label} + + ); +} \ No newline at end of file diff --git a/src/components/shared/DepartmentModals.tsx b/src/components/shared/DepartmentModals.tsx index 0e749c2..7ea5682 100644 --- a/src/components/shared/DepartmentModals.tsx +++ b/src/components/shared/DepartmentModals.tsx @@ -9,6 +9,7 @@ import { PrimaryButton, SecondaryButton, PaginatedSelect, + FormTextArea, } from "@/components/shared"; import type { Department, @@ -140,11 +141,18 @@ export const NewDepartmentModal = ({ /> - */} +
@@ -320,11 +328,18 @@ export const EditDepartmentModal = ({ />
- */} +
diff --git a/src/components/shared/DesignationModals.tsx b/src/components/shared/DesignationModals.tsx index f930e23..7797e98 100644 --- a/src/components/shared/DesignationModals.tsx +++ b/src/components/shared/DesignationModals.tsx @@ -8,6 +8,7 @@ import { FormSelect, PrimaryButton, SecondaryButton, + FormTextArea, } from "@/components/shared"; import type { Designation, @@ -113,11 +114,18 @@ export const NewDesignationModal = ({ />
- */} +
@@ -243,11 +251,18 @@ export const EditDesignationModal = ({ />
- */} +
diff --git a/src/components/shared/EditRoleModal.tsx b/src/components/shared/EditRoleModal.tsx index ab32a6e..479773c 100644 --- a/src/components/shared/EditRoleModal.tsx +++ b/src/components/shared/EditRoleModal.tsx @@ -9,6 +9,7 @@ import { FormField, PrimaryButton, SecondaryButton, + FormTextArea, } from "@/components/shared"; import type { Role, UpdateRoleRequest } from "@/types/role"; import { useAppSelector } from "@/hooks/redux-hooks"; @@ -486,12 +487,20 @@ export const EditRoleModal = ({
{/* Description */} - */} + {/* Permissions Section */} diff --git a/src/components/shared/NewRoleModal.tsx b/src/components/shared/NewRoleModal.tsx index 2761e34..2779973 100644 --- a/src/components/shared/NewRoleModal.tsx +++ b/src/components/shared/NewRoleModal.tsx @@ -9,6 +9,7 @@ import { FormField, PrimaryButton, SecondaryButton, + FormTextArea, } from "@/components/shared"; import type { CreateRoleRequest } from "@/types/role"; import { useAppSelector } from "@/hooks/redux-hooks"; @@ -378,12 +379,20 @@ export const NewRoleModal = ({ {/* Description */} - */} + {/* Permissions Section */} diff --git a/src/components/shared/SupplierModal.tsx b/src/components/shared/SupplierModal.tsx index 0abade7..20a0e94 100644 --- a/src/components/shared/SupplierModal.tsx +++ b/src/components/shared/SupplierModal.tsx @@ -8,6 +8,7 @@ import { FormSelect, PrimaryButton, SecondaryButton, + ActiveOnlyToggle, } from "@/components/shared"; import { Plus, Trash2 } from "lucide-react"; import { supplierService } from "@/services/supplier-service"; @@ -23,12 +24,31 @@ const supplierSchema = z.object({ status: z.string().optional(), risk_level: z.string().optional(), website: z.string().url("Invalid URL").optional().or(z.literal("")), - products_services_input: z.string().min(1, "At least one product/service is required"), - + products_services_input: z + .string() + .min(1, "At least one product/service is required"), + // Primary Contact primary_contact_name: z.string().optional(), - primary_contact_email: z.string().email("Invalid email").optional().or(z.literal("")), - primary_contact_phone: z.string().optional(), + primary_contact_email: z + .string() + .email("Invalid email") + .optional() + .or(z.literal("")), + // primary_contact_phone: z.string().optional(), + primary_contact_phone: z + .string() + .optional() + .nullable() + .refine( + (val) => { + if (!val || val.trim() === "") return true; // Optional field, empty is valid + return /^\d{10}$/.test(val); + }, + { + message: "Phone number must be exactly 10 digits", + }, + ), // Address address_line1: z.string().optional(), @@ -44,15 +64,29 @@ const supplierSchema = z.object({ // Quality Agreement quality_agreement_signed: z.boolean().optional(), - quality_agreement_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(), - + quality_agreement_date: z + .union([z.date(), z.string(), z.null()]) + .optional() + .nullable(), + // Certifications - certifications: z.array(z.object({ - name: z.string().min(1, "Name is required"), - issuing_body: z.string().optional().or(z.literal("")), - expiry_date: z.union([z.date(), z.string(), z.null()]).optional().nullable(), - document_url: z.string().url("Invalid URL").optional().or(z.literal("")), - })).default([]), + certifications: z + .array( + z.object({ + name: z.string().min(1, "Name is required"), + issuing_body: z.string().optional().or(z.literal("")), + expiry_date: z + .union([z.date(), z.string(), z.null()]) + .optional() + .nullable(), + document_url: z + .string() + .url("Invalid URL") + .optional() + .or(z.literal("")), + }), + ) + .default([]), }); type SupplierFormData = z.infer; @@ -91,6 +125,7 @@ export const SupplierModal = ({ handleSubmit, control, reset, + setValue, formState: { errors }, } = useForm({ resolver: zodResolver(supplierSchema) as any, @@ -152,7 +187,7 @@ export const SupplierModal = ({ const formatDateForInput = (val: any) => { if (!val) return ""; const d = new Date(val); - return isNaN(d.getTime()) ? "" : d.toISOString().split('T')[0]; + return isNaN(d.getTime()) ? "" : d.toISOString().split("T")[0]; }; useEffect(() => { @@ -173,24 +208,30 @@ export const SupplierModal = ({ status: s.status || "pending", risk_level: s.risk_level || "low", website: s.website || "", - products_services_input: Array.isArray(s.products_services) ? s.products_services.join(", ") : "", - + products_services_input: Array.isArray(s.products_services) + ? s.products_services.join(", ") + : "", + primary_contact_name: s.contact?.name || "", primary_contact_email: s.contact?.email || "", primary_contact_phone: s.contact?.phone || "", - + address_line1: s.address?.line1 || "", address_line2: s.address?.line2 || "", city: s.address?.city || "", state: s.address?.state || "", country: s.address?.country || "", postal_code: s.address?.postal_code || "", - + tax_id: s.tax_id || "", duns_number: s.duns_number || "", - quality_agreement_signed: s.quality_agreement?.signed || false, - quality_agreement_date: s.quality_agreement?.date ? new Date(s.quality_agreement.date) : null, - certifications: Array.isArray(s.certifications) ? s.certifications : [], + quality_agreement_signed: s.quality_agreement?.signed || false, + quality_agreement_date: s.quality_agreement?.date + ? new Date(s.quality_agreement.date) + : null, + certifications: Array.isArray(s.certifications) + ? s.certifications + : [], }); } } catch (error: any) { @@ -221,7 +262,7 @@ export const SupplierModal = ({ postal_code: "", tax_id: "", duns_number: "", - quality_agreement_date: null, + quality_agreement_date: null, certifications: [], }); } @@ -233,15 +274,18 @@ export const SupplierModal = ({ const onSubmit = async (data: any) => { try { setIsSubmitting(true); - + // Map form fields to backend structure const payload: any = { ...data, - products_services: data.products_services_input - ? data.products_services_input.split(',').map((item: string) => item.trim()).filter(Boolean) - : [] + products_services: data.products_services_input + ? data.products_services_input + .split(",") + .map((item: string) => item.trim()) + .filter(Boolean) + : [], }; - + // Remove the UI-specific field before sending delete payload.products_services_input; @@ -258,7 +302,8 @@ export const SupplierModal = ({ showToast.error( mode === "create" ? error?.response?.data?.error?.message || "Failed to create supplier" - : error?.response?.data?.error?.message || "Failed to update supplier", + : error?.response?.data?.error?.message || + "Failed to update supplier", error?.message, ); } finally { @@ -270,7 +315,13 @@ export const SupplierModal = ({ @@ -300,7 +351,9 @@ export const SupplierModal = ({
{/* Basic Information */}
-

Basic Information

+

+ Basic Information +

-

Products & Services

+

+ Products & Services +

-

Primary Contact Person

+

+ Primary Contact Person +

- */} + { + // Only allow digits and limit to 10 characters + const value = e.target.value + .replace(/\D/g, "") + .slice(0, 10); + setValue("primary_contact_phone", value, { + shouldValidate: true, + }); + }, + })} />
{/* Address Information */}
-

Company Address

+

+ Company Address +

-

Quality Agreement

+

+ Quality Agreement +

- ( + + )} /> -
- {/* Certifications Section */} + {/* Certifications Section */}
-

Certifications

+

+ Certifications +

{mode !== "view" && ( )}
- +
{fields.length === 0 ? ( -

No certifications added.

+

+ No certifications added. +

) : ( fields.map((field, index) => ( -
+
{mode !== "view" && (
diff --git a/src/components/shared/SuppliersTable.tsx b/src/components/shared/SuppliersTable.tsx index bd22faa..d45c87b 100644 --- a/src/components/shared/SuppliersTable.tsx +++ b/src/components/shared/SuppliersTable.tsx @@ -5,18 +5,19 @@ import { ActionDropdown, DataTable, Pagination, - FilterDropdown, + // FilterDropdown, type Column, SupplierModal, ViewSupplierModal, SupplierContactsModal, SupplierScorecardsModal, + SearchBox, } 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"; -import { useAppTheme } from "@/hooks/useAppTheme"; +// import { useAppTheme } from "@/hooks/useAppTheme"; interface SuppliersTableProps { tenantId?: string | null; @@ -60,14 +61,14 @@ export const SuppliersTable = ({ showHeader = true, compact = false, }: SuppliersTableProps): ReactElement => { - const { primaryColor } = useAppTheme(); + // const { primaryColor } = useAppTheme(); 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 [statusFilter, setStatusFilter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); // Modal state @@ -89,7 +90,7 @@ export const SuppliersTable = ({ setError(null); const response = await supplierService.list({ tenantId, - status: statusFilter || undefined, + // status: statusFilter || undefined, search: searchQuery || undefined, limit, offset: (currentPage - 1) * limit, @@ -112,7 +113,7 @@ export const SuppliersTable = ({ useEffect(() => { fetchSuppliers(); - }, [tenantId, currentPage, limit, statusFilter, searchQuery]); + }, [tenantId, currentPage, limit, searchQuery]); const handleCreate = () => { setModalMode("create"); @@ -146,7 +147,7 @@ export const SuppliersTable = ({ const columns: Column[] = [ { key: "name", - label: "Supplier", + label: "Supplier Name & Code", render: (supplier) => (
@@ -185,15 +186,15 @@ export const SuppliersTable = ({ ), }, - { - key: "status", - label: "Status", - render: (supplier) => ( - - {supplier.status} - - ), - }, + // { + // key: "status", + // label: "Status", + // render: (supplier) => ( + // + // {supplier.status} + // + // ), + // }, // { // key: "location", // label: "Location", @@ -268,38 +269,21 @@ export const SuppliersTable = ({ return (
{showHeader && ( -
+
-
- { - e.currentTarget.style.borderColor = primaryColor; - e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`; - }} - onBlur={(e) => { - e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)'; - e.currentTarget.style.boxShadow = 'none'; - }} - value={searchQuery} - onChange={(e) => { - setSearchQuery(e.target.value); - setCurrentPage(1); - }} - /> -
- { + setSearchQuery(val); + setCurrentPage(1); + }} + placeholder="Search by name or code..." + /> + {/* + /> */}
{ - switch (status.toLowerCase()) { - case "approved": - case "active": - return "success"; - case "rejected": - case "inactive": - return "failure"; - case "pending": - return "process"; - default: - return "info"; - } -}; +// 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 => { @@ -231,7 +231,7 @@ export const ViewSupplierModal = ({
-
+ {/*
)} -
+
*/}
@@ -276,16 +276,18 @@ export const ViewSupplierModal = ({
- {supplier.address ? ( + {supplier.address && (supplier.address.line1 || supplier.address.city || supplier.address.country) ? ( <> -

{supplier.address.line1}

+ {supplier.address.line1 &&

{supplier.address.line1}

} {supplier.address.line2 &&

{supplier.address.line2}

}

{[supplier.address.city, supplier.address.state, supplier.address.postal_code].filter(Boolean).join(', ')}

-

- {supplier.address.country} -

+ {supplier.address.country && ( +

+ {supplier.address.country} +

+ )} ) : (

No address recorded

@@ -299,8 +301,8 @@ export const ViewSupplierModal = ({ {supplier.workflow_instance_id && ( )} - - + {/* + */}
@@ -321,10 +323,10 @@ export const ViewSupplierModal = ({
)}
-
+ {/*
-
+
*/}
@@ -363,7 +365,7 @@ export const ViewSupplierModal = ({ )} {/* Extended Contacts Section */} - {supplier.contacts && supplier.contacts.length > 0 && ( + {/* {supplier.contacts && supplier.contacts.length > 0 && (
@@ -390,7 +392,7 @@ export const ViewSupplierModal = ({ ))}
- )} + )} */} {/* Operational Narrative */} {/*
diff --git a/src/components/shared/WorkflowDefinitionsTable.tsx b/src/components/shared/WorkflowDefinitionsTable.tsx index 22f23bb..e8947bf 100644 --- a/src/components/shared/WorkflowDefinitionsTable.tsx +++ b/src/components/shared/WorkflowDefinitionsTable.tsx @@ -17,6 +17,7 @@ import type { WorkflowDefinition } from "@/types/workflow"; import { showToast } from "@/utils/toast"; import type { RootState } from "@/store/store"; import { formatDate } from "@/utils/format-date"; +import CodeBadge from "./CodeBadge"; interface WorkflowDefinitionsTableProps { tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) @@ -206,7 +207,7 @@ const WorkflowDefinitionsTable = ({ key: "entity_type", label: "Entity Type", render: (wf) => ( - {wf.entity_type} + ), }, { diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index 7a7c8f8..374bd16 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -38,4 +38,6 @@ export { FormSlider } from './FormSlider'; export { RichTextEditor } from './RichTextEditor'; export { FileUploadModal } from './FileUploadModal'; export type { FileUploadModalProps } from './FileUploadModal'; -export { FileShareModal } from './FileShareModal';export { SearchBox } from './SearchBox'; +export { FileShareModal } from './FileShareModal'; +export { ActiveOnlyToggle } from './ActiveOnlyToggle'; +export { SearchBox } from './SearchBox'; diff --git a/src/components/superadmin/DepartmentListView.tsx b/src/components/superadmin/DepartmentListView.tsx new file mode 100644 index 0000000..e6ffb9b --- /dev/null +++ b/src/components/superadmin/DepartmentListView.tsx @@ -0,0 +1,53 @@ +import { type ReactElement } from "react"; +import { DataTable, Pagination, type Column } from "@/components/shared"; +import type { Department } from "@/types/department"; + +interface DepartmentListViewProps { + data: Department[]; + columns: Column[]; + isLoading: boolean; + error: string | null; + currentPage: number; + totalPages: number; + totalItems: number; + limit: number; + onPageChange: (page: number) => void; + onLimitChange: (limit: number) => void; +} + +export const DepartmentListView = ({ + data, + columns, + isLoading, + error, + currentPage, + totalPages, + totalItems, + limit, + onPageChange, + onLimitChange, +}: DepartmentListViewProps): ReactElement => { + return ( + <> + dept.id} + isLoading={isLoading} + error={error} + emptyMessage="No departments found" + /> + + {totalItems > 0 && ( + + )} + + ); +}; diff --git a/src/components/superadmin/DepartmentTreeView.tsx b/src/components/superadmin/DepartmentTreeView.tsx new file mode 100644 index 0000000..bccd53b --- /dev/null +++ b/src/components/superadmin/DepartmentTreeView.tsx @@ -0,0 +1,210 @@ +import { useState, type ReactElement } from "react"; +import { useAppTheme } from "@/hooks/useAppTheme"; +import { + ChevronRight, + ChevronDown, + Folder, + Plus, + Edit2, + Trash2, +} from "lucide-react"; +import type { Department } from "@/types/department"; + +interface TreeItemProps { + item: Department; + level?: number; + onAddSub: (item: Department) => void; + onEdit: (item: Department) => void; + onDelete?: (item: Department) => void; +} + +const TreeItem = ({ + item, + level = 0, + onAddSub, + onEdit, + onDelete, +}: TreeItemProps) => { + const { primaryColor } = useAppTheme(); + const [isExpanded, setIsExpanded] = useState(level === 0); + const hasChildren = item.children && item.children.length > 0; + + return ( +
+
0 ? `${level * 28}px` : "0", + marginBottom: "4px", + }} + > +
+
+ {hasChildren && ( + + )} +
+ +
+ +
+ +
+ + {item.name} + + + {item.code} + + + {level === 0 ? ( + + {item.user_count || 0} total + + ) : hasChildren ? ( + + {item.child_count || 0} sub-departments + + ) : null} +
+
+ +
+ + + {onDelete && ( + + )} +
+
+ + {isExpanded && hasChildren && ( +
+ {item.children?.map((child) => ( + + ))} +
+ )} +
+ ); +}; + +interface DepartmentTreeViewProps { + data: Department[]; + isLoading: boolean; + error: string | null; + onAddSub: (item: Department) => void; + onEdit: (item: Department) => void; + onDelete?: (item: Department) => void; +} + +export const DepartmentTreeView = ({ + data, + isLoading, + error, + onAddSub, + onEdit, + onDelete, +}: DepartmentTreeViewProps): ReactElement => { + const { primaryColor } = useAppTheme(); + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (data.length === 0) { + return ( +
+ No departments found in hierarchy +
+ ); + } + + return ( +
+ {data.map((item) => ( + + ))} +
+ ); +}; diff --git a/src/components/superadmin/DepartmentsTable.tsx b/src/components/superadmin/DepartmentsTable.tsx index 47af4df..154e9b7 100644 --- a/src/components/superadmin/DepartmentsTable.tsx +++ b/src/components/superadmin/DepartmentsTable.tsx @@ -1,13 +1,15 @@ -import { useState, useEffect, type ReactElement } from "react"; +import { useState, useEffect, useImperativeHandle, forwardRef, type ReactElement } from "react"; import { useSelector } from "react-redux"; import { - PrimaryButton, + // PrimaryButton, StatusBadge, ActionDropdown, - DataTable, - Pagination, - FilterDropdown, + // DataTable, + // Pagination, + // FilterDropdown, // DeleteConfirmationModal, + SearchBox, + ActiveOnlyToggle, type Column, } from "@/components/shared"; import { @@ -15,8 +17,10 @@ import { EditDepartmentModal, ViewDepartmentModal, } from "@/components/shared/DepartmentModals"; -import { Plus, Search } from "lucide-react"; +// import { Plus } from "lucide-react"; import { departmentService } from "@/services/department-service"; +import { DepartmentListView } from "./DepartmentListView"; +import { DepartmentTreeView } from "./DepartmentTreeView"; import type { Department, CreateDepartmentRequest, @@ -25,6 +29,7 @@ import type { import { showToast } from "@/utils/toast"; import type { RootState } from "@/store/store"; import { useAppTheme } from "@/hooks/useAppTheme"; +import CodeBadge from "../shared/CodeBadge"; interface DepartmentsTableProps { tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) @@ -32,18 +37,24 @@ interface DepartmentsTableProps { showHeader?: boolean; } -const DepartmentsTable = ({ +export interface DepartmentsTableRef { + openNewModal: () => void; +} + +export const DepartmentsTable = forwardRef(({ tenantId: propsTenantId, compact = false, showHeader = true, -}: DepartmentsTableProps): ReactElement => { +}: DepartmentsTableProps, ref): ReactElement => { const { primaryColor } = useAppTheme(); const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); const effectiveTenantId = propsTenantId || reduxTenantId; const [departments, setDepartments] = useState([]); + const [treeData, setTreeData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'list' | 'tree'>('list'); // Pagination state (Client-side since backend doesn't support it yet) const [currentPage, setCurrentPage] = useState(1); @@ -63,18 +74,32 @@ const DepartmentsTable = ({ useState(null); const [isActionLoading, setIsActionLoading] = useState(false); + // Expose methods to parent + useImperativeHandle(ref, () => ({ + openNewModal: () => setIsNewModalOpen(true), + })); + const fetchDepartments = async () => { try { setIsLoading(true); setError(null); - const response = await departmentService.list(effectiveTenantId, { - active_only: activeOnly, - search: debouncedSearchQuery, - }); - if (response.success) { - setDepartments(response.data); + if (viewMode === 'list') { + const response = await departmentService.list(effectiveTenantId, { + active_only: activeOnly, + search: debouncedSearchQuery, + }); + if (response.success) { + setDepartments(response.data); + } else { + setError("Failed to load departments"); + } } else { - setError("Failed to load departments"); + const response = await departmentService.getTree(effectiveTenantId, activeOnly); + if (response.success) { + setTreeData(response.data); + } else { + setError("Failed to load department tree"); + } } } catch (err: any) { setError( @@ -96,7 +121,7 @@ const DepartmentsTable = ({ useEffect(() => { fetchDepartments(); - }, [effectiveTenantId, activeOnly, debouncedSearchQuery]); + }, [effectiveTenantId, activeOnly, debouncedSearchQuery, viewMode]); const handleCreate = async (data: CreateDepartmentRequest) => { try { @@ -176,6 +201,13 @@ const DepartmentsTable = ({ {dept.name} ), }, + { + key: "code", + label: "Code", + render: (dept) => ( + + ), + }, { key: "parent_name", label: "Parent", @@ -237,10 +269,6 @@ const DepartmentsTable = ({ setSelectedDepartment(dept); setIsEditModalOpen(true); }} - // onDelete={() => { - // setSelectedDepartment(dept); - // setIsDeleteModalOpen(true); - // }} />
), @@ -252,63 +280,66 @@ const DepartmentsTable = ({ className={`flex flex-col gap-4 ${!compact ? "bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-sm" : ""}`} > {showHeader && ( -
-
-
- - { - e.currentTarget.style.borderColor = primaryColor; - e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`; - }} - onBlur={(e) => { - e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)'; - e.currentTarget.style.boxShadow = 'none'; - }} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> +
+ {/* Tabs */} +
+
+ +
- setActiveOnly(value === "active")} + + {/* Active Only Toggle */} +
- setIsNewModalOpen(true)} - > - - New Department - + +
+
+ {viewMode === 'list' && ( + + )} +
+
)} - dept.id} - isLoading={isLoading} - error={error} - emptyMessage="No departments found" - /> - - {totalItems > 0 && ( - + ) : ( + { + setSelectedDepartment(item); + setIsNewModalOpen(true); + }} + onEdit={(item) => { + setSelectedDepartment(item); + setIsEditModalOpen(true); + }} + /> )} */}
); -}; - -export default DepartmentsTable; +}); diff --git a/src/components/superadmin/DesignationsTable.tsx b/src/components/superadmin/DesignationsTable.tsx index 5fd3c64..334fc96 100644 --- a/src/components/superadmin/DesignationsTable.tsx +++ b/src/components/superadmin/DesignationsTable.tsx @@ -6,16 +6,19 @@ import { ActionDropdown, DataTable, Pagination, - FilterDropdown, - // DeleteConfirmationModal, type Column, + SearchBox, + ActiveOnlyToggle, } from "@/components/shared"; import { NewDesignationModal, EditDesignationModal, ViewDesignationModal, } from "@/components/shared/DesignationModals"; -import { Plus, Search } from "lucide-react"; +import { + Plus, + // , Search +} from "lucide-react"; import { designationService } from "@/services/designation-service"; import type { Designation, @@ -24,7 +27,8 @@ import type { } from "@/types/designation"; import { showToast } from "@/utils/toast"; import type { RootState } from "@/store/store"; -import { useAppTheme } from "@/hooks/useAppTheme"; +// import { useAppTheme } from "@/hooks/useAppTheme"; +import CodeBadge from "../shared/CodeBadge"; interface DesignationsTableProps { tenantId?: string | null; // If provided, use this tenantId (Super Admin mode) @@ -37,7 +41,7 @@ const DesignationsTable = ({ compact = false, showHeader = true, }: DesignationsTableProps): ReactElement => { - const { primaryColor } = useAppTheme(); + // const { primaryColor } = useAppTheme(); const reduxTenantId = useSelector((state: RootState) => state.auth.tenantId); const effectiveTenantId = propsTenantId || reduxTenantId; @@ -179,9 +183,7 @@ const DesignationsTable = ({ { key: "code", label: "Code", - render: (desig) => ( - {desig.code} - ), + render: (desig) => , }, { key: "level", @@ -245,37 +247,14 @@ const DesignationsTable = ({ {showHeader && (
-
- - { - e.currentTarget.style.borderColor = primaryColor; - e.currentTarget.style.boxShadow = `0 0 0 2px ${primaryColor}1A`; - }} - onBlur={(e) => { - e.currentTarget.style.borderColor = 'rgba(0,0,0,0.08)'; - e.currentTarget.style.boxShadow = 'none'; - }} - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - /> -
- setActiveOnly(value === "active")} + + setActiveOnly(val)} />

Basic Information

-
+ {/*
*/} -
+ {/*
{module.module_id}
-
-
- */} + {/*
*/} +
diff --git a/src/components/superadmin/NewModuleModal.tsx b/src/components/superadmin/NewModuleModal.tsx index f62386b..cd22993 100644 --- a/src/components/superadmin/NewModuleModal.tsx +++ b/src/components/superadmin/NewModuleModal.tsx @@ -1,71 +1,122 @@ -import { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { Modal, FormField, PrimaryButton, SecondaryButton } from '@/components/shared'; -import { Copy, Check } from 'lucide-react'; -import { showToast } from '@/utils/toast'; +import { useEffect, useState } from "react"; +import type { ReactElement } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + Modal, + FormField, + PrimaryButton, + SecondaryButton, + FormTextArea, +} from "@/components/shared"; +import { Copy, Check } from "lucide-react"; +import { showToast } from "@/utils/toast"; // Validation schema - matches backend validation const newModuleSchema = z.object({ module_id: z .string() - .min(1, 'module_id is required') - .min(3, 'module_id must be at least 3 characters') - .max(100, 'module_id must be at most 100 characters'), + .min(1, "module_id is required") + .min(3, "module_id must be at least 3 characters") + .max(100, "module_id must be at most 100 characters"), name: z .string() - .min(1, 'name is required') - .min(3, 'name must be at least 3 characters') - .max(100, 'name must be at most 100 characters'), - description: z.string().max(1000, 'description must be at most 1000 characters').optional().nullable(), + .min(1, "name is required") + .min(3, "name must be at least 3 characters") + .max(100, "name must be at most 100 characters"), + description: z + .string() + .max(1000, "description must be at most 1000 characters") + .optional() + .nullable(), version: z .string() - .min(1, 'version is required') - .max(20, 'version must be at most 20 characters') - .regex(/^[0-9]+\.[0-9]+\.[0-9]+$/, 'version format is invalid (must be X.Y.Z)'), + .min(1, "version is required") + .max(20, "version must be at most 20 characters") + .regex( + /^[0-9]+\.[0-9]+\.[0-9]+$/, + "version format is invalid (must be X.Y.Z)", + ), runtime_language: z .string() - .min(1, 'runtime_language is required') - .max(50, 'runtime_language must be at most 50 characters'), - framework: z.string().max(50, 'framework must be at most 50 characters').optional().nullable(), + .min(1, "runtime_language is required") + .max(50, "runtime_language must be at most 50 characters"), + framework: z + .string() + .max(50, "framework must be at most 50 characters") + .optional() + .nullable(), webhookurl: z .union([ - z.string().url("Invalid URL format").max(500, "webhookurl must be at most 500 characters"), + z + .string() + .url("Invalid URL format") + .max(500, "webhookurl must be at most 500 characters"), z.literal("").transform(() => null), z.null(), ]) .optional(), sync_webhook_url: z .union([ - z.string().url("Invalid URL format").max(500, "sync_webhook_url must be at most 500 characters"), + z + .string() + .url("Invalid URL format") + .max(500, "sync_webhook_url must be at most 500 characters"), z.literal("").transform(() => null), z.null(), ]) .optional(), frontend_base_url: z .string() - .min(1, 'frontend_base_url is required') - .max(255, 'frontend_base_url must be at most 255 characters') - .url('Invalid URL format'), + .min(1, "frontend_base_url is required") + .max(255, "frontend_base_url must be at most 255 characters") + .url("Invalid URL format"), backend_base_url: z .string() - .min(1, 'backend_base_url is required') - .max(255, 'backend_base_url must be at most 255 characters') - .url('Invalid URL format'), + .min(1, "backend_base_url is required") + .max(255, "backend_base_url must be at most 255 characters") + .url("Invalid URL format"), health_endpoint: z .string() - .min(1, 'health_endpoint is required') - .max(255, 'health_endpoint must be at most 255 characters'), + .min(1, "health_endpoint is required") + .max(255, "health_endpoint must be at most 255 characters"), endpoints: z.any().optional().nullable(), kafka_topics: z.any().optional().nullable(), - cpu_request: z.string().max(20, 'cpu_request must be at most 20 characters').optional().nullable(), - cpu_limit: z.string().max(20, 'cpu_limit must be at most 20 characters').optional().nullable(), - memory_request: z.string().max(20, 'memory_request must be at most 20 characters').optional().nullable(), - memory_limit: z.string().max(20, 'memory_limit must be at most 20 characters').optional().nullable(), - min_replicas: z.number().int().min(1, 'min_replicas must be at least 1').max(50, 'min_replicas must be at most 50').optional().nullable(), - max_replicas: z.number().int().min(1, 'max_replicas must be at least 1').max(50, 'max_replicas must be at most 50').optional().nullable(), + cpu_request: z + .string() + .max(20, "cpu_request must be at most 20 characters") + .optional() + .nullable(), + cpu_limit: z + .string() + .max(20, "cpu_limit must be at most 20 characters") + .optional() + .nullable(), + memory_request: z + .string() + .max(20, "memory_request must be at most 20 characters") + .optional() + .nullable(), + memory_limit: z + .string() + .max(20, "memory_limit must be at most 20 characters") + .optional() + .nullable(), + min_replicas: z + .number() + .int() + .min(1, "min_replicas must be at least 1") + .max(50, "min_replicas must be at most 50") + .optional() + .nullable(), + max_replicas: z + .number() + .int() + .min(1, "max_replicas must be at least 1") + .max(50, "max_replicas must be at most 50") + .optional() + .nullable(), last_health_check: z.string().optional().nullable(), consecutive_failures: z.number().int().optional().nullable(), registered_by: z.uuid().optional().nullable(), @@ -125,16 +176,16 @@ export const NewModuleModal = ({ useEffect(() => { if (!isOpen) { reset({ - module_id: '', - name: '', + module_id: "", + name: "", description: null, - version: '', - runtime_language: '', + version: "", + runtime_language: "", framework: null, webhookurl: null, - frontend_base_url: '', - backend_base_url: '', - health_endpoint: '', + frontend_base_url: "", + backend_base_url: "", + health_endpoint: "", endpoints: null, kafka_topics: null, cpu_request: null, @@ -164,38 +215,75 @@ export const NewModuleModal = ({ if (response?.api_key?.key) { setApiKey(response.api_key.key); showToast.success( - 'Module registered successfully', - 'Your API key has been generated. Please copy and store it securely - this is the only time you will see it.' + "Module registered successfully", + "Your API key has been generated. Please copy and store it securely - this is the only time you will see it.", ); } else { - showToast.success('Module registered successfully'); + showToast.success("Module registered successfully"); onClose(); } } catch (error: any) { // Handle validation errors from API - if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { + if ( + error?.response?.data?.details && + Array.isArray(error.response.data.details) + ) { const validationErrors = error.response.data.details; - validationErrors.forEach((detail: { path: string; message: string }) => { - if (detail.path === 'name' || detail.path === 'module_id' || detail.path === 'description' || detail.path === 'version' || detail.path === 'runtime_language' || detail.path === 'framework' || detail.path === 'webhookurl' || detail.path === 'sync_webhook_url' || detail.path === 'frontend_base_url' || detail.path === 'backend_base_url' || detail.path === 'health_endpoint' || detail.path === 'endpoints' || detail.path === 'kafka_topics' || detail.path === 'cpu_request' || detail.path === 'cpu_limit' || detail.path === 'memory_request' || detail.path === 'memory_limit' || detail.path === 'min_replicas' || detail.path === 'max_replicas' || detail.path === 'last_health_check' || detail.path === 'consecutive_failures' || detail.path === 'registered_by' || detail.path === 'tenant_id' || detail.path === 'metadata') { - setError(detail.path as keyof NewModuleFormData, { - type: 'server', - message: detail.message, - }); - } - }); + validationErrors.forEach( + (detail: { path: string; message: string }) => { + if ( + detail.path === "name" || + detail.path === "module_id" || + detail.path === "description" || + detail.path === "version" || + detail.path === "runtime_language" || + detail.path === "framework" || + detail.path === "webhookurl" || + detail.path === "sync_webhook_url" || + detail.path === "frontend_base_url" || + detail.path === "backend_base_url" || + detail.path === "health_endpoint" || + detail.path === "endpoints" || + detail.path === "kafka_topics" || + detail.path === "cpu_request" || + detail.path === "cpu_limit" || + detail.path === "memory_request" || + detail.path === "memory_limit" || + detail.path === "min_replicas" || + detail.path === "max_replicas" || + detail.path === "last_health_check" || + detail.path === "consecutive_failures" || + detail.path === "registered_by" || + detail.path === "tenant_id" || + detail.path === "metadata" + ) { + setError(detail.path as keyof NewModuleFormData, { + type: "server", + message: detail.message, + }); + } + }, + ); } else { // Handle general errors // Check for nested error object with message property const errorObj = error?.response?.data?.error; const errorMessage = - (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || - (typeof errorObj === 'string' ? errorObj : null) || + (typeof errorObj === "object" && + errorObj !== null && + "message" in errorObj + ? errorObj.message + : null) || + (typeof errorObj === "string" ? errorObj : null) || error?.response?.data?.message || error?.message || - 'Failed to create module. Please try again.'; - setError('root', { - type: 'server', - message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create module. Please try again.', + "Failed to create module. Please try again."; + setError("root", { + type: "server", + message: + typeof errorMessage === "string" + ? errorMessage + : "Failed to create module. Please try again.", }); } } @@ -207,9 +295,9 @@ export const NewModuleModal = ({ await navigator.clipboard.writeText(apiKey); setCopied(true); setTimeout(() => setCopied(false), 2000); - showToast.success('API key copied to clipboard'); + showToast.success("API key copied to clipboard"); } catch (err) { - showToast.error('Failed to copy API key'); + showToast.error("Failed to copy API key"); } } }; @@ -235,7 +323,7 @@ export const NewModuleModal = ({ disabled={isLoading} className="px-4 py-2.5 text-sm" > - {apiKey ? 'Close' : 'Cancel'} + {apiKey ? "Close" : "Cancel"} {!apiKey && ( - {isLoading ? 'Registering...' : 'Register Module'} + {isLoading ? "Registering..." : "Register Module"} )} @@ -260,11 +348,16 @@ export const NewModuleModal = ({ ⚠️ Important: Save Your API Key

- Your API key has been generated. This is the only time you will see this key. Store it securely in your module project to authenticate with QAssure services. If you lose this key, you cannot retrieve it. + Your API key has been generated. This is the{" "} + only time you will see this key. Store it + securely in your module project to authenticate with QAssure + services. If you lose this key, you cannot retrieve it.

- {apiKey} + + {apiKey} +