Etapa completada (local) — widgets de Mirada Empresa
This commit is contained in:
@@ -4,18 +4,25 @@ import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils'
|
|||||||
|
|
||||||
// Route Handler server-side: llama a las RPC con el cliente Supabase.
|
// Route Handler server-side: llama a las RPC con el cliente Supabase.
|
||||||
// El service_role nunca llega al browser — vive solo en este servidor.
|
// El service_role nunca llega al browser — vive solo en este servidor.
|
||||||
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen 3.4/7.1 internamente.
|
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen datos
|
||||||
|
// sensibles internamente.
|
||||||
|
//
|
||||||
|
// Las 9 RPCs v2 (rpc_dashboard_kpis, etc.) se retiraron de este mapa junto
|
||||||
|
// con sus widgets — referencian códigos de pregunta que no existen en v3
|
||||||
|
// (Etapa 4H, migración de códigos, queda pendiente aparte). Mirada Empresa
|
||||||
|
// usa las rpc_me_* nuevas, ya verificadas contra datos reales.
|
||||||
const RPC_MAP: Record<string, string> = {
|
const RPC_MAP: Record<string, string> = {
|
||||||
kpis: 'rpc_dashboard_kpis',
|
'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
|
||||||
prevalencia: 'rpc_prevalencia_por_depto',
|
'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
|
||||||
heatmap: 'rpc_heatmap_intensidad',
|
'me-ausentismo': 'rpc_me_ausentismo',
|
||||||
distribucion: 'rpc_perfil_poblacion',
|
'me-presentismo': 'rpc_me_presentismo',
|
||||||
apoyos: 'rpc_apoyos_demandados',
|
'me-punto-ciego': 'rpc_me_punto_ciego',
|
||||||
score: 'rpc_score_organizacional',
|
'me-apoyos': 'rpc_me_apoyos_demandados',
|
||||||
piramide: 'rpc_piramide_demografica',
|
'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
|
||||||
intensidad: 'rpc_intensidad_vs_bienestar',
|
'me-carrera-congelada': 'rpc_me_carrera_congelada',
|
||||||
saturacion: 'rpc_saturacion_cuidador',
|
'me-intensidad-carga': 'rpc_me_intensidad_carga',
|
||||||
|
'me-segmentacion': 'rpc_me_segmentacion',
|
||||||
|
'me-disposicion-red': 'rpc_me_disposicion_red',
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSupabaseServer() {
|
function getSupabaseServer() {
|
||||||
@@ -45,10 +52,18 @@ export async function GET(
|
|||||||
const filters = queryToFilters(sp)
|
const filters = queryToFilters(sp)
|
||||||
const supabase = getSupabaseServer()
|
const supabase = getSupabaseServer()
|
||||||
|
|
||||||
const { data, error } = await supabase.rpc(rpcName, {
|
const rpcParams: Record<string, unknown> = {
|
||||||
p_survey_id: surveyId,
|
p_survey_id: surveyId,
|
||||||
p_filters: filtersToRpc(filters),
|
p_filters: filtersToRpc(filters),
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// Único caso con parámetro extra: el umbral parametrizable de la estrella.
|
||||||
|
if (widget === 'me-talento-riesgo') {
|
||||||
|
const umbral = sp.get('umbral')
|
||||||
|
if (umbral) rpcParams.p_umbral_senales = parseInt(umbral, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase.rpc(rpcName, rpcParams)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error(`[dashboard/${widget}] RPC error:`, error.message)
|
console.error(`[dashboard/${widget}] RPC error:`, error.message)
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useSearchParams } from 'next/navigation'
|
import { useSearchParams } from 'next/navigation'
|
||||||
import { KpiCards } from '@/components/dashboard/kpi-cards'
|
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
||||||
import { ParaguayMap } from '@/components/dashboard/paraguay-map'
|
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
|
||||||
import { DonutCuidados } from '@/components/dashboard/donut-cuidados'
|
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
|
||||||
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional'
|
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
|
||||||
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget'
|
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
|
||||||
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica'
|
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
|
||||||
import { ApoyosBar } from '@/components/dashboard/apoyos-bar'
|
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
|
||||||
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad'
|
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
|
||||||
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
|
|
||||||
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
|
||||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||||
|
|
||||||
// El survey_id se pasa como query param: /dashboard?survey_id=UUID
|
// El survey_id se pasa como query param: /dashboard?survey_id=UUID
|
||||||
@@ -22,15 +20,30 @@ const SURVEY_OPTIONS = [
|
|||||||
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
|
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
type Lens = 'empresa' | 'pais' | 'humana'
|
||||||
|
|
||||||
|
const LENSES: { id: Lens; label: string; sub: string }[] = [
|
||||||
|
{ id: 'empresa', label: 'Mirada Empresa', sub: 'rendimiento · rotación · clima' },
|
||||||
|
{ id: 'pais', label: 'Mirada País', sub: 'desarrollo · vulnerabilidad · política' },
|
||||||
|
{ id: 'humana', label: 'Mirada Humana', sub: 'bienestar · derechos · desarrollo' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function PlaceholderLens({ title, hint }: { title: string; hint: string }) {
|
||||||
|
return (
|
||||||
|
<div className="db-placeholder" role="status">
|
||||||
|
<span className="db-placeholder-icon" aria-hidden="true">🔒</span>
|
||||||
|
<p className="db-placeholder-title">{title} — Disponible próximamente</p>
|
||||||
|
<p className="db-placeholder-hint">{hint}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
|
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
|
||||||
const [filters, setFilters] = useState<DashboardFilters>({})
|
const [filters, setFilters] = useState<DashboardFilters>({})
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
const [lens, setLens] = useState<Lens>('empresa')
|
||||||
function handleDeptoClick(depto: string) {
|
|
||||||
setFilters(f => ({ ...f, depto: depto || undefined }))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="db-page">
|
<div className="db-page">
|
||||||
@@ -38,7 +51,7 @@ export default function DashboardPage() {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={setFilters}
|
onChange={setFilters}
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggle={() => setSidebarCollapsed(c => !c)}
|
onToggle={() => setSidebarCollapsed((c) => !c)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="db-main">
|
<div className="db-main">
|
||||||
@@ -66,34 +79,70 @@ export default function DashboardPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Fila 1: KPIs (span 12) */}
|
<nav className="db-tabs" aria-label="Mirada del dashboard">
|
||||||
<section className="db-section" aria-label="Indicadores clave">
|
{LENSES.map((l) => (
|
||||||
<KpiCards surveyId={surveyId} filters={filters} />
|
<button
|
||||||
</section>
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
|
||||||
|
onClick={() => setLens(l.id)}
|
||||||
|
aria-current={lens === l.id ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
<span>{l.label}</span>
|
||||||
|
<span className="db-tab-lens">{l.sub}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */}
|
{lens === 'empresa' && (
|
||||||
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos">
|
<>
|
||||||
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} />
|
<div className="db-lens-intro">
|
||||||
<DonutCuidados surveyId={surveyId} filters={filters} />
|
<strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el
|
||||||
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
|
punto ciego y qué talento está en riesgo.
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
{/* Fila 3: Heatmap (6) + Pirámide (6) */}
|
{/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
|
||||||
<section className="db-grid db-section" aria-label="Intensidad y demografía">
|
<section className="db-grid db-section" aria-label="Indicadores núcleo">
|
||||||
<HeatmapWidget surveyId={surveyId} filters={filters} />
|
<TalentoRiesgoCard surveyId={surveyId} filters={filters} />
|
||||||
<PiramideDemografica surveyId={surveyId} filters={filters} />
|
<PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
|
||||||
</section>
|
<PuntoCiegoCard surveyId={surveyId} filters={filters} />
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
|
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
|
||||||
<section className="db-grid db-section" aria-label="Apoyos e impacto">
|
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
|
||||||
<ApoyosBar surveyId={surveyId} filters={filters} />
|
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
|
||||||
<IntensidadProductividad surveyId={surveyId} filters={filters} />
|
<ApoyosBarME surveyId={surveyId} filters={filters} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Fila 5: Saturación del cuidador (12) */}
|
{/* Fila 3: segmentación (12, SEC) */}
|
||||||
<section className="db-grid db-section" aria-label="Saturación del cuidador">
|
<section className="db-grid db-section" aria-label="Segmentación de la carga">
|
||||||
<TablaSaturacion surveyId={surveyId} filters={filters} />
|
<SegmentacionBar surveyId={surveyId} filters={filters} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
|
||||||
|
<section className="db-grid db-section" aria-label="Disposición a participar">
|
||||||
|
<DisposicionRedCard surveyId={surveyId} filters={filters} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="db-table-note" style={{ textAlign: 'right' }}>
|
||||||
|
k≥5 aplicado · segmentos con n<5 suprimidos
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lens === 'pais' && (
|
||||||
|
<PlaceholderLens
|
||||||
|
title="Mirada País"
|
||||||
|
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lens === 'humana' && (
|
||||||
|
<PlaceholderLens
|
||||||
|
title="Mirada Humana"
|
||||||
|
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -912,6 +912,56 @@ body {
|
|||||||
}
|
}
|
||||||
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
|
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
|
||||||
|
|
||||||
|
/* ── Tres miradas: tabs ── */
|
||||||
|
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
|
||||||
|
.db-tab {
|
||||||
|
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #9ca3af;
|
||||||
|
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
|
||||||
|
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
|
||||||
|
}
|
||||||
|
.db-tab:hover { color: #E7E6E5; }
|
||||||
|
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
|
||||||
|
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
|
||||||
|
|
||||||
|
/* ── Tres miradas: intro de cada lente ── */
|
||||||
|
.db-lens-intro {
|
||||||
|
background: #101a30; border-left: 3px solid var(--color-re-teal);
|
||||||
|
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
|
||||||
|
font-size: 0.8125rem; color: #9fb0cc; line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card destacada (estrella) ── */
|
||||||
|
.db-widget-star {
|
||||||
|
border-color: var(--color-re-teal);
|
||||||
|
background: linear-gradient(135deg, #0e1b2e, #10243000);
|
||||||
|
}
|
||||||
|
.db-widget-star .db-widget-title { color: var(--color-re-teal); }
|
||||||
|
|
||||||
|
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
|
||||||
|
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
|
||||||
|
.db-big-stat-warn { color: #f0b429; }
|
||||||
|
.db-big-stat-danger { color: #f06b6b; }
|
||||||
|
.db-big-stat-sub { font-size: 0.75rem; color: #7d8da5; margin-top: 6px; line-height: 1.4; }
|
||||||
|
|
||||||
|
/* ── Punto ciego: contraste de dos barras ── */
|
||||||
|
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
|
||||||
|
.db-gap-col { text-align: center; }
|
||||||
|
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
|
||||||
|
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
|
||||||
|
.db-gap-lab { font-size: 0.6875rem; color: #9fb0cc; margin-top: 6px; }
|
||||||
|
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #f0b429; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
|
||||||
|
.db-placeholder {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 8px; min-height: 220px; text-align: center; color: #7d8da5;
|
||||||
|
background: #101a30; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
.db-placeholder-icon { font-size: 1.75rem; }
|
||||||
|
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #E7E6E5; }
|
||||||
|
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
|
||||||
|
|
||||||
/* ── Admin: control de respuestas (Etapa 3V) ── */
|
/* ── Admin: control de respuestas (Etapa 3V) ── */
|
||||||
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||||
.admin-header {
|
.admin-header {
|
||||||
|
|||||||
52
components/dashboard/apoyos-bar-me.tsx
Normal file
52
components/dashboard/apoyos-bar-me.tsx
Normal 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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
53
components/dashboard/disposicion-red-card.tsx
Normal file
53
components/dashboard/disposicion-red-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 0–100" 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 < 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 1–5',
|
|
||||||
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 < 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
42
components/dashboard/prevalencia-cuidadores-card.tsx
Normal file
42
components/dashboard/prevalencia-cuidadores-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
components/dashboard/punto-ciego-card.tsx
Normal file
73
components/dashboard/punto-ciego-card.tsx
Normal 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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
components/dashboard/segmentacion-bar.tsx
Normal file
74
components/dashboard/segmentacion-bar.tsx
Normal 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 n≥5 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 n≥5 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
components/dashboard/senales-impacto-bar.tsx
Normal file
126
components/dashboard/senales-impacto-bar.tsx
Normal 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<5
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</WidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<5)</span>
|
|
||||||
: seg.apoyo ?? '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</WidgetShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
58
components/dashboard/talento-riesgo-card.tsx
Normal file
58
components/dashboard/talento-riesgo-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,11 +8,16 @@ interface WidgetShellProps {
|
|||||||
n?: number
|
n?: number
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
colSpan?: number
|
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 (
|
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-header">
|
||||||
<div className="db-widget-title-group">
|
<div className="db-widget-title-group">
|
||||||
<h3 className="db-widget-title">{title}</h3>
|
<h3 className="db-widget-title">{title}</h3>
|
||||||
|
|||||||
@@ -4,21 +4,43 @@
|
|||||||
import type { DashboardFilters } from './dashboard-types'
|
import type { DashboardFilters } from './dashboard-types'
|
||||||
import { filtersToQuery } from './dashboard-utils'
|
import { filtersToQuery } from './dashboard-utils'
|
||||||
|
|
||||||
async function fetchWidget<T>(widget: string, surveyId: string, filters: DashboardFilters): Promise<T> {
|
async function fetchWidget<T>(
|
||||||
|
widget: string,
|
||||||
|
surveyId: string,
|
||||||
|
filters: DashboardFilters,
|
||||||
|
extra?: Record<string, string>
|
||||||
|
): Promise<T> {
|
||||||
const qs = filtersToQuery(surveyId, filters)
|
const qs = filtersToQuery(surveyId, filters)
|
||||||
const res = await fetch(`/api/dashboard/${widget}?${qs}`, { cache: 'no-store' })
|
const extraQs = extra ? `&${new URLSearchParams(extra).toString()}` : ''
|
||||||
|
const res = await fetch(`/api/dashboard/${widget}?${qs}${extraQs}`, { cache: 'no-store' })
|
||||||
if (!res.ok) throw new Error(`Widget ${widget}: ${res.status}`)
|
if (!res.ok) throw new Error(`Widget ${widget}: ${res.status}`)
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mirada Empresa — una entrada por métrica (E1-E10 + estrella).
|
||||||
export const queries = {
|
export const queries = {
|
||||||
kpis: (sid: string, f: DashboardFilters) => fetchWidget('kpis', sid, f),
|
mePrevalencia: (sid: string, f: DashboardFilters) =>
|
||||||
prevalencia: (sid: string, f: DashboardFilters) => fetchWidget('prevalencia', sid, f),
|
fetchWidget('me-prevalencia', sid, f),
|
||||||
heatmap: (sid: string, f: DashboardFilters) => fetchWidget('heatmap', sid, f),
|
meRiesgoRotacion: (sid: string, f: DashboardFilters) =>
|
||||||
distribucion: (sid: string, f: DashboardFilters) => fetchWidget('distribucion', sid, f),
|
fetchWidget('me-riesgo-rotacion', sid, f),
|
||||||
apoyos: (sid: string, f: DashboardFilters) => fetchWidget('apoyos', sid, f),
|
meAusentismo: (sid: string, f: DashboardFilters) =>
|
||||||
score: (sid: string, f: DashboardFilters) => fetchWidget('score', sid, f),
|
fetchWidget('me-ausentismo', sid, f),
|
||||||
piramide: (sid: string, f: DashboardFilters) => fetchWidget('piramide', sid, f),
|
mePresentismo: (sid: string, f: DashboardFilters) =>
|
||||||
intensidad: (sid: string, f: DashboardFilters) => fetchWidget('intensidad', sid, f),
|
fetchWidget('me-presentismo', sid, f),
|
||||||
saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f),
|
mePuntoCiego: (sid: string, f: DashboardFilters) =>
|
||||||
|
fetchWidget('me-punto-ciego', sid, f),
|
||||||
|
meApoyos: (sid: string, f: DashboardFilters) =>
|
||||||
|
fetchWidget('me-apoyos', sid, f),
|
||||||
|
// p_umbral_senales es parametrizable en la RPC — el widget puede pasarlo,
|
||||||
|
// si no se pasa nada la RPC usa su propio default (2 de 3).
|
||||||
|
meTalentoRiesgo: (sid: string, f: DashboardFilters, umbral?: number) =>
|
||||||
|
fetchWidget('me-talento-riesgo', sid, f, umbral !== undefined ? { umbral: String(umbral) } : undefined),
|
||||||
|
meCarreraCongelada: (sid: string, f: DashboardFilters) =>
|
||||||
|
fetchWidget('me-carrera-congelada', sid, f),
|
||||||
|
meIntensidadCarga: (sid: string, f: DashboardFilters) =>
|
||||||
|
fetchWidget('me-intensidad-carga', sid, f),
|
||||||
|
meSegmentacion: (sid: string, f: DashboardFilters) =>
|
||||||
|
fetchWidget('me-segmentacion', sid, f),
|
||||||
|
meDisposicionRed: (sid: string, f: DashboardFilters) =>
|
||||||
|
fetchWidget('me-disposicion-red', sid, f),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,51 +9,6 @@ export interface DashboardFilters {
|
|||||||
// Estado de cada widget
|
// Estado de cada widget
|
||||||
export type WidgetStatus = 'loading' | 'ok' | 'suppressed' | 'error'
|
export type WidgetStatus = 'loading' | 'ok' | 'suppressed' | 'error'
|
||||||
|
|
||||||
// Payloads de cada RPC
|
|
||||||
export interface KpiData {
|
|
||||||
status: 'ok'
|
|
||||||
n: number
|
|
||||||
pct_cuidadores: number
|
|
||||||
score_bienestar: number | null // avg raw 4.3 (1.0–5.0)
|
|
||||||
pct_ausencias: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeptoData {
|
|
||||||
depto: string
|
|
||||||
n: number | null
|
|
||||||
pct_cuidadores: number | null
|
|
||||||
suppressed: boolean
|
|
||||||
}
|
|
||||||
export interface PrevalenciaData { status: 'ok'; n_total: number; data: DeptoData[] }
|
|
||||||
|
|
||||||
export interface HeatmapCell { horas: string; ausencias: string; n: number | null; suppressed: boolean }
|
|
||||||
export interface HeatmapData { status: 'ok'; n_total: number; data: HeatmapCell[] }
|
|
||||||
|
|
||||||
export interface DistribucionData {
|
|
||||||
status: 'ok'; n: number
|
|
||||||
disc_propia: number; familiar: number; adulto_mayor: number; ninguno: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApoyoItem { politica: string; n: number; pct: number }
|
|
||||||
export interface ApoyosData { status: 'ok'; n_total: number; data: ApoyoItem[] }
|
|
||||||
|
|
||||||
export interface ScoreData { status: 'ok'; n: number; score: number; zona: 'baja' | 'desarrollo' | 'consciente' }
|
|
||||||
|
|
||||||
export interface PiramideCell { etario: string; genero: string; n: number | null; suppressed: boolean }
|
|
||||||
export interface PiramideData { status: 'ok'; n_total: number; data: PiramideCell[] }
|
|
||||||
|
|
||||||
export interface IntensidadCell { horas: string; bienestar: string; n: number | null; suppressed: boolean }
|
|
||||||
export interface IntensidadData { status: 'ok'; n_total: number; data: IntensidadCell[] }
|
|
||||||
|
|
||||||
export interface SaturacionSegmento {
|
|
||||||
segmento: string
|
|
||||||
n: number | null
|
|
||||||
pct: number | null
|
|
||||||
apoyo: string | null
|
|
||||||
suppressed: boolean
|
|
||||||
}
|
|
||||||
export interface SaturacionData { status: 'ok'; n_total: number; segmentos: SaturacionSegmento[] }
|
|
||||||
|
|
||||||
export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number }
|
export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number }
|
||||||
|
|
||||||
export type RpcResult<T> = T | SuppressedPayload
|
export type RpcResult<T> = T | SuppressedPayload
|
||||||
@@ -61,3 +16,123 @@ export type RpcResult<T> = T | SuppressedPayload
|
|||||||
export function isSuppressed(r: unknown): r is SuppressedPayload {
|
export function isSuppressed(r: unknown): r is SuppressedPayload {
|
||||||
return typeof r === 'object' && r !== null && (r as Record<string, unknown>)['status'] === 'suppressed'
|
return typeof r === 'object' && r !== null && (r as Record<string, unknown>)['status'] === 'suppressed'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
// Mirada Empresa — shapes tal cual los devuelven las rpc_me_*
|
||||||
|
// (migración 20260625000002_dashboard_mirada_empresa.sql, verificada
|
||||||
|
// contra Postgres real). NO copiar del SPEC_MIRADA_EMPRESA.md a ciegas:
|
||||||
|
// el SQL real difiere un poco del shape ilustrativo de la spec (ej. E6
|
||||||
|
// agrega apoyo_percibido_suppressed; E7 usa "apoyos", no "data").
|
||||||
|
// ════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// E1 — Prevalencia de cuidadores
|
||||||
|
export interface PrevalenciaCuidadoresData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
pct_cuidadores: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// E2 — Riesgo de rotación
|
||||||
|
export interface RiesgoRotacionData {
|
||||||
|
status: 'ok'
|
||||||
|
n_cuidadores: number
|
||||||
|
pct_riesgo: number
|
||||||
|
desglose: { algo: number; muy: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// E3 — Ausentismo
|
||||||
|
export interface AusentismoData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
pct_ausentismo_recurrente: number
|
||||||
|
desglose: { nunca: number; pocas: number; varias: number; frecuente: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// E4 — Presentismo
|
||||||
|
export interface PresentismoData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
pct_presentismo: number
|
||||||
|
desglose: {
|
||||||
|
nunca_o_casi_nunca: number
|
||||||
|
algunas_al_mes: number
|
||||||
|
varias_por_semana: number
|
||||||
|
casi_todos_los_dias: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// E6 — Punto ciego organizacional
|
||||||
|
// apoyo_percibido_pct puede ser null con status='ok' si SU población (9.1)
|
||||||
|
// no llega a 5, aunque el total (carga_real) sí — dos poblaciones, dos
|
||||||
|
// supresiones independientes (ver comentario en la RPC).
|
||||||
|
export interface PuntoCiegoData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
carga_real_pct: number
|
||||||
|
apoyo_percibido_pct: number | null
|
||||||
|
apoyo_percibido_suppressed: boolean
|
||||||
|
brecha_pp: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// E7 — Apoyos demandados
|
||||||
|
export interface ApoyoMEItem { apoyo: string; n: number; pct: number }
|
||||||
|
export interface ApoyosMEData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
apoyos: ApoyoMEItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ Talento en riesgo
|
||||||
|
export interface TalentoRiesgoData {
|
||||||
|
status: 'ok'
|
||||||
|
n_cuidadores: number
|
||||||
|
umbral_senales: number
|
||||||
|
pct_talento_riesgo: number
|
||||||
|
criterios_promedio: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// E5 — Carrera congelada [SEC]
|
||||||
|
export interface CarreraCongeladaItem { opcion: string; n: number; pct: number }
|
||||||
|
export interface CarreraCongeladaData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
pct_carrera_congelada: number
|
||||||
|
top_opciones: CarreraCongeladaItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// E8 — Intensidad de carga [SEC]
|
||||||
|
export interface IntensidadCargaCell {
|
||||||
|
dias_semana: string
|
||||||
|
horas_dia: string
|
||||||
|
n: number | null
|
||||||
|
suppressed: boolean
|
||||||
|
}
|
||||||
|
export interface IntensidadCargaData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
distribucion_intensidad: IntensidadCargaCell[]
|
||||||
|
pct_guardia_permanente: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// E9 — Segmentación por nivel y modalidad [SEC]
|
||||||
|
export interface SegmentoNivelItem { nivel: string; n: number; pct: number }
|
||||||
|
export interface SegmentoModalidadItem { modalidad: string; n: number; pct: number }
|
||||||
|
export interface SegmentacionData {
|
||||||
|
status: 'ok'
|
||||||
|
n: number
|
||||||
|
por_nivel: SegmentoNivelItem[]
|
||||||
|
por_modalidad: SegmentoModalidadItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// E10 — Disposición a red de apoyo [SEC]
|
||||||
|
// Shape "suppressed" de esta RPC difiere del genérico (usa
|
||||||
|
// n_grupo_apoyo/n_voluntariado, no min_n/n) — isSuppressed() solo mira
|
||||||
|
// `status`, así que sigue funcionando; no leer .n de un resultado
|
||||||
|
// suprimido de esta RPC en particular.
|
||||||
|
export interface DisposicionRedData {
|
||||||
|
status: 'ok'
|
||||||
|
n_grupo_apoyo: number
|
||||||
|
pct_grupo_apoyo: number | null
|
||||||
|
n_voluntariado: number
|
||||||
|
pct_voluntariado: number | null
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,18 +33,4 @@ export function filtersToRpc(filters: DashboardFilters): Record<string, unknown>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HORAS_ORDER = ['Menos de 1h','Entre 1 y 3h','Entre 3 y 6h','Más de 6h','Varía mucho']
|
|
||||||
export const AUSENCIAS_ORDER = ['Nunca','Pocas veces (1–3 veces al año)','Varias veces (4–10 veces al año)','Con frecuencia (más de 10 veces al año)']
|
|
||||||
export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir']
|
export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir']
|
||||||
|
|
||||||
export function sortByOrder<T extends Record<string, unknown>>(
|
|
||||||
arr: T[],
|
|
||||||
key: keyof T,
|
|
||||||
order: string[]
|
|
||||||
): T[] {
|
|
||||||
return [...arr].sort((a, b) => {
|
|
||||||
const ia = order.indexOf(a[key] as string)
|
|
||||||
const ib = order.indexOf(b[key] as string)
|
|
||||||
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
100
sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md
Normal file
100
sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# ETAPA — Widgets de Mirada Empresa (frontend del dashboard v3)
|
||||||
|
|
||||||
|
## 1. Objetivo
|
||||||
|
Construir el frontend del dashboard de Mirada Empresa: la estructura de tres miradas
|
||||||
|
(Empresa funcional, País/Humana como "próximamente") y los widgets que consumen las RPCs
|
||||||
|
`rpc_me_*` ya implementadas y verificadas contra datos reales. El cliente ve las métricas.
|
||||||
|
|
||||||
|
## 2. Contexto previo
|
||||||
|
- Las RPCs de Mirada Empresa están hechas y VERIFICADAS contra 50 respuestas reales
|
||||||
|
(migración `20260625000002_dashboard_mirada_empresa.sql`): E1-E10 + estrella "Talento en riesgo".
|
||||||
|
- Contrato de cada RPC (shapes de retorno): `docs/dashboard/SPEC_MIRADA_EMPRESA.md`.
|
||||||
|
- Aspecto objetivo: `docs/dashboard/dashboard_tres_miradas_mockup.html`.
|
||||||
|
- Patrón de widget existente a REUTILIZAR (no reinventar):
|
||||||
|
- `components/dashboard/widget-shell.tsx` — estados loading/ok/suppressed/error.
|
||||||
|
- hooks `useEffect` + `queries.X(surveyId, filters)` (lib/dashboard-queries.ts).
|
||||||
|
- `isSuppressed()` type guard (lib/dashboard-types.ts).
|
||||||
|
- Gráficos en SVG/CSS a mano (db-bar-*, db-donut-*, db-kpi-*) — SIN librería de charts.
|
||||||
|
- Tema oscuro db-*, acento único teal `#43BBC8` (--color-re-teal).
|
||||||
|
- Tres archivos de plomería a extender (mapeos mecánicos): `lib/dashboard-types.ts` (tipos
|
||||||
|
de retorno), `lib/dashboard-queries.ts` (entradas queries), `app/api/dashboard/[widget]/route.ts`
|
||||||
|
(RPC_MAP con los `rpc_me_*`).
|
||||||
|
|
||||||
|
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||||
|
- **Opción A:** reemplazar el dashboard actual por la estructura de tres miradas. Los widgets
|
||||||
|
v2 rotos (que consumen RPCs v2 inexistentes en v3) se RETIRAN. País y Humana = placeholder
|
||||||
|
"Disponible próximamente".
|
||||||
|
- Reutilizar WidgetShell, el patrón de hooks, isSuppressed, y los gráficos SVG/CSS a mano.
|
||||||
|
- NO traer librería de charts nueva — replicar el patrón existente.
|
||||||
|
- Mantener el tema db-* y el teal como acento.
|
||||||
|
- NO tocar las RPCs (están verificadas). Solo el frontend + la plomería de tipos/queries/route.
|
||||||
|
- Mirada Empresa NO muestra datos sensibles (ya garantizado en las RPCs; el frontend solo refleja lo que viene).
|
||||||
|
|
||||||
|
## 4. Tareas
|
||||||
|
### 4.1 Plomería (mecánica)
|
||||||
|
- `lib/dashboard-types.ts`: tipos de retorno de cada `rpc_me_*` según los shapes de la SPEC.
|
||||||
|
- `lib/dashboard-queries.ts`: una entrada por métrica (E1-E10 + estrella).
|
||||||
|
- `app/api/dashboard/[widget]/route.ts`: agregar los `rpc_me_*` al RPC_MAP.
|
||||||
|
|
||||||
|
### 4.2 Estructura de tres miradas
|
||||||
|
- Tabs/secciones Empresa | País | Humana (como el mockup).
|
||||||
|
- Empresa: funcional. País/Humana: placeholder "Disponible próximamente" (no widgets vacíos).
|
||||||
|
- El selector de survey/p_all y los filtros del sidebar siguen funcionando como hoy.
|
||||||
|
|
||||||
|
### 4.3 Widgets de Mirada Empresa
|
||||||
|
Implementar los widgets del NÚCLEO primero (los que el cliente necesita):
|
||||||
|
- **Talento en riesgo** (estrella) — KPI destacado, métrica compuesta.
|
||||||
|
- **E1 Prevalencia de cuidadores** — KPI.
|
||||||
|
- **E6 Punto ciego organizacional** — el widget de contraste (carga real vs apoyo percibido),
|
||||||
|
dos barras enfrentadas como en el mockup.
|
||||||
|
- **E2/E3/E4 Señales de impacto** — barras (riesgo, ausentismo, presentismo).
|
||||||
|
- **E7 Apoyos demandados** — barras horizontales (igual patrón que apoyos-bar viejo).
|
||||||
|
Después, si el alcance lo permite, los [SEC]: E5, E8, E9 (segmentación), E10.
|
||||||
|
|
||||||
|
### 4.4 Manejo de estados
|
||||||
|
- Cada widget usa WidgetShell. Estado `suppressed` (k<5) muestra el mensaje estándar 🔒.
|
||||||
|
- Las métricas que segmentan (E7, E9) suprimen filas individuales con n<5 (las RPCs ya lo hacen;
|
||||||
|
el widget solo no muestra lo que no viene).
|
||||||
|
|
||||||
|
## 5. Entregables (DoD)
|
||||||
|
- [ ] Estructura de tres miradas; Empresa funcional, País/Humana placeholder.
|
||||||
|
- [ ] Widgets del núcleo consumiendo las RPCs reales vía la plomería.
|
||||||
|
- [ ] Widgets v2 rotos retirados.
|
||||||
|
- [ ] WidgetShell y patrón existente reutilizados (no librería nueva, no reinvención).
|
||||||
|
- [ ] Estados loading/ok/suppressed manejados.
|
||||||
|
- [ ] Build sin errores.
|
||||||
|
- [ ] **Validación visual del director** contra el mockup + datos reales sembrados.
|
||||||
|
|
||||||
|
## 6. Qué reportar
|
||||||
|
```
|
||||||
|
Verificado:
|
||||||
|
- Estructura 3 miradas ✓ — Empresa funcional, País/Humana placeholder
|
||||||
|
- Widgets implementados: [lista con qué RPC consume cada uno]
|
||||||
|
- Plomería: tipos + queries + RPC_MAP extendidos ✓
|
||||||
|
- Widgets v2 retirados: [lista]
|
||||||
|
- WidgetShell reutilizado ✓ / sin librería de charts nueva ✓
|
||||||
|
- Build: [resultado]
|
||||||
|
- Descripción visual: cómo se ve cada widget núcleo con datos
|
||||||
|
Asumido / decisiones de diseño:
|
||||||
|
- [lo que haya]
|
||||||
|
```
|
||||||
|
Reporte con datos concretos (C.4). Para validación visual necesito ver el dashboard con datos:
|
||||||
|
indicá cómo levantarlo (el seed_prueba_50.sql siembra los datos).
|
||||||
|
|
||||||
|
## 7. Qué NO hacer
|
||||||
|
- ❌ No tocar las RPCs (verificadas).
|
||||||
|
- ❌ No traer librería de charts — SVG/CSS a mano como el resto.
|
||||||
|
- ❌ No mostrar datos sensibles (3.x, 4.2, 4.3).
|
||||||
|
- ❌ No implementar widgets de País/Humana (placeholder solo).
|
||||||
|
- ❌ No aplicar a producción sin validación visual.
|
||||||
|
- ❌ No tocar v2.1.0-stable, el motor, el seed del formulario.
|
||||||
|
|
||||||
|
## 8. Versionado
|
||||||
|
```
|
||||||
|
feat(dashboard): widgets de Mirada Empresa + estructura tres miradas [dashboard]
|
||||||
|
|
||||||
|
Reemplaza el dashboard v2 (widgets rotos contra v3) por la estructura de tres
|
||||||
|
miradas. Mirada Empresa funcional: talento en riesgo, prevalencia, punto ciego,
|
||||||
|
señales de impacto, apoyos demandados. País/Humana como placeholder. Reutiliza
|
||||||
|
WidgetShell y el patrón SVG/CSS existente. Consume las rpc_me_* verificadas.
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user