369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import { Layout } from '@/components/layout/Layout';
|
|
import {
|
|
StatusBadge,
|
|
ViewModuleModal,
|
|
DataTable,
|
|
Pagination,
|
|
FilterDropdown,
|
|
type Column,
|
|
} from '@/components/shared';
|
|
import { Download, ArrowUpDown } from 'lucide-react';
|
|
import { moduleService } from '@/services/module-service';
|
|
import type { Module } from '@/types/module';
|
|
|
|
// 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' });
|
|
};
|
|
|
|
// Helper function to get status badge variant
|
|
const getStatusVariant = (status: string | null): 'success' | 'failure' | 'process' => {
|
|
if (!status) return 'process';
|
|
switch (status.toLowerCase()) {
|
|
case 'running':
|
|
case 'active':
|
|
case 'healthy':
|
|
return 'success';
|
|
case 'stopped':
|
|
case 'failed':
|
|
case 'unhealthy':
|
|
return 'failure';
|
|
default:
|
|
return 'process';
|
|
}
|
|
};
|
|
|
|
const Modules = (): ReactElement => {
|
|
const [modules, setModules] = useState<Module[]>([]);
|
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Pagination state
|
|
const [currentPage, setCurrentPage] = useState<number>(1);
|
|
const [limit, setLimit] = useState<number>(5);
|
|
const [pagination, setPagination] = useState<{
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
totalPages: number;
|
|
hasMore: boolean;
|
|
}>({
|
|
page: 1,
|
|
limit: 5,
|
|
total: 0,
|
|
totalPages: 1,
|
|
hasMore: false,
|
|
});
|
|
|
|
// Filter state
|
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
|
|
|
// View modal
|
|
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
|
const [selectedModuleId, setSelectedModuleId] = useState<string | null>(null);
|
|
|
|
const fetchModules = async (
|
|
page: number,
|
|
itemsPerPage: number,
|
|
status: string | null = null,
|
|
sortBy: string[] | null = null
|
|
): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await moduleService.getAll(page, itemsPerPage, status, sortBy);
|
|
if (response.success) {
|
|
setModules(response.data);
|
|
setPagination(response.pagination);
|
|
} else {
|
|
setError('Failed to load modules');
|
|
}
|
|
} catch (err: any) {
|
|
setError(err?.response?.data?.error?.message || 'Failed to load modules');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
// Fetch modules on mount and when pagination/filters change
|
|
useEffect(() => {
|
|
fetchModules(currentPage, limit, statusFilter, orderBy);
|
|
}, [currentPage, limit, statusFilter, orderBy]);
|
|
|
|
// View module handler
|
|
const handleViewModule = (moduleId: string): void => {
|
|
setSelectedModuleId(moduleId);
|
|
setViewModalOpen(true);
|
|
};
|
|
|
|
// Load module for view
|
|
const loadModule = async (id: string): Promise<Module> => {
|
|
const response = await moduleService.getById(id);
|
|
return response.data;
|
|
};
|
|
|
|
// Define table columns
|
|
const columns: Column<Module>[] = [
|
|
{
|
|
key: 'name',
|
|
label: 'Module Name',
|
|
render: (module) => (
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
|
<span className="text-xs font-normal text-[#9aa6b2]">
|
|
{module.name.substring(0, 2).toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-normal text-[#0f1724]">{module.name}</span>
|
|
<span className="text-xs text-[#6b7280] font-mono">{module.module_id}</span>
|
|
</div>
|
|
</div>
|
|
),
|
|
mobileLabel: 'Name',
|
|
},
|
|
{
|
|
key: 'description',
|
|
label: 'Description',
|
|
render: (module) => (
|
|
<span className="text-sm font-normal text-[#0f1724] line-clamp-1">{module.description}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'version',
|
|
label: 'Version',
|
|
render: (module) => (
|
|
<span className="text-sm font-normal text-[#0f1724]">{module.version}</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'status',
|
|
label: 'Status',
|
|
render: (module) => (
|
|
<StatusBadge variant={getStatusVariant(module.status)}>
|
|
{module.status || 'Unknown'}
|
|
</StatusBadge>
|
|
),
|
|
},
|
|
{
|
|
key: 'health_status',
|
|
label: 'Health Status',
|
|
render: (module) => (
|
|
<StatusBadge variant={getStatusVariant(module.health_status)}>
|
|
{module.health_status || 'N/A'}
|
|
</StatusBadge>
|
|
),
|
|
},
|
|
{
|
|
key: 'runtime_language',
|
|
label: 'Runtime',
|
|
render: (module) => (
|
|
<span className="text-sm font-normal text-[#0f1724]">
|
|
{module.runtime_language || 'N/A'}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: 'created_at',
|
|
label: 'Registered Date',
|
|
render: (module) => (
|
|
<span className="text-sm font-normal text-[#6b7280]">{formatDate(module.created_at)}</span>
|
|
),
|
|
mobileLabel: 'Registered',
|
|
},
|
|
{
|
|
key: 'actions',
|
|
label: 'Actions',
|
|
align: 'right',
|
|
render: (module) => (
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewModule(module.id)}
|
|
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors"
|
|
>
|
|
View
|
|
</button>
|
|
</div>
|
|
),
|
|
},
|
|
];
|
|
|
|
// Mobile card renderer
|
|
const mobileCardRenderer = (module: Module) => (
|
|
<div className="p-4">
|
|
<div className="flex items-start justify-between gap-3 mb-3">
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
<div className="w-8 h-8 bg-[#f5f7fa] border border-[rgba(0,0,0,0.08)] rounded-lg flex items-center justify-center shrink-0">
|
|
<span className="text-xs font-normal text-[#9aa6b2]">
|
|
{module.name.substring(0, 2).toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-sm font-medium text-[#0f1724] truncate">{module.name}</h3>
|
|
<p className="text-xs text-[#6b7280] mt-0.5 truncate font-mono">{module.module_id}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleViewModule(module.id)}
|
|
className="text-sm text-[#112868] hover:text-[#0d1f4e] font-medium transition-colors shrink-0"
|
|
>
|
|
View
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Status:</span>
|
|
<div className="mt-1">
|
|
<StatusBadge variant={getStatusVariant(module.status)}>
|
|
{module.status || 'Unknown'}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Health:</span>
|
|
<div className="mt-1">
|
|
<StatusBadge variant={getStatusVariant(module.health_status)}>
|
|
{module.health_status || 'N/A'}
|
|
</StatusBadge>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Version:</span>
|
|
<p className="text-[#0f1724] font-normal mt-1">{module.version}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Runtime:</span>
|
|
<p className="text-[#0f1724] font-normal mt-1">{module.runtime_language || 'N/A'}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Registered:</span>
|
|
<p className="text-[#6b7280] font-normal mt-1">{formatDate(module.created_at)}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-[#9aa6b2]">Description:</span>
|
|
<p className="text-[#0f1724] font-normal mt-1 line-clamp-2">{module.description}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Modules"
|
|
pageHeader={{
|
|
title: 'Module List',
|
|
description: 'View and manage all system modules registered in the QAssure platform.',
|
|
}}
|
|
>
|
|
{/* Table Container */}
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
{/* Table Header with Filters */}
|
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Status Filter */}
|
|
<FilterDropdown
|
|
label="Status"
|
|
options={[
|
|
{ value: 'running', label: 'Running' },
|
|
{ value: 'stopped', label: 'Stopped' },
|
|
{ value: 'failed', label: 'Failed' },
|
|
]}
|
|
value={statusFilter}
|
|
onChange={(value) => {
|
|
setStatusFilter(value as string | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="All"
|
|
/>
|
|
|
|
{/* Sort Filter */}
|
|
<FilterDropdown
|
|
label="Sort by"
|
|
options={[
|
|
{ value: ['name', 'asc'], label: 'Name (A-Z)' },
|
|
{ value: ['name', 'desc'], label: 'Name (Z-A)' },
|
|
{ value: ['module_id', 'asc'], label: 'Module ID (A-Z)' },
|
|
{ value: ['module_id', 'desc'], label: 'Module ID (Z-A)' },
|
|
{ value: ['status', 'asc'], label: 'Status (A-Z)' },
|
|
{ value: ['status', 'desc'], label: 'Status (Z-A)' },
|
|
{ value: ['created_at', 'asc'], label: 'Created (Oldest)' },
|
|
{ value: ['created_at', 'desc'], label: 'Created (Newest)' },
|
|
{ value: ['updated_at', 'asc'], label: 'Updated (Oldest)' },
|
|
{ value: ['updated_at', 'desc'], label: 'Updated (Newest)' },
|
|
]}
|
|
value={orderBy}
|
|
onChange={(value) => {
|
|
setOrderBy(value as string[] | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="Default"
|
|
showIcon
|
|
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
|
|
/>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
{/* Export Button */}
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 px-3 py-2 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50 transition-colors"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
<span>Export</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Table */}
|
|
<DataTable
|
|
columns={columns}
|
|
data={modules}
|
|
keyExtractor={(module) => module.id}
|
|
isLoading={isLoading}
|
|
error={error}
|
|
mobileCardRenderer={mobileCardRenderer}
|
|
emptyMessage="No modules found"
|
|
/>
|
|
|
|
{/* Pagination */}
|
|
{pagination.total > 0 && (
|
|
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
|
|
<Pagination
|
|
currentPage={pagination.page}
|
|
totalPages={pagination.totalPages}
|
|
totalItems={pagination.total}
|
|
limit={limit}
|
|
onPageChange={(page: number) => setCurrentPage(page)}
|
|
onLimitChange={(newLimit: number) => {
|
|
setLimit(newLimit);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* View Module Modal */}
|
|
<ViewModuleModal
|
|
isOpen={viewModalOpen}
|
|
onClose={() => {
|
|
setViewModalOpen(false);
|
|
setSelectedModuleId(null);
|
|
}}
|
|
moduleId={selectedModuleId}
|
|
onLoadModule={loadModule}
|
|
/>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Modules;
|