merge: quick/frontend-ui-components-sqlite-open — componentes UI nuevos y mejorados, sqlite_open basePath
This commit is contained in:
@@ -3,7 +3,7 @@ name: bar_chart
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "BarChart(props: BarChartProps): JSX.Element"
|
||||
description: "Gráfico de barras Recharts con multi-series, orientación horizontal/vertical, tooltips temáticos y bordes redondeados."
|
||||
@@ -50,3 +50,7 @@ source_file: "frontend/src/components/ui/charts/bar-chart.tsx"
|
||||
<BarChart data={data} xKey="category" yKey="sales" showLegend />
|
||||
<BarChart data={data} xKey="name" series={series} horizontal />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
En modo `horizontal=true`: el layout de Recharts es `'vertical'`, YAxis recibe `dataKey={xKey}` con `type="category"` (categorías en eje Y), XAxis recibe `type="number"` (valores en eje X). El radius de las barras se ajusta a `[0, 4, 4, 0]` para redondear la punta derecha. Este intercambio de ejes es obligatorio — sin él las barras horizontales no se renderizan.
|
||||
|
||||
@@ -43,7 +43,7 @@ function BarChartComponent({
|
||||
)}
|
||||
<Tooltip content={<ChartTooltipContent valueFormatter={valueFormatter} />} cursor={{ fill: 'hsl(var(--muted) / 0.3)' }} />
|
||||
{showLegend && <Legend />}
|
||||
{bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={[4, 4, 0, 0]} />)}
|
||||
{bars.map((bar) => <Bar key={bar.dataKey} dataKey={bar.dataKey} name={bar.name} fill={bar.fill} radius={horizontal ? [0, 4, 4, 0] : [4, 4, 0, 0]} />)}
|
||||
</RechartsBarChart>
|
||||
</ChartContainer>
|
||||
)
|
||||
|
||||
@@ -3,11 +3,11 @@ name: card
|
||||
kind: component
|
||||
lang: ts
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "Card(props: { size?: 'default' | 'sm'; className?: string; children: ReactNode }): JSX.Element"
|
||||
description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable."
|
||||
tags: [card, container, layout, component, ui]
|
||||
signature: "Card(props: { size?: 'default' | 'sm'; variant?: 'default' | 'borderless' | 'ghost'; className?: string; children: ReactNode }): JSX.Element"
|
||||
description: "Contenedor card con header, title, description, action, content y footer. Sistema de slots composable. Variantes default, borderless y ghost para dashboards dark."
|
||||
tags: [card, container, layout, component, ui, dashboard, dark]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -23,6 +23,10 @@ props:
|
||||
type: "'default' | 'sm'"
|
||||
required: false
|
||||
description: "Tamaño del card"
|
||||
- name: variant
|
||||
type: "'default' | 'borderless' | 'ghost'"
|
||||
required: false
|
||||
description: "Variante visual. borderless quita borde/shadow, ghost además hace bg transparente"
|
||||
- name: className
|
||||
type: "string"
|
||||
required: false
|
||||
@@ -30,7 +34,7 @@ props:
|
||||
emits: []
|
||||
has_state: false
|
||||
framework: react
|
||||
variant: [default, sm]
|
||||
variant: [default, sm, borderless, ghost]
|
||||
source_repo: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/Bl4cksmith/Frontend_Library"
|
||||
source_license: "MIT"
|
||||
source_file: "frontend/src/components/ui/card.tsx"
|
||||
@@ -47,8 +51,20 @@ source_file: "frontend/src/components/ui/card.tsx"
|
||||
<CardContent>Contenido</CardContent>
|
||||
<CardFooter>Footer</CardFooter>
|
||||
</Card>
|
||||
|
||||
{/* Dashboard dark — sin bordes */}
|
||||
<Card variant="borderless">
|
||||
<CardContent>Widget sin marco</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Completamente transparente */}
|
||||
<Card variant="ghost">
|
||||
<CardContent>Sin fondo ni borde</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Sistema de slots via data-slot attributes. Card detecta automáticamente la presencia de CardFooter y ajusta el padding. Exporta 7 subcomponentes composables.
|
||||
|
||||
Las variantes `borderless` y `ghost` eliminan el `ring-1` del borde por defecto. `ghost` además hace el fondo transparente. Alternativa con CSS global: `[data-slot="card"] { --tw-ring-opacity: 0; }` o `[data-variant="borderless"] { ring: 0 }` via `data-variant` attribute expuesto.
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../core/cn"
|
||||
|
||||
type CardVariant = "default" | "borderless" | "ghost"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm"; variant?: CardVariant }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
variant === "default" && "ring-1 ring-foreground/10",
|
||||
variant === "borderless" && "ring-0 shadow-none",
|
||||
variant === "ghost" && "ring-0 shadow-none bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -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 }
|
||||
@@ -3,11 +3,11 @@ name: kpi_card
|
||||
kind: component
|
||||
lang: typescript
|
||||
domain: ui
|
||||
version: "1.0.0"
|
||||
version: "2.0.0"
|
||||
purity: impure
|
||||
signature: "KPICard(props: KPICardProps): JSX.Element"
|
||||
description: "Card de KPI con label, valor, delta porcentual con color semántico, icono y subtítulo. 3 tamaños."
|
||||
tags: [kpi, card, metrics, dashboard, component, ui]
|
||||
description: "Card de KPI con label, valor+unidad, delta descriptivo con color semántico, icono, slot de chart inline y action. 3 tamaños."
|
||||
tags: [kpi, card, metrics, dashboard, component, ui, sparkline]
|
||||
uses_functions: [cn_typescript_core]
|
||||
uses_types: []
|
||||
returns: []
|
||||
@@ -27,14 +27,26 @@ props:
|
||||
type: "string | number"
|
||||
required: true
|
||||
description: "Valor principal"
|
||||
- name: delta
|
||||
type: "{ value: number; isPositive: boolean }"
|
||||
- name: unit
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Cambio porcentual con dirección"
|
||||
description: "Unidad junto al valor en font menor (ej: k, ms, %)"
|
||||
- name: delta
|
||||
type: "{ value: number; isPositive: boolean; label?: string; suffix?: string }"
|
||||
required: false
|
||||
description: "Cambio con dirección, label descriptivo y sufijo"
|
||||
- name: icon
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Icono decorativo"
|
||||
description: "Icono a la izquierda del label"
|
||||
- name: action
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Slot top-right para menú o acciones"
|
||||
- name: chart
|
||||
type: "ReactNode"
|
||||
required: false
|
||||
description: "Slot para mini chart inline junto al valor"
|
||||
- name: size
|
||||
type: "'sm' | 'default' | 'lg'"
|
||||
required: false
|
||||
@@ -51,6 +63,31 @@ source_file: "frontend/src/components/ui/kpi-card.tsx"
|
||||
## Ejemplo
|
||||
|
||||
```tsx
|
||||
import { KPICard, Sparkline } from '@anthropic/frontend-lib'
|
||||
|
||||
{/* Básico */}
|
||||
<KPICard label="Revenue" value="$12,450" delta={{ value: 12.5, isPositive: true }} />
|
||||
<KPICard label="Users" value={1234} size="lg" icon={<UsersIcon />} />
|
||||
|
||||
{/* Con unidad separada, delta descriptivo, y mini barras */}
|
||||
<KPICard
|
||||
label="Processed Prompts"
|
||||
value="124"
|
||||
unit="k"
|
||||
icon={<ZapIcon className="h-4 w-4" />}
|
||||
delta={{ value: 15, isPositive: true, label: "Prompts Increased by", suffix: "vs yesterday" }}
|
||||
chart={<Sparkline data={[5, 8, 3, 9, 6, 12, 7]} variant="bar" colors={['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ef4444', '#ec4899', '#06b6d4']} height={32} />}
|
||||
action={<button className="text-muted-foreground hover:text-foreground">...</button>}
|
||||
/>
|
||||
|
||||
{/* Dashboard dark sin bordes */}
|
||||
<KPICard label="Sessions" value={9821} className="border-0 shadow-none" />
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
- El icono ahora se renderiza a la **izquierda** del label (antes estaba a la derecha).
|
||||
- `unit` separa la unidad del valor con font menor para el efecto "124 k" del diseño.
|
||||
- `delta.label` y `delta.suffix` permiten texto descriptivo: "Increased by ▲ +15% vs yesterday".
|
||||
- `chart` es un slot genérico — pasar un `<Sparkline variant="bar" colors={[...]} />` para mini barras multicolor.
|
||||
- `action` es un slot top-right para menú contextual.
|
||||
- Usa `cn()` para merge de clases. `className="border-0 shadow-none"` para dashboards dark.
|
||||
|
||||
@@ -6,25 +6,35 @@ type KPICardSize = 'sm' | 'default' | 'lg'
|
||||
interface Delta {
|
||||
value: number
|
||||
isPositive: boolean
|
||||
/** Descriptive label before value, e.g. "Increased by" */
|
||||
label?: string
|
||||
/** Suffix after value, e.g. "vs yesterday" */
|
||||
suffix?: string
|
||||
}
|
||||
|
||||
interface KPICardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
label: string
|
||||
value: string | number
|
||||
/** Unit displayed next to value in smaller font, e.g. "k", "ms", "%" */
|
||||
unit?: string
|
||||
delta?: Delta
|
||||
icon?: React.ReactNode
|
||||
/** Action slot rendered top-right, e.g. a menu button */
|
||||
action?: React.ReactNode
|
||||
/** Inline chart slot rendered to the right of the value */
|
||||
chart?: React.ReactNode
|
||||
subtitle?: string
|
||||
size?: KPICardSize
|
||||
}
|
||||
|
||||
const sizeStyles: Record<KPICardSize, { value: string; label: string }> = {
|
||||
sm: { value: 'text-2xl font-bold', label: 'text-xs' },
|
||||
default: { value: 'text-3xl font-bold', label: 'text-sm' },
|
||||
lg: { value: 'text-4xl font-bold', label: 'text-base' },
|
||||
const sizeStyles: Record<KPICardSize, { value: string; unit: string; label: string }> = {
|
||||
sm: { value: 'text-2xl font-bold', unit: 'text-base font-medium', label: 'text-xs' },
|
||||
default: { value: 'text-3xl font-bold', unit: 'text-lg font-medium', label: 'text-sm' },
|
||||
lg: { value: 'text-4xl font-bold', unit: 'text-xl font-medium', label: 'text-base' },
|
||||
}
|
||||
|
||||
const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
|
||||
({ label, value, delta, icon, subtitle, size = 'default', className, ...props }, ref) => {
|
||||
({ label, value, unit, delta, icon, action, chart, subtitle, size = 'default', className, ...props }, ref) => {
|
||||
const styles = sizeStyles[size]
|
||||
const deltaColor = delta
|
||||
? delta.value === 0 ? 'text-muted-foreground'
|
||||
@@ -35,21 +45,32 @@ const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
|
||||
return (
|
||||
<div ref={ref} className={cn('rounded-lg border bg-card p-4 text-card-foreground shadow-sm', className)} {...props}>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('text-muted-foreground', styles.label)}>{label}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<p className={cn('text-muted-foreground', styles.label)}>{label}</p>
|
||||
{subtitle && <p className="text-xs text-muted-foreground/80">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
{action && <div className="text-muted-foreground">{action}</div>}
|
||||
</div>
|
||||
<div className="mt-3 flex items-end justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className={cn('tracking-tight', styles.value)}>{value}</p>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={cn('tracking-tight', styles.value)}>{value}</span>
|
||||
{unit && <span className={cn('text-muted-foreground', styles.unit)}>{unit}</span>}
|
||||
</div>
|
||||
{delta && (
|
||||
<div className={cn('flex items-center gap-1 text-sm font-medium', deltaColor)}>
|
||||
<span>{delta.value > 0 ? '+' : ''}{delta.value}%</span>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{delta.label && <span>{delta.label}</span>}
|
||||
<span className={cn('font-medium', deltaColor)}>
|
||||
{delta.isPositive ? '▲' : '▼'} {delta.value > 0 ? '+' : ''}{delta.value}{delta.label ? '' : '%'}
|
||||
</span>
|
||||
{delta.suffix && <span>{delta.suffix}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{chart && <div className="flex-shrink-0">{chart}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -57,4 +78,5 @@ const KPICard = React.forwardRef<HTMLDivElement, KPICardProps>(
|
||||
)
|
||||
KPICard.displayName = 'KPICard'
|
||||
|
||||
export { KPICard, type KPICardProps, type Delta, type KPICardSize }
|
||||
export { KPICard }
|
||||
export type { KPICardProps, Delta, KPICardSize }
|
||||
|
||||
@@ -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 }
|
||||
@@ -31,6 +31,10 @@ props:
|
||||
type: "string"
|
||||
required: false
|
||||
description: "Color del gráfico"
|
||||
- name: colors
|
||||
type: "string[]"
|
||||
required: false
|
||||
description: "Colores por barra para variant 'bar'. Cicla si es más corto que data."
|
||||
- name: width
|
||||
type: "number"
|
||||
required: false
|
||||
|
||||
@@ -7,6 +7,8 @@ interface SparklineProps extends React.SVGAttributes<SVGSVGElement> {
|
||||
data: number[]
|
||||
variant?: SparklineVariant
|
||||
color?: string
|
||||
/** Per-bar colors for 'bar' variant. Cycles if shorter than data. */
|
||||
colors?: string[]
|
||||
width?: number
|
||||
height?: number
|
||||
strokeWidth?: number
|
||||
@@ -30,7 +32,7 @@ function getPath(data: number[], width: number, height: number, padding: number
|
||||
}
|
||||
|
||||
const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
|
||||
({ data, variant = 'line', color = 'currentColor', width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => {
|
||||
({ data, variant = 'line', color = 'currentColor', colors, width = 80, height = 24, strokeWidth = 1.5, showLastPoint = true, className, ...props }, ref) => {
|
||||
if (data.length === 0) return <svg ref={ref} width={width} height={height} viewBox={`0 0 ${width} ${height}`} className={cn('text-primary', className)} {...props} />
|
||||
|
||||
if (variant === 'bar') {
|
||||
@@ -46,7 +48,8 @@ const Sparkline = React.forwardRef<SVGSVGElement, SparklineProps>(
|
||||
const bh = ((value - min) / range) * eh
|
||||
const x = p + index * ((width - p * 2) / data.length) + 0.5
|
||||
const y = p + eh - bh
|
||||
return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={color} opacity={0.8} />
|
||||
const barColor = colors ? colors[index % colors.length] : color
|
||||
return <rect key={index} x={x} y={y} width={Math.max(bw, 1)} height={Math.max(bh, 1)} fill={barColor} rx={1} opacity={0.85} />
|
||||
})}
|
||||
</svg>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ package infra
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
@@ -10,18 +11,27 @@ import (
|
||||
// SQLiteOpen opens (or creates) a SQLite database file with WAL mode and
|
||||
// foreign key support enabled. Returns a ready-to-use *sql.DB or an error.
|
||||
// Pass ":memory:" for an in-memory database.
|
||||
func SQLiteOpen(path string) (*sql.DB, error) {
|
||||
//
|
||||
// If basePath is non-empty and path is relative, the path is resolved as
|
||||
// filepath.Join(basePath, path). This is useful when the path comes from a
|
||||
// config file and must be interpreted relative to that file's directory rather
|
||||
// than the process working directory.
|
||||
func SQLiteOpen(path string, basePath string) (*sql.DB, error) {
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("sqlite_open: path must not be empty (use ':memory:' for in-memory)")
|
||||
}
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", path)
|
||||
resolved := path
|
||||
if basePath != "" && path != ":memory:" && !filepath.IsAbs(path) {
|
||||
resolved = filepath.Join(basePath, path)
|
||||
}
|
||||
dsn := fmt.Sprintf("file:%s?_journal_mode=WAL&_foreign_keys=on", resolved)
|
||||
db, err := sql.Open("sqlite3", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sqlite_open: open %q: %w", path, err)
|
||||
return nil, fmt.Errorf("sqlite_open: open %q: %w", resolved, err)
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("sqlite_open: ping %q: %w", path, err)
|
||||
return nil, fmt.Errorf("sqlite_open: ping %q: %w", resolved, err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -3,17 +3,17 @@ name: sqlite_open
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
version: "1.1.0"
|
||||
purity: impure
|
||||
signature: "func SQLiteOpen(path string) (*sql.DB, error)"
|
||||
description: "Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion."
|
||||
signature: "func SQLiteOpen(path string, basePath string) (*sql.DB, error)"
|
||||
description: "Abre (o crea) una base de datos SQLite con WAL mode y foreign keys habilitados. Hace ping para verificar la conexion. Si basePath es no-vacio y path es relativo, resuelve el path como filepath.Join(basePath, path)."
|
||||
tags: [database, sqlite, connection, sql]
|
||||
uses_functions: []
|
||||
uses_types: [db_config_go_infra]
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: "error_go_core"
|
||||
imports: ["database/sql", "github.com/mattn/go-sqlite3"]
|
||||
imports: ["database/sql", "path/filepath", "github.com/mattn/go-sqlite3"]
|
||||
tested: false
|
||||
tests: []
|
||||
test_file_path: ""
|
||||
@@ -23,15 +23,27 @@ file_path: "functions/infra/sqlite_open.go"
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
db, err := SQLiteOpen("/data/myapp.db")
|
||||
// Path absoluto o relativo al cwd
|
||||
db, err := SQLiteOpen("/data/myapp.db", "")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer DBClose(db)
|
||||
|
||||
// Path relativo al directorio del archivo YAML de configuracion
|
||||
configDir := filepath.Dir(configPath)
|
||||
db, err := SQLiteOpen(cfg.DatabasePath, configDir)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := DBQuery(db, "SELECT * FROM users WHERE active = ?", 1)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria. Hace ping al abrir para detectar errores temprano.
|
||||
Usa el driver `github.com/mattn/go-sqlite3` (CGO). El DSN incluye `_journal_mode=WAL` para mejor concurrencia y `_foreign_keys=on`. Acepta `:memory:` para base de datos en memoria (basePath se ignora en este caso). Hace ping al abrir para detectar errores temprano.
|
||||
|
||||
El parametro `basePath` resuelve el problema de paths relativos en configs YAML: cuando el binario corre desde un directorio distinto al del archivo de config, el path relativo se interpreta incorrectamente. Pasar `filepath.Dir(configPath)` como basePath corrige esto.
|
||||
|
||||
Para mantener el comportamiento anterior (resolver vs cwd), pasar `basePath = ""`.
|
||||
|
||||
Reference in New Issue
Block a user