Con marca y dashboards
This commit is contained in:
81
components/dashboard/donut-cuidados.tsx
Normal file
81
components/dashboard/donut-cuidados.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'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="🍩 Tipo de cuidado" 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>
|
||||
)
|
||||
}
|
||||
86
components/dashboard/filter-sidebar.tsx
Normal file
86
components/dashboard/filter-sidebar.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
import { DEPARTMENTS } from '@/lib/paraguay-geo'
|
||||
import { ETARIO_ORDER } from '@/lib/dashboard-utils'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
|
||||
const GENEROS = ['Femenino','Masculino','No binario/Otro','Prefiero no decir']
|
||||
const MODALIDADES = ['Presencial','Remoto (home office)','Híbrido']
|
||||
|
||||
interface Props {
|
||||
filters: DashboardFilters
|
||||
onChange: (f: DashboardFilters) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
export function FilterSidebar({ filters, onChange, collapsed, onToggle }: Props) {
|
||||
function toggleArray(key: keyof DashboardFilters, val: string) {
|
||||
const arr = (filters[key] as string[] | undefined) ?? []
|
||||
const next = arr.includes(val) ? arr.filter(v => v !== val) : [...arr, val]
|
||||
onChange({ ...filters, [key]: next.length ? next : undefined })
|
||||
}
|
||||
|
||||
function reset() { onChange({}) }
|
||||
|
||||
return (
|
||||
<aside className={`db-sidebar${collapsed ? ' db-sidebar-collapsed' : ''}`}
|
||||
aria-label="Filtros del dashboard">
|
||||
<button className="db-sidebar-toggle" onClick={onToggle}
|
||||
aria-expanded={!collapsed} aria-controls="db-sidebar-content">
|
||||
{collapsed ? '▶ Filtros' : '◀ Cerrar'}
|
||||
</button>
|
||||
|
||||
{!collapsed && (
|
||||
<div id="db-sidebar-content" className="db-sidebar-content">
|
||||
<div className="db-filter-group">
|
||||
<label className="db-filter-label" htmlFor="filter-depto">Departamento</label>
|
||||
<select id="filter-depto" className="db-filter-select"
|
||||
value={filters.depto ?? ''}
|
||||
onChange={e => onChange({ ...filters, depto: e.target.value || undefined })}>
|
||||
<option value="">Todos</option>
|
||||
{DEPARTMENTS.map(d => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="db-filter-group">
|
||||
<span className="db-filter-label">Género</span>
|
||||
{GENEROS.map(g => (
|
||||
<label key={g} className="db-filter-check">
|
||||
<input type="checkbox"
|
||||
checked={(filters.genero ?? []).includes(g)}
|
||||
onChange={() => toggleArray('genero', g)} />
|
||||
{g}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="db-filter-group">
|
||||
<span className="db-filter-label">Rango etario</span>
|
||||
{ETARIO_ORDER.filter(e => e !== 'Prefiero no decir').map(e => (
|
||||
<label key={e} className="db-filter-check">
|
||||
<input type="checkbox"
|
||||
checked={(filters.etario ?? []).includes(e)}
|
||||
onChange={() => toggleArray('etario', e)} />
|
||||
{e}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="db-filter-group">
|
||||
<span className="db-filter-label">Modalidad</span>
|
||||
{MODALIDADES.map(m => (
|
||||
<label key={m} className="db-filter-check">
|
||||
<input type="checkbox"
|
||||
checked={(filters.modalidad ?? []).includes(m)}
|
||||
onChange={() => toggleArray('modalidad', m)} />
|
||||
{m}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="db-filter-reset" onClick={reset}>Resetear filtros</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
61
components/dashboard/gauge-organizacional.tsx
Normal file
61
components/dashboard/gauge-organizacional.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'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" 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">
|
||||
0–33 baja · 34–66 desarrollo · 67–100 consciente
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
70
components/dashboard/heatmap-widget.tsx
Normal file
70
components/dashboard/heatmap-widget.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'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 × Ausencias laborales" 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>
|
||||
)
|
||||
}
|
||||
75
components/dashboard/intensidad-productividad.tsx
Normal file
75
components/dashboard/intensidad-productividad.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'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 (bins N≥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 < 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>
|
||||
)
|
||||
}
|
||||
62
components/dashboard/kpi-cards.tsx
Normal file
62
components/dashboard/kpi-cards.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'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'
|
||||
|
||||
interface KpiCardProps { label: string; value: string; sub?: string }
|
||||
|
||||
function KpiCard({ label, value, sub }: KpiCardProps) {
|
||||
return (
|
||||
<div className="db-kpi-card">
|
||||
<span className="db-kpi-value" aria-live="polite">{value}</span>
|
||||
<span className="db-kpi-label">{label}</span>
|
||||
{sub && <span className="db-kpi-sub">{sub}</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>
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
{[1,2,3,4].map(i => <div key={i} className="db-kpi-card db-skeleton-block" style={{height:96}} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isSuppressed(data)) {
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
{[1,2,3,4].map(i => (
|
||||
<div key={i} className="db-kpi-card db-suppressed-card">
|
||||
<span className="db-suppressed-icon">🔒</span>
|
||||
<span className="db-kpi-sub">n < 5</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = data as KpiData
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
<KpiCard label="Cuidadores activos" value={`${d.pct_cuidadores}%`} sub={`n = ${d.n}`} />
|
||||
<KpiCard label="Score bienestar (0-100)" value={d.score_bienestar !== null ? `${d.score_bienestar}` : '—'} />
|
||||
<KpiCard label="Ausencias frecuentes" value={`${d.pct_ausencias}%`} />
|
||||
<KpiCard label="Total respuestas" value={d.n.toLocaleString('es-PY')} sub="en este corte" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
111
components/dashboard/paraguay-map.tsx
Normal file
111
components/dashboard/paraguay-map.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'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" 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>
|
||||
)
|
||||
}
|
||||
80
components/dashboard/piramide-demografica.tsx
Normal file
80
components/dashboard/piramide-demografica.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'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" 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>
|
||||
)
|
||||
}
|
||||
42
components/dashboard/politicas-bar.tsx
Normal file
42
components/dashboard/politicas-bar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type PoliticasData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
export function PoliticasBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<PoliticasData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.politicas(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PoliticasData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
return (
|
||||
<WidgetShell title="📈 Políticas más demandadas" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<ul className="db-bar-list" aria-label="Políticas demandadas">
|
||||
{data.data.length === 0 && (
|
||||
<li className="db-suppressed-hint">No hay datos suficientes por política (n<5 en todas).</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>
|
||||
)
|
||||
}
|
||||
65
components/dashboard/tabla-riesgo.tsx
Normal file
65
components/dashboard/tabla-riesgo.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type RiesgoData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
const RIESGO_COLOR: Record<string, string> = {
|
||||
Alto: '#ef4444',
|
||||
Medio: '#F8AD13',
|
||||
Bajo: '#43BBC8',
|
||||
}
|
||||
|
||||
export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<RiesgoData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.riesgo(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as RiesgoData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
return (
|
||||
<WidgetShell title="📋 Tabla de riesgo de retención" status={status} n={data?.n_total} colSpan={12}>
|
||||
{data && (
|
||||
<div className="db-table-wrapper">
|
||||
<p className="db-table-note">
|
||||
Alto: ausencias frecuentes + empresa no comprende. Medio: una condición. Bajo: ninguna.
|
||||
La columna de departamento fue eliminada para evitar cruces reidentificables.
|
||||
</p>
|
||||
<table className="db-table" aria-label="Tabla de riesgo de retención">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Segmento</th>
|
||||
<th>N</th>
|
||||
<th>% del total</th>
|
||||
<th>Política más demandada</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.segmentos.map(seg => (
|
||||
<tr key={seg.segmento}>
|
||||
<td>
|
||||
<span className="db-risk-badge"
|
||||
style={{ background: RIESGO_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<5)</span>
|
||||
: seg.politica ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
41
components/dashboard/widget-shell.tsx
Normal file
41
components/dashboard/widget-shell.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { WidgetStatus } from '@/lib/dashboard-types'
|
||||
|
||||
interface WidgetShellProps {
|
||||
title: string
|
||||
status: WidgetStatus
|
||||
n?: number
|
||||
children: React.ReactNode
|
||||
colSpan?: number
|
||||
}
|
||||
|
||||
export function WidgetShell({ title, status, n, children, colSpan = 6 }: WidgetShellProps) {
|
||||
return (
|
||||
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}>
|
||||
<div className="db-widget-header">
|
||||
<h3 className="db-widget-title">{title}</h3>
|
||||
{n !== undefined && status === 'ok' && (
|
||||
<span className="db-widget-n">n = {n.toLocaleString('es-PY')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="db-widget-body">
|
||||
{status === 'loading' && (
|
||||
<div className="db-skeleton-block" aria-label="Cargando…" aria-busy="true" />
|
||||
)}
|
||||
{status === 'suppressed' && (
|
||||
<div className="db-suppressed" role="status">
|
||||
<span className="db-suppressed-icon">🔒</span>
|
||||
<p>Datos insuficientes</p>
|
||||
<p className="db-suppressed-hint">Se necesitan al menos 5 respuestas en este corte.</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="db-suppressed" role="alert">
|
||||
<p>Error al cargar los datos.</p>
|
||||
</div>
|
||||
)}
|
||||
{status === 'ok' && children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user