File size: 8,742 Bytes
d6c9678
 
614e632
 
 
 
0ad99f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614e632
d0f2c73
 
 
 
 
 
 
d6c9678
 
 
 
0ad99f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614e632
 
 
 
 
0ad99f2
 
 
 
 
 
 
 
 
 
 
614e632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ad99f2
 
 
 
 
 
 
 
 
 
 
614e632
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0ad99f2
 
614e632
 
 
d0f2c73
 
 
f7fe834
d6c9678
614e632
d6c9678
 
 
0ad99f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6c9678
 
 
614e632
 
 
 
 
d0f2c73
614e632
d0f2c73
 
 
 
 
614e632
0ad99f2
614e632
d0f2c73
 
 
 
614e632
 
 
 
 
d6c9678
 
614e632
f7fe834
614e632
f7fe834
 
d6c9678
 
 
 
 
 
f7fe834
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
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)