From 56443a739ff75a2820bfa7eda9ec0b5514857677 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Fri, 26 Jun 2026 11:29:16 -0300 Subject: [PATCH] =?UTF-8?q?Etapa=20completada=20(local)=20=E2=80=94=20widg?= =?UTF-8?q?ets=20de=20Mirada=20Empresa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/dashboard/[widget]/route.ts | 43 +++-- app/dashboard/page.tsx | 127 +++++++++----- app/globals.css | 50 ++++++ components/dashboard/apoyos-bar-me.tsx | 52 ++++++ components/dashboard/apoyos-bar.tsx | 42 ----- components/dashboard/disposicion-red-card.tsx | 53 ++++++ components/dashboard/donut-cuidados.tsx | 81 --------- components/dashboard/gauge-organizacional.tsx | 61 ------- components/dashboard/heatmap-widget.tsx | 70 -------- .../dashboard/intensidad-productividad.tsx | 75 -------- components/dashboard/kpi-cards.tsx | 96 ---------- components/dashboard/paraguay-map.tsx | 111 ------------ components/dashboard/piramide-demografica.tsx | 80 --------- .../dashboard/prevalencia-cuidadores-card.tsx | 42 +++++ components/dashboard/punto-ciego-card.tsx | 73 ++++++++ components/dashboard/segmentacion-bar.tsx | 74 ++++++++ components/dashboard/senales-impacto-bar.tsx | 126 +++++++++++++ components/dashboard/tabla-saturacion.tsx | 67 ------- components/dashboard/talento-riesgo-card.tsx | 58 ++++++ components/dashboard/widget-shell.tsx | 9 +- lib/dashboard-queries.ts | 44 +++-- lib/dashboard-types.ts | 165 +++++++++++++----- lib/dashboard-utils.ts | 14 -- .../ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md | 100 +++++++++++ 24 files changed, 905 insertions(+), 808 deletions(-) create mode 100644 components/dashboard/apoyos-bar-me.tsx delete mode 100644 components/dashboard/apoyos-bar.tsx create mode 100644 components/dashboard/disposicion-red-card.tsx delete mode 100644 components/dashboard/donut-cuidados.tsx delete mode 100644 components/dashboard/gauge-organizacional.tsx delete mode 100644 components/dashboard/heatmap-widget.tsx delete mode 100644 components/dashboard/intensidad-productividad.tsx delete mode 100644 components/dashboard/kpi-cards.tsx delete mode 100644 components/dashboard/paraguay-map.tsx delete mode 100644 components/dashboard/piramide-demografica.tsx create mode 100644 components/dashboard/prevalencia-cuidadores-card.tsx create mode 100644 components/dashboard/punto-ciego-card.tsx create mode 100644 components/dashboard/segmentacion-bar.tsx create mode 100644 components/dashboard/senales-impacto-bar.tsx delete mode 100644 components/dashboard/tabla-saturacion.tsx create mode 100644 components/dashboard/talento-riesgo-card.tsx create mode 100644 sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md diff --git a/app/api/dashboard/[widget]/route.ts b/app/api/dashboard/[widget]/route.ts index ab4bf63..e8a252b 100644 --- a/app/api/dashboard/[widget]/route.ts +++ b/app/api/dashboard/[widget]/route.ts @@ -4,18 +4,25 @@ import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils' // Route Handler server-side: llama a las RPC con el cliente Supabase. // 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 = { - kpis: 'rpc_dashboard_kpis', - prevalencia: 'rpc_prevalencia_por_depto', - heatmap: 'rpc_heatmap_intensidad', - distribucion: 'rpc_perfil_poblacion', - apoyos: 'rpc_apoyos_demandados', - score: 'rpc_score_organizacional', - piramide: 'rpc_piramide_demografica', - intensidad: 'rpc_intensidad_vs_bienestar', - saturacion: 'rpc_saturacion_cuidador', + 'me-prevalencia': 'rpc_me_prevalencia_cuidadores', + 'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion', + 'me-ausentismo': 'rpc_me_ausentismo', + 'me-presentismo': 'rpc_me_presentismo', + 'me-punto-ciego': 'rpc_me_punto_ciego', + 'me-apoyos': 'rpc_me_apoyos_demandados', + 'me-talento-riesgo': 'rpc_me_talento_en_riesgo', + 'me-carrera-congelada': 'rpc_me_carrera_congelada', + 'me-intensidad-carga': 'rpc_me_intensidad_carga', + 'me-segmentacion': 'rpc_me_segmentacion', + 'me-disposicion-red': 'rpc_me_disposicion_red', } function getSupabaseServer() { @@ -45,10 +52,18 @@ export async function GET( const filters = queryToFilters(sp) const supabase = getSupabaseServer() - const { data, error } = await supabase.rpc(rpcName, { + const rpcParams: Record = { 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) { console.error(`[dashboard/${widget}] RPC error:`, error.message) diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 1a8c9ff..14fa0af 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,16 +1,14 @@ 'use client' import { useState } from 'react' import { useSearchParams } from 'next/navigation' -import { KpiCards } from '@/components/dashboard/kpi-cards' -import { ParaguayMap } from '@/components/dashboard/paraguay-map' -import { DonutCuidados } from '@/components/dashboard/donut-cuidados' -import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional' -import { HeatmapWidget } from '@/components/dashboard/heatmap-widget' -import { PiramideDemografica } from '@/components/dashboard/piramide-demografica' -import { ApoyosBar } from '@/components/dashboard/apoyos-bar' -import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad' -import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion' -import { FilterSidebar } from '@/components/dashboard/filter-sidebar' +import { FilterSidebar } from '@/components/dashboard/filter-sidebar' +import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card' +import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card' +import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card' +import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar' +import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me' +import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar' +import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card' import type { DashboardFilters } from '@/lib/dashboard-types' // 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' }, ] +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 ( +
+ +

{title} — Disponible próximamente

+

{hint}

+
+ ) +} + export default function DashboardPage() { const searchParams = useSearchParams() const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001' const [filters, setFilters] = useState({}) const [sidebarCollapsed, setSidebarCollapsed] = useState(false) - - function handleDeptoClick(depto: string) { - setFilters(f => ({ ...f, depto: depto || undefined })) - } + const [lens, setLens] = useState('empresa') return (
@@ -38,7 +51,7 @@ export default function DashboardPage() { filters={filters} onChange={setFilters} collapsed={sidebarCollapsed} - onToggle={() => setSidebarCollapsed(c => !c)} + onToggle={() => setSidebarCollapsed((c) => !c)} />
@@ -66,34 +79,70 @@ export default function DashboardPage() { - {/* Fila 1: KPIs (span 12) */} -
- -
+ - {/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */} -
- - - -
+ {lens === 'empresa' && ( + <> +
+ Mirada Empresa. Traduce el cuidado a lenguaje de negocio: cuánto cuesta el + punto ciego y qué talento está en riesgo. +
- {/* Fila 3: Heatmap (6) + Pirámide (6) */} -
- - -
+ {/* Fila 1: núcleo — estrella + prevalencia + punto ciego */} +
+ + + +
- {/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */} -
- - -
+ {/* Fila 2: señales de impacto (6) + apoyos demandados (6) */} +
+ + +
- {/* Fila 5: Saturación del cuidador (12) */} -
- -
+ {/* Fila 3: segmentación (12, SEC) */} +
+ +
+ + {/* Fila 4: disposición a red de apoyo (4, SEC) */} +
+ +
+ +

+ k≥5 aplicado · segmentos con n<5 suprimidos +

+ + )} + + {lens === 'pais' && ( + + )} + + {lens === 'humana' && ( + + )}
) diff --git a/app/globals.css b/app/globals.css index 7011baa..a5ee911 100644 --- a/app/globals.css +++ b/app/globals.css @@ -912,6 +912,56 @@ body { } .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-page { max-width: 1100px; margin: 0 auto; padding: 24px; } .admin-header { diff --git a/components/dashboard/apoyos-bar-me.tsx b/components/dashboard/apoyos-bar-me.tsx new file mode 100644 index 0000000..de8f124 --- /dev/null +++ b/components/dashboard/apoyos-bar-me.tsx @@ -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(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 ( + + {ok && ( +
    + {ok.apoyos.length === 0 && ( +
  • No hay datos suficientes por apoyo (n<5 en todos).
  • + )} + {ok.apoyos.map((item) => ( +
  • + {item.apoyo} +
    +
    +
    + {item.pct}% +
  • + ))} +
+ )} +
+ ) +} diff --git a/components/dashboard/apoyos-bar.tsx b/components/dashboard/apoyos-bar.tsx deleted file mode 100644 index 3935183..0000000 --- a/components/dashboard/apoyos-bar.tsx +++ /dev/null @@ -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(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 ( - - {data && ( -
    - {data.data.length === 0 && ( -
  • No hay datos suficientes por apoyo (n<5 en todos).
  • - )} - {data.data.map(item => ( -
  • - {item.politica} -
  • - ))} -
- )} -
- ) -} diff --git a/components/dashboard/disposicion-red-card.tsx b/components/dashboard/disposicion-red-card.tsx new file mode 100644 index 0000000..42629e7 --- /dev/null +++ b/components/dashboard/disposicion-red-card.tsx @@ -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(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 ( + + {ok && ( +
+
+
+ {ok.pct_grupo_apoyo !== null ? `${ok.pct_grupo_apoyo}%` : 'n<5'} +
+

grupo de apoyo (n={ok.n_grupo_apoyo})

+
+
+
+ {ok.pct_voluntariado !== null ? `${ok.pct_voluntariado}%` : 'n<5'} +
+

voluntariado (n={ok.n_voluntariado})

+
+
+ )} +
+ ) +} diff --git a/components/dashboard/donut-cuidados.tsx b/components/dashboard/donut-cuidados.tsx deleted file mode 100644 index 9938c8b..0000000 --- a/components/dashboard/donut-cuidados.tsx +++ /dev/null @@ -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(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 ( - - {data && ( -
- -
    - {SEGMENTS.map(s => ( -
  • - - {s.label} - - {(data[s.key as keyof DistribucionData] as number ?? 0)}% - -
  • - ))} -
-
- )} -
- ) -} diff --git a/components/dashboard/gauge-organizacional.tsx b/components/dashboard/gauge-organizacional.tsx deleted file mode 100644 index 6799d8e..0000000 --- a/components/dashboard/gauge-organizacional.tsx +++ /dev/null @@ -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(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 ( - - {data && ( -
- - {/* Fondo semicírculo */} - - {/* Arco coloreado proporcional al score */} - - {/* Aguja */} - - - {/* Score */} - {score} - / 100 - -

- {ZONA_LABEL[data.zona]} -

-

- 0–33 baja · 34–66 desarrollo · 67–100 consciente -

-
- )} -
- ) -} diff --git a/components/dashboard/heatmap-widget.tsx b/components/dashboard/heatmap-widget.tsx deleted file mode 100644 index c6d766b..0000000 --- a/components/dashboard/heatmap-widget.tsx +++ /dev/null @@ -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(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 ( - - {data && ( -
-
- {/* Header row */} -
- {SHORT_AUSENCIAS.map(h => ( -
{h}
- ))} - {/* Data rows */} - {HORAS_ORDER.map(horas => ( - <> -
{horas}
- {AUSENCIAS_ORDER.map(aus => { - const cell = cellMap.get(`${horas}|${aus}`) - const n = cell?.n ?? null - const sup2 = cell?.suppressed ?? (!cell) - return ( -
- {!sup2 && n !== null && {n}} -
- ) - })} - - ))} -
-
- )} - - ) -} diff --git a/components/dashboard/intensidad-productividad.tsx b/components/dashboard/intensidad-productividad.tsx deleted file mode 100644 index c0b1d5f..0000000 --- a/components/dashboard/intensidad-productividad.tsx +++ /dev/null @@ -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(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 ( - - {data && ( -
-

- Cada celda = respondentes agregados. Celdas sin color: N < 5 (suprimido). -

-
-
-
- {BIENESTAR_BINS.map(b => ( -
Bienestar {b}
- ))} - {HORAS_ORDER.map(horas => ( - <> -
{horas}
- {BIENESTAR_BINS.map(bien => { - const cell = cellMap.get(`${horas}|${bien}`) - const n = cell?.n ?? null - const suppressed = cell?.suppressed ?? true - return ( -
- {!suppressed && n !== null && {n}} -
- ) - })} - - ))} -
-
-
- )} - - ) -} diff --git a/components/dashboard/kpi-cards.tsx b/components/dashboard/kpi-cards.tsx deleted file mode 100644 index 0f4febf..0000000 --- a/components/dashboard/kpi-cards.tsx +++ /dev/null @@ -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 ( -
- {title} - {subtitle} - - {loading && ( -
- )} - {!loading && suppressed && ( -
- - n < 5 -
- )} - {!loading && !suppressed && ( - <> - {value} - {valueNote} - - )} -
- ) -} - -interface Props { surveyId: string; filters: DashboardFilters } - -export function KpiCards({ surveyId, filters }: Props) { - const [data, setData] = useState(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

Error al cargar KPIs

- - const loading = data === null - const suppressed = !loading && isSuppressed(data) - const ok = !loading && !suppressed - - return ( -
- {KPI_DEFS.map((def) => ( - - ))} -
- ) -} diff --git a/components/dashboard/paraguay-map.tsx b/components/dashboard/paraguay-map.tsx deleted file mode 100644 index 306b359..0000000 --- a/components/dashboard/paraguay-map.tsx +++ /dev/null @@ -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(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 ( - -
- {tooltip && ( -
- {tooltip.name} - {tooltip.pct !== null - ? <> · {tooltip.pct}% · n={tooltip.n} - : <> · Datos insuficientes} -
- )} -
- {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 ( - - ) - })} -
-
-
- ) -} diff --git a/components/dashboard/piramide-demografica.tsx b/components/dashboard/piramide-demografica.tsx deleted file mode 100644 index e59ef77..0000000 --- a/components/dashboard/piramide-demografica.tsx +++ /dev/null @@ -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 = { - '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(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 ( - - {data && ( -
-
- {generos.map(g => ( - - - {g} - - ))} -
- {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 ( -
- {etario} -
- {row.map(({ gen, n, suppressed }) => ( -
-
-
- ))} -
- - {row.every(r => r.suppressed) ? '🔒' : row.reduce((s, r) => s + (r.n ?? 0), 0)} - -
- ) - })} -
- )} - - ) -} diff --git a/components/dashboard/prevalencia-cuidadores-card.tsx b/components/dashboard/prevalencia-cuidadores-card.tsx new file mode 100644 index 0000000..ea585dc --- /dev/null +++ b/components/dashboard/prevalencia-cuidadores-card.tsx @@ -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(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 ( + + {ok && ( + <> +
{ok.pct_cuidadores}%
+

tiene algún rol de cuidado

+ + )} +
+ ) +} diff --git a/components/dashboard/punto-ciego-card.tsx b/components/dashboard/punto-ciego-card.tsx new file mode 100644 index 0000000..15891f9 --- /dev/null +++ b/components/dashboard/punto-ciego-card.tsx @@ -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(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 ( + + {ok && ( + <> +
+
+
+ {ok.carga_real_pct}% +
+
+
carga real
+
+
+ {ok.apoyo_percibido_pct !== null ? ( + <> +
+ {ok.apoyo_percibido_pct}% +
+
+ + ) : ( +
+ n<5 +
+ )} +
se siente apoyado
+
+
+ {ok.brecha_pp !== null && ( +

Brecha: {ok.brecha_pp} puntos porcentuales

+ )} + + )} + + ) +} diff --git a/components/dashboard/segmentacion-bar.tsx b/components/dashboard/segmentacion-bar.tsx new file mode 100644 index 0000000..1efbb30 --- /dev/null +++ b/components/dashboard/segmentacion-bar.tsx @@ -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(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 ( + + {ok && ( +
+
+

Por nivel organizacional

+ {ok.por_nivel.length === 0 && ( +

Ningún nivel alcanza n≥5 con este filtro.

+ )} +
    + {ok.por_nivel.map((row) => ( +
  • + {row.nivel} +
    +
    +
    + {row.pct}% +
  • + ))} +
+
+
+

Por modalidad de trabajo

+ {ok.por_modalidad.length === 0 && ( +

Ninguna modalidad alcanza n≥5 con este filtro.

+ )} +
    + {ok.por_modalidad.map((row) => ( +
  • + {row.modalidad} +
    +
    +
    + {row.pct}% +
  • + ))} +
+
+
+ )} +
+ ) +} diff --git a/components/dashboard/senales-impacto-bar.tsx b/components/dashboard/senales-impacto-bar.tsx new file mode 100644 index 0000000..bbe6aaf --- /dev/null +++ b/components/dashboard/senales-impacto-bar.tsx @@ -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(null) + const [ausentismo, setAusentismo] = useState(null) + const [presentismo, setPresentismo] = useState(null) + const [carrera, setCarrera] = useState(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 ( + + {!loading && ( +
    + {rows.map((row) => ( +
  • + {row.label} + {row.pct !== null ? ( + <> +
    +
    +
    + {row.pct}% + + ) : ( + <> +
    + + n<5 + + + )} +
  • + ))} +
+ )} +
+ ) +} diff --git a/components/dashboard/tabla-saturacion.tsx b/components/dashboard/tabla-saturacion.tsx deleted file mode 100644 index 19364b4..0000000 --- a/components/dashboard/tabla-saturacion.tsx +++ /dev/null @@ -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 = { - '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(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 ( - - {data && ( -
-

- Alta: bienestar ≤2, rendimiento afectado casi todos los días y ausentismo frecuente. - Media: dos de los tres criterios. Baja: uno o ninguno. -

- - - - - - - - - - - {data.segmentos.map(seg => ( - - - - - - - ))} - -
SegmentoN% del totalApoyo más demandado
- - {seg.segmento} - - {seg.suppressed ? '—' : seg.n}{seg.suppressed ? '—' : `${seg.pct}%`}{seg.suppressed - ? Datos insuficientes (n<5) - : seg.apoyo ?? '—'} -
-
- )} -
- ) -} diff --git a/components/dashboard/talento-riesgo-card.tsx b/components/dashboard/talento-riesgo-card.tsx new file mode 100644 index 0000000..4b42e94 --- /dev/null +++ b/components/dashboard/talento-riesgo-card.tsx @@ -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(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 ( + + {ok && ( + <> +
+ {ok.pct_talento_riesgo}% +
+

+ 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. +

+ + )} +
+ ) +} diff --git a/components/dashboard/widget-shell.tsx b/components/dashboard/widget-shell.tsx index 49bfa23..e9fba8d 100644 --- a/components/dashboard/widget-shell.tsx +++ b/components/dashboard/widget-shell.tsx @@ -8,11 +8,16 @@ interface WidgetShellProps { n?: number children: React.ReactNode colSpan?: number + /** 'star' resalta la card (borde + fondo distinto) para métricas destacadas. */ + variant?: 'star' } -export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6 }: WidgetShellProps) { +export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6, variant }: WidgetShellProps) { return ( -
+

{title}

diff --git a/lib/dashboard-queries.ts b/lib/dashboard-queries.ts index 8afd210..5c4715d 100644 --- a/lib/dashboard-queries.ts +++ b/lib/dashboard-queries.ts @@ -4,21 +4,43 @@ import type { DashboardFilters } from './dashboard-types' import { filtersToQuery } from './dashboard-utils' -async function fetchWidget(widget: string, surveyId: string, filters: DashboardFilters): Promise { +async function fetchWidget( + widget: string, + surveyId: string, + filters: DashboardFilters, + extra?: Record +): Promise { 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}`) return res.json() } +// Mirada Empresa — una entrada por métrica (E1-E10 + estrella). export const queries = { - kpis: (sid: string, f: DashboardFilters) => fetchWidget('kpis', sid, f), - prevalencia: (sid: string, f: DashboardFilters) => fetchWidget('prevalencia', sid, f), - heatmap: (sid: string, f: DashboardFilters) => fetchWidget('heatmap', sid, f), - distribucion: (sid: string, f: DashboardFilters) => fetchWidget('distribucion', sid, f), - apoyos: (sid: string, f: DashboardFilters) => fetchWidget('apoyos', sid, f), - score: (sid: string, f: DashboardFilters) => fetchWidget('score', sid, f), - piramide: (sid: string, f: DashboardFilters) => fetchWidget('piramide', sid, f), - intensidad: (sid: string, f: DashboardFilters) => fetchWidget('intensidad', sid, f), - saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f), + mePrevalencia: (sid: string, f: DashboardFilters) => + fetchWidget('me-prevalencia', sid, f), + meRiesgoRotacion: (sid: string, f: DashboardFilters) => + fetchWidget('me-riesgo-rotacion', sid, f), + meAusentismo: (sid: string, f: DashboardFilters) => + fetchWidget('me-ausentismo', sid, f), + mePresentismo: (sid: string, f: DashboardFilters) => + fetchWidget('me-presentismo', 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), } diff --git a/lib/dashboard-types.ts b/lib/dashboard-types.ts index f79e672..06c12de 100644 --- a/lib/dashboard-types.ts +++ b/lib/dashboard-types.ts @@ -9,51 +9,6 @@ export interface DashboardFilters { // Estado de cada widget 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 RpcResult = T | SuppressedPayload @@ -61,3 +16,123 @@ export type RpcResult = T | SuppressedPayload export function isSuppressed(r: unknown): r is SuppressedPayload { return typeof r === 'object' && r !== null && (r as Record)['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 +} diff --git a/lib/dashboard-utils.ts b/lib/dashboard-utils.ts index e8f5120..5a4cfac 100644 --- a/lib/dashboard-utils.ts +++ b/lib/dashboard-utils.ts @@ -33,18 +33,4 @@ export function filtersToRpc(filters: DashboardFilters): Record } } -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 function sortByOrder>( - 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) - }) -} diff --git a/sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md b/sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md new file mode 100644 index 0000000..732a3b5 --- /dev/null +++ b/sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md @@ -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. +``` \ No newline at end of file