signal-batch/frontend/src/components/common/DataTable.tsx
htlee a0f24d5757 feat: API/WS 쿼리 메트릭 이력 조회 기능 구현
- QueryMetricsBufferService: ConcurrentLinkedQueue + 10초 batch flush
- GisServiceV2: REST API 메트릭 수집 추가
- ChunkedTrackStreamingService: saveAsync → buffer.enqueue 전환
- QueryMetricsController: /history (페이지네이션+필터), /summary (P95 포함)
- ApiMetrics.tsx: 요약카드 + 버튼그룹 필터 + 서버사이드 DataTable + 30s 폴링
- DataTable: server-side pagination props 확장 (하위 호환)
2026-03-10 08:41:56 +09:00

147 lines
4.5 KiB
TypeScript

import { useState, useMemo, type ReactNode } from 'react'
import { useI18n } from '../../hooks/useI18n.ts'
export interface Column<T> {
key: string
label: string
render?: (row: T) => ReactNode
sortable?: boolean
align?: 'left' | 'center' | 'right'
}
interface DataTableProps<T> {
columns: Column<T>[]
data: T[]
keyExtractor: (row: T) => string | number
onRowClick?: (row: T) => void
emptyMessage?: string
pageSize?: number
// Server-side pagination (optional)
totalElements?: number
currentPage?: number
onPageChange?: (page: number) => void
}
export default function DataTable<T>({
columns,
data,
keyExtractor,
onRowClick,
emptyMessage,
pageSize = 20,
totalElements,
currentPage,
onPageChange,
}: DataTableProps<T>) {
const { t } = useI18n()
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortAsc, setSortAsc] = useState(true)
const [page, setPage] = useState(0)
const isServerSide = totalElements != null && currentPage != null && onPageChange != null
const sorted = useMemo(() => {
if (isServerSide || !sortKey) return data
return [...data].sort((a, b) => {
const av = (a as Record<string, unknown>)[sortKey]
const bv = (b as Record<string, unknown>)[sortKey]
if (av == null || bv == null) return 0
const cmp = av < bv ? -1 : av > bv ? 1 : 0
return sortAsc ? cmp : -cmp
})
}, [data, sortKey, sortAsc, isServerSide])
const effectivePage = isServerSide ? currentPage! : page
const total = isServerSide ? totalElements! : sorted.length
const totalPages = Math.ceil(total / pageSize)
const paged = isServerSide ? sorted : sorted.slice(effectivePage * pageSize, (effectivePage + 1) * pageSize)
const handleSort = (key: string) => {
if (sortKey === key) {
setSortAsc(!sortAsc)
} else {
setSortKey(key)
setSortAsc(true)
}
}
const handlePageChange = (newPage: number) => {
if (isServerSide) {
onPageChange!(newPage)
} else {
setPage(newPage)
}
}
return (
<div>
<div className="sb-table-wrapper">
<table className="sb-table">
<thead>
<tr>
{columns.map(col => (
<th
key={col.key}
onClick={col.sortable !== false ? () => handleSort(col.key) : undefined}
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
>
{col.label}
{sortKey === col.key && (sortAsc ? ' ▲' : ' ▼')}
</th>
))}
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr>
<td colSpan={columns.length} className="sb-table-empty">
{emptyMessage ?? t('common.noData')}
</td>
</tr>
) : (
paged.map(row => (
<tr
key={keyExtractor(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
>
{columns.map(col => (
<td key={col.key} style={{ textAlign: col.align ?? 'left' }}>
{col.render
? col.render(row)
: String((row as Record<string, unknown>)[col.key] ?? '-')}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="mt-3 flex items-center justify-between text-sm text-muted">
<span>
{total}{t('common.items')} {t('common.of')} {effectivePage * pageSize + 1}-{Math.min((effectivePage + 1) * pageSize, total)}
</span>
<div className="flex gap-1">
<button
onClick={() => handlePageChange(Math.max(0, effectivePage - 1))}
disabled={effectivePage === 0}
className="rounded border border-border px-2 py-1 disabled:opacity-40"
>
{t('common.prev')}
</button>
<button
onClick={() => handlePageChange(Math.min(totalPages - 1, effectivePage + 1))}
disabled={effectivePage >= totalPages - 1}
className="rounded border border-border px-2 py-1 disabled:opacity-40"
>
{t('common.next')}
</button>
</div>
</div>
)}
</div>
)
}