import React from 'react' const LIMIAR_RENDERIZACAO_VIRTUAL = 1500 const ESTIMATIVA_ALTURA_LINHA_PX = 33 const OVERSCAN_LINHAS = 40 const MIN_JANELA_LINHAS = 220 const SORT_COLLATOR = new Intl.Collator('pt-BR', { numeric: true, sensitivity: 'base' }) function normalizeSortText(value) { if (value === null || value === undefined) return '' return String(value).trim() } function parseSortableNumber(value) { if (typeof value === 'number') { return Number.isFinite(value) ? value : null } const raw = normalizeSortText(value) if (!raw) return null const compact = raw .replace(/\u00a0/g, '') .replace(/\s+/g, '') .replace('%', '') if (!compact) return null const candidates = [ compact, compact.replace(/\./g, '').replace(',', '.'), compact.replace(/,/g, ''), ] for (let index = 0; index < candidates.length; index += 1) { const parsed = Number(candidates[index]) if (Number.isFinite(parsed)) return parsed } return null } function DataTable({ table, maxHeight = 320, highlightedRowIndices = null, highlightIndexColumn = 'Índice', highlightClassName = 'table-row-highlight', }) { if (!table || !table.columns || !table.rows) { return
Sem dados.
} const columns = Array.isArray(table.columns) ? table.columns : [] const sourceRows = Array.isArray(table.rows) ? table.rows : [] const [sortConfig, setSortConfig] = React.useState(null) const sortedRows = React.useMemo(() => { if (!sortConfig || !columns.includes(sortConfig.column)) return sourceRows const direction = sortConfig.direction === 'desc' ? 'desc' : 'asc' const column = sortConfig.column const indexedRows = sourceRows.map((row, index) => ({ row, index })) indexedRows.sort((leftItem, rightItem) => { const leftValue = leftItem.row?.[column] const rightValue = rightItem.row?.[column] const leftText = normalizeSortText(leftValue) const rightText = normalizeSortText(rightValue) const leftEmpty = leftText === '' const rightEmpty = rightText === '' if (leftEmpty || rightEmpty) { if (leftEmpty && rightEmpty) return leftItem.index - rightItem.index return leftEmpty ? 1 : -1 } const leftNumber = parseSortableNumber(leftValue) const rightNumber = parseSortableNumber(rightValue) let comparison = 0 if (leftNumber !== null && rightNumber !== null) { if (leftNumber < rightNumber) comparison = -1 else if (leftNumber > rightNumber) comparison = 1 } else { comparison = SORT_COLLATOR.compare(leftText, rightText) } if (comparison === 0) return leftItem.index - rightItem.index return direction === 'asc' ? comparison : -comparison }) return indexedRows.map((item) => item.row) }, [columns, sourceRows, sortConfig]) const totalRows = sortedRows.length const colSpan = Math.max(1, columns.length) const virtualizacaoAtiva = totalRows > LIMIAR_RENDERIZACAO_VIRTUAL const wrapperRef = React.useRef(null) const [scrollTop, setScrollTop] = React.useState(0) const [viewportHeight, setViewportHeight] = React.useState(Number(maxHeight) || 320) const tableIdentity = React.useMemo(() => { const colunas = columns.join("|") return `${colunas}::${sourceRows.length}` }, [columns, sourceRows.length]) React.useEffect(() => { setSortConfig((prev) => { if (!prev) return prev if (!columns.includes(prev.column)) return null return prev }) }, [columns]) React.useEffect(() => { setScrollTop(0) if (wrapperRef.current) { wrapperRef.current.scrollTop = 0 setViewportHeight(wrapperRef.current.clientHeight || Number(maxHeight) || 320) } }, [tableIdentity, maxHeight]) React.useEffect(() => { if (!wrapperRef.current) return undefined const target = wrapperRef.current if (typeof ResizeObserver === 'undefined') return undefined const observer = new ResizeObserver(() => { setViewportHeight(target.clientHeight || Number(maxHeight) || 320) }) observer.observe(target) return () => observer.disconnect() }, [maxHeight]) const onWrapperScroll = React.useCallback((event) => { if (!virtualizacaoAtiva) return setScrollTop(event.currentTarget.scrollTop || 0) }, [virtualizacaoAtiva]) const onToggleSort = React.useCallback((column) => { setSortConfig((prev) => { if (!prev || prev.column !== column) { return { column, direction: 'asc' } } if (prev.direction === 'asc') { return { column, direction: 'desc' } } return null }) }, []) const alturaViewport = Math.max(1, Number(viewportHeight) || Number(maxHeight) || 320) const linhasVisiveis = Math.max(1, Math.ceil(alturaViewport / ESTIMATIVA_ALTURA_LINHA_PX)) const janelaLinhas = Math.max(MIN_JANELA_LINHAS, linhasVisiveis + (OVERSCAN_LINHAS * 2)) let startIndex = 0 let endIndex = totalRows if (virtualizacaoAtiva) { const primeiraLinhaVisivel = Math.max(0, Math.floor(scrollTop / ESTIMATIVA_ALTURA_LINHA_PX)) startIndex = Math.max(0, primeiraLinhaVisivel - OVERSCAN_LINHAS) endIndex = Math.min(totalRows, startIndex + janelaLinhas) if (endIndex - startIndex < janelaLinhas && startIndex > 0) { startIndex = Math.max(0, endIndex - janelaLinhas) } } const rowsToRender = virtualizacaoAtiva ? sortedRows.slice(startIndex, endIndex) : sortedRows const topSpacerHeight = virtualizacaoAtiva ? startIndex * ESTIMATIVA_ALTURA_LINHA_PX : 0 const bottomSpacerHeight = virtualizacaoAtiva ? Math.max(0, (totalRows - endIndex) * ESTIMATIVA_ALTURA_LINHA_PX) : 0 const highlightedSet = Array.isArray(highlightedRowIndices) && highlightedRowIndices.length > 0 ? new Set(highlightedRowIndices.map((item) => String(item))) : null return (
{columns.map((col) => { const isActiveSort = sortConfig?.column === col const direction = isActiveSort ? sortConfig.direction : null const sortIndicator = !direction ? '-' : (direction === 'asc' ? '^' : 'v') const ariaSort = direction === 'asc' ? 'ascending' : direction === 'desc' ? 'descending' : 'none' return ( ) })} {virtualizacaoAtiva && topSpacerHeight > 0 ? ( ) : null} {rowsToRender.map((row, i) => { const absoluteIndex = virtualizacaoAtiva ? startIndex + i : i const rowIndex = row?.[highlightIndexColumn] const rowClassName = highlightedSet && rowIndex != null && highlightedSet.has(String(rowIndex)) ? highlightClassName : '' return ( {columns.map((col) => ( ))} ) })} {virtualizacaoAtiva && bottomSpacerHeight > 0 ? ( ) : null}
{String(row[col] ?? '')}
{virtualizacaoAtiva ? (
Exibindo janela virtual com {rowsToRender.length} linhas de {totalRows} (ativada acima de {LIMIAR_RENDERIZACAO_VIRTUAL} linhas, sem supressão de dados).
) : null} {table.truncated ? (
Mostrando {table.returned_rows} de {table.total_rows} linhas.
) : null}
) } export default React.memo(DataTable)