refactor completo + truncate de base de datos + reingenieria dashboard
This commit is contained in:
18
SEED_SPEC.md
18
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,
|
||||
|
||||
60
TECH_DEBT.md
Normal file
60
TECH_DEBT.md
Normal file
@@ -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).
|
||||
@@ -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}` }
|
||||
|
||||
@@ -10,12 +10,12 @@ const RPC_MAP: Record<string, string> = {
|
||||
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() {
|
||||
|
||||
@@ -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() {
|
||||
<PiramideDemografica surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 4: Barras políticas (6) + Hexbin intensidad/bienestar (6) */}
|
||||
<section className="db-grid db-section" aria-label="Políticas e impacto">
|
||||
<PoliticasBar surveyId={surveyId} filters={filters} />
|
||||
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
|
||||
<section className="db-grid db-section" aria-label="Apoyos e impacto">
|
||||
<ApoyosBar surveyId={surveyId} filters={filters} />
|
||||
<IntensidadProductividad surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 5: Tabla riesgo (12) */}
|
||||
<section className="db-grid db-section" aria-label="Riesgo de retención">
|
||||
<TablaRiesgo surveyId={surveyId} filters={filters} />
|
||||
{/* Fila 5: Saturación del cuidador (12) */}
|
||||
<section className="db-grid db-section" aria-label="Saturación del cuidador">
|
||||
<TablaSaturacion surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
assets/re/.DS_Store
vendored
Normal file
BIN
assets/re/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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<PoliticasData | null>(null)
|
||||
export function ApoyosBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<ApoyosData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.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 (
|
||||
<WidgetShell title="📈 Políticas más demandadas" status={status} n={data?.n_total} colSpan={6}>
|
||||
<WidgetShell title="Apoyos más demandados" subtitle="6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<ul className="db-bar-list" aria-label="Políticas demandadas">
|
||||
<ul className="db-bar-list" aria-label="Apoyos más demandados">
|
||||
{data.data.length === 0 && (
|
||||
<li className="db-suppressed-hint">No hay datos suficientes por política (n<5 en todas).</li>
|
||||
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n<5 en todos).</li>
|
||||
)}
|
||||
{data.data.map(item => (
|
||||
<li key={item.politica} className="db-bar-item">
|
||||
@@ -41,7 +41,7 @@ export function DonutCuidados({ surveyId, filters }: { surveyId: string; filters
|
||||
}) : []
|
||||
|
||||
return (
|
||||
<WidgetShell title="🍩 Tipo de cuidado" status={status} n={data?.n} colSpan={3}>
|
||||
<WidgetShell title="Perfil de la población" subtitle="1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)" status={status} n={data?.n} colSpan={3}>
|
||||
{data && (
|
||||
<div className="db-donut-wrapper">
|
||||
<svg viewBox="0 0 150 150" className="db-donut-svg" aria-hidden="true">
|
||||
|
||||
@@ -31,7 +31,7 @@ export function GaugeOrganizacional({ surveyId, filters }: { surveyId: string; f
|
||||
const color = data ? ZONA_COLOR[data.zona] : '#43BBC8'
|
||||
|
||||
return (
|
||||
<WidgetShell title="🎯 Madurez organizacional" status={status} n={data?.n} colSpan={3}>
|
||||
<WidgetShell title="Madurez organizacional" subtitle="6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0–100" status={status} n={data?.n} colSpan={3}>
|
||||
{data && (
|
||||
<div className="db-gauge-wrapper">
|
||||
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>
|
||||
|
||||
@@ -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 (
|
||||
<WidgetShell title="🔥 Horas de cuidado × Ausencias laborales" status={status} n={data?.n_total} colSpan={6}>
|
||||
<WidgetShell title="Horas de cuidado × Frecuencia de ausentismo" subtitle="2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<div className="db-heatmap-scroll" role="table" aria-label="Heatmap de intensidad">
|
||||
<div className="db-heatmap-grid"
|
||||
|
||||
@@ -33,7 +33,7 @@ export function IntensidadProductividad({ surveyId, filters }: { surveyId: strin
|
||||
}
|
||||
|
||||
return (
|
||||
<WidgetShell title="💸 Intensidad de cuidado vs. Bienestar (bins N≥5)" status={status} n={data?.n_total} colSpan={6}>
|
||||
<WidgetShell title="Intensidad de cuidado vs. Bienestar" subtitle="2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k≥5)" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<div className="db-hexbin-wrapper">
|
||||
<p className="db-hexbin-note">
|
||||
|
||||
@@ -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 (
|
||||
<div className="db-kpi-card">
|
||||
<span className="db-kpi-value" aria-live="polite">{value}</span>
|
||||
<span className="db-kpi-label">{label}</span>
|
||||
{sub && <span className="db-kpi-sub">{sub}</span>}
|
||||
<span className="db-kpi-title">{title}</span>
|
||||
<span className="db-kpi-subtitle">{subtitle}</span>
|
||||
|
||||
{loading && (
|
||||
<div className="db-skeleton-block" style={{ height: 36, margin: '10px 0 4px', borderRadius: 6 }} />
|
||||
)}
|
||||
{!loading && suppressed && (
|
||||
<div className="db-kpi-suppressed-block" role="status">
|
||||
<span className="db-suppressed-icon" aria-hidden="true">🔒</span>
|
||||
<span className="db-kpi-value db-kpi-value-suppressed">n < 5</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !suppressed && (
|
||||
<>
|
||||
<span className="db-kpi-value" aria-live="polite">{value}</span>
|
||||
<span className="db-kpi-note">{valueNote}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -30,33 +73,24 @@ export function KpiCards({ surveyId, filters }: Props) {
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
if (error) return <p className="db-error">Error al cargar KPIs</p>
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
{[1,2,3,4].map(i => <div key={i} className="db-kpi-card db-skeleton-block" style={{height:96}} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isSuppressed(data)) {
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
{[1,2,3,4].map(i => (
|
||||
<div key={i} className="db-kpi-card db-suppressed-card">
|
||||
<span className="db-suppressed-icon">🔒</span>
|
||||
<span className="db-kpi-sub">n < 5</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const d = data as KpiData
|
||||
const loading = data === null
|
||||
const suppressed = !loading && isSuppressed(data)
|
||||
const ok = !loading && !suppressed
|
||||
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
<KpiCard label="Cuidadores activos" value={`${d.pct_cuidadores}%`} sub={`n = ${d.n}`} />
|
||||
<KpiCard label="Score bienestar (0-100)" value={d.score_bienestar !== null ? `${d.score_bienestar}` : '—'} />
|
||||
<KpiCard label="Ausencias frecuentes" value={`${d.pct_ausencias}%`} />
|
||||
<KpiCard label="Total respuestas" value={d.n.toLocaleString('es-PY')} sub="en este corte" />
|
||||
{KPI_DEFS.map((def) => (
|
||||
<KpiCard
|
||||
key={def.title}
|
||||
title={def.title}
|
||||
subtitle={def.subtitle}
|
||||
value={ok ? def.getValue(data as KpiData) : '—'}
|
||||
valueNote={ok ? def.getN(data as KpiData) : ''}
|
||||
suppressed={suppressed}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ParaguayMap({ surveyId, filters, onDeptoClick }: Props) {
|
||||
const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined
|
||||
|
||||
return (
|
||||
<WidgetShell title="🗺️ Prevalencia de cuidadores por departamento" status={status} n={n} colSpan={6}>
|
||||
<WidgetShell title="Prevalencia de cuidadores por departamento" subtitle="A1 · % cuidadores activos por departamento" status={status} n={n} colSpan={6}>
|
||||
<div className="db-map-wrapper" role="img" aria-label="Mapa de Paraguay con prevalencia de cuidadores por departamento">
|
||||
{tooltip && (
|
||||
<div className="db-map-tooltip" aria-live="polite">
|
||||
|
||||
@@ -31,7 +31,7 @@ export function PiramideDemografica({ surveyId, filters }: { surveyId: string; f
|
||||
const generos = ['Femenino','Masculino','No binario/Otro']
|
||||
|
||||
return (
|
||||
<WidgetShell title="👥 Pirámide demográfica" status={status} n={data?.n_total} colSpan={6}>
|
||||
<WidgetShell title="Pirámide demográfica" subtitle="A5 × A6 · rango etario por género (celdas con k≥5)" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<div className="db-piramide">
|
||||
<div className="db-piramide-legend" aria-label="Leyenda">
|
||||
|
||||
@@ -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<string, string> = {
|
||||
Alto: '#ef4444',
|
||||
Medio: '#F8AD13',
|
||||
Bajo: '#43BBC8',
|
||||
const SATURACION_COLOR: Record<string, string> = {
|
||||
'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<RiesgoData | null>(null)
|
||||
export function TablaSaturacion({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<SaturacionData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.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 (
|
||||
<WidgetShell title="📋 Tabla de riesgo de retención" status={status} n={data?.n_total} colSpan={12}>
|
||||
<WidgetShell title="Saturación del cuidador" subtitle="4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)" status={status} n={data?.n_total} colSpan={12}>
|
||||
{data && (
|
||||
<div className="db-table-wrapper">
|
||||
<p className="db-table-note">
|
||||
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.
|
||||
</p>
|
||||
<table className="db-table" aria-label="Tabla de riesgo de retención">
|
||||
<table className="db-table" aria-label="Tabla de saturación del cuidador">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Segmento</th>
|
||||
<th>N</th>
|
||||
<th>% del total</th>
|
||||
<th>Política más demandada</th>
|
||||
<th>Apoyo más demandado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -46,14 +46,16 @@ export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters:
|
||||
<tr key={seg.segmento}>
|
||||
<td>
|
||||
<span className="db-risk-badge"
|
||||
style={{ background: RIESGO_COLOR[seg.segmento] ?? '#6b7280' }}>
|
||||
style={{ background: SATURACION_COLOR[seg.segmento] ?? '#6b7280' }}>
|
||||
{seg.segmento}
|
||||
</span>
|
||||
</td>
|
||||
<td>{seg.suppressed ? '—' : seg.n}</td>
|
||||
<td>{seg.suppressed ? '—' : `${seg.pct}%`}</td>
|
||||
<td>{seg.suppressed ? <span className="db-suppressed-hint">Datos insuficientes (n<5)</span>
|
||||
: seg.politica ?? '—'}</td>
|
||||
<td>{seg.suppressed
|
||||
? <span className="db-suppressed-hint">Datos insuficientes (n<5)</span>
|
||||
: seg.apoyo ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -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 (
|
||||
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}>
|
||||
<div className="db-widget-header">
|
||||
<h3 className="db-widget-title">{title}</h3>
|
||||
<div className="db-widget-title-group">
|
||||
<h3 className="db-widget-title">{title}</h3>
|
||||
{subtitle && <p className="db-widget-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
{n !== undefined && status === 'ok' && (
|
||||
<span className="db-widget-n">n = {n.toLocaleString('es-PY')}</span>
|
||||
)}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
127
sprints/sprint-2/ETAPA_2D_PROMPT.md
Normal file
127
sprints/sprint-2/ETAPA_2D_PROMPT.md
Normal file
@@ -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:
|
||||
`"<código(s) de pregunta> · <descripción corta en lenguaje natural>"`
|
||||
- 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.
|
||||
292
sprints/sprint-2/ETAPA_2E_PROMPT.md
Normal file
292
sprints/sprint-2/ETAPA_2E_PROMPT.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
677
supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql
Normal file
677
supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql
Normal file
@@ -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;
|
||||
$$;
|
||||
Reference in New Issue
Block a user