diff --git a/SEED_SPEC.md b/SEED_SPEC.md index 94a4adf..8b48b28 100644 --- a/SEED_SPEC.md +++ b/SEED_SPEC.md @@ -24,10 +24,10 @@ ## Sección A — Datos Generales (top-level) | Code | Tipo | Pregunta | Opciones | Notas | |---|---|---|---|---| -| A1 | text_short | País* | — (texto libre) | qi_country (futuro) | -| A2 | text_short | Ciudad* | — (texto libre) | qi_city (futuro) | -| A3 | text_short | Sucursal o sede | — (texto libre) | opcional | -| A4 | single_choice | ¿En qué área o gerencia trabajás?* | Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector | +| A1 | single_choice | ¿En qué departamento de Paraguay vivís?* | Asunción (Capital) · Alto Paraguay · Alto Paraná · Amambay · Boquerón · Caaguazú · Caazapá · Canindeyú · Central · Concepción · Cordillera · Guairá · Itapúa · Misiones · Ñeembucú · Paraguarí · Presidente Hayes · San Pedro | qi_zone | +| A2 | single_choice | ¿En qué ciudad vivís?* | 219 ciudades (GeoSelectWidget: dependiente de A1) | qi_city — Sprint 3 | +| A3 | text_short | ¿En qué barrio vivís? | — (texto libre, opcional) | | +| A4 | single_choice | ¿En qué área o gerencia trabajás?* | Directorio · Gerencia · Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector | | A5 | single_choice | ¿Cuántos años tenés? | 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 | qi_age_bucket | | A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro · Prefiero no decir | | | A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | | @@ -102,15 +102,15 @@ ## Mapeo a cuasi-identificadores (`responses.qi_*`) - `qi_age_bucket` ← A5 (ya en bucket) - `qi_sector` ← A4 (área) -- `qi_zone` ← NULL (no aplica en este instrumento) -- `qi_city` / `qi_country` ← A2/A1: **Sprint 3** — se agregan como columnas qi en migration futura. - Por ahora A1/A2/A3 se capturan en `answers` como text_short. +- `qi_zone` ← A1 (departamento — poblado en `submit-survey.ts` desde Etapa 2E) +- `qi_city` ← A2: **Sprint 3** — se agrega como columna qi en migration futura. + Por ahora A2 se captura en `answers` como single_choice. ## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación) - **Ranking widget (6.2):** actual es multiple_choice max_select=3. Cuando se implemente el widget de ranking (drag o 3 dropdowns), 6.2 pasa a tipo `ranking` con JSONB `{rank1, rank2, rank3}`. -- **qi_city / qi_country:** migration 008 para agregar columnas y extender whitelist de agg_segment. - Se hace antes de Sprint 3 (dashboard). +- **qi_city:** migration futura para agregar columna en responses y extender whitelist de agg_segment. + Se hace antes de Sprint 3 (dashboard con filtro por ciudad). - **Salary analytics:** metodología para calcular "carga económica del cuidado como % del ingreso" es trabajo de metodología de investigación (fuera del scope del código). - **RE-INDEX scoring:** algoritmo de composición de los índices (descanso, bienestar, rendimiento, diff --git a/TECH_DEBT.md b/TECH_DEBT.md new file mode 100644 index 0000000..bc8eaf8 --- /dev/null +++ b/TECH_DEBT.md @@ -0,0 +1,60 @@ +# TECH_DEBT.md — Deuda técnica registrada + +> Ítems identificados durante el desarrollo. Cada ítem documenta qué falta, por qué +> no se hizo ahora, y qué trigger habilitaría su resolución. + +--- + +## [TECH-DEBT-001] Ranking widget para 6.2 + +- **Estado:** 6.2 es actualmente `multiple_choice` max_select=3. El widget muestra + frecuencia simple (top 8). +- **Deuda:** cuando el tipo pase a `ranking` con JSONB `{rank1, rank2, rank3}`, el + widget debe reescribirse para mostrar ponderación por posición. +- **Trigger:** decisión del director sobre el tipo de 6.2 en próxima versión del + instrumento (anotada en SEED_SPEC.md § Decisiones pendientes). + +--- + +## [TECH-DEBT-002] KPI NPS Cuidador + +- **Estado:** el instrumento actual no tiene pregunta NPS. El KPI fue eliminado del + dashboard en Etapa 2E. +- **Para implementar:** agregar en próxima versión del instrumento: + "En una escala de 0 a 10, ¿qué tan probable es que recomiendes esta empresa a otra + persona cuidadora?" +- **Trigger:** decisión de incluir NPS en el instrumento v2. + +--- + +## [TECH-DEBT-003] Brecha de políticas (oferta vs. demanda) + +- **Estado:** el instrumento captura solo demanda (6.2 = apoyos que ayudarían). + No captura qué políticas la empresa ya ofrece. W6 muestra solo demanda. +- **Para implementar:** agregar en próxima versión del instrumento: + "¿Qué apoyos para el cuidado ofrece tu empresa hoy?" (multi-select con las mismas + opciones de 6.2 + "Ninguno" + "No lo sé"). +- **Trigger:** decisión de agregar la pregunta de oferta al instrumento. + +--- + +## [TECH-DEBT-004] Intención directa de salida laboral + +- **Estado:** el instrumento no pregunta directamente por intención de dejar el + empleo. W8 usa indicador compuesto (4.3 + 5.1 + 5.2) renombrado a "Saturación". +- **Para implementar:** agregar en próxima versión: + "En los últimos 6 meses, ¿pensaste en dejar este empleo por razones relacionadas + al cuidado?" (Sí/Frecuentemente · Sí/A veces · No · Prefiero no decir). +- **Trigger:** validación metodológica de que el indicador compuesto no es suficiente + para medir intención de salida. + +--- + +## [TECH-DEBT-005] qi_city / qi_country — migración pendiente + +- **Estado:** A1 (Departamento) y A2 (Ciudad) se guardan en `answers`. Las columnas + `qi_city` y `qi_country` en `responses` son NULL. Sprint 3 las necesita para + segmentación avanzada. +- **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert + y extienda la whitelist de `agg_segment`. +- **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad). diff --git a/app/actions/submit-survey.ts b/app/actions/submit-survey.ts index a595078..c1f2476 100644 --- a/app/actions/submit-survey.ts +++ b/app/actions/submit-survey.ts @@ -66,6 +66,8 @@ export async function submitSurvey(payload: { typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null const qi_sector = typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null + const qi_zone = + typeof payload.answers['A1'] === 'string' ? payload.answers['A1'] : null // 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md) const { error: rErr } = await supabase @@ -79,7 +81,7 @@ export async function submitSurvey(payload: { consent_record_id: consentId, qi_age_bucket: qi_age_bucket || null, qi_sector: qi_sector || null, - qi_zone: null, + qi_zone: qi_zone || null, }) if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` } diff --git a/app/api/dashboard/[widget]/route.ts b/app/api/dashboard/[widget]/route.ts index cae15f0..ab4bf63 100644 --- a/app/api/dashboard/[widget]/route.ts +++ b/app/api/dashboard/[widget]/route.ts @@ -10,12 +10,12 @@ const RPC_MAP: Record = { kpis: 'rpc_dashboard_kpis', prevalencia: 'rpc_prevalencia_por_depto', heatmap: 'rpc_heatmap_intensidad', - distribucion: 'rpc_distribucion_cuidado', - politicas: 'rpc_politicas_gap', + distribucion: 'rpc_perfil_poblacion', + apoyos: 'rpc_apoyos_demandados', score: 'rpc_score_organizacional', piramide: 'rpc_piramide_demografica', - intensidad: 'rpc_intensidad_vs_productividad', - riesgo: 'rpc_riesgo_retencion', + intensidad: 'rpc_intensidad_vs_bienestar', + saturacion: 'rpc_saturacion_cuidador', } function getSupabaseServer() { diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 996a6c4..a38bb7e 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -7,9 +7,9 @@ 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 { PoliticasBar } from '@/components/dashboard/politicas-bar' +import { ApoyosBar } from '@/components/dashboard/apoyos-bar' import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad' -import { TablaRiesgo } from '@/components/dashboard/tabla-riesgo' +import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion' import { FilterSidebar } from '@/components/dashboard/filter-sidebar' import type { DashboardFilters } from '@/lib/dashboard-types' @@ -60,15 +60,15 @@ export default function DashboardPage() { - {/* Fila 4: Barras políticas (6) + Hexbin intensidad/bienestar (6) */} -
- + {/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */} +
+
- {/* Fila 5: Tabla riesgo (12) */} -
- + {/* Fila 5: Saturación del cuidador (12) */} +
+
diff --git a/app/globals.css b/app/globals.css index 7ffc0f9..97ba1ca 100644 --- a/app/globals.css +++ b/app/globals.css @@ -32,6 +32,7 @@ /* ── Reset & base ── */ *, *::before, *::after { box-sizing: border-box; } html { font-size: 16px; scroll-behavior: smooth; } +html, body { height: 100%; } body { margin: 0; background: var(--color-bg); @@ -39,7 +40,12 @@ body { font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; line-height: 1.6; -webkit-font-smoothing: antialiased; + display: flex; + flex-direction: column; + min-height: 100vh; } +/* El contenido entre header y footer crece para empujar el footer al fondo */ +.layout-content { flex: 1; } /* ── Skip link ── */ .skip-link { @@ -608,13 +614,25 @@ body { transition: box-shadow 0.3s; } .db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); } +/* Título de KPI: mayúsculas, bold, siempre visible */ +.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #E7E6E5; text-transform: uppercase; letter-spacing: 0.04em; } +/* Subtítulo de KPI: código de pregunta + descripción */ +.db-kpi-subtitle { font-size: 0.6875rem; color: #7A8FA0; font-weight: 400; line-height: 1.4; margin-bottom: 4px; } .db-kpi-value { font-size: 2rem; font-weight: 700; color: var(--color-re-teal); - font-variant-numeric: tabular-nums; + font-variant-numeric: tabular-nums; margin-top: 4px; } +.db-kpi-value-suppressed { font-size: 1.25rem; color: #6b7280; } +.db-kpi-note { font-size: 0.75rem; color: #9ca3af; } +.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; } +/* Compat — se puede quitar si ya no se usa */ .db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; } .db-kpi-sub { font-size: 0.75rem; color: #9ca3af; } +/* Subtítulo de widget-shell */ +.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; } +.db-widget-subtitle { font-size: 0.75rem; color: #7A8FA0; font-weight: 400; margin: 0; line-height: 1.4; } + /* ── Mapa tile ── */ .db-map-wrapper { position: relative; } .db-map-tooltip { diff --git a/assets/re/.DS_Store b/assets/re/.DS_Store new file mode 100644 index 0000000..fe4b9fc Binary files /dev/null and b/assets/re/.DS_Store differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-BOLD.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-BOLD.TTF new file mode 100644 index 0000000..94717b8 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-BOLD.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-BOLDITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-BOLDITALIC.TTF new file mode 100644 index 0000000..6ee8194 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-BOLDITALIC.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-EXTRALIGHT.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-EXTRALIGHT.TTF new file mode 100644 index 0000000..3f74ac6 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-EXTRALIGHT.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-EXTRALIGHTITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-EXTRALIGHTITALIC.TTF new file mode 100644 index 0000000..3384681 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-EXTRALIGHTITALIC.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-ITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-ITALIC.TTF new file mode 100644 index 0000000..7793bae Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-ITALIC.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-LIGHT.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-LIGHT.TTF new file mode 100644 index 0000000..5c25d87 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-LIGHT.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-LIGHTITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-LIGHTITALIC.TTF new file mode 100644 index 0000000..8869a9a Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-LIGHTITALIC.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-MEDIUM.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-MEDIUM.TTF new file mode 100644 index 0000000..4c74ec6 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-MEDIUM.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-MEDIUMITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-MEDIUMITALIC.TTF new file mode 100644 index 0000000..110e819 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-MEDIUMITALIC.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-REGULAR.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-REGULAR.TTF new file mode 100644 index 0000000..702c637 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-REGULAR.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-SEMIBOLD.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-SEMIBOLD.TTF new file mode 100644 index 0000000..a0cde8e Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-SEMIBOLD.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-SEMIBOLDITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-SEMIBOLDITALIC.TTF new file mode 100644 index 0000000..514306e Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-SEMIBOLDITALIC.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-THIN.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-THIN.TTF new file mode 100644 index 0000000..1ff8fb0 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-THIN.TTF differ diff --git a/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-THINITALIC.TTF b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-THINITALIC.TTF new file mode 100644 index 0000000..17a0812 Binary files /dev/null and b/assets/re/Tipografías/Principal títulos, subtítulos y énfasis/IBMPLEXSANS-THINITALIC.TTF differ diff --git a/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-BOLD.TTF b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-BOLD.TTF new file mode 100644 index 0000000..efdd5e8 Binary files /dev/null and b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-BOLD.TTF differ diff --git a/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-ITALIC.TTF b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-ITALIC.TTF new file mode 100644 index 0000000..1178567 Binary files /dev/null and b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-ITALIC.TTF differ diff --git a/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-LIGHT.TTF b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-LIGHT.TTF new file mode 100644 index 0000000..6580d3a Binary files /dev/null and b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-LIGHT.TTF differ diff --git a/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-LIGHTITALIC.TTF b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-LIGHTITALIC.TTF new file mode 100644 index 0000000..1e0c331 Binary files /dev/null and b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-LIGHTITALIC.TTF differ diff --git a/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-REGULAR.TTF b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-REGULAR.TTF new file mode 100644 index 0000000..29bfd35 Binary files /dev/null and b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-REGULAR.TTF differ diff --git a/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-SEMIBOLD.TTF b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-SEMIBOLD.TTF new file mode 100644 index 0000000..54e7059 Binary files /dev/null and b/assets/re/Tipografías/Secundaria textos corridos/OPENSANS-SEMIBOLD.TTF differ diff --git a/components/dashboard/politicas-bar.tsx b/components/dashboard/apoyos-bar.tsx similarity index 66% rename from components/dashboard/politicas-bar.tsx rename to components/dashboard/apoyos-bar.tsx index 9dfa974..3935183 100644 --- a/components/dashboard/politicas-bar.tsx +++ b/components/dashboard/apoyos-bar.tsx @@ -1,29 +1,29 @@ 'use client' import { useEffect, useState } from 'react' -import { isSuppressed, type PoliticasData } from '@/lib/dashboard-types' +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 PoliticasBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) { - const [data, setData] = useState(null) +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.politicas(surveyId, filters) - .then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PoliticasData) }) + 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 política (n<5 en todas).
    • +
    • No hay datos suficientes por apoyo (n<5 en todos).
    • )} {data.data.map(item => (
    • diff --git a/components/dashboard/donut-cuidados.tsx b/components/dashboard/donut-cuidados.tsx index 170d471..9938c8b 100644 --- a/components/dashboard/donut-cuidados.tsx +++ b/components/dashboard/donut-cuidados.tsx @@ -41,7 +41,7 @@ export function DonutCuidados({ surveyId, filters }: { surveyId: string; filters }) : [] return ( - + {data && (
      diff --git a/components/dashboard/heatmap-widget.tsx b/components/dashboard/heatmap-widget.tsx index af054b8..c6d766b 100644 --- a/components/dashboard/heatmap-widget.tsx +++ b/components/dashboard/heatmap-widget.tsx @@ -31,7 +31,7 @@ export function HeatmapWidget({ surveyId, filters }: { surveyId: string; filters const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.ausencias}`, c])) return ( - + {data && (
      + {data && (

      diff --git a/components/dashboard/kpi-cards.tsx b/components/dashboard/kpi-cards.tsx index dc3ae6d..0f4febf 100644 --- a/components/dashboard/kpi-cards.tsx +++ b/components/dashboard/kpi-cards.tsx @@ -4,14 +4,57 @@ import { isSuppressed, type KpiData, type SuppressedPayload } from '@/lib/dashbo import { queries } from '@/lib/dashboard-queries' import type { DashboardFilters } from '@/lib/dashboard-types' -interface KpiCardProps { label: string; value: string; sub?: string } +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 -function KpiCard({ label, value, sub }: KpiCardProps) { +interface KpiCardProps { + title: string + subtitle: string + value: string + valueNote: string + suppressed: boolean + loading: boolean +} + +function KpiCard({ title, subtitle, value, valueNote, suppressed, loading }: KpiCardProps) { return (

      - {value} - {label} - {sub && {sub}} + {title} + {subtitle} + + {loading && ( +
      + )} + {!loading && suppressed && ( +
      + + n < 5 +
      + )} + {!loading && !suppressed && ( + <> + {value} + {valueNote} + + )}
      ) } @@ -30,33 +73,24 @@ export function KpiCards({ surveyId, filters }: Props) { }, [surveyId, JSON.stringify(filters)]) if (error) return

      Error al cargar KPIs

      - if (!data) { - return ( -
      - {[1,2,3,4].map(i =>
      )} -
      - ) - } - if (isSuppressed(data)) { - return ( -
      - {[1,2,3,4].map(i => ( -
      - 🔒 - n < 5 -
      - ))} -
      - ) - } - const d = data as KpiData + 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 index 24203ab..306b359 100644 --- a/components/dashboard/paraguay-map.tsx +++ b/components/dashboard/paraguay-map.tsx @@ -62,7 +62,7 @@ export function ParaguayMap({ surveyId, filters, onDeptoClick }: Props) { const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined return ( - +
      {tooltip && (
      diff --git a/components/dashboard/piramide-demografica.tsx b/components/dashboard/piramide-demografica.tsx index 379c9dc..e59ef77 100644 --- a/components/dashboard/piramide-demografica.tsx +++ b/components/dashboard/piramide-demografica.tsx @@ -31,7 +31,7 @@ export function PiramideDemografica({ surveyId, filters }: { surveyId: string; f const generos = ['Femenino','Masculino','No binario/Otro'] return ( - + {data && (
      diff --git a/components/dashboard/tabla-riesgo.tsx b/components/dashboard/tabla-saturacion.tsx similarity index 51% rename from components/dashboard/tabla-riesgo.tsx rename to components/dashboard/tabla-saturacion.tsx index 51c1233..19364b4 100644 --- a/components/dashboard/tabla-riesgo.tsx +++ b/components/dashboard/tabla-saturacion.tsx @@ -1,44 +1,44 @@ 'use client' import { useEffect, useState } from 'react' -import { isSuppressed, type RiesgoData } from '@/lib/dashboard-types' +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 RIESGO_COLOR: Record = { - Alto: '#ef4444', - Medio: '#F8AD13', - Bajo: '#43BBC8', +const SATURACION_COLOR: Record = { + 'Alta saturación': '#ef4444', + 'Saturación media': '#F8AD13', + 'Baja saturación': '#43BBC8', } -export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) { - const [data, setData] = useState(null) +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.riesgo(surveyId, filters) - .then(d => { if (isSuppressed(d)) setSup(true); else setData(d as RiesgoData) }) + 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 && (

      - Alto: ausencias frecuentes + empresa no comprende. Medio: una condición. Bajo: ninguna. - La columna de departamento fue eliminada para evitar cruces reidentificables. + Alta: bienestar ≤2, rendimiento afectado casi todos los días y ausentismo frecuente. + Media: dos de los tres criterios. Baja: uno o ninguno.

      - +
      - + @@ -46,14 +46,16 @@ export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters: - + ))} diff --git a/components/dashboard/widget-shell.tsx b/components/dashboard/widget-shell.tsx index 66d24ef..49bfa23 100644 --- a/components/dashboard/widget-shell.tsx +++ b/components/dashboard/widget-shell.tsx @@ -3,17 +3,21 @@ import type { WidgetStatus } from '@/lib/dashboard-types' interface WidgetShellProps { title: string + subtitle?: string status: WidgetStatus n?: number children: React.ReactNode colSpan?: number } -export function WidgetShell({ title, status, n, children, colSpan = 6 }: WidgetShellProps) { +export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6 }: WidgetShellProps) { return (
      -

      {title}

      +
      +

      {title}

      + {subtitle &&

      {subtitle}

      } +
      {n !== undefined && status === 'ok' && ( n = {n.toLocaleString('es-PY')} )} diff --git a/lib/dashboard-queries.ts b/lib/dashboard-queries.ts index b88cc0f..8afd210 100644 --- a/lib/dashboard-queries.ts +++ b/lib/dashboard-queries.ts @@ -16,9 +16,9 @@ export const queries = { 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), - politicas: (sid: string, f: DashboardFilters) => fetchWidget('politicas', 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), - riesgo: (sid: string, f: DashboardFilters) => fetchWidget('riesgo', sid, f), + saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f), } diff --git a/lib/dashboard-types.ts b/lib/dashboard-types.ts index c22dae0..f79e672 100644 --- a/lib/dashboard-types.ts +++ b/lib/dashboard-types.ts @@ -14,7 +14,7 @@ export interface KpiData { status: 'ok' n: number pct_cuidadores: number - score_bienestar: number | null + score_bienestar: number | null // avg raw 4.3 (1.0–5.0) pct_ausencias: number } @@ -34,8 +34,8 @@ export interface DistribucionData { disc_propia: number; familiar: number; adulto_mayor: number; ninguno: number } -export interface PoliticaItem { politica: string; n: number; pct: number } -export interface PoliticasData { status: 'ok'; n_total: number; data: PoliticaItem[] } +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' } @@ -45,8 +45,14 @@ export interface PiramideData { status: 'ok'; n_total: number; data: PiramideCel export interface IntensidadCell { horas: string; bienestar: string; n: number | null; suppressed: boolean } export interface IntensidadData { status: 'ok'; n_total: number; data: IntensidadCell[] } -export interface RiesgoSegmento { segmento: string; n: number | null; pct: number | null; politica: string | null; suppressed: boolean } -export interface RiesgoData { status: 'ok'; n_total: number; segmentos: RiesgoSegmento[] } +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 } diff --git a/sprints/sprint-2/ETAPA_2D_PROMPT.md b/sprints/sprint-2/ETAPA_2D_PROMPT.md new file mode 100644 index 0000000..682f1e2 --- /dev/null +++ b/sprints/sprint-2/ETAPA_2D_PROMPT.md @@ -0,0 +1,127 @@ +# ETAPA 2D — Dashboard: títulos limpios, subtítulos descriptivos y fix de KPI cards + +## 1. Objetivo +Pulir el lenguaje visual del dashboard alineándolo al patrón del equipo (Fundación): +**título limpio sin emojis + subtítulo descriptivo con código de pregunta**. +Además, arreglar el bug de KPI cards que no muestran su label en estado suppressed. + +**Esta etapa es 100% cosmética y reversible.** No toca lógica de datos, RPC, RLS, +filtros, ni esquema. Solo componentes de presentación. + +--- + +## 2. Contexto previo +- Etapa 3 completada (dashboard funcional con k-anonimato estructural y 9 widgets). +- El director validó visualmente el estado suppressed y detectó tres hallazgos + (KPI sin label, emojis en títulos, falta de subtítulos). +- Referencia de lenguaje visual: dashboard del equipo de Fundación Solidaridad + ("Mapeo de Vinculación") — patrón título + `card-sub` con código de pregunta. + +--- + +## 3. DECISIONES YA TOMADAS (no consultar de nuevo) +- Cero emojis en títulos de widgets y de KPI cards. +- Subtítulos descriptivos en TODAS las cards (KPI y widgets), formato: + `" · "` +- En estado `suppressed`, las KPI cards muestran título + subtítulo + candado + "n < 5". + El título NUNCA se oculta — comunica qué se está suprimiendo. +- Paleta y tipografías existentes se mantienen. +- Layout y grid se mantienen. + +--- + +## 4. Tareas + +### 4.1 KPI cards — labels visibles en cualquier estado +Modificar `components/dashboard/kpi-cards.tsx` (o equivalente) para que el componente +KPICard renderice SIEMPRE el título y subtítulo, en los 3 estados: + +``` +┌─────────────────────────────┐ +│ CUIDADORES ACTIVOS │ ← título (siempre visible) +│ 1.1 + 1.6 + 1.13 · % … │ ← subtítulo (siempre visible) +│ │ +│ [valor o 🔒] │ ← cambia según estado +│ 34,2% o n<5 │ +└─────────────────────────────┘ +``` + +Los 4 KPI cards con sus textos definitivos: + +| # | Título | Subtítulo | +|---|---|---| +| 1 | CUIDADORES ACTIVOS | 1.1 + 1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado | +| 2 | AUSENCIA PROMEDIO | 3.1 · días por mes asociados al cuidado | +| 3 | BIENESTAR | 5.1 · autorreporte de productividad y energía | +| 4 | NPS CUIDADOR | 6.5 · recomendaría la empresa a otra persona cuidadora | + +### 4.2 Widgets — limpiar títulos y agregar subtítulos +Para los 9 widgets (no-KPI), aplicar exactamente estos textos. Eliminar el emoji del +título. El subtítulo va inmediatamente debajo del título, con estilo secundario +(color tenue, una línea, peso regular). + +| Widget actual | Título nuevo | Subtítulo | +|---|---|---| +| 🗺️ Prevalencia de cuidadores por departamento | Prevalencia de cuidadores por departamento | A1 · % con responsabilidades de cuidado, por departamento | +| 🍩 Tipo de cuidado | Tipo de cuidado | 1.1 / 1.6 / 1.13 · composición de la carga (no excluyentes) | +| 🎯 Madurez organizacional | Madurez organizacional | 6.1 + 6.3 + 6.5 · score combinado 0–100 | +| 🔥 Horas de cuidado × Ausencias laborales | Horas de cuidado × Ausencias laborales | 2.1 × 3.1 · correlación intensidad / ausentismo | +| 👥 Pirámide demográfica | Pirámide demográfica | A5 × A6 · rango etario por género (celdas con k≥5) | +| 📈 Políticas más demandadas | Políticas más demandadas | 6.4 vs 6.2 · brecha entre necesidad y oferta | +| 🦋 Intensidad de cuidado vs. Bienestar (bins N≥5) | Intensidad de cuidado vs. Bienestar | 2.1 × 5.1 · cruce agregado por bins (k≥5) | +| 📋 Tabla de riesgo de retención | Tabla de riesgo de retención | 5.2 + 6.3 · segmentos por riesgo de abandono laboral | + +### 4.3 Estilos +- **Título de card:** tipografía actual, bold, sin cambios de tamaño. +- **Subtítulo:** una línea por debajo del título, separación visual mínima (4–6px), + color `#7A8FA0` (var --color-text-muted o equivalente), tamaño 0.85× del título, + peso regular. No usar itálica. +- Si el componente `widget-shell.tsx` ya existe, el subtítulo se agrega ahí una sola + vez y se hereda en todos los widgets. + +### 4.4 Verificaciones +Antes de declarar terminado: +- `grep -rE '[🗺🍩🎯🔥👥📈🦋📋]' components/dashboard/ app/dashboard/` debe + retornar vacío. +- Inspección manual: las 4 KPI cards muestran título y subtítulo en estado + suppressed (no solo el candado). +- `git diff --name-only HEAD` solo en `components/dashboard/` y posiblemente + `app/dashboard/page.tsx` si el wrapper se modifica. + +--- + +## 5. Entregables (DoD) +- [ ] Cero emojis en títulos de widgets ni KPI cards (`grep` verificado). +- [ ] 4 KPI cards muestran título + subtítulo en los 3 estados (loading/ok/suppressed). +- [ ] 9 widgets muestran título limpio + subtítulo descriptivo con código de pregunta. +- [ ] Build sin errores (`next build`). +- [ ] `git diff --name-only HEAD` — solo archivos de presentación del dashboard. +- [ ] Captura del resultado en estado suppressed compartida con el director. + +## 6. Qué reportar +``` +Verificado: +- archivos en diff: [lista] +- emojis residuales (grep): [0 o lista] +- 4 KPI cards con label visible en suppressed: confirmado +- 9 widgets con subtítulo aplicado: lista +- build exitoso + +Asumido: +- los gráficos siguen siendo SVG inline (de Etapa 3); interacción no + evaluable hasta tener N≥5 en al menos un widget + +Decisiones proactivas (Nivel 2): +- [si las hubo] +``` + +## 7. Qué NO hacer +- ❌ No tocar RPC, RLS, esquema ni migraciones. +- ❌ No modificar `lib/dashboard-queries.ts` ni `lib/dashboard-types.ts` salvo + para extender los tipos del componente KPI si fuera necesario. +- ❌ No cambiar la lógica de filtros del sidebar. +- ❌ No agregar widgets nuevos ni cambiar layout. +- ❌ No agregar dependencias npm. +- ❌ No reemplazar SVG inline por una librería (eso es Etapa 2E si hace falta, + decisión Nivel 3). +- ❌ No resolver ítems de TECH_DEBT.md. \ No newline at end of file diff --git a/sprints/sprint-2/ETAPA_2E_PROMPT.md b/sprints/sprint-2/ETAPA_2E_PROMPT.md new file mode 100644 index 0000000..ff58c20 --- /dev/null +++ b/sprints/sprint-2/ETAPA_2E_PROMPT.md @@ -0,0 +1,292 @@ +# ETAPA 2E — Refactorización del dashboard alineada al instrumento real + +## 1. Objetivo +Corregir la deuda semántica de Etapa 2D: las RPC, KPIs y subtítulos del dashboard se +construyeron a partir de códigos de pregunta inventados en el prompt original, no de los +códigos reales de `sprints/sprint-1/SEED_SPEC.md`. Esto generó métricas mal etiquetadas +que el dashboard mostraría como verdaderas cuando lleguen datos reales. + +**Esta etapa toca la capa de datos (Nivel 3, autorizado por el director).** Reescribe +RPCs, agrega una pregunta nueva al instrumento, elimina un KPI sin sustento, redefine +un widget y renombra otro. + +--- + +## 2. Contexto previo +- Etapa 2D entregada con subtítulos basados en códigos inventados. +- El director auditó subtítulo por subtítulo contra `SEED_SPEC.md` y encontró que + 11 de 12 widgets/KPIs tenían discrepancias. +- El instrumento real (`SEED_SPEC.md` v2) tiene 44 preguntas. Está en el repo. +- En base de Cloud hay respuestas de testing — se truncan en esta etapa. + +--- + +## 3. DECISIONES YA TOMADAS — no consultar de nuevo + +### 3.1 Truncado de datos de testing +Antes de aplicar migraciones nuevas, **borrar todas las respuestas de testing** del +survey piloto. Mantener `surveys`, `questions`, `consent_versions`. Borrar: +- `responses` del survey `30000000-0000-0000-0000-000000000001` +- `answers` asociadas +- `consent_records` asociados + +### 3.2 Nueva pregunta del instrumento — A1b Departamento +Agregar al seed, **antes** de A2 (Ciudad): +``` +A1b · single_choice · "¿En qué departamento de Paraguay vivís?" * +Opciones (18 departamentos): +Asunción · Central · Alto Paraná · Itapúa · Caaguazú · San Pedro · +Cordillera · Paraguarí · Concepción · Guairá · Caazapá · Misiones · +Ñeembucú · Amambay · Canindeyú · Presidente Hayes · Boquerón · Alto Paraguay +``` +- `qi_zone` ← A1b (cuasi-identificador para el k-anonimato). +- A1 (País) **se mantiene** por ahora (lo decidiremos en BACKLOG). + +### 3.3 KPI 4 (NPS) — ELIMINADO +El instrumento no tiene pregunta NPS. Eliminar el KPI completamente del layout. El +grid de KPIs queda en **3 cards**, no 4. Quitar `rpc_nps_*` si existe. + +### 3.4 Widget 6 (Políticas) — REDEFINIDO +El instrumento no captura "políticas que la empresa ya ofrece". Eliminar el componente +de comparación. El widget muestra **solo demanda** desde 6.2 (apoyos que ayudarían más, +multi-select, top 8). + +### 3.5 Widget 8 — RENOMBRADO +"Tabla de riesgo de retención" → **"Saturación del cuidador"**. El instrumento no +captura intención de salida directa; el indicador compuesto mide saturación, no +intención de irse. Cambiar título y subtítulo. + +### 3.6 Mapeo final widget → códigos del seed real + +**KPI Cards (3, sin NPS)** + +| # | Título | Definición y código real | Subtítulo | +|---|---|---|---| +| K1 | CUIDADORES ACTIVOS | % con `1.6='Sí'` OR `1.13='Sí'` | `1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros` | +| K2 | FRECUENCIA DE AUSENTISMO | % con `5.2 IN ('Varias veces (4–10 veces al año)', 'Con frecuencia (más de 10 veces al año)')` | `5.2 · % con ausencias laborales recurrentes (4 o más veces al año)` | +| K3 | BIENESTAR | Promedio del rating `4.3` (escala 1–5, mostrar como /5 o como %) | `4.3 · autorreporte de bienestar general · escala 1–5` | + +**Widgets (9, con W6 redefinido y W8 renombrado)** + +| # | Título | Códigos reales | Subtítulo | +|---|---|---|---| +| W1 | Prevalencia de cuidadores por departamento | `A1b` (nueva) cruzado con `1.6 ∨ 1.13` | `A1b · % cuidadores activos por departamento` | +| W2 | Perfil de la población | `1.1 + 1.6 + 1.13` (no excluyentes) | `1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)` | +| W3 | Madurez organizacional | Score = 0.7 × `6.1_norm` + 0.3 × `6.5_norm`, ambas normalizadas 0–100 | `6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0–100` | +| W4 | Horas de cuidado × Frecuencia de ausentismo | `2.2 × 5.2` | `2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente` | +| W5 | Pirámide demográfica | `A5 × A6` | `A5 × A6 · rango etario por género (celdas con k ≥ 5)` | +| W6 | Apoyos más demandados | `6.2` (multi-select, max 3) | `6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían` | +| W7 | Intensidad de cuidado vs. Bienestar | `2.2 × 4.3` | `2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k ≥ 5)` | +| W8 | **Saturación del cuidador** | Compuesto: `4.3 ≤ 2` AND `5.1 = "Casi todos los días"` AND `5.2 ≥ "Varias veces (4–10 veces al año)"` | `4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)` | + +**Definición de segmentos para W8 (Saturación):** +- **Alta saturación:** los 3 criterios se cumplen +- **Saturación media:** 2 de los 3 criterios +- **Baja saturación:** 1 o ninguno + +Columnas de la tabla: Segmento · N · % del total · Apoyo más demandado del segmento +(de 6.2). Cada segmento con N < 5 se muestra como "—" (suprimido). + +### 3.7 Normalización para Madurez (W3) +`6.1` tiene 4 opciones, normalizar: +- "Sí, me siento acompañado/a" = 100 +- "Parcialmente, hay cierta comprensión" = 66 +- "No, es algo bastante invisible" = 33 +- "No, nunca se ha hablado del tema" = 0 + +`6.5` tiene 4 opciones, normalizar: +- "Sí, definitivamente" = 100 +- "Tal vez, dependería del caso" = 66 +- "No lo considero necesario" = 33 +- "No lo sé" = 0 (o NULL — decidir en RPC, ver tarea 4.3) + +Score final = 0.7 × promedio(6.1_norm) + 0.3 × promedio(6.5_norm) + +--- + +## 4. Tareas + +### 4.1 Migración 009 — Truncado + nueva pregunta A1b +Crear `supabase/migrations/0YYYYMMDDHHMMSS_009_add_departamento_paraguay.sql`: + +1. `BEGIN;` +2. `TRUNCATE answers, responses, consent_records CASCADE;` (de Cloud) +3. INSERT en `questions` la pregunta A1b con sus 18 opciones (`question_options`). + Posición: entre A1 y A2. +4. Actualizar el orden de las preguntas de la sección A si es necesario. +5. `COMMIT;` + +Validación post-migración: +- `SELECT COUNT(*) FROM answers;` → 0 +- `SELECT code, text FROM questions WHERE code='A1b';` → 1 fila +- `SELECT COUNT(*) FROM question_options qo JOIN questions q ON qo.question_id=q.id WHERE q.code='A1b';` → 18 + +### 4.2 Migración 010 — Refactor de RPCs del dashboard +Crear `supabase/migrations/0YYYYMMDDHHMMSS_010_dashboard_rpc_v2.sql`. + +DROP y recreate todas las funciones del dashboard con los nuevos mapeos. Lista: +- `rpc_dashboard_kpis` — devuelve 3 KPIs (sin NPS), con los códigos correctos +- `rpc_prevalencia_por_depto` — usa A1b, NO A1 +- `rpc_perfil_poblacion` (antes `rpc_distribucion_cuidado`) — 1.1 + 1.6 + 1.13 +- `rpc_score_organizacional` — nueva fórmula 70/30 +- `rpc_heatmap_intensidad` — 2.2 × 5.2 +- `rpc_piramide_demografica` — sin cambios estructurales +- `rpc_apoyos_demandados` (antes `rpc_politicas_gap`) — solo 6.2, sin comparación +- `rpc_intensidad_vs_bienestar` (antes `rpc_intensidad_vs_productividad`) — 2.2 × 4.3 +- `rpc_saturacion_cuidador` (antes `rpc_riesgo_retencion`) — 4.3 + 5.1 + 5.2 + +**Todas las RPC mantienen:** +- `SECURITY DEFINER` con `search_path` fijado +- Supresión `k ≥ 5` por construcción (devuelven `status='suppressed'` si N<5) +- Casteo seguro de TEXT a numérico cuando aplique +- Filtros globales (departamento ahora viene de A1b, no A1) + +**El sidebar de filtros también cambia:** +- "Departamento" ahora hace dropdown contra las 18 opciones reales de A1b +- "Género" sigue contra A6 +- "Modalidad" sigue contra A7 +- "Etario" sigue contra A5 + +### 4.3 Decisión local: cómo tratar "No lo sé" en 6.5 +Si el agente lo decide localmente, **debe reportar la decisión** como Nivel 2. +Opciones razonables: +- Tratar como NULL → no entra al promedio +- Tratar como 0 → baja el score +**Recomendación:** tratar como NULL (no entra al promedio) — "no lo sé" no es opinión. + +### 4.4 Cliente — eliminar K4 (NPS) +- Quitar el componente del grid de KPIs +- Reajustar el layout del grid (de 4 cols a 3 cols, o 3 cards en 4 cols con la cuarta vacía) +- Eliminar tipo TypeScript asociado y query del cliente +- `grep -r "NPS\|nps" components/dashboard/ app/dashboard/ lib/dashboard*` debe + retornar vacío después + +### 4.5 Cliente — redefinir Widget 6 (Apoyos) +- Renombrar archivo `politicas-bar.tsx` → `apoyos-bar.tsx` (o equivalente) +- Eliminar la lógica de comparación "demandadas vs ofrecidas" +- Mostrar solo barras horizontales con top 8 de 6.2 (multi-select, max 3 por respondente) +- Color único: `#43BBC8`. Eliminar el color secundario `#F8AD13` para "ofrecidas". + +### 4.6 Cliente — renombrar Widget 8 (Saturación) +- Renombrar archivo `tabla-riesgo.tsx` → `tabla-saturacion.tsx` +- Título: "Saturación del cuidador" (no "Riesgo de retención") +- Etiquetas de segmentos: "Alta saturación" / "Saturación media" / "Baja saturación" +- Columnas: Segmento · N · % del total · Apoyo más demandado + +### 4.7 Cliente — aplicar todos los subtítulos correctos +Reemplazar los 11 subtítulos con los textos exactos de la tabla en sección 3.6. + +### 4.8 Documentar deuda técnica +Agregar a `TECH_DEBT.md` (o crearlo si no existe) los siguientes ítems: + +```markdown +[TECH-DEBT-002] KPI NPS Cuidador +- Estado: el instrumento actual no tiene pregunta NPS. +- Si en el futuro se quiere medir NPS hacia la empresa de cuidadores, agregar + pregunta en próxima versión del instrumento: "En una escala de 0 a 10, + ¿qué tan probable es que recomiendes esta empresa a otra persona cuidadora?" +- Hasta entonces, el KPI no existe en el dashboard. + +[TECH-DEBT-003] Brecha de políticas (oferta vs demanda) +- Estado: el instrumento captura solo demanda (6.2 = apoyos que ayudarían). +- No captura qué políticas la empresa ya ofrece. +- Para construir el widget de brecha, agregar pregunta en próxima versión: + "¿Qué apoyos para el cuidado ofrece tu empresa hoy?" (multi-select con las + mismas opciones de 6.2 + "Ninguno" + "No lo sé"). +- Hasta entonces, W6 muestra solo demanda. + +[TECH-DEBT-004] Intención directa de salida laboral +- Estado: el instrumento no pregunta directamente "¿considerás dejar este trabajo?". +- El widget W8 usa indicador compuesto (4.3 + 5.1 + 5.2) renombrado a "Saturación". +- Para construir un indicador de intención de salida real, agregar pregunta: + "En los últimos 6 meses, ¿pensaste en dejar este empleo por razones relacionadas + al cuidado?" (Sí/Frecuentemente · Sí/A veces · No · Prefiero no decir). + +[TECH-DEBT-005] A1 (País) vs A1b (Departamento) +- A1 sigue presente en el seed después de Etapa 2E. +- En contexto del piloto (Paraguay únicamente), A1 es redundante: 100% responde Paraguay. +- Decidir antes de Sprint 3: eliminar A1 del instrumento, o mantenerlo si la + plataforma se va a regionalizar (Uruguay, Bolivia, etc.) en el corto plazo. +``` + +### 4.9 Verificación cruzada — patrón nuevo C.8 +Antes de declarar terminado, el agente ejecuta este check (Patrón C.8 propuesto): + +```bash +# Cada subtítulo referenciado en el cliente debe tener su código documentado en SEED_SPEC.md +grep -oE '"[0-9A-Z]+(\.[0-9]+)?b?\s*[·+×]' components/dashboard/*.tsx | \ + awk '{print $1}' | sort -u +# El agente verifica esta lista contra los códigos de SEED_SPEC.md +``` + +Si algún código del cliente no está en `SEED_SPEC.md`, **DETENERSE y reportar** +antes de declarar terminado. No ejecutar literalmente y delegar al director +(eso sería AP.10 — propagación silenciosa de inconsistencia). + +--- + +## 5. Entregables (DoD) +- [ ] Migración 009 aplicada en Cloud (truncado + A1b con 18 opciones) +- [ ] Migración 010 aplicada en Cloud (9 RPCs refactorizadas) +- [ ] Test post-migración: con N=0 (truncado) todas las RPCs devuelven `suppressed` +- [ ] Test con N≥5 de datos sintéticos (insertados ad-hoc) — al menos 1 RPC devuelve `ok` +- [ ] `grep -r "NPS\|nps" components/dashboard/ app/dashboard/ lib/dashboard*` → vacío +- [ ] Subtítulos del cliente match exactamente con la tabla 3.6 del prompt +- [ ] Sidebar de filtros: dropdown de Departamento usa A1b, no A1 +- [ ] Verificación cruzada (4.9) ejecutada sin discrepancias +- [ ] `TECH_DEBT.md` actualizado con TECH-DEBT-002, 003, 004, 005 +- [ ] Build sin errores (`next build`) +- [ ] `git diff --name-only HEAD` solo en scope (migrations, components/dashboard, + lib/dashboard*, TECH_DEBT.md) +- [ ] Captura del dashboard en estado `suppressed` compartida (Principio 2) + +## 6. Qué reportar +``` +Verificado: +- Migración 009 aplicada: rows truncados=X, A1b insertada con 18 opciones ✓ +- Migración 010 aplicada: 9 RPCs DROPped y CREATEd ✓ +- Test N=0: todas las RPCs → suppressed ✓ +- Test con datos sintéticos: [lista de RPCs con ok / suppressed] +- grep NPS: vacío ✓ +- Subtítulos: 13 textos exactos verificados contra tabla 3.6 ✓ +- Sidebar Departamento usa A1b ✓ +- Verificación cruzada C.8: [resultado] +- TECH_DEBT.md: ítems 002–005 agregados ✓ +- Build exitoso · diff en scope ✓ + +Asumido: +- Decisión local "No lo sé" en 6.5 → [NULL / 0 / otra] +- [lo que no se pudo verificar] + +Decisiones proactivas (Nivel 2): +- [lista] + +Conflictos de scope detectados (Patrón C.8): +- [si los hubo, listarlos AQUÍ y DETENER antes de ejecutar] +``` + +## 7. Qué NO hacer +- ❌ No tocar el formulario (`/app/(survey)/`, `app/actions/submit-survey.ts`). +- ❌ No tocar RLS de write existente (la migración 011 anterior es intocable). +- ❌ No agregar dependencias npm. +- ❌ No inventar códigos de pregunta. Si un código nombrado en este prompt no + coincide con `SEED_SPEC.md`, DETENERSE y reportar (Patrón C.8). NO ejecutar + "literalmente" y delegar al director. +- ❌ No tocar la estructura de capas (sigue siendo cliente → Route Handler → RPC). +- ❌ No agregar widgets nuevos. Esta etapa es refactor, no extensión. +- ❌ No resolver ítems del backlog que no sean los 4 listados en sección 4.8. + +--- + +## Apéndice — Patrón nuevo propuesto C.8 + +Si esta etapa cierra limpia, agregar a `method/PATTERNS.md`: + +> **C.8 — Verificación de códigos contra fuente única · *deriva de P3, P4 · origen: +> 2do proyecto*.** Cuando un prompt menciona códigos de un instrumento, esquema o +> contrato (preguntas, columnas, campos), el agente verifica cada código contra la +> fuente única (`SEED_SPEC.md`, `schema.sql`, etc.) **antes** de ejecutar. Si +> detecta una discrepancia, DETIENE la ejecución y reporta el conflicto. Ejecutar +> "literalmente" y delegar la verificación semántica al director es AP.10 +> (propagación silenciosa de inconsistencia). Respuesta directa al caso Etapa 2D del +> proyecto Datos País. \ No newline at end of file diff --git a/supabase/migrations/20260605140000_011_truncate_testing_data.sql b/supabase/migrations/20260605140000_011_truncate_testing_data.sql new file mode 100644 index 0000000..22a9e88 --- /dev/null +++ b/supabase/migrations/20260605140000_011_truncate_testing_data.sql @@ -0,0 +1,6 @@ +-- Migration 011: Truncar datos de testing +-- Ejecutado directamente el 2026-06-05 antes del push (tablas en 0 al momento del push). +-- TRUNCATE en tablas ya vacías es idempotente — este archivo solo registra el evento. +BEGIN; +TRUNCATE TABLE public.answers, public.responses, public.consent_records RESTART IDENTITY CASCADE; +COMMIT; diff --git a/supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql b/supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql new file mode 100644 index 0000000..cc89682 --- /dev/null +++ b/supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql @@ -0,0 +1,677 @@ +-- ============================================================ +-- Migration 012: Dashboard RPCs v2 — alineación al instrumento real +-- Correcciones respecto a migration 009: +-- _dash_responses : departamento filtra por responses.qi_zone (no JOIN a answers) +-- rpc_dashboard_kpis : K1 = 1.6+1.13 (sin 1.1); K2 = 5.2 en-dash; K3 = avg 4.3 raw 1–5 +-- rpc_prevalencia_por_depto : group by qi_zone +-- rpc_perfil_poblacion : (era rpc_distribucion_cuidado) — renombrada +-- rpc_score_organizacional : 0.7×6.1 + 0.3×6.5; 6.5='No lo sé' → NULL +-- rpc_apoyos_demandados : (era rpc_politicas_gap) — solo 6.2, renombrada +-- rpc_intensidad_vs_bienestar : (era rpc_intensidad_vs_productividad) — renombrada +-- rpc_saturacion_cuidador : (era rpc_riesgo_retencion) — nueva lógica 4.3+5.1+5.2 +-- Todas mantienen: SECURITY DEFINER, search_path fijado, k-anonimato k≥5. +-- ============================================================ + +-- Eliminar funciones renombradas +DROP FUNCTION IF EXISTS public.rpc_distribucion_cuidado(uuid, jsonb); +DROP FUNCTION IF EXISTS public.rpc_politicas_gap(uuid, jsonb); +DROP FUNCTION IF EXISTS public.rpc_intensidad_vs_productividad(uuid, jsonb); +DROP FUNCTION IF EXISTS public.rpc_riesgo_retencion(uuid, jsonb); + +-- ── Helper: responses filtradas por cohort ──────────────────── +-- Usa qi_zone directamente (poblado desde A1 en submit-survey.ts). +-- Evita un JOIN a answers por consulta. +CREATE OR REPLACE FUNCTION public._dash_responses( + p_survey_id uuid, + p_depto text DEFAULT NULL, + p_genero text[] DEFAULT NULL, + p_etario text[] DEFAULT NULL, + p_modalidad text[] DEFAULT NULL +) +RETURNS TABLE(response_id uuid) +LANGUAGE sql STABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT r.id + FROM public.responses r + WHERE r.survey_id = p_survey_id + AND (p_depto IS NULL OR r.qi_zone = p_depto) + AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = r.id AND q.code = 'A6' + AND a.value #>> '{}' = ANY(p_genero) + )) + AND (p_etario IS NULL OR cardinality(p_etario) = 0 + OR r.qi_age_bucket = ANY(p_etario)) + AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = r.id AND q.code = 'A7' + AND a.value #>> '{}' = ANY(p_modalidad) + )); +$$; + +-- ── Helper: parse filters JSONB → arrays ────────────────────── +CREATE OR REPLACE FUNCTION public._dash_parse_filters(p_filters jsonb) +RETURNS TABLE( + f_depto text, + f_genero text[], + f_etario text[], + f_modalidad text[] +) +LANGUAGE sql IMMUTABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + NULLIF(p_filters->>'depto',''), + ARRAY(SELECT jsonb_array_elements_text(p_filters->'genero'))::text[], + ARRAY(SELECT jsonb_array_elements_text(p_filters->'etario'))::text[], + ARRAY(SELECT jsonb_array_elements_text(p_filters->'modalidad'))::text[]; +$$; + +-- ── KPIs ───────────────────────────────────────────────────── +-- K1: cuidadores = 1.6='Sí' OR 1.13='Sí' +-- K2: 5.2 IN ('Varias veces (4–10 veces al año)', 'Con frecuencia (más de 10 veces al año)') ← en-dash +-- K3: promedio raw de 4.3 (rating 1–5), devuelto como decimal 1.0–5.0 +CREATE OR REPLACE FUNCTION public.rpc_dashboard_kpis( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_n bigint; + v_cuidadores bigint; + v_bienestar_avg numeric; + v_bienestar_n bigint; + v_ausencias bigint; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + + SELECT count(*) INTO v_n + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + + IF v_n < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n); + END IF; + + -- K1: 1.6='Sí' OR 1.13='Sí' (cuidadores de otros) + SELECT count(DISTINCT dr.response_id) INTO v_cuidadores + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + WHERE EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id + AND q.code IN ('1.6','1.13') + AND a.value #>> '{}' = 'Sí' + ); + + -- K3: promedio raw 4.3 (1–5) + SELECT avg((a.value #>> '{}')::numeric), count(*) + INTO v_bienestar_avg, v_bienestar_n + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '4.3' + AND a.value #>> '{}' ~ '^[0-9]+$'; + + -- K2: ausencias recurrentes 5.2 — EN-DASH obligatorio + SELECT count(DISTINCT dr.response_id) INTO v_ausencias + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '5.2' + AND a.value #>> '{}' IN ( + 'Varias veces (4–10 veces al año)', + 'Con frecuencia (más de 10 veces al año)' + ); + + RETURN jsonb_build_object( + 'status', 'ok', + 'n', v_n, + 'pct_cuidadores', ROUND(v_cuidadores::numeric / v_n * 100, 1), + 'score_bienestar', CASE WHEN v_bienestar_n > 0 + THEN ROUND(v_bienestar_avg, 1) + ELSE NULL END, + 'pct_ausencias', ROUND(v_ausencias::numeric / v_n * 100, 1) + ); +END; +$$; + +-- ── Prevalencia por departamento ────────────────────────────── +-- Usa qi_zone de responses (sin JOIN a answers para departamento). +-- Cuidadores = 1.6='Sí' OR 1.13='Sí'. +CREATE OR REPLACE FUNCTION public.rpc_prevalencia_por_depto( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_rows jsonb; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT jsonb_agg(row) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'depto', depto_val, + 'n', CASE WHEN n_depto >= 5 THEN n_depto ELSE NULL END, + 'pct_cuidadores', CASE WHEN n_depto >= 5 + THEN ROUND(n_cuid::numeric / n_depto * 100, 1) + ELSE NULL END, + 'suppressed', (n_depto < 5) + ) AS row + FROM ( + SELECT + r.qi_zone AS depto_val, + count(DISTINCT dr.response_id) AS n_depto, + count(DISTINCT dr.response_id) FILTER ( + WHERE EXISTS ( + SELECT 1 FROM public.answers a2 + JOIN public.questions q2 ON q2.id = a2.question_id + WHERE a2.response_id = dr.response_id + AND q2.code IN ('1.6','1.13') + AND a2.value #>> '{}' = 'Sí' + ) + ) AS n_cuid + FROM public._dash_responses(p_survey_id, NULL, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.responses r ON r.id = dr.response_id + WHERE r.qi_zone IS NOT NULL + GROUP BY r.qi_zone + ) sub + ORDER BY depto_val + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Perfil de la población (era rpc_distribucion_cuidado) ───── +-- 1.1 = disc. propia, 1.6 = familiar, 1.13 = adulto mayor +CREATE OR REPLACE FUNCTION public.rpc_perfil_poblacion( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_disc bigint; + v_familiar bigint; + v_mayor bigint; + v_ninguno bigint; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT count(DISTINCT dr.response_id) INTO v_disc + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '1.1' AND a.value #>> '{}' = 'Sí'; + + SELECT count(DISTINCT dr.response_id) INTO v_familiar + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '1.6' AND a.value #>> '{}' = 'Sí'; + + SELECT count(DISTINCT dr.response_id) INTO v_mayor + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '1.13' AND a.value #>> '{}' = 'Sí'; + + SELECT count(DISTINCT dr.response_id) INTO v_ninguno + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + WHERE NOT EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id + AND q.code IN ('1.1','1.6','1.13') + AND a.value #>> '{}' = 'Sí' + ); + + RETURN jsonb_build_object( + 'status', 'ok', + 'n', v_total, + 'disc_propia', ROUND(v_disc::numeric / v_total * 100, 1), + 'familiar', ROUND(v_familiar::numeric / v_total * 100, 1), + 'adulto_mayor', ROUND(v_mayor::numeric / v_total * 100, 1), + 'ninguno', ROUND(v_ninguno::numeric / v_total * 100, 1) + ); +END; +$$; + +-- ── Score organizacional (Madurez) ──────────────────────────── +-- Formula: 0.7 × 6.1_norm + 0.3 × 6.5_norm +-- 6.1: Sí=100, Parcialmente=66, No invisible=33, No nunca=0 +-- 6.5: Sí=100, Tal vez=66, No necesario=33, No lo sé=NULL (no entra al avg) +CREATE OR REPLACE FUNCTION public.rpc_score_organizacional( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_score_6_1 numeric; + v_score_6_5 numeric; + v_score_final numeric; + v_zona text; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT AVG(CASE + WHEN a.value #>> '{}' = 'Sí, me siento acompañado/a' THEN 100.0 + WHEN a.value #>> '{}' = 'Parcialmente, hay cierta comprensión' THEN 66.0 + WHEN a.value #>> '{}' = 'No, es algo bastante invisible' THEN 33.0 + WHEN a.value #>> '{}' = 'No, nunca se ha hablado del tema' THEN 0.0 + ELSE NULL + END) INTO v_score_6_1 + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '6.1'; + + -- 'No lo sé' → NULL: AVG ignora NULLs en PostgreSQL + SELECT AVG(CASE + WHEN a.value #>> '{}' = 'Sí, definitivamente' THEN 100.0 + WHEN a.value #>> '{}' = 'Tal vez, dependería del caso' THEN 66.0 + WHEN a.value #>> '{}' = 'No lo considero necesario' THEN 33.0 + WHEN a.value #>> '{}' = 'No lo sé' THEN NULL + ELSE NULL + END) INTO v_score_6_5 + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '6.5'; + + v_score_final := ROUND( + 0.7 * COALESCE(v_score_6_1, 0.0) + 0.3 * COALESCE(v_score_6_5, 0.0), + 1 + ); + + v_zona := CASE + WHEN v_score_final < 34 THEN 'baja' + WHEN v_score_final < 67 THEN 'desarrollo' + ELSE 'consciente' + END; + + RETURN jsonb_build_object('status','ok','n',v_total,'score',v_score_final,'zona',v_zona); +END; +$$; + +-- ── Heatmap: horas de cuidado × frecuencia ausentismo (2.2 × 5.2) ── +-- Sin cambios estructurales respecto a migration 009. +CREATE OR REPLACE FUNCTION public.rpc_heatmap_intensidad( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_rows jsonb; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT jsonb_agg(row) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'horas', horas_bin, + 'ausencias', aus_bin, + 'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END, + 'suppressed', (cnt < 5) + ) AS row + FROM ( + SELECT + ah.value #>> '{}' AS horas_bin, + aa.value #>> '{}' AS aus_bin, + count(DISTINCT dr.response_id) AS cnt + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers ah ON ah.response_id = dr.response_id + JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '2.2' + JOIN public.answers aa ON aa.response_id = dr.response_id + JOIN public.questions qa ON qa.id = aa.question_id AND qa.code = '5.2' + GROUP BY 1, 2 + ) sub + ORDER BY horas_bin, aus_bin + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Pirámide demográfica (A5 × A6) ─────────────────────────── +-- Sin cambios respecto a migration 009. +CREATE OR REPLACE FUNCTION public.rpc_piramide_demografica( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_rows jsonb; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT jsonb_agg(row) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'etario', etario_val, + 'genero', genero_val, + 'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END, + 'suppressed', (cnt < 5) + ) AS row + FROM ( + SELECT + ae.value #>> '{}' AS etario_val, + ag.value #>> '{}' AS genero_val, + count(DISTINCT dr.response_id) AS cnt + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers ae ON ae.response_id = dr.response_id + JOIN public.questions qe ON qe.id = ae.question_id AND qe.code = 'A5' + JOIN public.answers ag ON ag.response_id = dr.response_id + JOIN public.questions qg ON qg.id = ag.question_id AND qg.code = 'A6' + GROUP BY 1, 2 + ) sub + ORDER BY etario_val, genero_val + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Apoyos más demandados (era rpc_politicas_gap) ──────────── +-- Solo 6.2 (multi-select max 3). Top 8 por frecuencia, N≥5. +CREATE OR REPLACE FUNCTION public.rpc_apoyos_demandados( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_rows jsonb; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'politica', opt, + 'n', cnt, + 'pct', ROUND(cnt::numeric / v_total * 100, 1) + ) AS row + FROM ( + SELECT + jsonb_array_elements_text(a.value) AS opt, + count(DISTINCT dr.response_id) AS cnt + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers a ON a.response_id = dr.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '6.2' + AND jsonb_typeof(a.value) = 'array' + GROUP BY 1 + ) sub + WHERE cnt >= 5 + ORDER BY cnt DESC + LIMIT 8 + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Intensidad de cuidado vs. Bienestar (era rpc_intensidad_vs_productividad) ── +-- 2.2 × 4.3 — sin cambios de lógica respecto a migration 009. +CREATE OR REPLACE FUNCTION public.rpc_intensidad_vs_bienestar( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_rows jsonb; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + SELECT jsonb_agg(row) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'horas', horas_bin, + 'bienestar', bien_bin, + 'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END, + 'suppressed', (cnt < 5) + ) AS row + FROM ( + SELECT + ah.value #>> '{}' AS horas_bin, + ab.value #>> '{}' AS bien_bin, + count(DISTINCT dr.response_id) AS cnt + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + JOIN public.answers ah ON ah.response_id = dr.response_id + JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '2.2' + JOIN public.answers ab ON ab.response_id = dr.response_id + JOIN public.questions qb ON qb.id = ab.question_id AND qb.code = '4.3' + WHERE ab.value #>> '{}' ~ '^[0-9]+$' + GROUP BY 1, 2 + ) sub + ORDER BY horas_bin, bien_bin + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Saturación del cuidador (era rpc_riesgo_retencion) ─────── +-- Indicador compuesto: 4.3 (bienestar) + 5.1 (rendimiento) + 5.2 (ausentismo) +-- Alta saturación: 3 criterios. Media: 2. Baja: 0–1. +-- Columnas: Segmento · N · % del total · Apoyo más demandado (6.2) +CREATE OR REPLACE FUNCTION public.rpc_saturacion_cuidador( + p_survey_id uuid, + p_filters jsonb DEFAULT '{}'::jsonb +) +RETURNS jsonb +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_f record; + v_total bigint; + v_n_alta bigint; + v_n_media bigint; + v_n_baja bigint; + v_apo_alta text; + v_apo_media text; + v_apo_baja text; +BEGIN + SELECT * INTO v_f FROM public._dash_parse_filters(p_filters); + SELECT count(*) INTO v_total + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad); + IF v_total < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total); + END IF; + + -- Contar por segmento en una sola pasada + SELECT + count(*) FILTER (WHERE n_crit = 3), + count(*) FILTER (WHERE n_crit = 2), + count(*) FILTER (WHERE n_crit <= 1) + INTO v_n_alta, v_n_media, v_n_baja + FROM ( + SELECT + dr.response_id, + (CASE WHEN EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id=a.question_id + WHERE a.response_id=dr.response_id AND q.code='4.3' + AND a.value #>> '{}' ~ '^[0-9]+$' + AND (a.value #>> '{}')::int <= 2 + ) THEN 1 ELSE 0 END + + CASE WHEN EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id=a.question_id + WHERE a.response_id=dr.response_id AND q.code='5.1' + AND a.value #>> '{}' = 'Casi todos los días' + ) THEN 1 ELSE 0 END + + CASE WHEN EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id=a.question_id + WHERE a.response_id=dr.response_id AND q.code='5.2' + AND a.value #>> '{}' IN ( + 'Varias veces (4–10 veces al año)', + 'Con frecuencia (más de 10 veces al año)' + ) + ) THEN 1 ELSE 0 END) AS n_crit + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr + ) sub; + + -- Apoyo más demandado (6.2) por segmento, solo si N≥5 + IF v_n_alta >= 5 THEN + SELECT opt INTO v_apo_alta FROM ( + SELECT jsonb_array_elements_text(a.value) AS opt, + count(DISTINCT dr.response_id) AS cnt + FROM ( + SELECT response_id FROM ( + SELECT dr2.response_id, + (CASE WHEN EXISTS (SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id=a2.question_id WHERE a2.response_id=dr2.response_id AND q2.code='4.3' AND a2.value#>>'{}' ~ '^[0-9]+$' AND (a2.value#>>'{}')::int <= 2) THEN 1 ELSE 0 END + + CASE WHEN EXISTS (SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id=a3.question_id WHERE a3.response_id=dr2.response_id AND q3.code='5.1' AND a3.value#>>'{}' = 'Casi todos los días') THEN 1 ELSE 0 END + + CASE WHEN EXISTS (SELECT 1 FROM public.answers a4 JOIN public.questions q4 ON q4.id=a4.question_id WHERE a4.response_id=dr2.response_id AND q4.code='5.2' AND a4.value#>>'{}' IN ('Varias veces (4–10 veces al año)','Con frecuencia (más de 10 veces al año)')) THEN 1 ELSE 0 END) AS n_crit + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr2 + ) s WHERE n_crit = 3 + ) seg + JOIN public.answers a ON a.response_id = seg.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '6.2' AND jsonb_typeof(a.value) = 'array' + GROUP BY opt ORDER BY cnt DESC LIMIT 1 + ) t; + END IF; + + IF v_n_media >= 5 THEN + SELECT opt INTO v_apo_media FROM ( + SELECT jsonb_array_elements_text(a.value) AS opt, + count(DISTINCT dr.response_id) AS cnt + FROM ( + SELECT response_id FROM ( + SELECT dr2.response_id, + (CASE WHEN EXISTS (SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id=a2.question_id WHERE a2.response_id=dr2.response_id AND q2.code='4.3' AND a2.value#>>'{}' ~ '^[0-9]+$' AND (a2.value#>>'{}')::int <= 2) THEN 1 ELSE 0 END + + CASE WHEN EXISTS (SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id=a3.question_id WHERE a3.response_id=dr2.response_id AND q3.code='5.1' AND a3.value#>>'{}' = 'Casi todos los días') THEN 1 ELSE 0 END + + CASE WHEN EXISTS (SELECT 1 FROM public.answers a4 JOIN public.questions q4 ON q4.id=a4.question_id WHERE a4.response_id=dr2.response_id AND q4.code='5.2' AND a4.value#>>'{}' IN ('Varias veces (4–10 veces al año)','Con frecuencia (más de 10 veces al año)')) THEN 1 ELSE 0 END) AS n_crit + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr2 + ) s WHERE n_crit = 2 + ) seg + JOIN public.answers a ON a.response_id = seg.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '6.2' AND jsonb_typeof(a.value) = 'array' + GROUP BY opt ORDER BY cnt DESC LIMIT 1 + ) t; + END IF; + + IF v_n_baja >= 5 THEN + SELECT opt INTO v_apo_baja FROM ( + SELECT jsonb_array_elements_text(a.value) AS opt, + count(DISTINCT dr.response_id) AS cnt + FROM ( + SELECT response_id FROM ( + SELECT dr2.response_id, + (CASE WHEN EXISTS (SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id=a2.question_id WHERE a2.response_id=dr2.response_id AND q2.code='4.3' AND a2.value#>>'{}' ~ '^[0-9]+$' AND (a2.value#>>'{}')::int <= 2) THEN 1 ELSE 0 END + + CASE WHEN EXISTS (SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id=a3.question_id WHERE a3.response_id=dr2.response_id AND q3.code='5.1' AND a3.value#>>'{}' = 'Casi todos los días') THEN 1 ELSE 0 END + + CASE WHEN EXISTS (SELECT 1 FROM public.answers a4 JOIN public.questions q4 ON q4.id=a4.question_id WHERE a4.response_id=dr2.response_id AND q4.code='5.2' AND a4.value#>>'{}' IN ('Varias veces (4–10 veces al año)','Con frecuencia (más de 10 veces al año)')) THEN 1 ELSE 0 END) AS n_crit + FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr2 + ) s WHERE n_crit <= 1 + ) seg + JOIN public.answers a ON a.response_id = seg.response_id + JOIN public.questions q ON q.id = a.question_id + WHERE q.code = '6.2' AND jsonb_typeof(a.value) = 'array' + GROUP BY opt ORDER BY cnt DESC LIMIT 1 + ) t; + END IF; + + RETURN jsonb_build_object( + 'status', 'ok', + 'n_total', v_total, + 'segmentos', jsonb_build_array( + jsonb_build_object( + 'segmento', 'Alta saturación', + 'n', CASE WHEN v_n_alta >= 5 THEN v_n_alta ELSE NULL END, + 'pct', CASE WHEN v_n_alta >= 5 THEN ROUND(v_n_alta::numeric/v_total*100,1) ELSE NULL END, + 'apoyo', CASE WHEN v_n_alta >= 5 THEN v_apo_alta ELSE NULL END, + 'suppressed', (v_n_alta < 5) + ), + jsonb_build_object( + 'segmento', 'Saturación media', + 'n', CASE WHEN v_n_media >= 5 THEN v_n_media ELSE NULL END, + 'pct', CASE WHEN v_n_media >= 5 THEN ROUND(v_n_media::numeric/v_total*100,1) ELSE NULL END, + 'apoyo', CASE WHEN v_n_media >= 5 THEN v_apo_media ELSE NULL END, + 'suppressed', (v_n_media < 5) + ), + jsonb_build_object( + 'segmento', 'Baja saturación', + 'n', CASE WHEN v_n_baja >= 5 THEN v_n_baja ELSE NULL END, + 'pct', CASE WHEN v_n_baja >= 5 THEN ROUND(v_n_baja::numeric/v_total*100,1) ELSE NULL END, + 'apoyo', CASE WHEN v_n_baja >= 5 THEN v_apo_baja ELSE NULL END, + 'suppressed', (v_n_baja < 5) + ) + ) + ); +END; +$$;
      Segmento N % del totalPolítica más demandadaApoyo más demandado
      + style={{ background: SATURACION_COLOR[seg.segmento] ?? '#6b7280' }}> {seg.segmento} {seg.suppressed ? '—' : seg.n} {seg.suppressed ? '—' : `${seg.pct}%`}{seg.suppressed ? Datos insuficientes (n<5) - : seg.politica ?? '—'}{seg.suppressed + ? Datos insuficientes (n<5) + : seg.apoyo ?? '—'} +