Etapa completada (local) — widgets de Mirada Empresa

This commit is contained in:
markosbenitez
2026-06-26 11:29:16 -03:00
parent df9b168577
commit 56443a739f
24 changed files with 905 additions and 808 deletions

View File

@@ -0,0 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type ApoyosMEData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function ApoyosBarME({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ApoyosMEData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meApoyos(surveyId, filters)
.then((d) => setData(d as ApoyosMEData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as ApoyosMEData) : null
return (
<WidgetShell
title="Apoyos más demandados"
subtitle="9.2 · qué beneficios diseñar (máx. 3 opciones por persona)"
status={status}
n={ok?.n}
colSpan={6}
>
{ok && (
<ul className="db-bar-list" aria-label="Apoyos más demandados">
{ok.apoyos.length === 0 && (
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n&lt;5 en todos).</li>
)}
{ok.apoyos.map((item) => (
<li key={item.apoyo} className="db-bar-item">
<span className="db-bar-label">{item.apoyo}</span>
<div className="db-bar-track">
<div className="db-bar-fill" style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
</div>
<span className="db-bar-pct">{item.pct}%</span>
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -1,42 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type ApoyosData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
export function ApoyosBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ApoyosData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.apoyos(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ApoyosData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
return (
<WidgetShell title="Apoyos más demandados" subtitle="6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían" status={status} n={data?.n_total} colSpan={6}>
{data && (
<ul className="db-bar-list" aria-label="Apoyos más demandados">
{data.data.length === 0 && (
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n&lt;5 en todos).</li>
)}
{data.data.map(item => (
<li key={item.politica} className="db-bar-item">
<span className="db-bar-label">{item.politica}</span>
<div className="db-bar-track" aria-hidden="true">
<div className="db-bar-fill"
style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
</div>
<span className="db-bar-pct">{item.pct}%</span>
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type DisposicionRedData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function DisposicionRedCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<DisposicionRedData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meDisposicionRed(surveyId, filters)
.then((d) => setData(d as DisposicionRedData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
// Esta RPC tiene su propio shape de "suppressed" (n_grupo_apoyo/n_voluntariado,
// no min_n/n genérico) — isSuppressed() solo mira `status`, sigue funcionando.
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as DisposicionRedData) : null
return (
<WidgetShell
title="Disposición a red de apoyo"
subtitle="9.3 (grupo de apoyo) + 9.4 (voluntariado)"
status={status}
colSpan={4}
>
{ok && (
<div style={{ display: 'flex', gap: 20 }}>
<div style={{ flex: 1, textAlign: 'center' }}>
<div className="db-big-stat" style={{ fontSize: '1.625rem' }}>
{ok.pct_grupo_apoyo !== null ? `${ok.pct_grupo_apoyo}%` : 'n<5'}
</div>
<p className="db-big-stat-sub">grupo de apoyo (n={ok.n_grupo_apoyo})</p>
</div>
<div style={{ flex: 1, textAlign: 'center' }}>
<div className="db-big-stat" style={{ fontSize: '1.625rem' }}>
{ok.pct_voluntariado !== null ? `${ok.pct_voluntariado}%` : 'n<5'}
</div>
<p className="db-big-stat-sub">voluntariado (n={ok.n_voluntariado})</p>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,81 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type DistribucionData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const SEGMENTS = [
{ key: 'disc_propia', label: 'Discapacidad propia', color: '#43BBC8' },
{ key: 'familiar', label: 'Familiar con disc.', color: '#F8AD13' },
{ key: 'adulto_mayor', label: 'Adulto mayor', color: '#EE761A' },
{ key: 'ninguno', label: 'Sin rol de cuidado', color: '#3d506b' },
] as const
export function DonutCuidados({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<DistribucionData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.distribucion(surveyId, filters)
.then(d => {
if (isSuppressed(d)) setSup(true)
else setData(d as DistribucionData)
}).catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
// SVG donut
const R = 60; const cx = 75; const cy = 75; const stroke = 22
let offset = 0
const circumference = 2 * Math.PI * R
const arcs = data ? SEGMENTS.map(s => {
const pct = (data[s.key as keyof DistribucionData] as number) ?? 0
const dash = (pct / 100) * circumference
const gap = circumference - dash
const arc = { ...s, dash, gap, offset: circumference * (1 - offset / 100) }
offset += pct
return arc
}) : []
return (
<WidgetShell title="Perfil de la población" subtitle="1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-donut-wrapper">
<svg viewBox="0 0 150 150" className="db-donut-svg" aria-hidden="true">
{arcs.map(arc => (
<circle key={arc.key}
cx={cx} cy={cy} r={R}
fill="none"
stroke={arc.color}
strokeWidth={stroke}
strokeDasharray={`${arc.dash} ${arc.gap}`}
strokeDashoffset={arc.offset}
transform="rotate(-90, 75, 75)"
/>
))}
<text x={cx} y={cy-6} textAnchor="middle" fill="white" fontSize="14" fontWeight="bold">
{data.disc_propia + data.familiar + data.adulto_mayor > 0
? `${Math.round(data.disc_propia + data.familiar + data.adulto_mayor)}%`
: '0%'}
</text>
<text x={cx} y={cy+12} textAnchor="middle" fill="#9ca3af" fontSize="8">cuidadores</text>
</svg>
<ul className="db-donut-legend" aria-label="Leyenda del donut">
{SEGMENTS.map(s => (
<li key={s.key} className="db-legend-item">
<span className="db-legend-dot" style={{ background: s.color }} />
<span>{s.label}</span>
<span className="db-legend-val">
{(data[s.key as keyof DistribucionData] as number ?? 0)}%
</span>
</li>
))}
</ul>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,61 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type ScoreData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const ZONA_COLOR = { baja: '#ef4444', desarrollo: '#F8AD13', consciente: '#43BBC8' }
const ZONA_LABEL = { baja: 'Baja madurez', desarrollo: 'En desarrollo', consciente: 'Consciente' }
export function GaugeOrganizacional({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ScoreData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.score(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ScoreData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
// SVG semicircle gauge
const score = data?.score ?? 0
const angle = (score / 100) * 180 // 0° = left, 180° = right
const rad = (angle - 90) * Math.PI / 180
const r = 60; const cx = 75; const cy = 80
const needleX = cx + r * Math.cos(rad)
const needleY = cy + r * Math.sin(rad)
const color = data ? ZONA_COLOR[data.zona] : '#43BBC8'
return (
<WidgetShell title="Madurez organizacional" subtitle="6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0100" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-gauge-wrapper">
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>
{/* Fondo semicírculo */}
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none" stroke="#2a3547" strokeWidth="18" strokeLinecap="round" />
{/* Arco coloreado proporcional al score */}
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none"
stroke={color} strokeWidth="18" strokeLinecap="round"
strokeDasharray={`${(score/100)*188.5} 188.5`} />
{/* Aguja */}
<line x1={cx} y1={cy} x2={needleX} y2={needleY} stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx={cx} cy={cy} r={4} fill="white" />
{/* Score */}
<text x={cx} y={cy-10} textAnchor="middle" fill="white" fontSize="18" fontWeight="bold">{score}</text>
<text x={cx} y={cy+6} textAnchor="middle" fill="#9ca3af" fontSize="7">/ 100</text>
</svg>
<p className="db-gauge-zona" style={{ color }}>
{ZONA_LABEL[data.zona]}
</p>
<p className="db-gauge-hint">
033 baja · 3466 desarrollo · 67100 consciente
</p>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,70 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type HeatmapData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
import { HORAS_ORDER, AUSENCIAS_ORDER } from '@/lib/dashboard-utils'
const SHORT_AUSENCIAS = ['Nunca','Pocas (1-3)','Varias (4-10)','Con freq.']
export function HeatmapWidget({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<HeatmapData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.heatmap(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as HeatmapData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
function cellColor(n: number | null): string {
if (n === null) return '#1e2d42'
const max = Math.max(...(data?.data.map(c => c.n ?? 0) ?? [1]), 1)
const t = n / max
return `rgba(67, 187, 200, ${0.15 + t * 0.85})`
}
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.ausencias}`, c]))
return (
<WidgetShell title="Horas de cuidado × Frecuencia de ausentismo" subtitle="2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-heatmap-scroll" role="table" aria-label="Heatmap de intensidad">
<div className="db-heatmap-grid"
style={{ gridTemplateColumns: `120px repeat(${AUSENCIAS_ORDER.length}, 1fr)` }}>
{/* Header row */}
<div role="columnheader" />
{SHORT_AUSENCIAS.map(h => (
<div key={h} className="db-heatmap-header" role="columnheader">{h}</div>
))}
{/* Data rows */}
{HORAS_ORDER.map(horas => (
<>
<div key={`row-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
{AUSENCIAS_ORDER.map(aus => {
const cell = cellMap.get(`${horas}|${aus}`)
const n = cell?.n ?? null
const sup2 = cell?.suppressed ?? (!cell)
return (
<div key={`${horas}|${aus}`}
className="db-heatmap-cell"
style={{ background: cellColor(n) }}
role="cell"
aria-label={`${horas} / ${aus}: ${sup2 ? 'suprimido' : `n=${n}`}`}
title={sup2 ? 'Datos insuficientes' : `n = ${n}`}>
{!sup2 && n !== null && <span>{n}</span>}
</div>
)
})}
</>
))}
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,75 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type IntensidadData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
import { HORAS_ORDER } from '@/lib/dashboard-utils'
// Hexbin implementado como CSS Grid con celdas coloreadas por densidad.
// NO es scatter de individuos — cada celda es un agregado con N≥5.
const BIENESTAR_BINS = ['1','2','3','4','5']
export function IntensidadProductividad({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<IntensidadData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.intensidad(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as IntensidadData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.bienestar}`, c]))
function cellColor(n: number | null): string {
if (n === null) return '#1e2d42'
const t = n / maxN
return `rgba(67, 187, 200, ${0.1 + t * 0.9})`
}
return (
<WidgetShell title="Intensidad de cuidado vs. Bienestar" subtitle="2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-hexbin-wrapper">
<p className="db-hexbin-note">
Cada celda = respondentes agregados. Celdas sin color: N &lt; 5 (suprimido).
</p>
<div className="db-heatmap-scroll">
<div className="db-heatmap-grid"
style={{ gridTemplateColumns: `120px repeat(${BIENESTAR_BINS.length}, 1fr)` }}>
<div role="columnheader" />
{BIENESTAR_BINS.map(b => (
<div key={b} className="db-heatmap-header" role="columnheader">Bienestar {b}</div>
))}
{HORAS_ORDER.map(horas => (
<>
<div key={`r-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
{BIENESTAR_BINS.map(bien => {
const cell = cellMap.get(`${horas}|${bien}`)
const n = cell?.n ?? null
const suppressed = cell?.suppressed ?? true
return (
<div key={`${horas}|${bien}`}
className="db-heatmap-cell"
style={{ background: cellColor(n) }}
role="cell"
title={suppressed ? 'Datos insuficientes' : `n = ${n}`}
aria-label={suppressed ? 'suprimido' : `n=${n}`}>
{!suppressed && n !== null && <span>{n}</span>}
</div>
)
})}
</>
))}
</div>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,96 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type KpiData, type SuppressedPayload } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
const KPI_DEFS = [
{
title: 'CUIDADORES ACTIVOS',
subtitle: '1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros',
getValue: (d: KpiData) => `${d.pct_cuidadores}%`,
getN: (d: KpiData) => `n = ${d.n}`,
},
{
title: 'FRECUENCIA DE AUSENTISMO',
subtitle: '5.2 · % con ausencias laborales recurrentes (4 o más veces al año)',
getValue: (d: KpiData) => `${d.pct_ausencias}%`,
getN: () => 'ausencias frecuentes',
},
{
title: 'BIENESTAR',
subtitle: '4.3 · autorreporte de bienestar general · escala 15',
getValue: (d: KpiData) => d.score_bienestar !== null ? d.score_bienestar.toFixed(1) : '—',
getN: () => '/ 5',
},
] as const
interface KpiCardProps {
title: string
subtitle: string
value: string
valueNote: string
suppressed: boolean
loading: boolean
}
function KpiCard({ title, subtitle, value, valueNote, suppressed, loading }: KpiCardProps) {
return (
<div className="db-kpi-card">
<span className="db-kpi-title">{title}</span>
<span className="db-kpi-subtitle">{subtitle}</span>
{loading && (
<div className="db-skeleton-block" style={{ height: 36, margin: '10px 0 4px', borderRadius: 6 }} />
)}
{!loading && suppressed && (
<div className="db-kpi-suppressed-block" role="status">
<span className="db-suppressed-icon" aria-hidden="true">🔒</span>
<span className="db-kpi-value db-kpi-value-suppressed">n &lt; 5</span>
</div>
)}
{!loading && !suppressed && (
<>
<span className="db-kpi-value" aria-live="polite">{value}</span>
<span className="db-kpi-note">{valueNote}</span>
</>
)}
</div>
)
}
interface Props { surveyId: string; filters: DashboardFilters }
export function KpiCards({ surveyId, filters }: Props) {
const [data, setData] = useState<KpiData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.kpis(surveyId, filters)
.then(d => setData(d as KpiData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
if (error) return <p className="db-error">Error al cargar KPIs</p>
const loading = data === null
const suppressed = !loading && isSuppressed(data)
const ok = !loading && !suppressed
return (
<div className="db-kpi-row">
{KPI_DEFS.map((def) => (
<KpiCard
key={def.title}
title={def.title}
subtitle={def.subtitle}
value={ok ? def.getValue(data as KpiData) : '—'}
valueNote={ok ? def.getN(data as KpiData) : ''}
suppressed={suppressed}
loading={loading}
/>
))}
</div>
)
}

View File

@@ -1,111 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type PrevalenciaData, type SuppressedPayload } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
// Tile-map de Paraguay (fallback aprobado en el prompt cuando no se consigue SVG fiel)
// col/row en grilla 5×6
const TILES = [
{ name: 'Alto Paraguay', col: 2, row: 0 },
{ name: 'Boquerón', col: 0, row: 1 },
{ name: 'Presidente Hayes', col: 1, row: 1 },
{ name: 'Concepción', col: 2, row: 1 },
{ name: 'San Pedro', col: 3, row: 1 },
{ name: 'Amambay', col: 4, row: 1 },
{ name: 'Asunción (Capital)', col: 1, row: 2 },
{ name: 'Cordillera', col: 2, row: 2 },
{ name: 'Caaguazú', col: 3, row: 2 },
{ name: 'Canindeyú', col: 4, row: 2 },
{ name: 'Central', col: 1, row: 3 },
{ name: 'Guairá', col: 2, row: 3 },
{ name: 'Alto Paraná', col: 4, row: 3 },
{ name: 'Ñeembucú', col: 0, row: 4 },
{ name: 'Misiones', col: 1, row: 4 },
{ name: 'Paraguarí', col: 2, row: 4 },
{ name: 'Caazapá', col: 3, row: 4 },
{ name: 'Itapúa', col: 2, row: 5 },
]
function lerp(t: number, a: string, b: string): string {
// simple teal interpolation: #E7E6E5 (light) → #43BBC8 (teal)
const parseHex = (h: string) => [
parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)
]
const ca = parseHex(a); const cb = parseHex(b)
const r = Math.round(ca[0] + (cb[0]-ca[0])*t)
const g = Math.round(ca[1] + (cb[1]-ca[1])*t)
const bv= Math.round(ca[2] + (cb[2]-ca[2])*t)
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${bv.toString(16).padStart(2,'0')}`
}
interface Props { surveyId: string; filters: DashboardFilters; onDeptoClick: (d: string) => void }
export function ParaguayMap({ surveyId, filters, onDeptoClick }: Props) {
const [data, setData] = useState<PrevalenciaData | SuppressedPayload | null>(null)
const [tooltip, setTooltip] = useState<{ name: string; pct: number | null; n: number | null } | null>(null)
useEffect(() => {
setData(null)
queries.prevalencia(surveyId, filters)
.then(d => setData(d as PrevalenciaData | SuppressedPayload))
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const deptoMap = new Map(
(!data || isSuppressed(data)) ? [] :
(data as PrevalenciaData).data.map(d => [d.depto, d])
)
const status = !data ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined
return (
<WidgetShell title="Prevalencia de cuidadores por departamento" subtitle="A1 · % cuidadores activos por departamento" status={status} n={n} colSpan={6}>
<div className="db-map-wrapper" role="img" aria-label="Mapa de Paraguay con prevalencia de cuidadores por departamento">
{tooltip && (
<div className="db-map-tooltip" aria-live="polite">
<strong>{tooltip.name}</strong>
{tooltip.pct !== null
? <> · {tooltip.pct}% · n={tooltip.n}</>
: <> · Datos insuficientes</>}
</div>
)}
<div className="db-tile-grid">
{TILES.map(tile => {
const d = deptoMap.get(tile.name)
const suppressed = !d || d.suppressed
const pct = d?.pct_cuidadores ?? null
const color = suppressed ? '#2a3547' : lerp((pct ?? 0)/100, '#E7E6E5', '#43BBC8')
const isSelected = filters.depto === tile.name
return (
<button
key={tile.name}
className={`db-tile${isSelected ? ' db-tile-selected' : ''}${suppressed ? ' db-tile-suppressed' : ''}`}
style={{
gridColumn: tile.col + 1,
gridRow: tile.row + 1,
background: color,
}}
onClick={() => onDeptoClick(isSelected ? '' : tile.name)}
onMouseEnter={() => setTooltip({ name: tile.name, pct, n: d?.n ?? null })}
onMouseLeave={() => setTooltip(null)}
title={suppressed ? `${tile.name} — Datos insuficientes` : `${tile.name}${pct}% (n=${d?.n})`}
aria-label={suppressed
? `${tile.name}: datos insuficientes`
: `${tile.name}: ${pct}% cuidadores, n=${d?.n}`}
aria-pressed={isSelected}
>
<span className="db-tile-name">{tile.name.replace(' (Capital)','')}</span>
{!suppressed && pct !== null && (
<span className="db-tile-pct">{pct}%</span>
)}
</button>
)
})}
</div>
</div>
</WidgetShell>
)
}

View File

@@ -1,80 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type PiramideData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
import { ETARIO_ORDER } from '@/lib/dashboard-utils'
const GENERO_COLOR: Record<string, string> = {
'Femenino': '#EE761A',
'Masculino': '#43BBC8',
'No binario/Otro': '#F8AD13',
'Prefiero no decir':'#6b7280',
}
export function PiramideDemografica({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PiramideData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.piramide(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PiramideData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
const generos = ['Femenino','Masculino','No binario/Otro']
return (
<WidgetShell title="Pirámide demográfica" subtitle="A5 × A6 · rango etario por género (celdas con k≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-piramide">
<div className="db-piramide-legend" aria-label="Leyenda">
{generos.map(g => (
<span key={g} className="db-legend-item">
<span className="db-legend-dot" style={{ background: GENERO_COLOR[g] }} />
{g}
</span>
))}
</div>
{ETARIO_ORDER.filter(e => e !== 'Prefiero no decir').map(etario => {
const row = generos.map(gen => {
const cell = data.data.find(c => c.etario === etario && c.genero === gen)
return { gen, n: cell?.n ?? null, suppressed: cell?.suppressed ?? true }
})
return (
<div key={etario} className="db-piramide-row" role="row" aria-label={etario}>
<span className="db-piramide-label">{etario}</span>
<div className="db-piramide-bars">
{row.map(({ gen, n, suppressed }) => (
<div key={gen} className="db-piramide-bar-wrap"
style={{ flex: `0 0 ${Math.max(10, (n ?? 0) / maxN * 100)}%` }}>
<div className="db-piramide-bar"
style={{
width: suppressed ? '100%' : `${(n ?? 0) / maxN * 100}%`,
background: suppressed ? '#2a3547' : GENERO_COLOR[gen],
opacity: suppressed ? 0.4 : 1,
}}
title={suppressed ? 'Datos insuficientes' : `${gen}: n=${n}`}
role="cell"
aria-label={suppressed ? `${gen}: suprimido` : `${gen}: ${n}`}
/>
</div>
))}
</div>
<span className="db-piramide-n">
{row.every(r => r.suppressed) ? '🔒' : row.reduce((s, r) => s + (r.n ?? 0), 0)}
</span>
</div>
)
})}
</div>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type PrevalenciaCuidadoresData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function PrevalenciaCuidadoresCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PrevalenciaCuidadoresData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.mePrevalencia(surveyId, filters)
.then((d) => setData(d as PrevalenciaCuidadoresData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as PrevalenciaCuidadoresData) : null
return (
<WidgetShell
title="Cuidadores en la plantilla"
subtitle="4.1 · vínculo de cuidado activo (incluye hijo/a menor sin discapacidad — criterio UNFPA)"
status={status}
n={ok?.n}
colSpan={4}
>
{ok && (
<>
<div className="db-big-stat">{ok.pct_cuidadores}%</div>
<p className="db-big-stat-sub">tiene algún rol de cuidado</p>
</>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type PuntoCiegoData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
const MAX_BAR_HEIGHT = 70
export function PuntoCiegoCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PuntoCiegoData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.mePuntoCiego(surveyId, filters)
.then((d) => setData(d as PuntoCiegoData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as PuntoCiegoData) : null
const cargaHeight = ok ? Math.max(6, (ok.carga_real_pct / 100) * MAX_BAR_HEIGHT) : 0
const apoyoHeight =
ok && ok.apoyo_percibido_pct !== null ? Math.max(6, (ok.apoyo_percibido_pct / 100) * MAX_BAR_HEIGHT) : 0
return (
<WidgetShell
title="Punto ciego organizacional"
subtitle="9.1 vs. prevalencia real (4.1) — brecha entre carga y percepción"
status={status}
n={ok?.n}
colSpan={4}
>
{ok && (
<>
<div className="db-gap-viz">
<div className="db-gap-col">
<div className="db-gap-val" style={{ color: 'var(--color-re-teal)' }}>
{ok.carga_real_pct}%
</div>
<div className="db-gap-bar" style={{ height: cargaHeight, background: 'var(--color-re-teal)' }} />
<div className="db-gap-lab">carga real</div>
</div>
<div className="db-gap-col">
{ok.apoyo_percibido_pct !== null ? (
<>
<div className="db-gap-val" style={{ color: '#f06b6b' }}>
{ok.apoyo_percibido_pct}%
</div>
<div className="db-gap-bar" style={{ height: apoyoHeight, background: '#f06b6b' }} />
</>
) : (
<div className="db-gap-val" style={{ color: '#6b7280' }}>
n&lt;5
</div>
)}
<div className="db-gap-lab">se siente apoyado</div>
</div>
</div>
{ok.brecha_pp !== null && (
<p className="db-gap-brecha">Brecha: {ok.brecha_pp} puntos porcentuales</p>
)}
</>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type SegmentacionData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function SegmentacionBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<SegmentacionData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meSegmentacion(surveyId, filters)
.then((d) => setData(d as SegmentacionData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as SegmentacionData) : null
return (
<WidgetShell
title="Dónde se concentra la carga"
subtitle="4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad) — % de cuidadores por segmento"
status={status}
n={ok?.n}
colSpan={12}
>
{ok && (
<div className="db-grid" style={{ gap: '24px' }}>
<div style={{ gridColumn: 'span 6' }}>
<p className="db-widget-subtitle" style={{ marginBottom: 8 }}>Por nivel organizacional</p>
{ok.por_nivel.length === 0 && (
<p className="db-suppressed-hint">Ningún nivel alcanza n5 con este filtro.</p>
)}
<ul className="db-bar-list" aria-label="Cuidadores por nivel organizacional">
{ok.por_nivel.map((row) => (
<li key={row.nivel} className="db-bar-item">
<span className="db-bar-label">{row.nivel}</span>
<div className="db-bar-track">
<div className="db-bar-fill" style={{ width: `${row.pct}%`, background: '#5e7299' }} />
</div>
<span className="db-bar-pct">{row.pct}%</span>
</li>
))}
</ul>
</div>
<div style={{ gridColumn: 'span 6' }}>
<p className="db-widget-subtitle" style={{ marginBottom: 8 }}>Por modalidad de trabajo</p>
{ok.por_modalidad.length === 0 && (
<p className="db-suppressed-hint">Ninguna modalidad alcanza n5 con este filtro.</p>
)}
<ul className="db-bar-list" aria-label="Cuidadores por modalidad de trabajo">
{ok.por_modalidad.map((row) => (
<li key={row.modalidad} className="db-bar-item">
<span className="db-bar-label">{row.modalidad}</span>
<div className="db-bar-track">
<div className="db-bar-fill" style={{ width: `${row.pct}%`, background: '#5e7299' }} />
</div>
<span className="db-bar-pct">{row.pct}%</span>
</li>
))}
</ul>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type RiesgoRotacionData,
type AusentismoData,
type PresentismoData,
type CarreraCongeladaData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
type Riesgo = RiesgoRotacionData | SuppressedPayload
type Ausentismo = AusentismoData | SuppressedPayload
type Presentismo = PresentismoData | SuppressedPayload
type Carrera = CarreraCongeladaData | SuppressedPayload
interface Senal {
label: string
pct: number | null
color: 'rose' | 'amber'
}
// Combina E2 (riesgo) + E3 (ausentismo) + E4 (presentismo) — núcleo — y
// E5 (carrera congelada) — [SEC] — en una sola card de 4 barras, igual al
// mockup ("Señales de impacto laboral"). Cada señal se suprime POR FILA
// (n<5 individual) sin tumbar las demás; la card entera solo se suprime
// si las 4 vienen suprimidas a la vez.
export function SenalesImpactoBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [riesgo, setRiesgo] = useState<Riesgo | null>(null)
const [ausentismo, setAusentismo] = useState<Ausentismo | null>(null)
const [presentismo, setPresentismo] = useState<Presentismo | null>(null)
const [carrera, setCarrera] = useState<Carrera | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setRiesgo(null); setAusentismo(null); setPresentismo(null); setCarrera(null); setError(false)
Promise.all([
queries.meRiesgoRotacion(surveyId, filters),
queries.meAusentismo(surveyId, filters),
queries.mePresentismo(surveyId, filters),
queries.meCarreraCongelada(surveyId, filters),
])
.then(([r, a, p, c]) => {
setRiesgo(r as Riesgo)
setAusentismo(a as Ausentismo)
setPresentismo(p as Presentismo)
setCarrera(c as Carrera)
})
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const loading = riesgo === null || ausentismo === null || presentismo === null || carrera === null
const allSuppressed =
!loading &&
isSuppressed(riesgo) && isSuppressed(ausentismo) && isSuppressed(presentismo) && isSuppressed(carrera)
const status = error ? 'error' : loading ? 'loading' : allSuppressed ? 'suppressed' : 'ok'
const rows: Senal[] = !loading
? [
{
label: 'Presentismo',
pct: !isSuppressed(presentismo) ? presentismo.pct_presentismo : null,
color: 'amber',
},
{
label: 'Ausentismo frecuente',
pct: !isSuppressed(ausentismo) ? ausentismo.pct_ausentismo_recurrente : null,
color: 'amber',
},
{
label: 'Riesgo de salida',
pct: !isSuppressed(riesgo) ? riesgo.pct_riesgo : null,
color: 'rose',
},
{
label: 'Carrera congelada',
pct: !isSuppressed(carrera) ? carrera.pct_carrera_congelada : null,
color: 'rose',
},
]
: []
const repN = !loading && !isSuppressed(riesgo) ? riesgo.n_cuidadores : undefined
return (
<WidgetShell
title="Señales de impacto laboral"
subtitle="7.2 · 7.3 · 7.6 · 7.5 — sobre la población cuidadora"
status={status}
n={repN}
colSpan={6}
>
{!loading && (
<ul className="db-bar-list" aria-label="Señales de impacto laboral">
{rows.map((row) => (
<li key={row.label} className="db-bar-item">
<span className="db-bar-label">{row.label}</span>
{row.pct !== null ? (
<>
<div className="db-bar-track">
<div
className="db-bar-fill"
style={{ width: `${row.pct}%`, background: row.color === 'rose' ? '#f06b6b' : '#f0b429' }}
/>
</div>
<span className="db-bar-pct">{row.pct}%</span>
</>
) : (
<>
<div className="db-bar-track" />
<span className="db-bar-pct" style={{ color: '#6b7280' }}>
n&lt;5
</span>
</>
)}
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -1,67 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type SaturacionData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const SATURACION_COLOR: Record<string, string> = {
'Alta saturación': '#ef4444',
'Saturación media': '#F8AD13',
'Baja saturación': '#43BBC8',
}
export function TablaSaturacion({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<SaturacionData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.saturacion(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as SaturacionData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
return (
<WidgetShell title="Saturación del cuidador" subtitle="4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)" status={status} n={data?.n_total} colSpan={12}>
{data && (
<div className="db-table-wrapper">
<p className="db-table-note">
Alta: bienestar 2, rendimiento afectado casi todos los días y ausentismo frecuente.
Media: dos de los tres criterios. Baja: uno o ninguno.
</p>
<table className="db-table" aria-label="Tabla de saturación del cuidador">
<thead>
<tr>
<th>Segmento</th>
<th>N</th>
<th>% del total</th>
<th>Apoyo más demandado</th>
</tr>
</thead>
<tbody>
{data.segmentos.map(seg => (
<tr key={seg.segmento}>
<td>
<span className="db-risk-badge"
style={{ background: SATURACION_COLOR[seg.segmento] ?? '#6b7280' }}>
{seg.segmento}
</span>
</td>
<td>{seg.suppressed ? '—' : seg.n}</td>
<td>{seg.suppressed ? '—' : `${seg.pct}%`}</td>
<td>{seg.suppressed
? <span className="db-suppressed-hint">Datos insuficientes (n&lt;5)</span>
: seg.apoyo ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type TalentoRiesgoData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
// Umbrales de color SOLO visuales (no son la definición de la métrica, que
// vive en la RPC vía p_umbral_senales) — afinar libremente si el director
// prefiere otros cortes.
function colorClass(pct: number): string {
if (pct >= 30) return ' db-big-stat-danger'
if (pct >= 15) return ' db-big-stat-warn'
return ''
}
export function TalentoRiesgoCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<TalentoRiesgoData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meTalentoRiesgo(surveyId, filters)
.then((d) => setData(d as TalentoRiesgoData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as TalentoRiesgoData) : null
return (
<WidgetShell
title="Talento en riesgo"
subtitle="7.6 + 7.3 + 7.5 · métrica compuesta sobre la población cuidadora"
status={status}
n={ok?.n_cuidadores}
colSpan={4}
variant="star"
>
{ok && (
<>
<div className={`db-big-stat${colorClass(ok.pct_talento_riesgo)}`}>
{ok.pct_talento_riesgo}%
</div>
<p className="db-big-stat-sub">
de la plantilla cuidadora cumple al menos {ok.umbral_senales} de 3 señales de fuga
(riesgo de salida, ausentismo recurrente, carrera congelada). Promedio:{' '}
{ok.criterios_promedio} señales por persona.
</p>
</>
)}
</WidgetShell>
)
}

View File

@@ -8,11 +8,16 @@ interface WidgetShellProps {
n?: number
children: React.ReactNode
colSpan?: number
/** 'star' resalta la card (borde + fondo distinto) para métricas destacadas. */
variant?: 'star'
}
export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6 }: WidgetShellProps) {
export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6, variant }: WidgetShellProps) {
return (
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}>
<div
className={`db-widget db-col-${colSpan}${variant === 'star' ? ' db-widget-star' : ''}`}
style={{ gridColumn: `span ${colSpan}` }}
>
<div className="db-widget-header">
<div className="db-widget-title-group">
<h3 className="db-widget-title">{title}</h3>