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,96 @@
|
|||||||
|
---
|
||||||
|
name: data_table
|
||||||
|
kind: component
|
||||||
|
lang: typescript
|
||||||
|
domain: ui
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "DataTable(props: DataTableProps): JSX.Element"
|
||||||
|
description: "Tabla de datos con sticky header, overflow scroll, heatmap por columna, formato condicional (number/datetime/currency) y hover rows. Auto-detecta columnas desde la primera fila si no se proveen."
|
||||||
|
tags: [table, data, heatmap, dashboard, component, ui, format, visualization]
|
||||||
|
uses_functions: [cn_typescript_core]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [react]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "frontend/functions/ui/data_table.tsx"
|
||||||
|
props:
|
||||||
|
- name: data
|
||||||
|
type: "Record<string, unknown>[]"
|
||||||
|
required: true
|
||||||
|
description: "Filas de datos. Cada objeto es una fila."
|
||||||
|
- name: columns
|
||||||
|
type: "ColumnDef[]"
|
||||||
|
required: false
|
||||||
|
description: "Definición de columnas con key, label, format y align. Si se omite, se auto-detectan desde la primera fila."
|
||||||
|
- name: heatmapColumns
|
||||||
|
type: "string[]"
|
||||||
|
required: false
|
||||||
|
description: "Keys de columnas numéricas que deben colorearse por intensidad (azul oscuro=bajo, azul claro=alto)."
|
||||||
|
- name: maxHeight
|
||||||
|
type: "number | string"
|
||||||
|
required: false
|
||||||
|
description: "Altura máxima antes de scroll. Default 500px."
|
||||||
|
- name: loading
|
||||||
|
type: "boolean"
|
||||||
|
required: false
|
||||||
|
description: "Estado de carga. Muestra spinner si data está vacía."
|
||||||
|
- name: error
|
||||||
|
type: "Error | null"
|
||||||
|
required: false
|
||||||
|
description: "Error a mostrar si la carga falló."
|
||||||
|
- name: className
|
||||||
|
type: "string"
|
||||||
|
required: false
|
||||||
|
description: "Clases CSS adicionales."
|
||||||
|
emits: []
|
||||||
|
has_state: false
|
||||||
|
framework: react
|
||||||
|
variant: [default, heatmap]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Tabla simple con auto-detección de columnas
|
||||||
|
<DataTable data={rows} />
|
||||||
|
|
||||||
|
// Con columnas definidas y heatmap
|
||||||
|
<DataTable
|
||||||
|
data={metrics}
|
||||||
|
columns={[
|
||||||
|
{ key: 'domain', label: 'Domain' },
|
||||||
|
{ key: 'count', label: 'Functions', format: ',' },
|
||||||
|
{ key: 'pure_pct', label: 'Pure %', format: '.1f' },
|
||||||
|
]}
|
||||||
|
heatmapColumns={['count', 'pure_pct']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Con formato moneda y fecha
|
||||||
|
<DataTable
|
||||||
|
data={transactions}
|
||||||
|
columns={[
|
||||||
|
{ key: 'date', label: 'Date', format: 'datetime' },
|
||||||
|
{ key: 'amount', label: 'Amount', format: '$,.2f', align: 'right' },
|
||||||
|
{ key: 'description', label: 'Description' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Formatos soportados (campo `format` en ColumnDef)
|
||||||
|
|
||||||
|
| format | Ejemplo input | Output |
|
||||||
|
|--------|--------------|--------|
|
||||||
|
| `','` | `1234567` | `1,234,567` |
|
||||||
|
| `',.2f'` | `1234.5` | `1,234.50` |
|
||||||
|
| `'$,.2f'` | `1234.5` | `$1,234.50` |
|
||||||
|
| `'.0f'` | `42.7` | `43` |
|
||||||
|
| `'datetime'` | `'2026-04-01T12:00:00Z'` | `4/1/2026, 12:00:00 PM` |
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/TableWidget.tsx`. El heatmap usa `useMemo` para calcular min/max por columna solo cuando cambian `data` o `heatmapColumns`. La alineación de celdas numéricas es automática (derecha) cuando el valor es `typeof 'number'`; se puede sobreescribir con el campo `align` en ColumnDef.
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
name: pie_chart
|
||||||
|
kind: component
|
||||||
|
lang: typescript
|
||||||
|
domain: ui
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "PieChart(props: PieChartProps): JSX.Element"
|
||||||
|
description: "Gráfico de torta/dona Recharts con Cell por segmento, colores automáticos, labels con porcentaje, Legend y Tooltip temático. Soporte donut con innerRadius configurable."
|
||||||
|
tags: [chart, pie, donut, visualization, recharts, component, ui, dashboard]
|
||||||
|
uses_functions: [cn_typescript_core]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [recharts]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "frontend/functions/ui/pie_chart.tsx"
|
||||||
|
props:
|
||||||
|
- name: data
|
||||||
|
type: "Record<string, unknown>[]"
|
||||||
|
required: true
|
||||||
|
description: "Array de datos. Los valores de valueKey se convierten a number automáticamente."
|
||||||
|
- name: nameKey
|
||||||
|
type: "string"
|
||||||
|
required: true
|
||||||
|
description: "Key del campo que contiene el nombre/etiqueta de cada segmento"
|
||||||
|
- name: valueKey
|
||||||
|
type: "string"
|
||||||
|
required: true
|
||||||
|
description: "Key del campo numérico que determina el tamaño de cada segmento"
|
||||||
|
- name: colors
|
||||||
|
type: "string[]"
|
||||||
|
required: false
|
||||||
|
description: "Paleta de colores hex. Default: 8 colores accesibles. Se repite cíclicamente."
|
||||||
|
- name: donut
|
||||||
|
type: "boolean"
|
||||||
|
required: false
|
||||||
|
description: "Modo dona. innerRadius pasa a 50 por defecto cuando donut=true."
|
||||||
|
- name: innerRadius
|
||||||
|
type: "number"
|
||||||
|
required: false
|
||||||
|
description: "Radio interno en px. Sobreescribe el default calculado por donut."
|
||||||
|
- name: outerRadius
|
||||||
|
type: "number"
|
||||||
|
required: false
|
||||||
|
description: "Radio externo en px. Default 100."
|
||||||
|
- name: showLegend
|
||||||
|
type: "boolean"
|
||||||
|
required: false
|
||||||
|
description: "Mostrar leyenda. Default true."
|
||||||
|
- name: showLabels
|
||||||
|
type: "boolean"
|
||||||
|
required: false
|
||||||
|
description: "Mostrar labels nombre+% en cada segmento. Default true."
|
||||||
|
- name: height
|
||||||
|
type: "number | string"
|
||||||
|
required: false
|
||||||
|
description: "Altura del contenedor. Default 300."
|
||||||
|
- name: valueFormatter
|
||||||
|
type: "(value: number) => string"
|
||||||
|
required: false
|
||||||
|
description: "Formateador de valores para el tooltip. Default toLocaleString."
|
||||||
|
emits: []
|
||||||
|
has_state: false
|
||||||
|
framework: react
|
||||||
|
variant: [pie, donut]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Pie simple
|
||||||
|
<PieChart
|
||||||
|
data={[{ lang: 'Go', count: 42 }, { lang: 'Python', count: 28 }, { lang: 'Bash', count: 15 }]}
|
||||||
|
nameKey="lang"
|
||||||
|
valueKey="count"
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Dona sin labels
|
||||||
|
<PieChart
|
||||||
|
data={distributions}
|
||||||
|
nameKey="domain"
|
||||||
|
valueKey="functions"
|
||||||
|
donut
|
||||||
|
showLabels={false}
|
||||||
|
valueFormatter={(v) => `${v} fns`}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Extraido y generalizado desde `apps/rapid_dashboards/frontend/src/components/widgets/PieChartWidget.tsx`. Los valores de `valueKey` se convierten a `Number()` para garantizar que Recharts los interprete correctamente (útil cuando los datos vienen de SQLite como strings). El `ResponsiveContainer` ocupa el 100% del ancho del padre.
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {
|
||||||
|
PieChart as RechartsPieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { cn } from '../core/cn'
|
||||||
|
|
||||||
|
const DEFAULT_COLORS = [
|
||||||
|
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
|
||||||
|
'#ec4899', '#06b6d4', '#f97316',
|
||||||
|
]
|
||||||
|
|
||||||
|
interface PieChartProps {
|
||||||
|
data: Record<string, unknown>[]
|
||||||
|
nameKey: string
|
||||||
|
valueKey: string
|
||||||
|
colors?: string[]
|
||||||
|
donut?: boolean
|
||||||
|
innerRadius?: number
|
||||||
|
outerRadius?: number
|
||||||
|
showLegend?: boolean
|
||||||
|
showLabels?: boolean
|
||||||
|
height?: number | string
|
||||||
|
className?: string
|
||||||
|
valueFormatter?: (value: number) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
function PieChartComponent({
|
||||||
|
data,
|
||||||
|
nameKey,
|
||||||
|
valueKey,
|
||||||
|
colors = DEFAULT_COLORS,
|
||||||
|
donut = false,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius = 100,
|
||||||
|
showLegend = true,
|
||||||
|
showLabels = true,
|
||||||
|
height = 300,
|
||||||
|
className,
|
||||||
|
valueFormatter = (v) => v.toLocaleString(),
|
||||||
|
}: PieChartProps) {
|
||||||
|
// Ensure numeric values for Recharts Pie
|
||||||
|
const pieData = data.map(row => ({
|
||||||
|
...row,
|
||||||
|
[valueKey]: Number(row[valueKey]) || 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const resolvedInnerRadius = donut ? (innerRadius ?? 50) : (innerRadius ?? 0)
|
||||||
|
|
||||||
|
const labelRenderer = showLabels
|
||||||
|
? ({ name, percent }: Record<string, unknown>) =>
|
||||||
|
`${name ?? ''} ${(((percent as number) ?? 0) * 100).toFixed(0)}%`
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResponsiveContainer width="100%" height={height} className={cn(className)}>
|
||||||
|
<RechartsPieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieData}
|
||||||
|
dataKey={valueKey}
|
||||||
|
nameKey={nameKey}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
innerRadius={resolvedInnerRadius}
|
||||||
|
strokeWidth={0}
|
||||||
|
fontSize={11}
|
||||||
|
label={labelRenderer}
|
||||||
|
labelLine={showLabels}
|
||||||
|
>
|
||||||
|
{pieData.map((_, i) => (
|
||||||
|
<Cell key={i} fill={colors[i % colors.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--card)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
formatter={(value: unknown) => [valueFormatter(value as number)]}
|
||||||
|
/>
|
||||||
|
{showLegend && <Legend wrapperStyle={{ fontSize: 11 }} />}
|
||||||
|
</RechartsPieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PieChart = PieChartComponent
|
||||||
|
export type { PieChartProps }
|
||||||
Reference in New Issue
Block a user