Spaces:
Running
Running
Guilherme Silberfarb Costa
botao reincluir todos outliers, casas decimais de cook, ordenamento de cabecalhos
0ad99f2 | 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 <div className="empty-box">Sem dados.</div> | |
| } | |
| 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 ( | |
| <div ref={wrapperRef} className="table-wrapper" style={{ maxHeight }} onScroll={onWrapperScroll}> | |
| <table> | |
| <thead> | |
| <tr> | |
| {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 ( | |
| <th key={col} aria-sort={ariaSort}> | |
| <button | |
| type="button" | |
| className={`table-sort-trigger${isActiveSort ? ' is-active' : ''}`} | |
| onClick={() => onToggleSort(col)} | |
| title={isActiveSort ? `Ordenado por ${col} (${direction === 'asc' ? 'crescente' : 'decrescente'})` : `Ordenar por ${col}`} | |
| > | |
| <span className="table-sort-label">{col}</span> | |
| <span className="table-sort-indicator" aria-hidden="true">{sortIndicator}</span> | |
| </button> | |
| </th> | |
| ) | |
| })} | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {virtualizacaoAtiva && topSpacerHeight > 0 ? ( | |
| <tr className="table-virtual-spacer" aria-hidden="true"> | |
| <td colSpan={colSpan} style={{ height: `${topSpacerHeight}px` }} /> | |
| </tr> | |
| ) : 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 ( | |
| <tr key={absoluteIndex} className={rowClassName}> | |
| {columns.map((col) => ( | |
| <td key={`${absoluteIndex}-${col}`}>{String(row[col] ?? '')}</td> | |
| ))} | |
| </tr> | |
| ) | |
| })} | |
| {virtualizacaoAtiva && bottomSpacerHeight > 0 ? ( | |
| <tr className="table-virtual-spacer" aria-hidden="true"> | |
| <td colSpan={colSpan} style={{ height: `${bottomSpacerHeight}px` }} /> | |
| </tr> | |
| ) : null} | |
| </tbody> | |
| </table> | |
| {virtualizacaoAtiva ? ( | |
| <div className="table-hint"> | |
| Exibindo janela virtual com {rowsToRender.length} linhas de {totalRows} (ativada acima de {LIMIAR_RENDERIZACAO_VIRTUAL} linhas, sem supressão de dados). | |
| </div> | |
| ) : null} | |
| {table.truncated ? ( | |
| <div className="table-hint">Mostrando {table.returned_rows} de {table.total_rows} linhas.</div> | |
| ) : null} | |
| </div> | |
| ) | |
| } | |
| export default React.memo(DataTable) | |