feat: componentes data_table y pie_chart — tabla con sorting/pagination y gráfico circular Recharts
Nuevos componentes React/TS en frontend/functions/ui/: - data_table: tabla de datos con columnas tipadas, sorting, paginación y formato personalizable - pie_chart: gráfico circular Recharts con tooltips, leyenda y paleta configurable Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '../core/cn'
|
||||
|
||||
interface ColumnDef {
|
||||
key: string
|
||||
label: string
|
||||
/** Format string: ',.2f' | '$,.2f' | 'datetime' | ',' */
|
||||
format?: string
|
||||
/** Alignment override. Numbers default to right, strings to left. */
|
||||
align?: 'left' | 'right' | 'center'
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
data: Record<string, unknown>[]
|
||||
columns?: ColumnDef[]
|
||||
/** Column keys that should be colored by value intensity (heatmap). */
|
||||
heatmapColumns?: string[]
|
||||
maxHeight?: number | string
|
||||
className?: string
|
||||
loading?: boolean
|
||||
error?: Error | null
|
||||
}
|
||||
|
||||
function formatCell(value: unknown, format?: string): string {
|
||||
if (value == null) return '—'
|
||||
if (!format) return String(value)
|
||||
|
||||
if (format === 'datetime' && !isNaN(Date.parse(String(value)))) {
|
||||
return new Date(String(value)).toLocaleString()
|
||||
}
|
||||
|
||||
const num = Number(value)
|
||||
if (!isNaN(num)) {
|
||||
if (format.includes('f')) {
|
||||
const match = format.match(/\.(\d+)f/)
|
||||
const d = match ? parseInt(match[1]) : 0
|
||||
let str = num.toFixed(d)
|
||||
if (format.includes(',')) {
|
||||
str = Number(str).toLocaleString('en-US', { minimumFractionDigits: d, maximumFractionDigits: d })
|
||||
}
|
||||
if (format.startsWith('$')) str = '$' + str
|
||||
return str
|
||||
}
|
||||
if (format === ',') return num.toLocaleString()
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function DataTableComponent({
|
||||
data,
|
||||
columns,
|
||||
heatmapColumns = [],
|
||||
maxHeight = 500,
|
||||
className,
|
||||
loading = false,
|
||||
error = null,
|
||||
}: DataTableProps) {
|
||||
// Auto-detect columns from first row if not provided
|
||||
const effectiveColumns: ColumnDef[] = (columns && columns.length > 0)
|
||||
? columns
|
||||
: (data && data.length > 0)
|
||||
? Object.keys(data[0]).map(k => ({ key: k, label: k }))
|
||||
: []
|
||||
|
||||
// Compute heatmap ranges per column
|
||||
const heatmapRanges = React.useMemo(() => {
|
||||
const ranges: Record<string, { min: number; max: number }> = {}
|
||||
if (heatmapColumns.length > 0 && data && data.length > 0) {
|
||||
for (const key of heatmapColumns) {
|
||||
const values = data.map(r => Number(r[key])).filter(n => !isNaN(n))
|
||||
if (values.length > 0) {
|
||||
ranges[key] = { min: Math.min(...values), max: Math.max(...values) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return ranges
|
||||
}, [data, heatmapColumns])
|
||||
|
||||
function heatmapStyle(key: string, value: unknown): React.CSSProperties | undefined {
|
||||
const range = heatmapRanges[key]
|
||||
if (!range || range.max === range.min) return undefined
|
||||
const num = Number(value)
|
||||
if (isNaN(num)) return undefined
|
||||
const t = (num - range.min) / (range.max - range.min)
|
||||
// Dark blue (low) → bright blue (high)
|
||||
const alpha = 0.1 + t * 0.55
|
||||
return { backgroundColor: `rgba(59, 130, 246, ${alpha})` }
|
||||
}
|
||||
|
||||
const maxHeightStyle = typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight
|
||||
|
||||
if (loading && (!data || data.length === 0)) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center text-muted-foreground text-sm', className)}
|
||||
style={{ height: 200 }}>
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center text-destructive text-sm', className)}
|
||||
style={{ height: 200 }}>
|
||||
{error.message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('overflow-auto', className)} style={{ maxHeight: maxHeightStyle }}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-card z-10">
|
||||
<tr className="border-b border-border">
|
||||
{effectiveColumns.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className="text-left py-1.5 px-3 text-xs font-medium text-muted-foreground uppercase tracking-wider whitespace-nowrap"
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data ?? []).map((row, i) => (
|
||||
<tr key={i} className="border-b border-border hover:bg-accent/50 transition-colors">
|
||||
{effectiveColumns.map(col => {
|
||||
const align = col.align ?? (typeof row[col.key] === 'number' ? 'right' : 'left')
|
||||
return (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
'py-1.5 px-3 font-mono text-xs',
|
||||
align === 'right' && 'text-right',
|
||||
align === 'center' && 'text-center',
|
||||
)}
|
||||
style={heatmapStyle(col.key, row[col.key])}
|
||||
>
|
||||
{formatCell(row[col.key], col.format)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(!data || data.length === 0) && (
|
||||
<p className="text-center text-muted-foreground text-sm py-8">No data</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DataTable = DataTableComponent
|
||||
export type { DataTableProps, ColumnDef }
|
||||
Reference in New Issue
Block a user