Implement Phase 2: Monitoring, Error Handling, and UI Components
Phase 2 - Monitoring & Observability: - Create metrics collection system with counters, gauges, and histograms - Add Prometheus-compatible /metrics endpoint - Implement request/response metrics tracking - Database and process metrics monitoring Phase 2 - Enhanced Error Handling: - Circuit breaker pattern for service resilience - Retry mechanism with exponential backoff - Comprehensive error handler middleware - Async error wrapper for route handlers - Request timeout middleware Phase 3 - UI Components: - SkeletonLoader components for better loading states - EmptyState component with helpful messages - ErrorState component with retry functionality - Enhanced DataTable with sorting, filtering, pagination All components are production-ready and integrated.
This commit is contained in:
@@ -42,6 +42,29 @@ import userRoutes from './routes/users';
|
||||
import complianceRoutes from './routes/compliance';
|
||||
import reportsRoutes from './routes/reports';
|
||||
import fxContractRoutes from './routes/fx-contracts';
|
||||
import metricsRoutes from './routes/metrics';
|
||||
import { errorHandler } from './middleware/errorHandler';
|
||||
import { incrementCounter, recordHistogram } from './monitoring/metrics';
|
||||
|
||||
// Request metrics middleware
|
||||
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||
const startTime = Date.now();
|
||||
incrementCounter('http_requests_total', { method: req.method, path: req.path });
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - startTime;
|
||||
recordHistogram('http_request_duration_ms', duration, {
|
||||
method: req.method,
|
||||
status: res.statusCode.toString(),
|
||||
});
|
||||
incrementCounter('http_responses_total', {
|
||||
method: req.method,
|
||||
status: res.statusCode.toString(),
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Register routes
|
||||
app.use('/api/v1/auth', authRoutes);
|
||||
@@ -51,6 +74,7 @@ app.use('/api/v1/users', userRoutes);
|
||||
app.use('/api/v1/compliance', complianceRoutes);
|
||||
app.use('/api/v1/reports', reportsRoutes);
|
||||
app.use('/api/v1/fx-contracts', fxContractRoutes);
|
||||
app.use('/metrics', metricsRoutes);
|
||||
|
||||
// Legacy evaluate transaction endpoint
|
||||
app.post('/api/v1/transactions/evaluate', async (req: Request, res: Response) => {
|
||||
@@ -73,14 +97,8 @@ app.post('/api/v1/transactions/evaluate', async (req: Request, res: Response) =>
|
||||
}
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
logger.error('Unhandled error', err);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
});
|
||||
});
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
// Initialize database and start server
|
||||
let server: any;
|
||||
|
||||
207
apps/api/src/middleware/errorHandler.ts
Normal file
207
apps/api/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Enhanced Error Handling Middleware
|
||||
* Provides retry logic, circuit breakers, and graceful degradation
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { incrementCounter, recordHistogram } from '../monitoring/metrics';
|
||||
|
||||
export interface AppError extends Error {
|
||||
statusCode?: number;
|
||||
code?: string;
|
||||
retryable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker implementation
|
||||
*/
|
||||
class CircuitBreaker {
|
||||
private failures: number = 0;
|
||||
private lastFailureTime: number = 0;
|
||||
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
||||
private readonly failureThreshold: number = 5;
|
||||
private readonly timeout: number = 60000; // 60 seconds
|
||||
|
||||
execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.state === 'open') {
|
||||
if (Date.now() - this.lastFailureTime > this.timeout) {
|
||||
this.state = 'half-open';
|
||||
} else {
|
||||
return Promise.reject(new Error('Circuit breaker is open'));
|
||||
}
|
||||
}
|
||||
|
||||
return fn()
|
||||
.then((result) => {
|
||||
if (this.state === 'half-open') {
|
||||
this.state = 'closed';
|
||||
this.failures = 0;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.catch((error) => {
|
||||
this.failures++;
|
||||
this.lastFailureTime = Date.now();
|
||||
|
||||
if (this.failures >= this.failureThreshold) {
|
||||
this.state = 'open';
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
getState(): string {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = 'closed';
|
||||
this.failures = 0;
|
||||
this.lastFailureTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const circuitBreakers = new Map<string, CircuitBreaker>();
|
||||
|
||||
/**
|
||||
* Get or create circuit breaker for a service
|
||||
*/
|
||||
function getCircuitBreaker(service: string): CircuitBreaker {
|
||||
if (!circuitBreakers.has(service)) {
|
||||
circuitBreakers.set(service, new CircuitBreaker());
|
||||
}
|
||||
return circuitBreakers.get(service)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry configuration
|
||||
*/
|
||||
interface RetryOptions {
|
||||
maxRetries?: number;
|
||||
retryDelay?: number;
|
||||
retryable?: (error: any) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry wrapper for async functions
|
||||
*/
|
||||
export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
retryable = (error: any) => error.retryable !== false,
|
||||
} = options;
|
||||
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const result = await fn();
|
||||
const duration = Date.now() - startTime;
|
||||
recordHistogram('request_duration_ms', duration);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
incrementCounter('request_retry_attempt', { attempt: attempt.toString() });
|
||||
|
||||
if (attempt < maxRetries && retryable(error)) {
|
||||
const delay = retryDelay * Math.pow(2, attempt); // Exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
incrementCounter('request_failed');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit breaker wrapper
|
||||
*/
|
||||
export async function withCircuitBreaker<T>(
|
||||
service: string,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const breaker = getCircuitBreaker(service);
|
||||
return breaker.execute(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error handler middleware
|
||||
*/
|
||||
export function errorHandler(
|
||||
err: AppError,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const statusCode = err.statusCode || 500;
|
||||
const code = err.code || 'INTERNAL_ERROR';
|
||||
|
||||
// Log error
|
||||
console.error('Error:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
statusCode,
|
||||
code,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
});
|
||||
|
||||
// Record metrics
|
||||
incrementCounter('http_errors', {
|
||||
status: statusCode.toString(),
|
||||
code,
|
||||
});
|
||||
|
||||
// Send error response
|
||||
res.status(statusCode).json({
|
||||
error: {
|
||||
message: err.message || 'Internal server error',
|
||||
code,
|
||||
statusCode,
|
||||
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Async error wrapper
|
||||
*/
|
||||
export function asyncHandler(
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<any>
|
||||
) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeout middleware
|
||||
*/
|
||||
export function timeout(ms: number) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (!res.headersSent) {
|
||||
res.status(504).json({
|
||||
error: {
|
||||
message: 'Request timeout',
|
||||
code: 'TIMEOUT',
|
||||
statusCode: 504,
|
||||
},
|
||||
});
|
||||
}
|
||||
}, ms);
|
||||
|
||||
res.on('finish', () => clearTimeout(timer));
|
||||
next();
|
||||
};
|
||||
}
|
||||
238
apps/api/src/monitoring/metrics.ts
Normal file
238
apps/api/src/monitoring/metrics.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Metrics Collection
|
||||
* Collects application metrics for monitoring
|
||||
*/
|
||||
|
||||
interface Metric {
|
||||
name: string;
|
||||
value: number;
|
||||
labels?: Record<string, string>;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
class MetricsCollector {
|
||||
private metrics: Map<string, Metric[]> = new Map();
|
||||
private counters: Map<string, number> = new Map();
|
||||
private histograms: Map<string, number[]> = new Map();
|
||||
|
||||
/**
|
||||
* Increment a counter
|
||||
*/
|
||||
incrementCounter(name: string, labels?: Record<string, string>): void {
|
||||
const key = this.getKey(name, labels);
|
||||
const current = this.counters.get(key) || 0;
|
||||
this.counters.set(key, current + 1);
|
||||
|
||||
this.recordMetric({
|
||||
name,
|
||||
value: current + 1,
|
||||
labels,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a gauge value
|
||||
*/
|
||||
recordGauge(name: string, value: number, labels?: Record<string, string>): void {
|
||||
this.recordMetric({
|
||||
name,
|
||||
value,
|
||||
labels,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a histogram value
|
||||
*/
|
||||
recordHistogram(name: string, value: number, labels?: Record<string, string>): void {
|
||||
const key = this.getKey(name, labels);
|
||||
const values = this.histograms.get(key) || [];
|
||||
values.push(value);
|
||||
// Keep only last 1000 values
|
||||
if (values.length > 1000) {
|
||||
values.shift();
|
||||
}
|
||||
this.histograms.set(key, values);
|
||||
|
||||
this.recordMetric({
|
||||
name,
|
||||
value,
|
||||
labels,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get counter value
|
||||
*/
|
||||
getCounter(name: string, labels?: Record<string, string>): number {
|
||||
const key = this.getKey(name, labels);
|
||||
return this.counters.get(key) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get histogram statistics
|
||||
*/
|
||||
getHistogramStats(name: string, labels?: Record<string, string>): {
|
||||
count: number;
|
||||
sum: number;
|
||||
min: number;
|
||||
max: number;
|
||||
avg: number;
|
||||
p50: number;
|
||||
p95: number;
|
||||
p99: number;
|
||||
} | null {
|
||||
const key = this.getKey(name, labels);
|
||||
const values = this.histograms.get(key);
|
||||
if (!values || values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const sum = sorted.reduce((a, b) => a + b, 0);
|
||||
const count = sorted.length;
|
||||
|
||||
return {
|
||||
count,
|
||||
sum,
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
avg: sum / count,
|
||||
p50: sorted[Math.floor(count * 0.5)],
|
||||
p95: sorted[Math.floor(count * 0.95)],
|
||||
p99: sorted[Math.floor(count * 0.99)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics in Prometheus format
|
||||
*/
|
||||
getPrometheusFormat(): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Counters
|
||||
for (const [key, value] of this.counters.entries()) {
|
||||
const [name, labelsStr] = this.parseKey(key);
|
||||
const labels = labelsStr ? `{${labelsStr}}` : '';
|
||||
lines.push(`# TYPE ${name} counter`);
|
||||
lines.push(`${name}${labels} ${value}`);
|
||||
}
|
||||
|
||||
// Histograms
|
||||
for (const [key] of this.histograms.entries()) {
|
||||
const [name, labelsStr] = this.parseKey(key);
|
||||
const stats = this.getHistogramStats(name, this.parseLabels(labelsStr));
|
||||
if (stats) {
|
||||
const labels = labelsStr ? `{${labelsStr}}` : '';
|
||||
lines.push(`# TYPE ${name} histogram`);
|
||||
lines.push(`${name}_count${labels} ${stats.count}`);
|
||||
lines.push(`${name}_sum${labels} ${stats.sum}`);
|
||||
lines.push(`${name}_min${labels} ${stats.min}`);
|
||||
lines.push(`${name}_max${labels} ${stats.max}`);
|
||||
lines.push(`${name}_avg${labels} ${stats.avg}`);
|
||||
lines.push(`${name}_p50${labels} ${stats.p50}`);
|
||||
lines.push(`${name}_p95${labels} ${stats.p95}`);
|
||||
lines.push(`${name}_p99${labels} ${stats.p99}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metrics as JSON
|
||||
*/
|
||||
getAllMetrics(): {
|
||||
counters: Record<string, number>;
|
||||
histograms: Record<string, any>;
|
||||
} {
|
||||
const counters: Record<string, number> = {};
|
||||
const histograms: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of this.counters.entries()) {
|
||||
counters[key] = value;
|
||||
}
|
||||
|
||||
for (const [key] of this.histograms.entries()) {
|
||||
const [name, labelsStr] = this.parseKey(key);
|
||||
const stats = this.getHistogramStats(name, this.parseLabels(labelsStr));
|
||||
if (stats) {
|
||||
histograms[key] = stats;
|
||||
}
|
||||
}
|
||||
|
||||
return { counters, histograms };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all metrics
|
||||
*/
|
||||
reset(): void {
|
||||
this.metrics.clear();
|
||||
this.counters.clear();
|
||||
this.histograms.clear();
|
||||
}
|
||||
|
||||
private recordMetric(metric: Metric): void {
|
||||
const key = this.getKey(metric.name, metric.labels);
|
||||
const metrics = this.metrics.get(key) || [];
|
||||
metrics.push(metric);
|
||||
// Keep only last 100 metrics per key
|
||||
if (metrics.length > 100) {
|
||||
metrics.shift();
|
||||
}
|
||||
this.metrics.set(key, metrics);
|
||||
}
|
||||
|
||||
private getKey(name: string, labels?: Record<string, string>): string {
|
||||
if (!labels || Object.keys(labels).length === 0) {
|
||||
return name;
|
||||
}
|
||||
const labelStr = Object.entries(labels)
|
||||
.map(([k, v]) => `${k}="${v}"`)
|
||||
.join(',');
|
||||
return `${name}{${labelStr}}`;
|
||||
}
|
||||
|
||||
private parseKey(key: string): [string, string] {
|
||||
const match = key.match(/^([^{]+)(\{.*\})?$/);
|
||||
if (!match) return [key, ''];
|
||||
return [match[1], match[2]?.slice(1, -1) || ''];
|
||||
}
|
||||
|
||||
private parseLabels(labelsStr: string): Record<string, string> | undefined {
|
||||
if (!labelsStr) return undefined;
|
||||
const labels: Record<string, string> = {};
|
||||
const pairs = labelsStr.split(',');
|
||||
for (const pair of pairs) {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key && value) {
|
||||
labels[key.trim()] = value.trim().replace(/^"|"$/g, '');
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const metricsCollector = new MetricsCollector();
|
||||
|
||||
export function getMetrics(): MetricsCollector {
|
||||
return metricsCollector;
|
||||
}
|
||||
|
||||
// Convenience functions
|
||||
export function incrementCounter(name: string, labels?: Record<string, string>): void {
|
||||
metricsCollector.incrementCounter(name, labels);
|
||||
}
|
||||
|
||||
export function recordGauge(name: string, value: number, labels?: Record<string, string>): void {
|
||||
metricsCollector.recordGauge(name, value, labels);
|
||||
}
|
||||
|
||||
export function recordHistogram(name: string, value: number, labels?: Record<string, string>): void {
|
||||
metricsCollector.recordHistogram(name, value, labels);
|
||||
}
|
||||
66
apps/api/src/routes/metrics.ts
Normal file
66
apps/api/src/routes/metrics.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Metrics Endpoint
|
||||
* Exposes Prometheus-compatible metrics
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getMetrics } from '../monitoring/metrics';
|
||||
import { getDatabaseStatus } from '../db/connection';
|
||||
|
||||
const router: Router = Router();
|
||||
|
||||
/**
|
||||
* GET /metrics
|
||||
* Prometheus-compatible metrics endpoint
|
||||
*/
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const metrics = getMetrics();
|
||||
const dbStatus = await getDatabaseStatus();
|
||||
|
||||
// Add database metrics
|
||||
metrics.recordGauge('database_pool_size', dbStatus.poolSize);
|
||||
metrics.recordGauge('database_idle_clients', dbStatus.idleClients);
|
||||
metrics.recordGauge('database_waiting_clients', dbStatus.waitingClients);
|
||||
|
||||
// Add process metrics
|
||||
const memUsage = process.memoryUsage();
|
||||
metrics.recordGauge('process_memory_heap_used', memUsage.heapUsed);
|
||||
metrics.recordGauge('process_memory_heap_total', memUsage.heapTotal);
|
||||
metrics.recordGauge('process_memory_rss', memUsage.rss);
|
||||
metrics.recordGauge('process_uptime_seconds', process.uptime());
|
||||
|
||||
const prometheusFormat = metrics.getPrometheusFormat();
|
||||
res.set('Content-Type', 'text/plain; version=0.0.4');
|
||||
res.send(prometheusFormat);
|
||||
} catch (error) {
|
||||
console.error('Metrics error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate metrics' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /metrics/json
|
||||
* JSON format metrics
|
||||
*/
|
||||
router.get('/json', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const metrics = getMetrics();
|
||||
const dbStatus = await getDatabaseStatus();
|
||||
|
||||
res.json({
|
||||
metrics: metrics.getAllMetrics(),
|
||||
database: dbStatus,
|
||||
process: {
|
||||
uptime: process.uptime(),
|
||||
memory: process.memoryUsage(),
|
||||
cpu: process.cpuUsage(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Metrics JSON error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate metrics' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
187
apps/web/src/components/DataTable.tsx
Normal file
187
apps/web/src/components/DataTable.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Enhanced Data Table Component
|
||||
* Supports sorting, filtering, pagination, and column visibility
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { FiChevronUp, FiChevronDown, FiChevronLeft, FiChevronRight } from 'react-icons/fi';
|
||||
|
||||
export interface Column<T> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
render?: (value: any, row: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
data: T[];
|
||||
columns: Column<T>[];
|
||||
pageSize?: number;
|
||||
showPagination?: boolean;
|
||||
showColumnToggle?: boolean;
|
||||
onRowClick?: (row: T) => void;
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
data,
|
||||
columns,
|
||||
pageSize = 10,
|
||||
showPagination = true,
|
||||
showColumnToggle = false,
|
||||
onRowClick,
|
||||
}: DataTableProps<T>) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(
|
||||
new Set(columns.map((c) => c.key))
|
||||
);
|
||||
|
||||
// Filter data
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter((row) => {
|
||||
return Object.entries(filters).every(([key, value]) => {
|
||||
if (!value) return true;
|
||||
const cellValue = String(row[key] || '').toLowerCase();
|
||||
return cellValue.includes(value.toLowerCase());
|
||||
});
|
||||
});
|
||||
}, [data, filters]);
|
||||
|
||||
// Sort data
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortColumn) return filteredData;
|
||||
|
||||
return [...filteredData].sort((a, b) => {
|
||||
const aVal = a[sortColumn];
|
||||
const bVal = b[sortColumn];
|
||||
|
||||
if (aVal === bVal) return 0;
|
||||
|
||||
const comparison = aVal < bVal ? -1 : 1;
|
||||
return sortDirection === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [filteredData, sortColumn, sortDirection]);
|
||||
|
||||
// Paginate data
|
||||
const paginatedData = useMemo(() => {
|
||||
if (!showPagination) return sortedData;
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
return sortedData.slice(start, start + pageSize);
|
||||
}, [sortedData, currentPage, pageSize, showPagination]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / pageSize);
|
||||
|
||||
const handleSort = (columnKey: string) => {
|
||||
if (sortColumn === columnKey) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortColumn(columnKey);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const visibleCols = columns.filter((col) => visibleColumns.has(col.key));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{/* Filters */}
|
||||
{columns.some((c) => c.filterable) && (
|
||||
<div className="p-4 border-b bg-gray-50">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{columns
|
||||
.filter((c) => c.filterable)
|
||||
.map((col) => (
|
||||
<input
|
||||
key={col.key}
|
||||
type="text"
|
||||
placeholder={`Filter ${col.label}...`}
|
||||
value={filters[col.key] || ''}
|
||||
onChange={(e) =>
|
||||
setFilters({ ...filters, [col.key]: e.target.value })
|
||||
}
|
||||
className="px-3 py-2 border rounded-md text-sm"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{visibleCols.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider ${
|
||||
col.sortable ? 'cursor-pointer hover:bg-gray-100' : ''
|
||||
}`}
|
||||
onClick={() => col.sortable && handleSort(col.key)}
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<span>{col.label}</span>
|
||||
{col.sortable && sortColumn === col.key && (
|
||||
sortDirection === 'asc' ? (
|
||||
<FiChevronUp className="w-4 h-4" />
|
||||
) : (
|
||||
<FiChevronDown className="w-4 h-4" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{paginatedData.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-gray-50' : ''}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{visibleCols.map((col) => (
|
||||
<td key={col.key} className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{col.render ? col.render(row[col.key], row) : String(row[col.key] || '')}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{showPagination && totalPages > 1 && (
|
||||
<div className="px-6 py-4 border-t flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
Showing {(currentPage - 1) * pageSize + 1} to{' '}
|
||||
{Math.min(currentPage * pageSize, sortedData.length)} of {sortedData.length} results
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 border rounded disabled:opacity-50"
|
||||
>
|
||||
<FiChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 border rounded disabled:opacity-50"
|
||||
>
|
||||
<FiChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user