17 Commits

Author SHA1 Message Date
markosbenitez
fcf93ea3ea chore(release): CHANGELOG v3.0.0 — wizard declarativo + dashboard tres miradas
Etapas 4A→4I completadas. Motor de flujo puro, wizard renderer,
instancias familiares, submit con validación server-side, dashboard
tres miradas (Mirada Empresa funcional), autocomplete barrios/ciudad.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:21:08 -03:00
markosbenitez
3e7109a0f1 feat(wizard): ciudad-select filtra por departamento desde geo json [4F]
- CiudadSelectField: autocomplete sobre Object.keys(geoData[depto]),
  mismo patrón que BarrioAutocompleteField (position:fixed, getBoundingClientRect,
  scroll listener, teclado ARIA). Reutiliza clases CSS .barrio-autocomplete-*.
- Limpia el valor de 2.2 via useEffect cuando cambia departamento (2.1),
  usando un ref estable para onChange para evitar dep cíclica.
- QuestionRenderer: branch code==='2.2' dentro del bloque single_choice;
  pasa answers['2.1:default'] como prop departamento.
- Sin cambios al seed, form-engine ni RPCs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:07:16 -03:00
markosbenitez
6e9ad89f7b fix(wizard): z-index dropdown barrio + texto cortado opciones
Issue A: barrio-autocomplete-list cambia a position:fixed + getBoundingClientRect()
para escapar el overflow-y:auto de wizard-answer-area que clippeaba el dropdown.
z-index: 20 → 30 (clearear sticky topbar/bottombar z-index:10 en stacking context raíz).
Listener de scroll cierra el dropdown si el contenedor scrollea.

Issue B: wizard-answer-area max-height 360px → 480px (desktop) / 320px → 400px (mobile)
para mostrar ~8 opciones completas. .wizard-answer-area.has-overflow { padding-bottom: 44px }
asegura que el fade ::after (28px) cubre espacio vacío, no texto de opción.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 20:03:09 -03:00
markosbenitez
3ec5da0e54 feat(wizard): autocomplete barrios desde geo_paraguay_full.json [4F]
- BarrioAutocompleteField: carga JSON estático, filtra por depto/ciudad (2.1/2.2), mín 2 chars, máx 10 sugerencias, texto libre permitido, teclado (↑↓ Enter Escape), ARIA listbox
- QuestionRenderer: branch code === '2.3' → BarrioAutocompleteField; prop answers? pasado desde WizardForm para contexto depto/ciudad
- globals.css: clases .barrio-autocomplete-* (wrapper, list, item, item--active)
- Incluye public/data/geo_paraguay_full.json (202 KB, sin commitear hasta ahora)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 19:41:55 -03:00
markosbenitez
dbcc60ad95 fix(dashboard): tema claro — widget-star, gap-brecha, lens-intro
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:59:31 -03:00
markosbenitez
474b501fff tema claro v1 2026-06-26 18:44:31 -03:00
markosbenitez
c847ef3ef5 feat(dashboard): tema claro marca Re — fondos, cards y texto
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 18:22:26 -03:00
markosbenitez
8dea04cae3 fix(rpc): expose cnt as outer column in apoyos and carrera (Opción B)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 17:59:35 -03:00
markosbenitez
56443a739f Etapa completada (local) — widgets de Mirada Empresa 2026-06-26 11:29:16 -03:00
markosbenitez
df9b168577 docs: actualizar estado — Mirada Empresa verificada 2026-06-25 14:10:48 -03:00
markosbenitez
83782ab6f8 feat(dashboard): RPCs Mirada Empresa v3 + fix códigos v2 en helper [dashboard]
11 RPCs de Mirada Empresa (E1-E10 + estrella Talento en riesgo) sobre
instrumento v3, k≥5 en dos niveles, helper _dash_es_cuidador (criterio
UNFPA). Fix de _dash_responses: A6/A7 → 2.5/2.7. Verificado contra 50
respuestas reales: métricas núcleo exactas, k-anonimato suprime segmentos
n<5, umbral de la estrella parametrizable. Modalidad por JOIN (TECH-DEBT-010).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 14:06:49 -03:00
markosbenitez
b0cf273bc8 docs: actualizar estado — cierre Etapa 4G. 2026-06-24 21:31:56 -03:00
markosbenitez
eaf71567a7 prompt 4G 2026-06-24 21:29:01 -03:00
markosbenitez
2e6fbd7801 feat(submit): submit v3 con validación de integridad server-side [4G]
RPC transaccional SECURITY DEFINER (rpc_submit_response) como único camino
de escritura. Dropea las policies WITH CHECK(true): cierra la falla donde
anon podía insertar cualquier cosa. Validación server-side (7 checks)
reusando el motor. Persistencia atómica con instance_id por instancia.
Verificado contra Postgres real: instancias separadas, atomicidad, RLS.
Riesgo residual de bypass por REST documentado (SEC-004).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 21:27:49 -03:00
markosbenitez
61676e1423 refactor(engine): mover limpieza de instancias y validación required al motor [4E]
applyAnswer y isRequiredAndUnanswered pasan de wizard-form.tsx al motor
como funciones puras — eran lógica de flujo en el presentador (viola ADR-005).
Limpieza de respuestas huérfanas al remover un vínculo y validación required
por instancia, ahora testeables. 20/20 tests. Verificado end-to-end con navegador.
4E sin construcción pendiente: el mini-wizard es propiedad emergente del motor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 22:57:42 -03:00
markosbenitez
ce679ec1c3 feat(wizard): renderer pregunta-por-pantalla con layout estable [4D] 2026-06-23 22:40:01 -03:00
markosbenitez
7c8b034756 Bug corregido — duplicación de enunciado + fade mal posicionado 2026-06-23 22:12:00 -03:00
54 changed files with 13196 additions and 1060 deletions

View File

@@ -7,6 +7,28 @@
## Seguridad ## Seguridad
### [SEC-004] RPC de submit es bypasseable vía REST directo (coherencia de flujo)
- **Estado:** Riesgo residual documentado — no resuelto en Etapa 4G
- **Contexto:** `rpc_submit_response` (migración `20260624000001_submit_rpc.sql`) es el único
camino de escritura para `consent_records`/`responses`/`answers` (las policies `WITH CHECK(true)`
que permitían INSERT directo a `anon` se eliminaron). La RPC valida lo ESTRUCTURAL en SQL
(el `question_id` pertenece a la encuesta, la encuesta está viva, el consentimiento llegó).
La validación de COHERENCIA DE FLUJO (visibilidad según `conditional_rules`, instancias
legítimas de `4.1`) vive en TypeScript (`lib/submit-validation.ts`, reusa `lib/form-engine.ts`)
porque reimplementarla en SQL violaría la regla de "no duplicar la lógica de flujo" (BRIEF 4G).
- **Riesgo:** quien llame a `rpc_submit_response` directo por la REST API de Supabase (sin pasar
por el Server Action de Next.js) puede mandar un payload con instancias/visibilidad incoherentes
que igual pase las validaciones estructurales de la RPC.
- **Por qué no es un incidente de privacidad:** el dato resultante sigue siendo anónimo
(`respondent_id=NULL`), `is_sensitive` se sigue respetando, y queda sujeto al umbral k-anonimato
en `agg_segment`/RPCs de dashboard igual que cualquier otra respuesta. Es un problema de calidad
de datos de investigación (una respuesta lógicamente imposible podría colarse), no de exposición
de datos.
- **Cierre posible (no implementado):** firmar el payload en el Server Action (HMAC con un secreto
server-only) y verificar la firma dentro de la RPC antes de insertar — así un caller directo sin
el secreto no puede producir un payload válido. Evaluar costo/beneficio antes de priorizar.
- **Dependencia:** no bloquea Sprint 4; evaluar junto con SEC-002 (sprint de compliance).
### [SEC-002] Sprint de compliance & seguridad ### [SEC-002] Sprint de compliance & seguridad
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4 - **Estado:** Diferido — pendiente de priorizar tras Sprint 4
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap - **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap

View File

@@ -7,17 +7,57 @@ y el proyecto adhiere a [Versionado Semántico](https://semver.org/lang/es/).
--- ---
## [No publicado] — Sprint 4: Formulario Wizard v3 ## [3.0.0] — 2026-06-27
En desarrollo en `main`. Cambio mayor: reescritura del formulario a wizard Reescritura del formulario a wizard declarativo pregunta-por-pantalla.
pregunta-por-pantalla con instancias de vínculo familiar. Motor de flujo puro, instancias familiares, submit transaccional server-side
y dashboard Tres Miradas con Mirada Empresa funcional.
### Por agregar ### Agregado
- Wizard pregunta-por-pantalla para todo el formulario - **Motor de flujo declarativo** (`lib/form-engine.ts`): `resolveVisibility`,
- Instrumento v3: 10 secciones, mini-wizard anidado para vínculos familiares `resolveScreens`, `expandInstances` — lógica de show/hide/instancias en TypeScript
- Columna `instance_id` en `answers` para datos multi-instancia puro, sin estado UI. Operadores `contains`, `eq_sole`, `contains_any`.
- Autocomplete de barrios desde dataset INE/DGEEC - **Wizard renderer** (`components/wizard-form.tsx`): pregunta-por-pantalla,
- Subtítulos explicativos del "porqué" por sección progreso lineal, layout estable sin saltos de scroll.
- **Instancias familiares**: columna `instance_id` en `answers`; preguntas
4.2/4.3 se expanden por cada vínculo seleccionado en 4.1 con IDs `familiar_N`.
- **Submit v3 transaccional**: Server Action + RPC `rpc_submit_response`
(SECURITY DEFINER) — persiste consent_record + response + answers en una
transacción atómica; revoca INSERT directo de `anon`.
- **Validación server-side** (`lib/submit-validation.ts`): 7 checks antes del
RPC (consent, códigos legítimos, forma de valores, visibilidad coherente,
instancias válidas, opciones en rango, requeridas presentes).
- **Dashboard Tres Miradas**: Mirada Empresa (`/dashboard`) con 13 widgets,
RPCs STABLE `rpc_me_*` con filtros de segmento y k-anonimato; Mirada País y
Mirada Perfil (base implementada).
- **Autocomplete de barrios** (`BarrioAutocompleteField`): filtra `geo_paraguay_full.json`
por departamento + ciudad, dropdown `position: fixed` con `getBoundingClientRect`.
- **Ciudad select** (`CiudadSelectField`): filtra ciudades por departamento,
limpia el valor al cambiar 2.1.
- **Instrumento v3**: 10 secciones, 40 preguntas, 3 encuestas (encuesta Demo
`30000000-…`, InQuality, Fundación).
- Tema claro marca Re — fondos, cards y paleta de texto revisada.
### Cambiado
- Esquema de DB: tabla `questions` con `instance_generator`, `instance_of`,
`conditional_rules` (jsonb), `code` único por encuesta.
- RLS submit: DROP de policies `*_insert` directas; único canal de escritura
es `rpc_submit_response`.
- Dashboard helper `getDashboardWidgetData` unificado con códigos v3 de pregunta.
### Seguridad
- RPC SECURITY DEFINER para submit: cierra el `WITH CHECK(true)` que dejaba
el store analítico abierto a escritura directa desde el cliente (BRIEF dec. 15).
- Validación de integridad del payload en el Server Action antes del RPC:
el servidor es la única fuente de verdad sobre lo que se persiste.
### Migraciones aplicadas (Sprint 4)
- `20260620000001``instance_id` en `answers` [4A]
- `20260620000002` — esquema declarativo v3 [4B]
- `20260624000001` — RPC submit transaccional [4G]
- `20260625000001` — RPCs dashboard Mirada Empresa [4H]
- `20260625000002` — RPCs prevalencia y segmentos [4H]
- `20260626000001` — fix `cnt` en RPCs `me_carrera` / `me_apoyos` [4H]
--- ---

View File

@@ -2,7 +2,7 @@
> Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado". > Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado".
> Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen). > Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen).
> Última actualización: 2026-06-20 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo) > Última actualización: 2026-06-24 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
--- ---
@@ -78,6 +78,12 @@ señal de inmadurez en seguridad.
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada, - **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
pero requiere gate de auth antes de exponer. pero requiere gate de auth antes de exponer.
- **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente. - **TECH-DEBT-009**: rotación de secrets Supabase post-CVE — pendiente.
- **SEC-004** (ver `BACKLOG.md`): el submit v3 (`rpc_submit_response`, Etapa 4G) cerró el INSERT
directo de `anon` sobre `consent_records`/`responses`/`answers` (antes `WITH CHECK(true)`).
La RPC valida estructura en SQL; la coherencia de flujo (visibilidad, instancias legítimas) se
valida en TypeScript antes de llamarla — quien la invoque directo por REST puede evadir esa
segunda capa. No es un riesgo de privacidad (el dato sigue anónimo y sujeto a k-anonimato),
es de calidad de datos de investigación.
--- ---
@@ -139,3 +145,4 @@ por inadecuación técnica y normativa, no por desconocimiento.
| Fecha | Versión producto | Cambio | | Fecha | Versión producto | Cambio |
|---|---|---| |---|---|---|
| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. | | 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. |
| 2026-06-24 | 2.1.0 / 3.0.0-dev | Etapa 4G: submit v3 vía RPC SECURITY DEFINER, cierra INSERT directo de anon. Registra SEC-004 (riesgo residual de bypass vía REST, ver BACKLOG.md). |

View File

@@ -97,6 +97,11 @@ qi_zone text
``` ```
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar > Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`. > (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
> **Mapeo v3 (Etapa 4G, confirmado):** `qi_zone` ← `2.1` (departamento), `qi_age_bucket` ← `2.4`
> (rango de edad). `qi_sector` ← `2.6` (nivel organizacional) — v3 no tiene una pregunta
> equivalente a "área/gerencia" como v2 (`A4`); `2.6` es el cuasi-identificador organizacional
> más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2.
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para > Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
> minimizar re-identificación. > minimizar re-identificación.

View File

@@ -85,3 +85,28 @@
mínimo viable). mínimo viable).
- **Impacto actual:** ninguno en producción — la URL no está publicada. - **Impacto actual:** ninguno en producción — la URL no está publicada.
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control. - **Bloqueante para:** ampliar visibilidad de datos en la vista de control.
---
## [TECH-DEBT-010] E9 lee modalidad por JOIN a `answers` en vez de `qi_modalidad`
- **Estado:** ABIERTO — decisión consciente, no un descuido.
- **Descripción:** `rpc_me_segmentacion` (E9, Mirada Empresa) segmenta por nivel
organizacional y por modalidad de trabajo. El nivel (`2.6`) sí se promovió a
`responses.qi_sector` en el submit (Etapa 4G) — E9 lee de ahí directo. La
modalidad (`2.7`) NO tiene una columna `qi_modalidad` equivalente — promoverla
hubiera significado reabrir `rpc_submit_response`, ya verificada contra
Postgres real en 4G. Para no reabrirla, E9 resuelve modalidad con un JOIN a
`answers`/`questions` por `code='2.7'` en tiempo de consulta.
- **Costo del JOIN vs. la columna:** una pasada extra por `answers`/`questions`
en cada llamada a E9 — aceptable para el volumen actual (decenas/cientos de
respuestas por encuesta), pero más lento que leer una columna denormalizada
si el dataset crece mucho o si la segmentación por modalidad se usa con
frecuencia en varios widgets.
- **Acción si se decide promover:** agregar `qi_modalidad text` a `responses`,
poblarla en `rpc_submit_response` (requiere re-verificar esa RPC contra
Postgres real, igual que se hizo en 4G), y migrar E9 a leerla directo —
mismo patrón que se usó para `qi_sector`.
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.

View File

@@ -1,7 +1,10 @@
'use server' 'use server'
import { headers } from 'next/headers' import { headers } from 'next/headers'
import { createHash, randomUUID } from 'crypto' import { createHash } from 'crypto'
import { createClient as createSupabaseClient } from '@supabase/supabase-js' import { createClient as createSupabaseClient } from '@supabase/supabase-js'
import { buildFormSchema } from '@/lib/form-engine'
import type { Question as EngineQuestion } from '@/lib/form-engine.types'
import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation'
export type SubmitResult = export type SubmitResult =
| { success: true } | { success: true }
@@ -9,103 +12,83 @@ export type SubmitResult =
export async function submitSurvey(payload: { export async function submitSurvey(payload: {
surveyId: string surveyId: string
answers: Record<string, unknown> answers: SubmitAnswerEntry[]
questionMap: Record<string, string> // código → question_id UUID
consentOperativo: boolean consentOperativo: boolean
consentMacroN1: boolean consentMacroN1: boolean
}): Promise<SubmitResult> { }): Promise<SubmitResult> {
// 1. ip_hash: server-only, nunca en texto plano // ip_hash: server-only, nunca en texto plano (dedup sin identidad)
const headersList = await headers() const headersList = await headers()
const rawIp = const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const salt = process.env.IP_HASH_SALT ?? '' const salt = process.env.IP_HASH_SALT ?? ''
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex') const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
// 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon) // Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
// NUNCA usar service_role — el anon key respeta RLS
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? '' const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '' const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey) const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
// 3. Leer survey para obtener org_id y consent_version_id // 1. Validación #1: survey existe y está activa
const { data: survey, error: sErr } = await supabase const { data: survey, error: sErr } = await supabase
.from('surveys') .from('surveys')
.select('org_id, consent_version_id') .select('id, org_id, status')
.eq('id', payload.surveyId) .eq('id', payload.surveyId)
.single() .eq('status', 'live')
.maybeSingle()
if (sErr || !survey) return { success: false, error: 'survey_not_found' } if (sErr) return { success: false, error: 'survey_lookup_failed' }
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
// 4. Pre-generar UUIDs server-side para evitar .select().single() post-INSERT. // 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
// // nunca contra lo que el cliente afirma que vio.
// DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que const { data: questionRows, error: qErr } = await supabase
// requiere SELECT policy para anon en consent_records/responses. Esa policy no existe .from('questions')
// (correcto — anon no debe leer consent_records de otros respondentes). .select(
// Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT. 'id, code, type, prompt, position, is_sensitive, required, hint, ' +
const consentId = randomUUID() 'max_select, options, conditional_rules, instance_generator, instance_of'
const responseId = randomUUID()
// 5. INSERT consent_record (inmutable, ADR-003)
// Sin .select() para no activar RETURNING y evitar el error RLS 42501.
const { error: cErr } = await supabase
.from('consent_records')
.insert({
id: consentId,
consent_version_id: survey.consent_version_id,
granted_scopes: {
operativo: payload.consentOperativo,
macro_n1: payload.consentMacroN1,
},
granted_at: new Date().toISOString(),
})
if (cErr) return { success: false, error: `consent_insert_failed: ${cErr.message}` }
// 6. Extraer qi_* de las respuestas
const qi_age_bucket =
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
.from('responses')
.insert({
id: responseId,
survey_id: payload.surveyId,
company_id: survey.org_id,
respondent_id: null,
ip_hash: ipHash,
consent_record_id: consentId,
qi_age_bucket: qi_age_bucket || null,
qi_sector: qi_sector || null,
qi_zone: qi_zone || null,
})
if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` }
// 8. INSERT answers — una fila por pregunta respondida
const answerRows = Object.entries(payload.answers)
.map(([code, value]) => ({
response_id: responseId,
question_id: payload.questionMap[code],
value,
}))
.filter(
(row) =>
row.question_id &&
row.value !== null &&
row.value !== undefined &&
row.value !== '' &&
!(Array.isArray(row.value) && row.value.length === 0)
) )
.eq('survey_id', payload.surveyId)
if (answerRows.length > 0) { if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
const { error: aErr } = await supabase.from('answers').insert(answerRows)
if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` } const schema = buildFormSchema(questionRows as unknown as EngineQuestion[])
// 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo.
const result = validateSubmission(schema, payload.answers, payload.consentOperativo)
if (!result.ok) {
return { success: false, error: `validation_failed: ${result.errors.join(', ')}` }
} }
// 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad).
// qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2.
// Se usa 2.6 (nivel organizacional) como proxy más cercano disponible —
// decisión a confirmar con el director, no hay un mapeo exacto.
const findDefault = (code: string): string | null => {
const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default')
return typeof entry?.value === 'string' ? entry.value : null
}
const qiZone = findDefault('2.1')
const qiAgeBucket = findDefault('2.4')
const qiSector = findDefault('2.6')
// 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración
// 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no
// INSERTs sueltos desde acá.
const { error: rpcErr } = await supabase.rpc('rpc_submit_response', {
p_survey_id: payload.surveyId,
p_ip_hash: ipHash,
p_qi_age_bucket: qiAgeBucket,
p_qi_sector: qiSector,
p_qi_zone: qiZone,
p_consent_operativo: payload.consentOperativo,
p_consent_macro_n1: payload.consentMacroN1,
p_answers: result.rows.map((r) => ({
question_id: r.questionId,
instance_id: r.instanceId,
value: r.value,
})),
})
if (rpcErr) return { success: false, error: 'submit_failed' }
return { success: true } return { success: true }
} }

View File

@@ -4,18 +4,25 @@ import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils'
// Route Handler server-side: llama a las RPC con el cliente Supabase. // Route Handler server-side: llama a las RPC con el cliente Supabase.
// El service_role nunca llega al browser — vive solo en este servidor. // El service_role nunca llega al browser — vive solo en este servidor.
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen 3.4/7.1 internamente. // Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen datos
// sensibles internamente.
//
// Las 9 RPCs v2 (rpc_dashboard_kpis, etc.) se retiraron de este mapa junto
// con sus widgets — referencian códigos de pregunta que no existen en v3
// (Etapa 4H, migración de códigos, queda pendiente aparte). Mirada Empresa
// usa las rpc_me_* nuevas, ya verificadas contra datos reales.
const RPC_MAP: Record<string, string> = { const RPC_MAP: Record<string, string> = {
kpis: 'rpc_dashboard_kpis', 'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
prevalencia: 'rpc_prevalencia_por_depto', 'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
heatmap: 'rpc_heatmap_intensidad', 'me-ausentismo': 'rpc_me_ausentismo',
distribucion: 'rpc_perfil_poblacion', 'me-presentismo': 'rpc_me_presentismo',
apoyos: 'rpc_apoyos_demandados', 'me-punto-ciego': 'rpc_me_punto_ciego',
score: 'rpc_score_organizacional', 'me-apoyos': 'rpc_me_apoyos_demandados',
piramide: 'rpc_piramide_demografica', 'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
intensidad: 'rpc_intensidad_vs_bienestar', 'me-carrera-congelada': 'rpc_me_carrera_congelada',
saturacion: 'rpc_saturacion_cuidador', 'me-intensidad-carga': 'rpc_me_intensidad_carga',
'me-segmentacion': 'rpc_me_segmentacion',
'me-disposicion-red': 'rpc_me_disposicion_red',
} }
function getSupabaseServer() { function getSupabaseServer() {
@@ -45,10 +52,18 @@ export async function GET(
const filters = queryToFilters(sp) const filters = queryToFilters(sp)
const supabase = getSupabaseServer() const supabase = getSupabaseServer()
const { data, error } = await supabase.rpc(rpcName, { const rpcParams: Record<string, unknown> = {
p_survey_id: surveyId, p_survey_id: surveyId,
p_filters: filtersToRpc(filters), p_filters: filtersToRpc(filters),
}) }
// Único caso con parámetro extra: el umbral parametrizable de la estrella.
if (widget === 'me-talento-riesgo') {
const umbral = sp.get('umbral')
if (umbral) rpcParams.p_umbral_senales = parseInt(umbral, 10)
}
const { data, error } = await supabase.rpc(rpcName, rpcParams)
if (error) { if (error) {
console.error(`[dashboard/${widget}] RPC error:`, error.message) console.error(`[dashboard/${widget}] RPC error:`, error.message)

View File

@@ -1,16 +1,14 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { KpiCards } from '@/components/dashboard/kpi-cards' import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import { ParaguayMap } from '@/components/dashboard/paraguay-map' import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
import { DonutCuidados } from '@/components/dashboard/donut-cuidados' import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional' import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget' import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica' import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
import { ApoyosBar } from '@/components/dashboard/apoyos-bar' import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad' import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import type { DashboardFilters } from '@/lib/dashboard-types' import type { DashboardFilters } from '@/lib/dashboard-types'
// El survey_id se pasa como query param: /dashboard?survey_id=UUID // El survey_id se pasa como query param: /dashboard?survey_id=UUID
@@ -22,15 +20,30 @@ const SURVEY_OPTIONS = [
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' }, { label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
] ]
type Lens = 'empresa' | 'pais' | 'humana'
const LENSES: { id: Lens; label: string; sub: string }[] = [
{ id: 'empresa', label: 'Mirada Empresa', sub: 'rendimiento · rotación · clima' },
{ id: 'pais', label: 'Mirada País', sub: 'desarrollo · vulnerabilidad · política' },
{ id: 'humana', label: 'Mirada Humana', sub: 'bienestar · derechos · desarrollo' },
]
function PlaceholderLens({ title, hint }: { title: string; hint: string }) {
return (
<div className="db-placeholder" role="status">
<span className="db-placeholder-icon" aria-hidden="true">🔒</span>
<p className="db-placeholder-title">{title} Disponible próximamente</p>
<p className="db-placeholder-hint">{hint}</p>
</div>
)
}
export default function DashboardPage() { export default function DashboardPage() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001' const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
const [filters, setFilters] = useState<DashboardFilters>({}) const [filters, setFilters] = useState<DashboardFilters>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [lens, setLens] = useState<Lens>('empresa')
function handleDeptoClick(depto: string) {
setFilters(f => ({ ...f, depto: depto || undefined }))
}
return ( return (
<div className="db-page"> <div className="db-page">
@@ -38,7 +51,7 @@ export default function DashboardPage() {
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(c => !c)} onToggle={() => setSidebarCollapsed((c) => !c)}
/> />
<div className="db-main"> <div className="db-main">
@@ -66,34 +79,70 @@ export default function DashboardPage() {
</nav> </nav>
</header> </header>
{/* Fila 1: KPIs (span 12) */} <nav className="db-tabs" aria-label="Mirada del dashboard">
<section className="db-section" aria-label="Indicadores clave"> {LENSES.map((l) => (
<KpiCards surveyId={surveyId} filters={filters} /> <button
</section> key={l.id}
type="button"
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
onClick={() => setLens(l.id)}
aria-current={lens === l.id ? 'page' : undefined}
>
<span>{l.label}</span>
<span className="db-tab-lens">{l.sub}</span>
</button>
))}
</nav>
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */} {lens === 'empresa' && (
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos"> <>
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} /> <div className="db-lens-intro">
<DonutCuidados surveyId={surveyId} filters={filters} /> <strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el
<GaugeOrganizacional surveyId={surveyId} filters={filters} /> punto ciego y qué talento está en riesgo.
</section> </div>
{/* Fila 3: Heatmap (6) + Pirámide (6) */} {/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
<section className="db-grid db-section" aria-label="Intensidad y demografía"> <section className="db-grid db-section" aria-label="Indicadores núcleo">
<HeatmapWidget surveyId={surveyId} filters={filters} /> <TalentoRiesgoCard surveyId={surveyId} filters={filters} />
<PiramideDemografica surveyId={surveyId} filters={filters} /> <PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
</section> <PuntoCiegoCard surveyId={surveyId} filters={filters} />
</section>
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */} {/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
<section className="db-grid db-section" aria-label="Apoyos e impacto"> <section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
<ApoyosBar surveyId={surveyId} filters={filters} /> <SenalesImpactoBar surveyId={surveyId} filters={filters} />
<IntensidadProductividad surveyId={surveyId} filters={filters} /> <ApoyosBarME surveyId={surveyId} filters={filters} />
</section> </section>
{/* Fila 5: Saturación del cuidador (12) */} {/* Fila 3: segmentación (12, SEC) */}
<section className="db-grid db-section" aria-label="Saturación del cuidador"> <section className="db-grid db-section" aria-label="Segmentación de la carga">
<TablaSaturacion surveyId={surveyId} filters={filters} /> <SegmentacionBar surveyId={surveyId} filters={filters} />
</section> </section>
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
<section className="db-grid db-section" aria-label="Disposición a participar">
<DisposicionRedCard surveyId={surveyId} filters={filters} />
</section>
<p className="db-table-note" style={{ textAlign: 'right' }}>
k5 aplicado · segmentos con n&lt;5 suprimidos
</p>
</>
)}
{lens === 'pais' && (
<PlaceholderLens
title="Mirada País"
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
/>
)}
{lens === 'humana' && (
<PlaceholderLens
title="Mirada Humana"
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
/>
)}
</div> </div>
</div> </div>
) )

View File

@@ -63,7 +63,11 @@ body {
.skip-link:focus { left: 4px; } .skip-link:focus { left: 4px; }
/* ── Screen reader only ── */ /* ── Screen reader only ── */
.sr-only { /* El legend/label de los widgets dentro del wizard se oculta visualmente —
el enunciado ya se muestra una sola vez en .wizard-prompt-block. Se deja
el texto accesible (nombre del fieldset/label) en vez de quitarlo. */
.sr-only,
.wizard-answer-area .question-legend {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
@@ -285,6 +289,36 @@ body {
box-shadow: 0 0 0 3px rgba(37,99,235,0.15); box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
} }
/* ── Barrio autocomplete ── */
.barrio-autocomplete-wrapper { position: relative; }
.barrio-autocomplete-list {
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
position: fixed;
background: var(--color-surface);
border: 1.5px solid var(--color-border-focus);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.10);
list-style: none;
margin: 0;
padding: 4px 0;
max-height: 220px;
overflow-y: auto;
z-index: 30;
}
.barrio-autocomplete-item {
padding: 9px 12px;
cursor: pointer;
font-size: 0.95rem;
color: var(--color-text);
transition: background 0.1s;
}
.barrio-autocomplete-item:hover,
.barrio-autocomplete-item--active {
background: var(--color-primary-light);
color: var(--color-primary);
}
/* ── Textarea ── */ /* ── Textarea ── */
.text-area { .text-area {
display: block; display: block;
@@ -586,15 +620,26 @@ body {
margin: 0; margin: 0;
} }
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo. */ /* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo.
flex: 0 1 auto (NO flex:1) — mide según su contenido, nunca se estira a llenar el alto
disponible. Si creciera con flex:1, una pregunta corta (3 opciones) dejaría la caja con
espacio vacío abajo y el fade ::after (sticky bottom:0) quedaría flotando justo después
del contenido real, lejos del borde inferior visual de la caja — en vez de pegado a él. */
.wizard-answer-area { .wizard-answer-area {
flex: 1; flex: 0 1 auto;
min-height: 0; min-height: 0;
max-height: 360px; max-height: 480px;
overflow-y: auto; overflow-y: auto;
padding-bottom: 8px; padding-bottom: 8px;
} }
.wizard-answer-area::after { .wizard-answer-area.has-overflow { padding-bottom: 44px; }
/* El fade SOLO se pinta cuando .wizard-answer-area mide overflow real
(clase has-overflow agregada por JS — ver wizard-form.tsx). CSS no tiene
forma de detectar scrollHeight > clientHeight por sí solo: un ::after
incondicional (o un mask-image fijo) se pinta igual sobre el final de la
caja aunque el contenido entre completo, mostrando una franja/degradado
fantasma en preguntas cortas. */
.wizard-answer-area.has-overflow::after {
content: ''; content: '';
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -627,7 +672,7 @@ body {
@media (max-width: 480px) { @media (max-width: 480px) {
.wizard-prompt-text { font-size: 1.1875rem; } .wizard-prompt-text { font-size: 1.1875rem; }
.wizard-section-title { font-size: 1.5rem; } .wizard-section-title { font-size: 1.5rem; }
.wizard-answer-area { max-height: 320px; } .wizard-answer-area { max-height: 400px; }
} }
/* ── Loading skeleton ── */ /* ── Loading skeleton ── */
@@ -694,27 +739,27 @@ body {
.survey-footer-logo { height: 32px; width: auto; display: block; } .survey-footer-logo { height: 32px; width: auto; display: block; }
/* ══════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════
DASHBOARD — tema oscuro premium DASHBOARD — tema claro (marca Re)
Fondo: #0F1923 · Widgets: #1A2535 · Teal: #43BBC8 Fondo: #E7E6E5 · Widgets: #FFFFFF · Teal: #43BBC8
══════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════ */
.db-page { .db-page {
display: flex; min-height: 100vh; display: flex; min-height: 100vh;
background: #0F1923; color: #E7E6E5; background: #E7E6E5; color: #494F4F;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
} }
.db-main { flex: 1; padding: 24px; overflow-x: hidden; } .db-main { flex: 1; padding: 24px; overflow-x: hidden; }
.db-page-header { margin-bottom: 20px; } .db-page-header { margin-bottom: 20px; }
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; } .db-page-title { font-size: 1.375rem; font-weight: 700; color: #494F4F; margin: 0 0 4px; }
.db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; } .db-page-subtitle { font-size: 0.8125rem; color: #6B7878; margin: 0; }
.db-active-filter { color: var(--color-re-teal); font-weight: 600; } .db-active-filter { color: var(--color-re-teal); font-weight: 600; }
.db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } .db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.db-survey-link { .db-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px; font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid rgba(67,187,200,0.3); color: #9ca3af; text-decoration: none; border: 1px solid rgba(67,187,200,0.3); color: #6B7878; text-decoration: none;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.db-survey-link:hover { color: #E7E6E5; border-color: var(--color-re-teal); } .db-survey-link:hover { color: #494F4F; border-color: var(--color-re-teal); }
.db-survey-link-active { background: var(--color-re-teal); color: #0F1923; border-color: var(--color-re-teal); } .db-survey-link-active { background: var(--color-re-teal); color: #FFFFFF; border-color: var(--color-re-teal); }
.db-section { margin-bottom: 16px; } .db-section { margin-bottom: 16px; }
.db-grid { .db-grid {
display: grid; display: grid;
@@ -724,7 +769,7 @@ body {
/* ── Widget shell ── */ /* ── Widget shell ── */
.db-widget { .db-widget {
background: #1A2535; background: #FFFFFF;
border: 1px solid rgba(67,187,200,0.2); border: 1px solid rgba(67,187,200,0.2);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
@@ -735,24 +780,24 @@ body {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px 8px; padding: 12px 16px 8px;
} }
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #E7E6E5; margin: 0; } .db-widget-title { font-size: 0.875rem; font-weight: 600; color: #494F4F; margin: 0; }
.db-widget-n { font-size: 0.75rem; color: #9ca3af; } .db-widget-n { font-size: 0.75rem; color: #6B7878; }
.db-widget-body { padding: 0 16px 16px; } .db-widget-body { padding: 0 16px 16px; }
/* ── Suppressed / loading states ── */ /* ── Suppressed / loading states ── */
.db-suppressed { .db-suppressed {
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100px; text-align: center; color: #9ca3af; gap: 6px; min-height: 100px; text-align: center; color: #6B7878; gap: 6px;
} }
.db-suppressed-icon { font-size: 1.5rem; } .db-suppressed-icon { font-size: 1.5rem; }
.db-suppressed-hint { font-size: 0.75rem; color: #6b7280; } .db-suppressed-hint { font-size: 0.75rem; color: #9CA8A8; }
.db-suppressed-card { .db-suppressed-card {
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; color: #6b7280; gap: 4px; color: #9CA8A8;
} }
.db-skeleton-block { background: #1e2d42; border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; } .db-skeleton-block { background: rgba(73,79,79,0.08); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
.db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; } .db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; }
.db-loading-page { padding: 24px; background: #0F1923; min-height: 100vh; } .db-loading-page { padding: 24px; background: #E7E6E5; min-height: 100vh; }
/* ── KPI row ── */ /* ── KPI row ── */
.db-kpi-row { .db-kpi-row {
@@ -760,36 +805,36 @@ body {
padding: 0; padding: 0;
} }
.db-kpi-card { .db-kpi-card {
background: #1A2535; border: 1px solid rgba(67,187,200,0.2); background: #FFFFFF; border: 1px solid rgba(67,187,200,0.2);
border-radius: 10px; padding: 16px; text-align: center; border-radius: 10px; padding: 16px; text-align: center;
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
transition: box-shadow 0.3s; transition: box-shadow 0.3s;
} }
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); } .db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
/* Título de KPI: mayúsculas, bold, siempre visible */ /* 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; } .db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #494F4F; text-transform: uppercase; letter-spacing: 0.04em; }
/* Subtítulo de KPI: código de pregunta + descripción */ /* 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-subtitle { font-size: 0.6875rem; color: #8A9A9A; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
.db-kpi-value { .db-kpi-value {
font-size: 2rem; font-weight: 700; color: var(--color-re-teal); font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
font-variant-numeric: tabular-nums; margin-top: 4px; font-variant-numeric: tabular-nums; margin-top: 4px;
} }
.db-kpi-value-suppressed { font-size: 1.25rem; color: #6b7280; } .db-kpi-value-suppressed { font-size: 1.25rem; color: #9CA8A8; }
.db-kpi-note { font-size: 0.75rem; color: #9ca3af; } .db-kpi-note { font-size: 0.75rem; color: #6B7878; }
.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; } .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 */ /* Compat — se puede quitar si ya no se usa */
.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; } .db-kpi-label { font-size: 0.8125rem; color: #494F4F; }
.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; } .db-kpi-sub { font-size: 0.75rem; color: #6B7878; }
/* Subtítulo de widget-shell */ /* Subtítulo de widget-shell */
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; } .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; } .db-widget-subtitle { font-size: 0.75rem; color: #8A9A9A; font-weight: 400; margin: 0; line-height: 1.4; }
/* ── Mapa tile ── */ /* ── Mapa tile ── */
.db-map-wrapper { position: relative; } .db-map-wrapper { position: relative; }
.db-map-tooltip { .db-map-tooltip {
position: absolute; top: -28px; left: 50%; transform: translateX(-50%); position: absolute; top: -28px; left: 50%; transform: translateX(-50%);
background: #0F1923; color: #E7E6E5; font-size: 0.75rem; background: #E7E6E5; color: #494F4F; font-size: 0.75rem;
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3); padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3);
white-space: nowrap; z-index: 10; pointer-events: none; white-space: nowrap; z-index: 10; pointer-events: none;
} }
@@ -824,13 +869,13 @@ body {
.db-gauge-wrapper { text-align: center; } .db-gauge-wrapper { text-align: center; }
.db-gauge-svg { width: 100%; max-width: 160px; } .db-gauge-svg { width: 100%; max-width: 160px; }
.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; } .db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; }
.db-gauge-hint { font-size: 0.7rem; color: #6b7280; margin: 0; } .db-gauge-hint { font-size: 0.7rem; color: #9CA8A8; margin: 0; }
/* ── Bar chart ── */ /* ── Bar chart ── */
.db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.db-bar-item { display: flex; align-items: center; gap: 8px; } .db-bar-item { display: flex; align-items: center; gap: 8px; }
.db-bar-label { font-size: 0.75rem; color: #E7E6E5; width: 180px; flex-shrink: 0; line-height: 1.3; } .db-bar-label { font-size: 0.75rem; color: #494F4F; width: 180px; flex-shrink: 0; line-height: 1.3; }
.db-bar-track { flex: 1; background: #2a3547; border-radius: 4px; height: 14px; overflow: hidden; } .db-bar-track { flex: 1; background: rgba(73,79,79,0.10); border-radius: 4px; height: 14px; overflow: hidden; }
.db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; } .db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; } .db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; }
@@ -838,39 +883,39 @@ body {
.db-heatmap-scroll { overflow-x: auto; } .db-heatmap-scroll { overflow-x: auto; }
.db-heatmap-grid { display: grid; gap: 2px; } .db-heatmap-grid { display: grid; gap: 2px; }
.db-heatmap-header { .db-heatmap-header {
font-size: 0.625rem; color: #9ca3af; text-align: center; padding: 2px; font-size: 0.625rem; color: #6B7878; text-align: center; padding: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.db-heatmap-rowlabel { font-size: 0.625rem; color: #9ca3af; padding: 2px 4px; line-height: 1.2; } .db-heatmap-rowlabel { font-size: 0.625rem; color: #6B7878; padding: 2px 4px; line-height: 1.2; }
.db-heatmap-cell { .db-heatmap-cell {
border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s; font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s;
} }
.db-heatmap-cell:hover { filter: brightness(1.2); } .db-heatmap-cell:hover { filter: brightness(1.2); }
.db-hexbin-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 8px; } .db-hexbin-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 8px; }
/* ── Pirámide ── */ /* ── Pirámide ── */
.db-piramide { display: flex; flex-direction: column; gap: 6px; } .db-piramide { display: flex; flex-direction: column; gap: 6px; }
.db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } .db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.db-piramide-row { display: flex; align-items: center; gap: 6px; } .db-piramide-row { display: flex; align-items: center; gap: 6px; }
.db-piramide-label { font-size: 0.7rem; color: #9ca3af; width: 90px; flex-shrink: 0; text-align: right; } .db-piramide-label { font-size: 0.7rem; color: #6B7878; width: 90px; flex-shrink: 0; text-align: right; }
.db-piramide-bars { flex: 1; display: flex; gap: 2px; } .db-piramide-bars { flex: 1; display: flex; gap: 2px; }
.db-piramide-bar-wrap { display: flex; flex-direction: column; } .db-piramide-bar-wrap { display: flex; flex-direction: column; }
.db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; } .db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; }
.db-piramide-n { font-size: 0.7rem; color: #9ca3af; width: 24px; text-align: right; } .db-piramide-n { font-size: 0.7rem; color: #6B7878; width: 24px; text-align: right; }
/* ── Tabla riesgo ── */ /* ── Tabla riesgo ── */
.db-table-wrapper { overflow-x: auto; } .db-table-wrapper { overflow-x: auto; }
.db-table-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 10px; } .db-table-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 10px; }
.db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; } .db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.db-table th { color: #9ca3af; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; } .db-table th { color: #6B7878; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #E7E6E5; } .db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #494F4F; }
.db-table tr:last-child td { border-bottom: none; } .db-table tr:last-child td { border-bottom: none; }
.db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; } .db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; }
/* ── Sidebar ── */ /* ── Sidebar ── */
.db-sidebar { .db-sidebar {
width: 220px; flex-shrink: 0; background: #14202e; width: 220px; flex-shrink: 0; background: #F0EFEE;
border-right: 1px solid rgba(67,187,200,0.15); border-right: 1px solid rgba(67,187,200,0.15);
padding: 12px; transition: width 0.3s ease; padding: 12px; transition: width 0.3s ease;
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;
@@ -883,12 +928,12 @@ body {
} }
.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } .db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.db-filter-group { display: flex; flex-direction: column; gap: 4px; } .db-filter-group { display: flex; flex-direction: column; gap: 4px; }
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; } .db-filter-label { font-size: 0.75rem; font-weight: 600; color: #6B7878; text-transform: uppercase; letter-spacing: 0.05em; }
.db-filter-select { .db-filter-select {
background: #1e2d42; border: 1px solid #2a3547; color: #E7E6E5; background: #FFFFFF; border: 1px solid #2a3547; color: #494F4F;
border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%; border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%;
} }
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #E7E6E5; cursor: pointer; } .db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #494F4F; cursor: pointer; }
.db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; } .db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; }
.db-filter-reset { .db-filter-reset {
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3); background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
@@ -898,6 +943,56 @@ body {
} }
.db-filter-reset:hover { background: rgba(67,187,200,0.2); } .db-filter-reset:hover { background: rgba(67,187,200,0.2); }
/* ── Tres miradas: tabs ── */
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-tab {
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #6B7878;
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
}
.db-tab:hover { color: #494F4F; }
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
/* ── Tres miradas: intro de cada lente ── */
.db-lens-intro {
background: #F0EFEE; border-left: 3px solid var(--color-re-teal);
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
font-size: 0.8125rem; color: #494F4F; line-height: 1.5;
}
/* ── Card destacada (estrella) ── */
.db-widget-star {
border-color: var(--color-re-teal);
background: linear-gradient(135deg, #dff4f5, #ffffff);
}
.db-widget-star .db-widget-title { color: var(--color-re-teal); }
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
.db-big-stat-warn { color: #f0b429; }
.db-big-stat-danger { color: #f06b6b; }
.db-big-stat-sub { font-size: 0.75rem; color: #8A9A9A; margin-top: 6px; line-height: 1.4; }
/* ── Punto ciego: contraste de dos barras ── */
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
.db-gap-col { text-align: center; }
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
.db-gap-lab { font-size: 0.6875rem; color: #8A9A9A; margin-top: 6px; }
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #9A7200; margin-top: 10px; }
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
.db-placeholder {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; min-height: 220px; text-align: center; color: #8A9A9A;
background: #F0EFEE; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
padding: 32px 24px;
}
.db-placeholder-icon { font-size: 1.75rem; }
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
/* ── Admin: control de respuestas (Etapa 3V) ── */ /* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; } .admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header { .admin-header {

View File

@@ -0,0 +1,52 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type ApoyosMEData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function ApoyosBarME({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ApoyosMEData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meApoyos(surveyId, filters)
.then((d) => setData(d as ApoyosMEData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as ApoyosMEData) : null
return (
<WidgetShell
title="Apoyos más demandados"
subtitle="9.2 · qué beneficios diseñar (máx. 3 opciones por persona)"
status={status}
n={ok?.n}
colSpan={6}
>
{ok && (
<ul className="db-bar-list" aria-label="Apoyos más demandados">
{ok.apoyos.length === 0 && (
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n&lt;5 en todos).</li>
)}
{ok.apoyos.map((item) => (
<li key={item.apoyo} className="db-bar-item">
<span className="db-bar-label">{item.apoyo}</span>
<div className="db-bar-track">
<div className="db-bar-fill" style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
</div>
<span className="db-bar-pct">{item.pct}%</span>
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -1,42 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type ApoyosData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
export function ApoyosBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ApoyosData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.apoyos(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ApoyosData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
return (
<WidgetShell title="Apoyos más demandados" subtitle="6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían" status={status} n={data?.n_total} colSpan={6}>
{data && (
<ul className="db-bar-list" aria-label="Apoyos más demandados">
{data.data.length === 0 && (
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n&lt;5 en todos).</li>
)}
{data.data.map(item => (
<li key={item.politica} className="db-bar-item">
<span className="db-bar-label">{item.politica}</span>
<div className="db-bar-track" aria-hidden="true">
<div className="db-bar-fill"
style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
</div>
<span className="db-bar-pct">{item.pct}%</span>
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,53 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type DisposicionRedData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function DisposicionRedCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<DisposicionRedData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meDisposicionRed(surveyId, filters)
.then((d) => setData(d as DisposicionRedData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
// Esta RPC tiene su propio shape de "suppressed" (n_grupo_apoyo/n_voluntariado,
// no min_n/n genérico) — isSuppressed() solo mira `status`, sigue funcionando.
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as DisposicionRedData) : null
return (
<WidgetShell
title="Disposición a red de apoyo"
subtitle="9.3 (grupo de apoyo) + 9.4 (voluntariado)"
status={status}
colSpan={4}
>
{ok && (
<div style={{ display: 'flex', gap: 20 }}>
<div style={{ flex: 1, textAlign: 'center' }}>
<div className="db-big-stat" style={{ fontSize: '1.625rem' }}>
{ok.pct_grupo_apoyo !== null ? `${ok.pct_grupo_apoyo}%` : 'n<5'}
</div>
<p className="db-big-stat-sub">grupo de apoyo (n={ok.n_grupo_apoyo})</p>
</div>
<div style={{ flex: 1, textAlign: 'center' }}>
<div className="db-big-stat" style={{ fontSize: '1.625rem' }}>
{ok.pct_voluntariado !== null ? `${ok.pct_voluntariado}%` : 'n<5'}
</div>
<p className="db-big-stat-sub">voluntariado (n={ok.n_voluntariado})</p>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,81 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type DistribucionData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const SEGMENTS = [
{ key: 'disc_propia', label: 'Discapacidad propia', color: '#43BBC8' },
{ key: 'familiar', label: 'Familiar con disc.', color: '#F8AD13' },
{ key: 'adulto_mayor', label: 'Adulto mayor', color: '#EE761A' },
{ key: 'ninguno', label: 'Sin rol de cuidado', color: '#3d506b' },
] as const
export function DonutCuidados({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<DistribucionData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.distribucion(surveyId, filters)
.then(d => {
if (isSuppressed(d)) setSup(true)
else setData(d as DistribucionData)
}).catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
// SVG donut
const R = 60; const cx = 75; const cy = 75; const stroke = 22
let offset = 0
const circumference = 2 * Math.PI * R
const arcs = data ? SEGMENTS.map(s => {
const pct = (data[s.key as keyof DistribucionData] as number) ?? 0
const dash = (pct / 100) * circumference
const gap = circumference - dash
const arc = { ...s, dash, gap, offset: circumference * (1 - offset / 100) }
offset += pct
return arc
}) : []
return (
<WidgetShell title="Perfil de la población" subtitle="1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-donut-wrapper">
<svg viewBox="0 0 150 150" className="db-donut-svg" aria-hidden="true">
{arcs.map(arc => (
<circle key={arc.key}
cx={cx} cy={cy} r={R}
fill="none"
stroke={arc.color}
strokeWidth={stroke}
strokeDasharray={`${arc.dash} ${arc.gap}`}
strokeDashoffset={arc.offset}
transform="rotate(-90, 75, 75)"
/>
))}
<text x={cx} y={cy-6} textAnchor="middle" fill="white" fontSize="14" fontWeight="bold">
{data.disc_propia + data.familiar + data.adulto_mayor > 0
? `${Math.round(data.disc_propia + data.familiar + data.adulto_mayor)}%`
: '0%'}
</text>
<text x={cx} y={cy+12} textAnchor="middle" fill="#9ca3af" fontSize="8">cuidadores</text>
</svg>
<ul className="db-donut-legend" aria-label="Leyenda del donut">
{SEGMENTS.map(s => (
<li key={s.key} className="db-legend-item">
<span className="db-legend-dot" style={{ background: s.color }} />
<span>{s.label}</span>
<span className="db-legend-val">
{(data[s.key as keyof DistribucionData] as number ?? 0)}%
</span>
</li>
))}
</ul>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,61 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type ScoreData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const ZONA_COLOR = { baja: '#ef4444', desarrollo: '#F8AD13', consciente: '#43BBC8' }
const ZONA_LABEL = { baja: 'Baja madurez', desarrollo: 'En desarrollo', consciente: 'Consciente' }
export function GaugeOrganizacional({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<ScoreData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.score(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ScoreData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
// SVG semicircle gauge
const score = data?.score ?? 0
const angle = (score / 100) * 180 // 0° = left, 180° = right
const rad = (angle - 90) * Math.PI / 180
const r = 60; const cx = 75; const cy = 80
const needleX = cx + r * Math.cos(rad)
const needleY = cy + r * Math.sin(rad)
const color = data ? ZONA_COLOR[data.zona] : '#43BBC8'
return (
<WidgetShell title="Madurez organizacional" subtitle="6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0100" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-gauge-wrapper">
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>
{/* Fondo semicírculo */}
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none" stroke="#2a3547" strokeWidth="18" strokeLinecap="round" />
{/* Arco coloreado proporcional al score */}
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none"
stroke={color} strokeWidth="18" strokeLinecap="round"
strokeDasharray={`${(score/100)*188.5} 188.5`} />
{/* Aguja */}
<line x1={cx} y1={cy} x2={needleX} y2={needleY} stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx={cx} cy={cy} r={4} fill="white" />
{/* Score */}
<text x={cx} y={cy-10} textAnchor="middle" fill="white" fontSize="18" fontWeight="bold">{score}</text>
<text x={cx} y={cy+6} textAnchor="middle" fill="#9ca3af" fontSize="7">/ 100</text>
</svg>
<p className="db-gauge-zona" style={{ color }}>
{ZONA_LABEL[data.zona]}
</p>
<p className="db-gauge-hint">
033 baja · 3466 desarrollo · 67100 consciente
</p>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,70 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type HeatmapData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
import { HORAS_ORDER, AUSENCIAS_ORDER } from '@/lib/dashboard-utils'
const SHORT_AUSENCIAS = ['Nunca','Pocas (1-3)','Varias (4-10)','Con freq.']
export function HeatmapWidget({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<HeatmapData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.heatmap(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as HeatmapData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
function cellColor(n: number | null): string {
if (n === null) return '#1e2d42'
const max = Math.max(...(data?.data.map(c => c.n ?? 0) ?? [1]), 1)
const t = n / max
return `rgba(67, 187, 200, ${0.15 + t * 0.85})`
}
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.ausencias}`, c]))
return (
<WidgetShell title="Horas de cuidado × Frecuencia de ausentismo" subtitle="2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-heatmap-scroll" role="table" aria-label="Heatmap de intensidad">
<div className="db-heatmap-grid"
style={{ gridTemplateColumns: `120px repeat(${AUSENCIAS_ORDER.length}, 1fr)` }}>
{/* Header row */}
<div role="columnheader" />
{SHORT_AUSENCIAS.map(h => (
<div key={h} className="db-heatmap-header" role="columnheader">{h}</div>
))}
{/* Data rows */}
{HORAS_ORDER.map(horas => (
<>
<div key={`row-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
{AUSENCIAS_ORDER.map(aus => {
const cell = cellMap.get(`${horas}|${aus}`)
const n = cell?.n ?? null
const sup2 = cell?.suppressed ?? (!cell)
return (
<div key={`${horas}|${aus}`}
className="db-heatmap-cell"
style={{ background: cellColor(n) }}
role="cell"
aria-label={`${horas} / ${aus}: ${sup2 ? 'suprimido' : `n=${n}`}`}
title={sup2 ? 'Datos insuficientes' : `n = ${n}`}>
{!sup2 && n !== null && <span>{n}</span>}
</div>
)
})}
</>
))}
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,75 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type IntensidadData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
import { HORAS_ORDER } from '@/lib/dashboard-utils'
// Hexbin implementado como CSS Grid con celdas coloreadas por densidad.
// NO es scatter de individuos — cada celda es un agregado con N≥5.
const BIENESTAR_BINS = ['1','2','3','4','5']
export function IntensidadProductividad({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<IntensidadData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.intensidad(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as IntensidadData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.bienestar}`, c]))
function cellColor(n: number | null): string {
if (n === null) return '#1e2d42'
const t = n / maxN
return `rgba(67, 187, 200, ${0.1 + t * 0.9})`
}
return (
<WidgetShell title="Intensidad de cuidado vs. Bienestar" subtitle="2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-hexbin-wrapper">
<p className="db-hexbin-note">
Cada celda = respondentes agregados. Celdas sin color: N &lt; 5 (suprimido).
</p>
<div className="db-heatmap-scroll">
<div className="db-heatmap-grid"
style={{ gridTemplateColumns: `120px repeat(${BIENESTAR_BINS.length}, 1fr)` }}>
<div role="columnheader" />
{BIENESTAR_BINS.map(b => (
<div key={b} className="db-heatmap-header" role="columnheader">Bienestar {b}</div>
))}
{HORAS_ORDER.map(horas => (
<>
<div key={`r-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
{BIENESTAR_BINS.map(bien => {
const cell = cellMap.get(`${horas}|${bien}`)
const n = cell?.n ?? null
const suppressed = cell?.suppressed ?? true
return (
<div key={`${horas}|${bien}`}
className="db-heatmap-cell"
style={{ background: cellColor(n) }}
role="cell"
title={suppressed ? 'Datos insuficientes' : `n = ${n}`}
aria-label={suppressed ? 'suprimido' : `n=${n}`}>
{!suppressed && n !== null && <span>{n}</span>}
</div>
)
})}
</>
))}
</div>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -1,96 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type KpiData, type SuppressedPayload } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
const KPI_DEFS = [
{
title: 'CUIDADORES ACTIVOS',
subtitle: '1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros',
getValue: (d: KpiData) => `${d.pct_cuidadores}%`,
getN: (d: KpiData) => `n = ${d.n}`,
},
{
title: 'FRECUENCIA DE AUSENTISMO',
subtitle: '5.2 · % con ausencias laborales recurrentes (4 o más veces al año)',
getValue: (d: KpiData) => `${d.pct_ausencias}%`,
getN: () => 'ausencias frecuentes',
},
{
title: 'BIENESTAR',
subtitle: '4.3 · autorreporte de bienestar general · escala 15',
getValue: (d: KpiData) => d.score_bienestar !== null ? d.score_bienestar.toFixed(1) : '—',
getN: () => '/ 5',
},
] as const
interface KpiCardProps {
title: string
subtitle: string
value: string
valueNote: string
suppressed: boolean
loading: boolean
}
function KpiCard({ title, subtitle, value, valueNote, suppressed, loading }: KpiCardProps) {
return (
<div className="db-kpi-card">
<span className="db-kpi-title">{title}</span>
<span className="db-kpi-subtitle">{subtitle}</span>
{loading && (
<div className="db-skeleton-block" style={{ height: 36, margin: '10px 0 4px', borderRadius: 6 }} />
)}
{!loading && suppressed && (
<div className="db-kpi-suppressed-block" role="status">
<span className="db-suppressed-icon" aria-hidden="true">🔒</span>
<span className="db-kpi-value db-kpi-value-suppressed">n &lt; 5</span>
</div>
)}
{!loading && !suppressed && (
<>
<span className="db-kpi-value" aria-live="polite">{value}</span>
<span className="db-kpi-note">{valueNote}</span>
</>
)}
</div>
)
}
interface Props { surveyId: string; filters: DashboardFilters }
export function KpiCards({ surveyId, filters }: Props) {
const [data, setData] = useState<KpiData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.kpis(surveyId, filters)
.then(d => setData(d as KpiData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
if (error) return <p className="db-error">Error al cargar KPIs</p>
const loading = data === null
const suppressed = !loading && isSuppressed(data)
const ok = !loading && !suppressed
return (
<div className="db-kpi-row">
{KPI_DEFS.map((def) => (
<KpiCard
key={def.title}
title={def.title}
subtitle={def.subtitle}
value={ok ? def.getValue(data as KpiData) : '—'}
valueNote={ok ? def.getN(data as KpiData) : ''}
suppressed={suppressed}
loading={loading}
/>
))}
</div>
)
}

View File

@@ -1,111 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type PrevalenciaData, type SuppressedPayload } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
// Tile-map de Paraguay (fallback aprobado en el prompt cuando no se consigue SVG fiel)
// col/row en grilla 5×6
const TILES = [
{ name: 'Alto Paraguay', col: 2, row: 0 },
{ name: 'Boquerón', col: 0, row: 1 },
{ name: 'Presidente Hayes', col: 1, row: 1 },
{ name: 'Concepción', col: 2, row: 1 },
{ name: 'San Pedro', col: 3, row: 1 },
{ name: 'Amambay', col: 4, row: 1 },
{ name: 'Asunción (Capital)', col: 1, row: 2 },
{ name: 'Cordillera', col: 2, row: 2 },
{ name: 'Caaguazú', col: 3, row: 2 },
{ name: 'Canindeyú', col: 4, row: 2 },
{ name: 'Central', col: 1, row: 3 },
{ name: 'Guairá', col: 2, row: 3 },
{ name: 'Alto Paraná', col: 4, row: 3 },
{ name: 'Ñeembucú', col: 0, row: 4 },
{ name: 'Misiones', col: 1, row: 4 },
{ name: 'Paraguarí', col: 2, row: 4 },
{ name: 'Caazapá', col: 3, row: 4 },
{ name: 'Itapúa', col: 2, row: 5 },
]
function lerp(t: number, a: string, b: string): string {
// simple teal interpolation: #E7E6E5 (light) → #43BBC8 (teal)
const parseHex = (h: string) => [
parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)
]
const ca = parseHex(a); const cb = parseHex(b)
const r = Math.round(ca[0] + (cb[0]-ca[0])*t)
const g = Math.round(ca[1] + (cb[1]-ca[1])*t)
const bv= Math.round(ca[2] + (cb[2]-ca[2])*t)
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${bv.toString(16).padStart(2,'0')}`
}
interface Props { surveyId: string; filters: DashboardFilters; onDeptoClick: (d: string) => void }
export function ParaguayMap({ surveyId, filters, onDeptoClick }: Props) {
const [data, setData] = useState<PrevalenciaData | SuppressedPayload | null>(null)
const [tooltip, setTooltip] = useState<{ name: string; pct: number | null; n: number | null } | null>(null)
useEffect(() => {
setData(null)
queries.prevalencia(surveyId, filters)
.then(d => setData(d as PrevalenciaData | SuppressedPayload))
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const deptoMap = new Map(
(!data || isSuppressed(data)) ? [] :
(data as PrevalenciaData).data.map(d => [d.depto, d])
)
const status = !data ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined
return (
<WidgetShell title="Prevalencia de cuidadores por departamento" subtitle="A1 · % cuidadores activos por departamento" status={status} n={n} colSpan={6}>
<div className="db-map-wrapper" role="img" aria-label="Mapa de Paraguay con prevalencia de cuidadores por departamento">
{tooltip && (
<div className="db-map-tooltip" aria-live="polite">
<strong>{tooltip.name}</strong>
{tooltip.pct !== null
? <> · {tooltip.pct}% · n={tooltip.n}</>
: <> · Datos insuficientes</>}
</div>
)}
<div className="db-tile-grid">
{TILES.map(tile => {
const d = deptoMap.get(tile.name)
const suppressed = !d || d.suppressed
const pct = d?.pct_cuidadores ?? null
const color = suppressed ? '#2a3547' : lerp((pct ?? 0)/100, '#E7E6E5', '#43BBC8')
const isSelected = filters.depto === tile.name
return (
<button
key={tile.name}
className={`db-tile${isSelected ? ' db-tile-selected' : ''}${suppressed ? ' db-tile-suppressed' : ''}`}
style={{
gridColumn: tile.col + 1,
gridRow: tile.row + 1,
background: color,
}}
onClick={() => onDeptoClick(isSelected ? '' : tile.name)}
onMouseEnter={() => setTooltip({ name: tile.name, pct, n: d?.n ?? null })}
onMouseLeave={() => setTooltip(null)}
title={suppressed ? `${tile.name} — Datos insuficientes` : `${tile.name}${pct}% (n=${d?.n})`}
aria-label={suppressed
? `${tile.name}: datos insuficientes`
: `${tile.name}: ${pct}% cuidadores, n=${d?.n}`}
aria-pressed={isSelected}
>
<span className="db-tile-name">{tile.name.replace(' (Capital)','')}</span>
{!suppressed && pct !== null && (
<span className="db-tile-pct">{pct}%</span>
)}
</button>
)
})}
</div>
</div>
</WidgetShell>
)
}

View File

@@ -1,80 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type PiramideData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
import { ETARIO_ORDER } from '@/lib/dashboard-utils'
const GENERO_COLOR: Record<string, string> = {
'Femenino': '#EE761A',
'Masculino': '#43BBC8',
'No binario/Otro': '#F8AD13',
'Prefiero no decir':'#6b7280',
}
export function PiramideDemografica({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PiramideData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.piramide(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PiramideData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
const generos = ['Femenino','Masculino','No binario/Otro']
return (
<WidgetShell title="Pirámide demográfica" subtitle="A5 × A6 · rango etario por género (celdas con k≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-piramide">
<div className="db-piramide-legend" aria-label="Leyenda">
{generos.map(g => (
<span key={g} className="db-legend-item">
<span className="db-legend-dot" style={{ background: GENERO_COLOR[g] }} />
{g}
</span>
))}
</div>
{ETARIO_ORDER.filter(e => e !== 'Prefiero no decir').map(etario => {
const row = generos.map(gen => {
const cell = data.data.find(c => c.etario === etario && c.genero === gen)
return { gen, n: cell?.n ?? null, suppressed: cell?.suppressed ?? true }
})
return (
<div key={etario} className="db-piramide-row" role="row" aria-label={etario}>
<span className="db-piramide-label">{etario}</span>
<div className="db-piramide-bars">
{row.map(({ gen, n, suppressed }) => (
<div key={gen} className="db-piramide-bar-wrap"
style={{ flex: `0 0 ${Math.max(10, (n ?? 0) / maxN * 100)}%` }}>
<div className="db-piramide-bar"
style={{
width: suppressed ? '100%' : `${(n ?? 0) / maxN * 100}%`,
background: suppressed ? '#2a3547' : GENERO_COLOR[gen],
opacity: suppressed ? 0.4 : 1,
}}
title={suppressed ? 'Datos insuficientes' : `${gen}: n=${n}`}
role="cell"
aria-label={suppressed ? `${gen}: suprimido` : `${gen}: ${n}`}
/>
</div>
))}
</div>
<span className="db-piramide-n">
{row.every(r => r.suppressed) ? '🔒' : row.reduce((s, r) => s + (r.n ?? 0), 0)}
</span>
</div>
)
})}
</div>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type PrevalenciaCuidadoresData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function PrevalenciaCuidadoresCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PrevalenciaCuidadoresData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.mePrevalencia(surveyId, filters)
.then((d) => setData(d as PrevalenciaCuidadoresData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as PrevalenciaCuidadoresData) : null
return (
<WidgetShell
title="Cuidadores en la plantilla"
subtitle="4.1 · vínculo de cuidado activo (incluye hijo/a menor sin discapacidad — criterio UNFPA)"
status={status}
n={ok?.n}
colSpan={4}
>
{ok && (
<>
<div className="db-big-stat">{ok.pct_cuidadores}%</div>
<p className="db-big-stat-sub">tiene algún rol de cuidado</p>
</>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,73 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type PuntoCiegoData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
const MAX_BAR_HEIGHT = 70
export function PuntoCiegoCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PuntoCiegoData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.mePuntoCiego(surveyId, filters)
.then((d) => setData(d as PuntoCiegoData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as PuntoCiegoData) : null
const cargaHeight = ok ? Math.max(6, (ok.carga_real_pct / 100) * MAX_BAR_HEIGHT) : 0
const apoyoHeight =
ok && ok.apoyo_percibido_pct !== null ? Math.max(6, (ok.apoyo_percibido_pct / 100) * MAX_BAR_HEIGHT) : 0
return (
<WidgetShell
title="Punto ciego organizacional"
subtitle="9.1 vs. prevalencia real (4.1) — brecha entre carga y percepción"
status={status}
n={ok?.n}
colSpan={4}
>
{ok && (
<>
<div className="db-gap-viz">
<div className="db-gap-col">
<div className="db-gap-val" style={{ color: 'var(--color-re-teal)' }}>
{ok.carga_real_pct}%
</div>
<div className="db-gap-bar" style={{ height: cargaHeight, background: 'var(--color-re-teal)' }} />
<div className="db-gap-lab">carga real</div>
</div>
<div className="db-gap-col">
{ok.apoyo_percibido_pct !== null ? (
<>
<div className="db-gap-val" style={{ color: '#f06b6b' }}>
{ok.apoyo_percibido_pct}%
</div>
<div className="db-gap-bar" style={{ height: apoyoHeight, background: '#f06b6b' }} />
</>
) : (
<div className="db-gap-val" style={{ color: '#6b7280' }}>
n&lt;5
</div>
)}
<div className="db-gap-lab">se siente apoyado</div>
</div>
</div>
{ok.brecha_pp !== null && (
<p className="db-gap-brecha">Brecha: {ok.brecha_pp} puntos porcentuales</p>
)}
</>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,74 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type SegmentacionData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
export function SegmentacionBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<SegmentacionData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meSegmentacion(surveyId, filters)
.then((d) => setData(d as SegmentacionData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as SegmentacionData) : null
return (
<WidgetShell
title="Dónde se concentra la carga"
subtitle="4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad) — % de cuidadores por segmento"
status={status}
n={ok?.n}
colSpan={12}
>
{ok && (
<div className="db-grid" style={{ gap: '24px' }}>
<div style={{ gridColumn: 'span 6' }}>
<p className="db-widget-subtitle" style={{ marginBottom: 8 }}>Por nivel organizacional</p>
{ok.por_nivel.length === 0 && (
<p className="db-suppressed-hint">Ningún nivel alcanza n5 con este filtro.</p>
)}
<ul className="db-bar-list" aria-label="Cuidadores por nivel organizacional">
{ok.por_nivel.map((row) => (
<li key={row.nivel} className="db-bar-item">
<span className="db-bar-label">{row.nivel}</span>
<div className="db-bar-track">
<div className="db-bar-fill" style={{ width: `${row.pct}%`, background: '#5e7299' }} />
</div>
<span className="db-bar-pct">{row.pct}%</span>
</li>
))}
</ul>
</div>
<div style={{ gridColumn: 'span 6' }}>
<p className="db-widget-subtitle" style={{ marginBottom: 8 }}>Por modalidad de trabajo</p>
{ok.por_modalidad.length === 0 && (
<p className="db-suppressed-hint">Ninguna modalidad alcanza n5 con este filtro.</p>
)}
<ul className="db-bar-list" aria-label="Cuidadores por modalidad de trabajo">
{ok.por_modalidad.map((row) => (
<li key={row.modalidad} className="db-bar-item">
<span className="db-bar-label">{row.modalidad}</span>
<div className="db-bar-track">
<div className="db-bar-fill" style={{ width: `${row.pct}%`, background: '#5e7299' }} />
</div>
<span className="db-bar-pct">{row.pct}%</span>
</li>
))}
</ul>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type RiesgoRotacionData,
type AusentismoData,
type PresentismoData,
type CarreraCongeladaData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
type Riesgo = RiesgoRotacionData | SuppressedPayload
type Ausentismo = AusentismoData | SuppressedPayload
type Presentismo = PresentismoData | SuppressedPayload
type Carrera = CarreraCongeladaData | SuppressedPayload
interface Senal {
label: string
pct: number | null
color: 'rose' | 'amber'
}
// Combina E2 (riesgo) + E3 (ausentismo) + E4 (presentismo) — núcleo — y
// E5 (carrera congelada) — [SEC] — en una sola card de 4 barras, igual al
// mockup ("Señales de impacto laboral"). Cada señal se suprime POR FILA
// (n<5 individual) sin tumbar las demás; la card entera solo se suprime
// si las 4 vienen suprimidas a la vez.
export function SenalesImpactoBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [riesgo, setRiesgo] = useState<Riesgo | null>(null)
const [ausentismo, setAusentismo] = useState<Ausentismo | null>(null)
const [presentismo, setPresentismo] = useState<Presentismo | null>(null)
const [carrera, setCarrera] = useState<Carrera | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setRiesgo(null); setAusentismo(null); setPresentismo(null); setCarrera(null); setError(false)
Promise.all([
queries.meRiesgoRotacion(surveyId, filters),
queries.meAusentismo(surveyId, filters),
queries.mePresentismo(surveyId, filters),
queries.meCarreraCongelada(surveyId, filters),
])
.then(([r, a, p, c]) => {
setRiesgo(r as Riesgo)
setAusentismo(a as Ausentismo)
setPresentismo(p as Presentismo)
setCarrera(c as Carrera)
})
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const loading = riesgo === null || ausentismo === null || presentismo === null || carrera === null
const allSuppressed =
!loading &&
isSuppressed(riesgo) && isSuppressed(ausentismo) && isSuppressed(presentismo) && isSuppressed(carrera)
const status = error ? 'error' : loading ? 'loading' : allSuppressed ? 'suppressed' : 'ok'
const rows: Senal[] = !loading
? [
{
label: 'Presentismo',
pct: !isSuppressed(presentismo) ? presentismo.pct_presentismo : null,
color: 'amber',
},
{
label: 'Ausentismo frecuente',
pct: !isSuppressed(ausentismo) ? ausentismo.pct_ausentismo_recurrente : null,
color: 'amber',
},
{
label: 'Riesgo de salida',
pct: !isSuppressed(riesgo) ? riesgo.pct_riesgo : null,
color: 'rose',
},
{
label: 'Carrera congelada',
pct: !isSuppressed(carrera) ? carrera.pct_carrera_congelada : null,
color: 'rose',
},
]
: []
const repN = !loading && !isSuppressed(riesgo) ? riesgo.n_cuidadores : undefined
return (
<WidgetShell
title="Señales de impacto laboral"
subtitle="7.2 · 7.3 · 7.6 · 7.5 — sobre la población cuidadora"
status={status}
n={repN}
colSpan={6}
>
{!loading && (
<ul className="db-bar-list" aria-label="Señales de impacto laboral">
{rows.map((row) => (
<li key={row.label} className="db-bar-item">
<span className="db-bar-label">{row.label}</span>
{row.pct !== null ? (
<>
<div className="db-bar-track">
<div
className="db-bar-fill"
style={{ width: `${row.pct}%`, background: row.color === 'rose' ? '#f06b6b' : '#f0b429' }}
/>
</div>
<span className="db-bar-pct">{row.pct}%</span>
</>
) : (
<>
<div className="db-bar-track" />
<span className="db-bar-pct" style={{ color: '#6b7280' }}>
n&lt;5
</span>
</>
)}
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -1,67 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { isSuppressed, type SaturacionData } from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import type { DashboardFilters } from '@/lib/dashboard-types'
import { WidgetShell } from './widget-shell'
const SATURACION_COLOR: Record<string, string> = {
'Alta saturación': '#ef4444',
'Saturación media': '#F8AD13',
'Baja saturación': '#43BBC8',
}
export function TablaSaturacion({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<SaturacionData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.saturacion(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as SaturacionData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
return (
<WidgetShell title="Saturación del cuidador" subtitle="4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)" status={status} n={data?.n_total} colSpan={12}>
{data && (
<div className="db-table-wrapper">
<p className="db-table-note">
Alta: bienestar 2, rendimiento afectado casi todos los días y ausentismo frecuente.
Media: dos de los tres criterios. Baja: uno o ninguno.
</p>
<table className="db-table" aria-label="Tabla de saturación del cuidador">
<thead>
<tr>
<th>Segmento</th>
<th>N</th>
<th>% del total</th>
<th>Apoyo más demandado</th>
</tr>
</thead>
<tbody>
{data.segmentos.map(seg => (
<tr key={seg.segmento}>
<td>
<span className="db-risk-badge"
style={{ background: SATURACION_COLOR[seg.segmento] ?? '#6b7280' }}>
{seg.segmento}
</span>
</td>
<td>{seg.suppressed ? '—' : seg.n}</td>
<td>{seg.suppressed ? '—' : `${seg.pct}%`}</td>
<td>{seg.suppressed
? <span className="db-suppressed-hint">Datos insuficientes (n&lt;5)</span>
: seg.apoyo ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</WidgetShell>
)
}

View File

@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import {
isSuppressed,
type TalentoRiesgoData,
type SuppressedPayload,
type DashboardFilters,
} from '@/lib/dashboard-types'
import { queries } from '@/lib/dashboard-queries'
import { WidgetShell } from './widget-shell'
// Umbrales de color SOLO visuales (no son la definición de la métrica, que
// vive en la RPC vía p_umbral_senales) — afinar libremente si el director
// prefiere otros cortes.
function colorClass(pct: number): string {
if (pct >= 30) return ' db-big-stat-danger'
if (pct >= 15) return ' db-big-stat-warn'
return ''
}
export function TalentoRiesgoCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<TalentoRiesgoData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.meTalentoRiesgo(surveyId, filters)
.then((d) => setData(d as TalentoRiesgoData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const ok = status === 'ok' ? (data as TalentoRiesgoData) : null
return (
<WidgetShell
title="Talento en riesgo"
subtitle="7.6 + 7.3 + 7.5 · métrica compuesta sobre la población cuidadora"
status={status}
n={ok?.n_cuidadores}
colSpan={4}
variant="star"
>
{ok && (
<>
<div className={`db-big-stat${colorClass(ok.pct_talento_riesgo)}`}>
{ok.pct_talento_riesgo}%
</div>
<p className="db-big-stat-sub">
de la plantilla cuidadora cumple al menos {ok.umbral_senales} de 3 señales de fuga
(riesgo de salida, ausentismo recurrente, carrera congelada). Promedio:{' '}
{ok.criterios_promedio} señales por persona.
</p>
</>
)}
</WidgetShell>
)
}

View File

@@ -8,11 +8,16 @@ interface WidgetShellProps {
n?: number n?: number
children: React.ReactNode children: React.ReactNode
colSpan?: number colSpan?: number
/** 'star' resalta la card (borde + fondo distinto) para métricas destacadas. */
variant?: 'star'
} }
export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6 }: WidgetShellProps) { export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6, variant }: WidgetShellProps) {
return ( return (
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}> <div
className={`db-widget db-col-${colSpan}${variant === 'star' ? ' db-widget-star' : ''}`}
style={{ gridColumn: `span ${colSpan}` }}
>
<div className="db-widget-header"> <div className="db-widget-header">
<div className="db-widget-title-group"> <div className="db-widget-title-group">
<h3 className="db-widget-title">{title}</h3> <h3 className="db-widget-title">{title}</h3>

View File

@@ -1,15 +1,18 @@
'use client' 'use client'
import type { AnswerValue, Question, QuestionOption } from '@/lib/form-engine.types' import type { AnswerValue, FormAnswers, Question, QuestionOption } from '@/lib/form-engine.types'
import { RadioGroup } from './widgets/radio-group' import { RadioGroup } from './widgets/radio-group'
import { CheckboxGroup } from './widgets/checkbox-group' import { CheckboxGroup } from './widgets/checkbox-group'
import { TextAreaField } from './widgets/text-area-field' import { TextAreaField } from './widgets/text-area-field'
import { TextShortField } from './widgets/text-short-field' import { TextShortField } from './widgets/text-short-field'
import { BarrioAutocompleteField } from './widgets/barrio-autocomplete-field'
import { CiudadSelectField } from './widgets/ciudad-select-field'
interface QuestionRendererProps { interface QuestionRendererProps {
question: Question question: Question
value: AnswerValue value: AnswerValue
onChange: (value: AnswerValue) => void onChange: (value: AnswerValue) => void
error?: string error?: string
answers?: FormAnswers
} }
const YES_NO_OPTIONS: QuestionOption[] = [ const YES_NO_OPTIONS: QuestionOption[] = [
@@ -17,7 +20,7 @@ const YES_NO_OPTIONS: QuestionOption[] = [
{ value: 'false', label: 'No' }, { value: 'false', label: 'No' },
] ]
export function QuestionRenderer({ question, value, onChange, error }: QuestionRendererProps) { export function QuestionRenderer({ question, value, onChange, error, answers }: QuestionRendererProps) {
const { code, type, prompt, required, max_select, options } = question const { code, type, prompt, required, max_select, options } = question
const legend = ( const legend = (
<> <>
@@ -26,6 +29,19 @@ export function QuestionRenderer({ question, value, onChange, error }: QuestionR
) )
if (type === 'single_choice' && options) { if (type === 'single_choice' && options) {
if (code === '2.2') {
return (
<CiudadSelectField
name={code}
label={legend}
value={(value as string) ?? ''}
required={required}
departamento={(answers?.['2.1:default'] as string) ?? null}
onChange={onChange}
error={error}
/>
)
}
return ( return (
<RadioGroup <RadioGroup
name={code} name={code}
@@ -70,6 +86,20 @@ export function QuestionRenderer({ question, value, onChange, error }: QuestionR
} }
if (type === 'text_short') { if (type === 'text_short') {
if (code === '2.3') {
return (
<BarrioAutocompleteField
name={code}
label={legend}
value={(value as string) ?? ''}
required={required}
departamento={(answers?.['2.1:default'] as string) ?? null}
ciudad={(answers?.['2.2:default'] as string) ?? null}
onChange={onChange}
error={error}
/>
)
}
return ( return (
<TextShortField <TextShortField
name={code} name={code}

View File

@@ -0,0 +1,164 @@
'use client'
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
type GeoData = Record<string, Record<string, string[]>>
interface BarrioAutocompleteFieldProps {
name: string
label: ReactNode
value: string
required: boolean
departamento?: string | null
ciudad?: string | null
onChange: (value: string) => void
error?: string
}
export function BarrioAutocompleteField({
name,
label,
value,
required,
departamento,
ciudad,
onChange,
error,
}: BarrioAutocompleteFieldProps) {
const [geoData, setGeoData] = useState<GeoData | null>(null)
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
fetch('/data/geo_paraguay_full.json')
.then((r) => r.json())
.then(setGeoData)
.catch(() => {})
}, [])
// Close dropdown on scroll so the fixed-position list doesn't drift from the input
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
window.addEventListener('scroll', close, { passive: true, capture: true })
return () => window.removeEventListener('scroll', close, { capture: true })
}, [open])
function computeDropdownStyle() {
if (!inputRef.current) return
const rect = inputRef.current.getBoundingClientRect()
setDropdownStyle({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}
const barrios: string[] = (() => {
if (!geoData) return []
if (departamento && ciudad) {
return geoData[departamento]?.[ciudad] ?? []
}
if (departamento) {
return Object.values(geoData[departamento] ?? {}).flat()
}
return Object.values(geoData).flatMap((d) => Object.values(d).flat())
})()
const query = value.trim().toLowerCase()
const suggestions =
query.length >= 2
? barrios.filter((b) => b.toLowerCase().includes(query)).slice(0, 10)
: []
function handleInput(v: string) {
onChange(v)
computeDropdownStyle()
setOpen(true)
setActiveIdx(-1)
}
function select(barrio: string) {
onChange(barrio)
setOpen(false)
setActiveIdx(-1)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!open || suggestions.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx((i) => Math.max(i - 1, -1))
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault()
select(suggestions[activeIdx])
} else if (e.key === 'Escape') {
setOpen(false)
}
}
const listId = `${name}-suggestions`
return (
<div className="question-fieldset">
<label className="question-legend" htmlFor={name}>
{label}
{required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>}
</label>
<div className="barrio-autocomplete-wrapper">
<input
type="text"
id={name}
name={name}
value={value}
required={required}
aria-required={required}
aria-describedby={error ? `${name}-error` : undefined}
aria-invalid={!!error}
aria-autocomplete="list"
aria-controls={open && suggestions.length > 0 ? listId : undefined}
aria-activedescendant={activeIdx >= 0 ? `${name}-opt-${activeIdx}` : undefined}
autoComplete="off"
className="text-short-input"
ref={inputRef}
onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
onBlur={() => setTimeout(() => setOpen(false), 150)}
/>
{open && suggestions.length > 0 && (
<ul
id={listId}
role="listbox"
className="barrio-autocomplete-list"
aria-label="Sugerencias de barrios"
style={dropdownStyle}
>
{suggestions.map((barrio, i) => (
<li
key={barrio}
id={`${name}-opt-${i}`}
role="option"
aria-selected={i === activeIdx}
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
onMouseDown={() => select(barrio)}
>
{barrio}
</li>
))}
</ul>
)}
</div>
{error && (
<p role="alert" className="field-error" id={`${name}-error`}>
{error}
</p>
)}
</div>
)
}

View File

@@ -40,13 +40,14 @@ export function CheckboxGroup({
{required && <span className="required-mark" aria-hidden="true"> *</span>} {required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>} {required && <span className="sr-only"> (obligatorio)</span>}
</legend> </legend>
{maxSelect !== null ? ( {/* El hint estático ("Podés marcar más de una opción") ya vive en
wizard-prompt-block (question.hint) — acá solo el contador vivo,
que es información que no está en ningún otro lado. */}
{maxSelect !== null && (
<p className="max-select-hint" aria-live="polite"> <p className="max-select-hint" aria-live="polite">
Elegí hasta {maxSelect} opciones Elegí hasta {maxSelect} opciones
{value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`} {value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`}
</p> </p>
) : (
<p className="max-select-hint">Podés marcar más de una opción</p>
)} )}
<div className="options-list" role="list"> <div className="options-list" role="list">
{options.map((opt) => { {options.map((opt) => {

View File

@@ -0,0 +1,168 @@
'use client'
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
type GeoData = Record<string, Record<string, string[]>>
interface CiudadSelectFieldProps {
name: string
label: ReactNode
value: string
required: boolean
departamento?: string | null
onChange: (value: string) => void
error?: string
}
export function CiudadSelectField({
name,
label,
value,
required,
departamento,
onChange,
error,
}: CiudadSelectFieldProps) {
const [geoData, setGeoData] = useState<GeoData | null>(null)
const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
const inputRef = useRef<HTMLInputElement>(null)
// Stable ref to always call the latest onChange without it in effect deps
const onChangeRef = useRef(onChange)
onChangeRef.current = onChange
const prevDeptoRef = useRef(departamento)
useEffect(() => {
fetch('/data/geo_paraguay_full.json')
.then((r) => r.json())
.then(setGeoData)
.catch(() => {})
}, [])
// Clear city when departamento changes so no orphan value remains
useEffect(() => {
if (prevDeptoRef.current !== departamento) {
onChangeRef.current('')
setOpen(false)
}
prevDeptoRef.current = departamento
}, [departamento])
// Close fixed dropdown on any scroll (input moves, list stays)
useEffect(() => {
if (!open) return
const close = () => setOpen(false)
window.addEventListener('scroll', close, { passive: true, capture: true })
return () => window.removeEventListener('scroll', close, { capture: true })
}, [open])
function computeDropdownStyle() {
if (!inputRef.current) return
const rect = inputRef.current.getBoundingClientRect()
setDropdownStyle({ top: rect.bottom + 4, left: rect.left, width: rect.width })
}
const ciudades: string[] = (() => {
if (!geoData) return []
if (departamento) return Object.keys(geoData[departamento] ?? {})
return Object.values(geoData).flatMap((d) => Object.keys(d))
})()
const query = value.trim().toLowerCase()
const suggestions =
query.length >= 2
? ciudades.filter((c) => c.toLowerCase().includes(query)).slice(0, 10)
: []
function handleInput(v: string) {
onChange(v)
computeDropdownStyle()
setOpen(true)
setActiveIdx(-1)
}
function select(ciudad: string) {
onChange(ciudad)
setOpen(false)
setActiveIdx(-1)
}
function handleKeyDown(e: React.KeyboardEvent) {
if (!open || suggestions.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIdx((i) => Math.max(i - 1, -1))
} else if (e.key === 'Enter' && activeIdx >= 0) {
e.preventDefault()
select(suggestions[activeIdx])
} else if (e.key === 'Escape') {
setOpen(false)
}
}
const listId = `${name}-suggestions`
return (
<div className="question-fieldset">
<label className="question-legend" htmlFor={name}>
{label}
{required && <span className="required-mark" aria-hidden="true"> *</span>}
{required && <span className="sr-only"> (obligatorio)</span>}
</label>
<div className="barrio-autocomplete-wrapper">
<input
type="text"
id={name}
name={name}
value={value}
required={required}
aria-required={required}
aria-describedby={error ? `${name}-error` : undefined}
aria-invalid={!!error}
aria-autocomplete="list"
aria-controls={open && suggestions.length > 0 ? listId : undefined}
aria-activedescendant={activeIdx >= 0 ? `${name}-opt-${activeIdx}` : undefined}
autoComplete="off"
className="text-short-input"
ref={inputRef}
onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
onBlur={() => setTimeout(() => setOpen(false), 150)}
/>
{open && suggestions.length > 0 && (
<ul
id={listId}
role="listbox"
className="barrio-autocomplete-list"
aria-label="Sugerencias de ciudades"
style={dropdownStyle}
>
{suggestions.map((ciudad, i) => (
<li
key={ciudad}
id={`${name}-opt-${i}`}
role="option"
aria-selected={i === activeIdx}
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
onMouseDown={() => select(ciudad)}
>
{ciudad}
</li>
))}
</ul>
)}
</div>
{error && (
<p role="alert" className="field-error" id={`${name}-error`}>
{error}
</p>
)}
</div>
)
}

View File

@@ -1,22 +1,24 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'
import type { BrandConfig, Survey } from '@/lib/types' import type { BrandConfig, Survey } from '@/lib/types'
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types' import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
import { import {
answerKey, answerKey,
applyAnswer,
expandInstances, expandInstances,
initialState, initialState,
isRequiredAndUnanswered,
next, next,
back, back,
resolveScreens, resolveScreens,
setAnswer,
} from '@/lib/form-engine' } from '@/lib/form-engine'
import { loadDraft, saveDraft } from '@/lib/storage' import { loadDraft, saveDraft, clearDraft } from '@/lib/storage'
import { submitSurvey } from '@/app/actions/submit-survey'
import { ConsentScreen } from './consent-screen' import { ConsentScreen } from './consent-screen'
import { ProgressBar } from './progress-bar' import { ProgressBar } from './progress-bar'
import { QuestionRenderer } from './question-renderer' import { QuestionRenderer } from './question-renderer'
type Stage = 'consent' | 'form' type Stage = 'consent' | 'form' | 'success' | 'error'
interface WizardFormProps { interface WizardFormProps {
survey: Survey survey: Survey
@@ -52,6 +54,10 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
const [errors, setErrors] = useState<Record<string, string>>({}) const [errors, setErrors] = useState<Record<string, string>>({})
const [hydrated, setHydrated] = useState(false) const [hydrated, setHydrated] = useState(false)
const [a11yAnnounce, setA11yAnnounce] = useState('') const [a11yAnnounce, setA11yAnnounce] = useState('')
const answerAreaRef = useRef<HTMLDivElement>(null)
const [hasOverflow, setHasOverflow] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
useEffect(() => { useEffect(() => {
const draft = loadDraft(survey.id) const draft = loadDraft(survey.id)
@@ -78,6 +84,24 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
else setA11yAnnounce('Fin del formulario') else setA11yAnnounce('Fin del formulario')
}, [screen?.id]) }, [screen?.id])
// El fade de wizard-answer-area solo debe aparecer si hay overflow real —
// CSS no puede expresar esa condición (no hay selector para "este elemento
// scrollea"), así que se mide el DOM. Se remide al cambiar de pantalla
// (cambia todo el contenido) y al resize (puede cambiar cuántas opciones entran).
useLayoutEffect(() => {
const el = answerAreaRef.current
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
}, [screen?.id])
useEffect(() => {
function recalc() {
const el = answerAreaRef.current
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
}
window.addEventListener('resize', recalc)
return () => window.removeEventListener('resize', recalc)
}, [])
if (stage === 'consent') { if (stage === 'consent') {
return ( return (
<ConsentScreen <ConsentScreen
@@ -91,6 +115,43 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
) )
} }
if (stage === 'success') {
return (
<div className="survey-wrapper">
<div className="thank-you-container" role="main">
<h1 className="thank-you-title">¡Gracias por participar!</h1>
<p className="thank-you-body">
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
forma de vincular tus respuestas con tu identidad.
</p>
<p className="thank-you-body">
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
diseñar mejores apoyos para las personas cuidadoras.
</p>
</div>
</div>
)
}
if (stage === 'error') {
return (
<div className="survey-wrapper">
<div className="error-container" role="main">
<h1 className="error-title">No se pudo enviar la encuesta</h1>
<p>Hubo un error al guardar tus respuestas. Podés intentarlo de nuevo.</p>
{submitError && <pre className="no-survey-hint">{submitError}</pre>}
<button
type="button"
className="btn btn-primary"
onClick={() => { setSubmitError(null); setStage('form') }}
>
Volver e intentar de nuevo
</button>
</div>
</div>
)
}
if (!screen) return null if (!screen) return null
const currentValue = valueFor(screen, state.answers) const currentValue = valueFor(screen, state.answers)
@@ -117,20 +178,7 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
const code = screen.question.code const code = screen.question.code
const instanceId = screen.instanceId ?? undefined const instanceId = screen.instanceId ?? undefined
setState((prev) => { setState((prev) => applyAnswer(prev, schema, code, value, instanceId))
const updated = setAnswer(prev, code, value, instanceId)
// Limpia respuestas de pantallas que dejaron de ser visibles tras este cambio.
const before = resolveScreens(schema, prev.answers)
const after = resolveScreens(schema, updated.answers)
const afterIds = new Set(after.map((s) => s.id))
const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question)
if (disappeared.length === 0) return updated
const clearedAnswers = { ...updated.answers }
for (const s of disappeared) {
delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)]
}
return { ...updated, answers: clearedAnswers }
})
setErrors((prev) => { setErrors((prev) => {
if (!prev[code]) return prev if (!prev[code]) return prev
@@ -140,7 +188,7 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
} }
function handleNext() { function handleNext() {
if (screen.kind === 'question' && screen.question?.required && isEmpty(currentValue)) { if (isRequiredAndUnanswered(screen, state.answers)) {
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' })) setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return return
} }
@@ -151,6 +199,33 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
setState((s) => back(s, schema)) setState((s) => back(s, schema))
} }
function handleSubmit() {
if (isPending) return
// state.answers usa claves "code:instanceId" — se descompone una sola vez
// acá, al borde cliente/servidor; el validador (lib/submit-validation.ts)
// recibe la forma explícita {code, instanceId, value}[].
const answers = Object.entries(state.answers).map(([key, value]) => {
const sep = key.indexOf(':')
return { code: key.slice(0, sep), instanceId: key.slice(sep + 1), value }
})
startTransition(async () => {
const result = await submitSurvey({
surveyId: survey.id,
answers,
consentOperativo,
consentMacroN1,
})
if (result.success) {
clearDraft(survey.id)
setStage('success')
} else {
setSubmitError(result.error)
setStage('error')
}
})
}
const bc: BrandConfig | null = survey.brand_config const bc: BrandConfig | null = survey.brand_config
return ( return (
@@ -193,12 +268,16 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
<h2 className="wizard-prompt-text">{screen.question.prompt}</h2> <h2 className="wizard-prompt-text">{screen.question.prompt}</h2>
{screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>} {screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>}
</div> </div>
<div className="wizard-answer-area"> <div
ref={answerAreaRef}
className={`wizard-answer-area${hasOverflow ? ' has-overflow' : ''}`}
>
<QuestionRenderer <QuestionRenderer
question={screen.question} question={screen.question}
value={currentValue} value={currentValue}
onChange={handleChange} onChange={handleChange}
error={errors[screen.question.code]} error={errors[screen.question.code]}
answers={state.answers}
/> />
</div> </div>
</> </>
@@ -208,8 +287,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
<div className="wizard-section-screen"> <div className="wizard-section-screen">
<h2 className="wizard-section-title">Llegaste al final</h2> <h2 className="wizard-section-title">Llegaste al final</h2>
<p className="wizard-section-desc"> <p className="wizard-section-desc">
El envío de la encuesta se habilita en una próxima etapa. Por ahora podés revisar tus Revisá tus respuestas con &quot;Anterior&quot; si querés corregir algo, o enviá la
respuestas con &quot;Anterior&quot;. encuesta cuando estés listo/a.
</p> </p>
</div> </div>
)} )}
@@ -220,8 +299,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
onClick={handleBack} onClick={handleBack}
disabled={idx === 0} disabled={idx === 0 || isPending}
aria-disabled={idx === 0} aria-disabled={idx === 0 || isPending}
> >
Anterior Anterior
</button> </button>
@@ -233,8 +312,14 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
Siguiente Siguiente
</button> </button>
) : ( ) : (
<button type="button" className="btn btn-primary btn-disabled" disabled aria-disabled="true"> <button
Enviar (próximamente) type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={isPending}
aria-disabled={isPending}
>
{isPending ? 'Enviando…' : 'Enviar encuesta'}
</button> </button>
)} )}
</div> </div>

View File

@@ -0,0 +1,57 @@
-- SEED DE PRUEBA v2 — 50 respuestas + modalidad (2.7). Determinista seed=42.
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
UPDATE surveys SET status='live' WHERE id='30000000-0000-0000-0000-000000000001';
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-000', '55 años o más', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"56"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-001', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"56"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"34"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Reducir tu jornada o pasar a medio tiempo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-002', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"34"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Apoyo económico o convenios/descuentos", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-003', '25 a 34 años', 'Gerencial', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-004', '55 años o más', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad", "Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (410 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-005', '55 años o más', 'Auxiliar', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"56"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"34"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Reducir tu jornada o pasar a medio tiempo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Acceso a beneficios para terapias o salud"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-006', '25 a 34 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar", "Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"34"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Redes de apoyo / grupos de pares", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-007', '35 a 44 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-008', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad", "Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"34"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (410 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Información sobre recursos y servicios"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-009', 'Menos de 25 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"34"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Flexibilidad horaria o trabajo remoto", "Información sobre recursos y servicios", "Capacitación y autocuidado"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"34"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Capacitación y autocuidado", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-011', '35 a 44 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"56"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Dejar un trabajo anterior"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-012', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad", "Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares", "Acceso a beneficios para terapias o salud", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-013', '25 a 34 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (410 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Capacitación y autocuidado", "Flexibilidad horaria o trabajo remoto"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-016', '25 a 34 años', 'Directivo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-017', '55 años o más', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"34"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (410 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (410 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Flexibilidad horaria o trabajo remoto"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-019', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"34"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Capacitación y autocuidado"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-020', '55 años o más', 'Jefatura', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad", "Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Capacitación y autocuidado", "Información sobre recursos y servicios"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-021', '45 a 54 años', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"34"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado", "Redes de apoyo / grupos de pares", "Acceso a beneficios para terapias o salud"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-023', '25 a 34 años', 'Operativo', 'Itapúa', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad", "Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"34"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado", "Redes de apoyo / grupos de pares"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Capacitación y autocuidado"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-026', '25 a 34 años', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Rechazar un ascenso o nueva responsabilidad"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Flexibilidad horaria o trabajo remoto", "Capacitación y autocuidado", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-027', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Pensar seriamente en dejar el trabajo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-028', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad", "Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Flexibilidad horaria o trabajo remoto"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-029', '55 años o más', 'Auxiliar', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"12"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Reducir tu jornada o pasar a medio tiempo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Flexibilidad horaria o trabajo remoto", "Capacitación y autocuidado", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-030', '35 a 44 años', 'Operativo', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Dejar un trabajo anterior"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (13 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad", "Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"56"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"56"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (410 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-034', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad", "Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"12"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"34"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Pensar seriamente en dejar el trabajo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Redes de apoyo / grupos de pares"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-035', '45 a 54 años', 'Jefatura', 'Itapúa', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"34"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"56"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"12"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares", "Capacitación y autocuidado"]}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-036', '25 a 34 años', 'Jefatura', 'Itapúa', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-037', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-038', '35 a 44 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-040', '45 a 54 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-042', '25 a 34 años', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-043', 'Menos de 25 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-044', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-045', '25 a 34 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-046', '25 a 34 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-047', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-048', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
-- LIMPIEZA: TRUNCATE answers, responses, consent_records CASCADE;
-- UPDATE surveys SET status='draft' WHERE id='30000000-0000-0000-0000-000000000001';

View File

@@ -0,0 +1,156 @@
# Propuesta de arquitectura de métricas — Dashboard v3
## "Datos País sobre el Cuidado" · documento para validar con el equipo
> **Estado: PROPUESTA para discusión.** Ninguna métrica está implementada. Cada una está
> anclada a preguntas reales del instrumento v3 y es construible con queries sobre `answers`.
> Donde un dato NO existe en el instrumento, se marca como **[REQUIERE PREGUNTA]**.
> Las atribuciones a marcos internacionales se hacen solo donde son verificables; donde es
> una analogía conceptual y no un estándar oficial, se dice explícitamente.
---
## Marco conceptual (fundamento, con fuentes)
La economía del cuidado tiene marcos de referencia consolidados que dan sustento a las métricas:
- **Marco de las "5R" de la OIT** (Reconocer, Reducir, Redistribuir, Recompensar, Representar
el trabajo de cuidado) — adoptado formalmente por la OIT en junio 2024 en la Resolución sobre
trabajo decente y economía del cuidado. Las 3R originales son de Diane Elson (2009).
- **CEPAL — "Sociedad del cuidado"**: propone medir el cuidado como sector económico y construir
sistemas de información sobre las desigualdades. La CEPAL lo identifica como uno de los diez
sectores promisorios para una recuperación transformadora.
- **Paraguay — Política Nacional de Cuidados (PNCUPA)**, aprobada 2022, y su **Plan de Acción
2025-2030**. El anteproyecto **SINACUP** busca un Sistema Nacional de Cuidados basado en
corresponsabilidad entre Estado, familias, comunidad y **sector privado**. La población
objetivo: niñez (0-13), personas con discapacidad de cualquier edad, y personas mayores
dependientes — exactamente las que mapea la pregunta 4.1 del instrumento.
- **Dato país de referencia:** el trabajo de cuidado no remunerado representó el **22,4% del PIB**
paraguayo (Encuesta de Uso del Tiempo 2016). El Censo 2022 registró 299.839 personas con
discapacidad permanente. Proyección de envejecimiento: la proporción de adultos mayores se
duplica de 8,6% (2025) a 16,4% (2050).
- **Mirada empresa:** investigación de Harvard Business School (Joseph Fuller, "Healthy Outcomes")
documenta que el cuidado impacta productividad, rotación y costos; estudios (Johns Hopkins, 2022)
cuantifican absentismo y **presentismo** (estar presente con productividad reducida). La métrica
clave es que las empresas suelen NO ver el problema: solo ~24% de empleadores reconoce que el
cuidado afecta el desempeño, mientras 3 de 4 empleados cuidadores dicen que su trabajo se vio
afectado. Este "punto ciego" es el que el dashboard ataca.
---
## Las tres miradas
El dashboard se organiza en tres lentes sobre los mismos datos. Una respuesta de encuesta
alimenta las tres — cambia la pregunta que cada lente le hace a los datos.
```
MIRADA EMPRESA → "¿qué me cuesta y qué gano gestionando el cuidado de mi gente?"
MIRADA PAÍS → "¿qué revela esto sobre la economía del cuidado en Paraguay?"
MIRADA HUMANA → "¿cómo está la persona que cuida y la que es cuidada?"
```
---
## MIRADA EMPRESA
**Audiencia:** RRHH, dirección, empresas con certificación tipo Great Place to Work.
**Pregunta rectora:** rendimiento, sostenibilidad del talento, riesgo de rotación, clima.
| # | Métrica | Qué responde | Preguntas v3 | Construible |
|---|---|---|---|---|
| E1 | **Prevalencia de cuidadores en la plantilla** | % de empleados con algún rol de cuidado | 4.1 (vínculo ≠ "Ninguna") | ✅ |
| E2 | **Riesgo de rotación por cuidado** | % que considera probable dejar/reducir su trabajo | 7.6 (Algo/Muy probable) | ✅ |
| E3 | **Ausentismo atribuible al cuidado** | % con faltas recurrentes por cuidado | 7.3 (4+ veces/año) | ✅ |
| E4 | **Presentismo (productividad reducida)** | % cuya concentración/rendimiento se ve afectado | 7.2 (varias veces/semana o más) | ✅ |
| E5 | **Carrera congelada** | % que rechazó ascenso / redujo jornada / declinó oportunidades | 7.5 | ✅ |
| E6 | **Punto ciego organizacional** | brecha entre carga real y percepción de apoyo de la empresa | 9.1 (No/Nunca) vs prevalencia E1 | ✅ |
| E7 | **Demanda de apoyos priorizada** | qué apoyos pedirían (para diseñar beneficios) | 9.2 | ✅ |
| E8 | **Intensidad de carga** | horas/semana de cuidado de la plantilla cuidadora | 6.1 × 6.2 (+ 6.4 guardia) | ✅ |
| E9 | **Segmentación por nivel y modalidad** | dónde se concentra la carga (jerarquía, presencial/remoto) | cruce con 2.6, 2.7 | ✅ |
| E10 | **Disposición a red de apoyo interna** | % dispuesto a grupo de apoyo / voluntariado | 9.3, 9.4 | ✅ |
**Métrica estrella sugerida — "Talento en riesgo":** combinación de E2+E3+E5 — el % de la
plantilla cuidadora que muestra señales de fuga (alta probabilidad de salida + ausentismo +
carrera congelada). Es el número que traduce cuidado a lenguaje de negocio (costo de reemplazo).
---
## MIRADA PAÍS
**Audiencia:** estudio país, Fundación Solidaridad, incidencia en política pública.
**Pregunta rectora:** desarrollo, vulnerabilidad, impacto económico, insumo para el SINACUP.
| # | Métrica | Qué responde | Preguntas v3 | Construible |
|---|---|---|---|---|
| P1 | **Mapa de prevalencia de cuidado por departamento** | dónde se concentra la carga de cuidado | 4.1 × 2.1 | ✅ |
| P2 | **Perfil de la población dependiente cuidada** | composición: niñez / discapacidad / adulto mayor | 4.1 (por instancia) | ✅ |
| P3 | **Carga económica privada del cuidado** | cuánto gastan los hogares de su bolsillo | 8.1 | ✅ |
| P4 | **Vulnerabilidad económica del cuidador** | relación gasto de cuidado vs franja salarial | 8.1 vs 8.4 | ✅ |
| P5 | **Red de soporte del cuidado** | quién sostiene (familia, contratado, estatal, comunidad) | 5.3, 8.3 | ✅ |
| P6 | **Brecha de género en el cuidado** | distribución de la carga por género | 4.1/cuidado × 2.5 | ✅ |
| P7 | **Dependencia del subsidio estatal** | % que apoya gastos con subsidios del Estado | 8.3 (subsidios estatales) | ✅ |
| P8 | **Cuidado de larga duración** | antigüedad en el rol (cronicidad) | 5.4 | ✅ |
| P9 | **Tecnología asistiva y certificación** | acceso a dispositivos / certificado de discapacidad | 3.3, 4.3, 3.4 | ✅ |
**Conexión con política pública:** P2, P5 y P7 son insumo directo para el SINACUP (mapean
demanda de cuidado y dependencia del Estado). P4 conecta con el riesgo de empobrecimiento por
cuidado, central en la PNCUPA.
**[REQUIERE PREGUNTA] — tiempo de cuidado no remunerado:** para conectar con el indicador
estrella de CEPAL/uso del tiempo (el 22,4% del PIB), faltaría una pregunta de "horas de cuidado
no remunerado por semana" más granular. Hoy 6.1×6.2 lo aproxima en rangos; una pregunta de horas
semanales permitiría comparar con la Encuesta de Uso del Tiempo nacional.
---
## MIRADA HUMANA
**Audiencia:** lectura de bienestar y derechos; alineamiento con marcos de organismos.
**Pregunta rectora:** desarrollo humano, bienestar del cuidador y la persona cuidada.
**Nota de honestidad:** estos indicadores se INSPIRAN en marcos de UNICEF/UNFPA/UNESCO sobre
derechos del niño, envejecimiento y educación inclusiva. NO afirmo que sean indicadores oficiales
de esos organismos — son lecturas propias del instrumento que dialogan con esas agendas. La
validación de cualquier alineamiento formal corresponde al equipo y los organismos.
| # | Métrica | Qué responde | Preguntas v3 | Marco que dialoga |
|---|---|---|---|---|
| H1 | **Agotamiento del cuidador** | nivel de descanso / sobrecarga | 7.1, 7.4 (salud física/emocional) | OIT 5R (Reducir) |
| H2 | **Aspectos de vida sacrificados** | qué pierde la persona por cuidar | 7.4 | Desarrollo humano (PNUD) |
| H3 | **Escolarización con barreras (niñez)** | niñez cuidada que enfrenta barreras de aprendizaje | **[REQUIERE PREGUNTA]** ver abajo | UNICEF / UNESCO educación inclusiva |
| H4 | **Autonomía de la persona dependiente** | uso de dispositivos / nivel de dependencia | 4.3, 6.4 (guardia permanente) | Convención Derechos PcD |
| H5 | **Soledad del cuidado** | cuida sin red de apoyo (carga en solitario) | 5.3 (solo/sin apoyo) | OIT 5R (Redistribuir) |
| H6 | **Inclusión laboral de PcD propia** | empleados con discapacidad propia y cómo les afecta | 3.1, 3.5 | Convención Derechos PcD |
| H7 | **Disposición solidaria** | voluntariado / participación comunitaria | 9.3, 9.4 | Capital social |
**[REQUIERE PREGUNTA] — H3 escolarización:** el mockup original del instrumento mencionaba
"si es niño/a en edad escolar: ¿está escolarizado y enfrenta barreras de aprendizaje?" como
posible dato por instancia de vínculo. En el seed v3 actual NO está. Si el equipo quiere la
mirada UNICEF/UNESCO sobre niñez, **sugiero agregar una pregunta de instancia** (cuando el
familiar es niño/a): "¿Está escolarizado/a? ¿Enfrenta barreras de aprendizaje?" — sería 4.4
(instance_of 4.1), construible con la arquitectura de instancias que ya existe.
---
## Decisión metodológica pendiente (para Maria) — métrica por persona vs por instancia
Las preguntas de vínculo (4.2/4.3) admiten dos lecturas, y conviene presentar AMBAS rotuladas:
- **Por persona:** "% de empleados con AL MENOS un familiar con discapacidad X" (prevalencia
en plantilla — denominador: respondentes). Para RRHH.
- **Por instancia:** "del total de familiares cuidados, % con discapacidad X" (composición de
la población cuidada — denominador: familiares declarados). Para estudio país.
**k-anonimato sobre la métrica por instancia:** el umbral k≥5 debe seguir aplicándose sobre
`count(DISTINCT response_id)` (personas), NO sobre instancias — para que un segmento no parezca
tener 5 "familiares" que en realidad vienen de 2 personas. A validar con metodología.
---
## Construcción técnica (todas las métricas)
- Cada métrica es una RPC `SECURITY DEFINER` con k≥5 en dos niveles (igual patrón que las RPCs
existentes), filtrable por `survey_id` y por el flag `p_all` (UUID cero = todos los surveys).
- Las métricas de instancia (P2, parte de H4, H3 futura) usan `instance_id LIKE 'familiar_%'`
y el índice `idx_answers_instance` que ya existe (creado en 4A, nunca usado aún).
- Las cuasi-identificadoras (departamento, género, nivel) ya están como `qi_*` en `responses`.
## Resumen de gaps de datos (lo que el instrumento NO captura hoy)
1. **[REQUIERE PREGUNTA] Horas de cuidado no remunerado semanales** (mirada país, comparar con
Uso del Tiempo nacional / 22,4% PIB). Hoy aproximado por rangos 6.1×6.2.
2. **[REQUIERE PREGUNTA] Escolarización y barreras de aprendizaje de niñez cuidada** (mirada
humana, diálogo UNICEF/UNESCO). Sugerencia: pregunta de instancia 4.4 condicional a vínculo niño.
3. Todo lo demás es construible con el instrumento v3 actual.

View File

@@ -0,0 +1,159 @@
# Especificación técnica — Mirada Empresa (Dashboard v3)
## Contrato para implementación · go-live
> Especificación de las 10 métricas de la Mirada Empresa, cada una con su query
> construible sobre el instrumento v3, regla de k-anonimato y shape de retorno.
> **Pendiente de validación del equipo** (especialmente la definición de CUIDADOR).
> Patrón: RPC `SECURITY DEFINER`, filtrable por `survey_id` y flag `p_all` (UUID cero),
> igual que las RPCs existentes. Reusa `_dash_responses` y `_dash_parse_filters`.
---
## Constante parametrizada — definición de CUIDADOR
**Decisión CERRADA (Maria, criterio UNFPA):** "Hijo menor sin discapacidad" SÍ cuenta como
cuidador activo. El cuidado de niñez es trabajo de cuidado, coherente con el enfoque de la
economía del cuidado y con un criterio explícitamente solicitado por UNFPA.
```
CUIDADOR_ACTIVO := respuesta a 4.1 contiene AL MENOS una opción
distinta de "Ninguna de las anteriores / No tengo vinculación de cuidado"
```
Es decir: las opciones 1..7 cuentan como cuidador; solo la opción 8 ("Ninguna...") excluye.
Todas las métricas que usan "cuidador" como población heredan esta definición desde este
único punto. Registrar en SEED_SPEC.md: "CUIDADOR incluye hijo menor sin discapacidad —
criterio UNFPA, validado por Maria (metodología), jun 2026."
**Nota de comparabilidad con v2:** esto ensancha la población "cuidador" respecto al dashboard
v2 (donde criar un niño sano no activaba los triggers de discapacidad/adulto mayor). Es un
cambio deliberado de definición, no un error de migración. Cualquier comparación con datos v2
debe tenerlo en cuenta.
---
## Núcleo mínimo viable (si el tiempo aprieta en implementación)
Marcadas **[NÚCLEO]** las imprescindibles para el cliente; **[SEC]** las secundarias.
---
## E1 — Prevalencia de cuidadores en la plantilla [NÚCLEO]
**Responde:** ¿qué % de mis empleados tiene algún rol de cuidado?
**Pregunta v3:** 4.1
**Query (concepto):**
```sql
v_total := count(*) FROM _dash_responses(...) -- denominador: toda la plantilla encuestada
v_cuid := count(DISTINCT response_id) WHERE q.code='4.1'
AND <CUIDADOR_ACTIVO> -- 4.1 contiene opción de cuidado
RETURN pct_cuidadores = round(v_cuid/v_total*100,1)
```
**k≥5:** si v_total < 5 → suppressed.
**Retorno:** `{status, n, pct_cuidadores}`
---
## E2 — Riesgo de rotación por cuidado [NÚCLEO]
**Responde:** ¿qué % de cuidadores considera probable dejar/reducir su trabajo?
**Pregunta v3:** 7.6 (single_choice: Nada/Poco/Algo/Muy probable)
**Query:** denominador = cuidadores activos; numerador = 7.6 IN ('Algo probable','Muy probable').
**k≥5:** sobre cuidadores; si < 5 → suppressed.
**Retorno:** `{status, n_cuidadores, pct_riesgo, desglose:{algo, muy}}`
---
## E3 — Ausentismo atribuible al cuidado [NÚCLEO]
**Responde:** ¿qué % faltó al trabajo por cuidado de forma recurrente?
**Pregunta v3:** 7.3 (Nunca / Pocas 1-3 / Varias 4-10 / Con frecuencia +10)
**Query:** numerador = 7.3 IN ('Varias veces (410 al año)','Con frecuencia (más de 10 al año)').
**Retorno:** `{status, n, pct_ausentismo_recurrente, desglose por categoría}`
---
## E4 — Presentismo (productividad reducida) [NÚCLEO]
**Responde:** ¿a qué % se le afecta la concentración/rendimiento por cuidar?
**Pregunta v3:** 7.2 (Nunca / Algunas veces al mes / Varias veces por semana / Casi todos los días)
**Query:** numerador = 7.2 IN ('Varias veces por semana','Casi todos los días').
**Retorno:** `{status, n, pct_presentismo, desglose}`
---
## E5 — Carrera congelada [SEC]
**Responde:** ¿qué % tuvo que frenar su carrera por cuidar?
**Pregunta v3:** 7.5 (multiple_choice: reducir jornada / rechazar ascenso / declinar / cambiar puesto / etc.)
**Query:** numerador = respondentes cuyo 7.5 contiene alguna opción distinta de "Ninguna de las anteriores".
**Retorno:** `{status, n, pct_carrera_congelada, top_opciones[]}`
---
## E6 — Punto ciego organizacional [NÚCLEO]
**Responde:** brecha entre la carga real de cuidado y cuántos se sienten apoyados.
**Preguntas v3:** 4.1 (prevalencia, = E1) vs 9.1 (¿la empresa comprende?)
**Query:** carga_real = pct_cuidadores (E1); se_siente_apoyado = % con 9.1 = 'Sí, me siento acompañado/a'.
La métrica es la BRECHA entre ambos.
**Retorno:** `{status, n, carga_real_pct, apoyo_percibido_pct, brecha_pp}`
**Nota visual:** es el widget de contraste de dos barras del mockup.
---
## E7 — Demanda de apoyos priorizada [NÚCLEO]
**Responde:** ¿qué apoyos pediría la gente? (para diseñar beneficios)
**Pregunta v3:** 9.2 (multiple_choice, máx 3)
**Query:** desagregar con jsonb_array_elements_text, contar por opción, ordenar desc.
**k≥5 por fila:** cada opción con cnt < 5 se omite (igual que rpc_apoyos_demandados v2).
**Retorno:** `{status, n, apoyos:[{apoyo, n, pct}] ordenado}`
---
## E8 — Intensidad de carga [SEC]
**Responde:** ¿cuánta carga horaria soporta la plantilla cuidadora?
**Preguntas v3:** 6.1 (días/semana) × 6.2 (horas/día), + 6.4 (guardia)
**Query:** distribución de combinaciones; o un índice de intensidad (días×horas en rangos).
**Nota:** como son rangos (no números), se reporta distribución por bucket, no un promedio exacto.
**Retorno:** `{status, n, distribucion_intensidad[], pct_guardia_permanente}`
---
## E9 — Segmentación por nivel y modalidad [SEC]
**Responde:** ¿dónde se concentra la carga? (jerarquía, presencial/remoto)
**Preguntas v3:** CUIDADOR cruzado con 2.6 (nivel) y 2.7 (modalidad)
**Query:** pct_cuidadores por cada valor de 2.6 y de 2.7.
**k≥5 por segmento:** cada nivel/modalidad con n < 5 → suppressed esa fila.
**Retorno:** `{status, por_nivel:[{nivel,n,pct}], por_modalidad:[{modalidad,n,pct}]}`
---
## E10 — Disposición a red de apoyo interna [SEC]
**Responde:** ¿cuántos participarían de un grupo de apoyo / voluntariado?
**Preguntas v3:** 9.3 (grupo de apoyo) + 9.4 (voluntariado)
**Query:** % con 9.3 IN ('Sí, definitivamente','Tal vez...') ; % con 9.4 = 'Sí'.
**Retorno:** `{status, n, pct_grupo_apoyo, pct_voluntariado}`
---
## Métrica estrella compuesta — "Talento en riesgo" [NÚCLEO]
**Responde:** el número que traduce cuidado a costo de negocio.
**Preguntas v3:** 7.6 + 7.3 + 7.5 (sobre población cuidadora)
**Definición propuesta (validar):** % de cuidadores que cumple AL MENOS 2 de 3 señales:
- riesgo de salida (7.6 IN Algo/Muy probable)
- ausentismo recurrente (7.3 IN 4-10 / +10)
- carrera congelada (7.5 contiene alguna opción de freno)
**Decisión pendiente:** ¿umbral de 2 de 3, o ponderado? Validar con el equipo.
**Retorno:** `{status, n_cuidadores, pct_talento_riesgo, criterios_promedio}`
---
## Reglas transversales (todas las RPCs)
1. **k-anonimato k≥5** en dos niveles: total (suppressed si n<5) y por-segmento (fila omitida/null si <5).
2. **Filtro survey_id + p_all:** delegado a `_dash_responses` (UUID cero = todos). No reimplementar.
3. **No exponer datos sensibles** (3.x salud propia, 4.2/4.3 disc. familiar) en Mirada Empresa.
Mirada Empresa usa solo: 4.1 (existencia de vínculo, no el detalle médico), 6.x, 7.x, 9.x, 2.6, 2.7.
4. **Shape consistente:** `{status:'ok'|'suppressed', n, ...campos}`. Status suppressed nunca expone n<5 desglosado.
5. Reusar el patrón de migración 012 (las RPCs que SÍ migran limpio sirven de molde).
## Lo que Mirada Empresa NO necesita (queda para sprint de dashboard)
- Bienestar tipo rating (4.3 v2 no existe en v3) — no se usa acá.
- Score organizacional con 6.5 — no se usa acá.
- Métricas de instancia (4.2/4.3 detalle médico del familiar) — son de Mirada País/Humana.
- Las preguntas faltantes (horas no remuneradas, escolarización) — son de País/Humana.
## Gaps de datos para Mirada Empresa
Ninguno. Las 10 métricas + la estrella son 100% construibles con el instrumento v3 actual.

View File

@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard 3 Miradas — Mockup Propuesta</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0b1220;color:#e7eef7;padding:1.5rem}
.wrap{max-width:1100px;margin:0 auto}
.head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;flex-wrap:wrap;gap:10px}
.head h1{font-size:22px;font-weight:600}
.sub{color:#7d8da5;font-size:13px;margin-bottom:1.2rem}
.badge-prop{display:inline-block;background:#3a2a08;color:#f0b429;font-size:11px;font-weight:600;padding:3px 10px;border-radius:20px;border:1px solid #6b4d12;margin-left:8px}
.org-sel{display:flex;gap:6px}
.org-sel button{background:#16203a;color:#9fb0cc;border:1px solid #243352;border-radius:20px;padding:5px 14px;font-size:12px;cursor:pointer}
.org-sel button.active{background:#43BBC8;color:#04161a;border-color:#43BBC8;font-weight:600}
.tabs{display:flex;gap:4px;margin-bottom:1.2rem;border-bottom:1px solid #1d2a44}
.tab{padding:10px 20px;font-size:14px;font-weight:500;color:#7d8da5;cursor:pointer;border-bottom:2px solid transparent;display:flex;align-items:center;gap:7px}
.tab:hover{color:#cdd9ec}
.tab.active{color:#43BBC8;border-bottom-color:#43BBC8}
.tab .lens{font-size:11px;color:#5e6f8c;display:block;font-weight:400}
.lens-intro{background:#101a30;border-left:3px solid #43BBC8;border-radius:0 8px 8px 0;padding:10px 14px;margin-bottom:1.2rem;font-size:13px;color:#9fb0cc;line-height:1.5}
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
.card{background:#101a30;border:1px solid #1d2a44;border-radius:12px;padding:16px;position:relative}
.card.c3{grid-column:span 3}.card.c4{grid-column:span 4}.card.c6{grid-column:span 6}.card.c8{grid-column:span 8}.card.c12{grid-column:span 12}
.card h3{font-size:12px;font-weight:600;color:#9fb0cc;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
.card .qref{font-size:10px;color:#5e6f8c;margin-bottom:10px}
.big{font-size:38px;font-weight:700;color:#43BBC8;line-height:1}
.big.warn{color:#f0b429}.big.danger{color:#f06b6b}
.big-sub{font-size:12px;color:#7d8da5;margin-top:4px}
.star{border-color:#43BBC8;background:linear-gradient(135deg,#0e1b2e,#10243000)}
.star h3{color:#43BBC8}
.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px;font-size:12px}
.bar-row .lab{width:130px;color:#9fb0cc;text-align:right;flex-shrink:0}
.bar-track{flex:1;background:#16203a;border-radius:4px;height:16px;overflow:hidden}
.bar-fill{height:100%;background:#43BBC8;border-radius:4px}
.bar-fill.amber{background:#f0b429}.bar-fill.rose{background:#f06b6b}.bar-fill.slate{background:#5e7299}
.bar-row .val{width:42px;color:#cdd9ec;font-weight:600}
.donut-wrap{display:flex;align-items:center;gap:18px}
.donut{width:120px;height:120px;flex-shrink:0}
.legend{font-size:12px}
.legend div{display:flex;align-items:center;gap:7px;margin-bottom:5px;color:#9fb0cc}
.legend .dot{width:9px;height:9px;border-radius:50%}
.dual{display:flex;gap:14px}
.dual>div{flex:1;background:#0c1426;border-radius:8px;padding:12px;border:1px solid #1a2640}
.dual .dtag{font-size:10px;color:#5e6f8c;text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px}
.dual .dn{font-size:26px;font-weight:700;color:#43BBC8}
.dual .dbase{font-size:11px;color:#7d8da5;margin-top:2px}
.gap-viz{display:flex;align-items:flex-end;gap:20px;justify-content:center;padding:10px 0}
.gap-col{text-align:center}
.gap-bar{width:60px;border-radius:6px 6px 0 0;margin:0 auto}
.gap-lab{font-size:11px;color:#9fb0cc;margin-top:6px}
.gap-val{font-size:13px;font-weight:600;margin-bottom:4px}
.note{font-size:11px;color:#5e6f8c;margin-top:10px;font-style:italic}
.req{background:#2a1208;border:1px dashed #6b3d12;color:#f0975a}
.req .big{color:#f0975a}
.footnote{margin-top:1.5rem;padding:12px;background:#101a30;border-radius:8px;font-size:11px;color:#7d8da5;line-height:1.6}
.k{font-size:10px;color:#4a5a76;text-align:right;margin-top:6px}
@media(max-width:900px){.card.c3,.card.c4,.card.c6,.card.c8{grid-column:span 12}}
</style>
</head>
<body>
<div class="wrap">
<div class="head">
<div>
<h1>Economía del Cuidado — Paraguay <span class="badge-prop">PROPUESTA · datos de ejemplo</span></h1>
</div>
<div class="org-sel">
<button class="active">Todos</button>
<button>InQuality</button>
<button>Fundación</button>
<button>Demo</button>
</div>
</div>
<div class="sub">Mockup de validación · k-anonimato k≥5 · datos ilustrativos, NO reales · n = 142 (ejemplo)</div>
<div class="tabs">
<div class="tab active" onclick="showLens('empresa',this)"><i class="ti ti-building"></i> <span>Mirada Empresa<span class="lens">rendimiento · rotación · clima</span></span></div>
<div class="tab" onclick="showLens('pais',this)"><i class="ti ti-map-2"></i> <span>Mirada País<span class="lens">desarrollo · vulnerabilidad · política</span></span></div>
<div class="tab" onclick="showLens('humana',this)"><i class="ti ti-heart-handshake"></i> <span>Mirada Humana<span class="lens">bienestar · derechos · desarrollo</span></span></div>
</div>
<!-- ===== MIRADA EMPRESA ===== -->
<div id="empresa" class="lens-content">
<div class="lens-intro"><strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el punto ciego y qué talento está en riesgo. Inspirada en la investigación de Harvard Business School (Fuller) sobre el impacto del cuidado en productividad y rotación.</div>
<div class="grid">
<div class="card c4 star">
<h3><i class="ti ti-alert-triangle"></i> Talento en riesgo</h3>
<div class="qref">7.6 + 7.3 + 7.5 · métrica compuesta</div>
<div class="big danger">18.3%</div>
<div class="big-sub">de la plantilla cuidadora muestra señales de fuga (alta probabilidad de salida + ausentismo + carrera congelada)</div>
</div>
<div class="card c4">
<h3>Cuidadores en la plantilla</h3>
<div class="qref">4.1 · vínculo de cuidado activo</div>
<div class="big">54.2%</div>
<div class="big-sub">tiene algún rol de cuidado</div>
</div>
<div class="card c4">
<h3>Punto ciego organizacional</h3>
<div class="qref">9.1 vs prevalencia real</div>
<div class="gap-viz">
<div class="gap-col"><div class="gap-val" style="color:#43BBC8">54%</div><div class="gap-bar" style="height:70px;background:#43BBC8"></div><div class="gap-lab">carga real</div></div>
<div class="gap-col"><div class="gap-val" style="color:#f06b6b">27%</div><div class="gap-bar" style="height:35px;background:#f06b6b"></div><div class="gap-lab">se siente apoyado</div></div>
</div>
</div>
<div class="card c6">
<h3>Señales de impacto laboral</h3>
<div class="qref">7.2 / 7.3 / 7.6</div>
<div class="bar-row"><span class="lab">Presentismo</span><div class="bar-track"><div class="bar-fill amber" style="width:41%"></div></div><span class="val">41%</span></div>
<div class="bar-row"><span class="lab">Ausentismo frecuente</span><div class="bar-track"><div class="bar-fill amber" style="width:27%"></div></div><span class="val">27%</span></div>
<div class="bar-row"><span class="lab">Riesgo de salida</span><div class="bar-track"><div class="bar-fill rose" style="width:23%"></div></div><span class="val">23%</span></div>
<div class="bar-row"><span class="lab">Carrera congelada</span><div class="bar-track"><div class="bar-fill rose" style="width:19%"></div></div><span class="val">19%</span></div>
</div>
<div class="card c6">
<h3>Apoyos más demandados</h3>
<div class="qref">9.2 · qué beneficios diseñar (máx 3)</div>
<div class="bar-row"><span class="lab">Flexibilidad horaria</span><div class="bar-track"><div class="bar-fill" style="width:68%"></div></div><span class="val">68%</span></div>
<div class="bar-row"><span class="lab">Apoyo económico</span><div class="bar-track"><div class="bar-fill" style="width:54%"></div></div><span class="val">54%</span></div>
<div class="bar-row"><span class="lab">Apoyo psicológico</span><div class="bar-track"><div class="bar-fill" style="width:47%"></div></div><span class="val">47%</span></div>
<div class="bar-row"><span class="lab">Convenios/descuentos</span><div class="bar-track"><div class="bar-fill" style="width:39%"></div></div><span class="val">39%</span></div>
</div>
<div class="card c12">
<h3>Dónde se concentra la carga</h3>
<div class="qref">4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad)</div>
<div class="bar-row"><span class="lab">Operativo</span><div class="bar-track"><div class="bar-fill slate" style="width:62%"></div></div><span class="val">62%</span></div>
<div class="bar-row"><span class="lab">Jefatura</span><div class="bar-track"><div class="bar-fill slate" style="width:51%"></div></div><span class="val">51%</span></div>
<div class="bar-row"><span class="lab">Gerencial</span><div class="bar-track"><div class="bar-fill slate" style="width:44%"></div></div><span class="val">44%</span></div>
<div class="note">Permite a RRHH ver si el cuidado afecta más a ciertos niveles — insumo para políticas diferenciadas.</div>
</div>
</div>
<div class="k">k≥5 aplicado · segmentos con n&lt;5 suprimidos</div>
</div>
<!-- ===== MIRADA PAÍS ===== -->
<div id="pais" class="lens-content" style="display:none">
<div class="lens-intro"><strong>Mirada País.</strong> Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP). Dialoga con la Política Nacional de Cuidados del Paraguay (PNCUPA, 2022) y el marco de la CEPAL sobre la sociedad del cuidado.</div>
<div class="grid">
<div class="card c4">
<h3>Carga económica privada</h3>
<div class="qref">8.1 · gasto mensual de bolsillo</div>
<div class="big">Gs. 2.8M</div>
<div class="big-sub">gasto mensual mediano en cuidado</div>
</div>
<div class="card c4">
<h3>Vulnerabilidad económica</h3>
<div class="qref">8.1 vs 8.4 · gasto/ingreso</div>
<div class="big warn">31%</div>
<div class="big-sub">destina más del 30% de su ingreso al cuidado</div>
</div>
<div class="card c4">
<h3>Dependencia del Estado</h3>
<div class="qref">8.3 · subsidios estatales</div>
<div class="big">12%</div>
<div class="big-sub">apoya los gastos con subsidios estatales</div>
</div>
<div class="card c6">
<h3>Perfil de la población dependiente cuidada</h3>
<div class="qref">4.1 · por instancia de vínculo</div>
<div class="donut-wrap">
<svg class="donut" viewBox="0 0 42 42">
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#16203a" stroke-width="6"/>
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#43BBC8" stroke-width="6" stroke-dasharray="38 62" stroke-dashoffset="25"/>
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#f0b429" stroke-width="6" stroke-dasharray="29 71" stroke-dashoffset="-13"/>
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#f06b6b" stroke-width="6" stroke-dasharray="33 67" stroke-dashoffset="-42"/>
</svg>
<div class="legend">
<div><span class="dot" style="background:#43BBC8"></span> Niñez (0-13) — 38%</div>
<div><span class="dot" style="background:#f0b429"></span> Persona con discapacidad — 29%</div>
<div><span class="dot" style="background:#f06b6b"></span> Adulto mayor dependiente — 33%</div>
</div>
</div>
<div class="note">Las tres poblaciones objetivo del SINACUP. Métrica por instancia (familiares declarados), no por persona.</div>
</div>
<div class="card c6">
<h3>Quién sostiene el cuidado</h3>
<div class="qref">5.3 + 8.3 · red de soporte</div>
<div class="bar-row"><span class="lab">Otro familiar</span><div class="bar-track"><div class="bar-fill" style="width:58%"></div></div><span class="val">58%</span></div>
<div class="bar-row"><span class="lab">Cuidador contratado</span><div class="bar-track"><div class="bar-fill" style="width:34%"></div></div><span class="val">34%</span></div>
<div class="bar-row"><span class="lab">Centro de cuidado</span><div class="bar-track"><div class="bar-fill" style="width:22%"></div></div><span class="val">22%</span></div>
<div class="bar-row"><span class="lab">Comunidad/vecinos</span><div class="bar-track"><div class="bar-fill slate" style="width:14%"></div></div><span class="val">14%</span></div>
</div>
<div class="card c8">
<h3>Prevalencia de cuidado por departamento</h3>
<div class="qref">4.1 × 2.1 · concentración geográfica</div>
<div class="bar-row"><span class="lab">Central</span><div class="bar-track"><div class="bar-fill" style="width:61%"></div></div><span class="val">61%</span></div>
<div class="bar-row"><span class="lab">Asunción</span><div class="bar-track"><div class="bar-fill" style="width:48%"></div></div><span class="val">48%</span></div>
<div class="bar-row"><span class="lab">Alto Paraná</span><div class="bar-track"><div class="bar-fill" style="width:39%"></div></div><span class="val">39%</span></div>
<div class="bar-row"><span class="lab">Itapúa</span><div class="bar-track"><div class="bar-fill slate" style="width:0%"></div></div><span class="val" style="color:#5e6f8c">n&lt;5</span></div>
<div class="note">Itapúa suprimido por k-anonimato (n&lt;5). Solo se muestran departamentos con datos suficientes.</div>
</div>
<div class="card c4">
<h3>Cuidado de larga duración</h3>
<div class="qref">5.4 · cronicidad</div>
<div class="big">46%</div>
<div class="big-sub">cuida hace más de 3 años</div>
</div>
<div class="card c12 req">
<h3><i class="ti ti-circle-plus"></i> Tiempo de cuidado no remunerado — requiere nueva pregunta</h3>
<div class="qref">aproximado hoy por 6.1×6.2 en rangos</div>
<div class="big-sub" style="color:#f0975a">Para conectar con el indicador de CEPAL/Uso del Tiempo (el cuidado no remunerado fue 22,4% del PIB paraguayo en 2016), convendría una pregunta de horas semanales de cuidado no remunerado. Hoy se aproxima con rangos de días×horas, pero no es comparable con la Encuesta de Uso del Tiempo nacional.</div>
</div>
</div>
<div class="k">k≥5 sobre count(DISTINCT response_id), también en métricas por instancia</div>
</div>
<!-- ===== MIRADA HUMANA ===== -->
<div id="humana" class="lens-content" style="display:none">
<div class="lens-intro"><strong>Mirada Humana.</strong> Bienestar de quien cuida y de quien es cuidado. <em>Estos indicadores dialogan con agendas de UNICEF, UNFPA y la Convención de Derechos de PcD; no son indicadores oficiales de esos organismos, son lecturas propias del instrumento.</em></div>
<div class="grid">
<div class="card c4 star">
<h3><i class="ti ti-battery-2"></i> Agotamiento del cuidador</h3>
<div class="qref">7.1 + 7.4 · sobrecarga</div>
<div class="big danger">37%</div>
<div class="big-sub">descansa muy poco y el agotamiento afecta su desempeño</div>
</div>
<div class="card c8">
<h3>Aspectos de vida sacrificados por cuidar</h3>
<div class="qref">7.4 · qué pierde la persona</div>
<div class="bar-row"><span class="lab">Tiempo libre</span><div class="bar-track"><div class="bar-fill rose" style="width:71%"></div></div><span class="val">71%</span></div>
<div class="bar-row"><span class="lab">Sueño y descanso</span><div class="bar-track"><div class="bar-fill rose" style="width:58%"></div></div><span class="val">58%</span></div>
<div class="bar-row"><span class="lab">Salud emocional</span><div class="bar-track"><div class="bar-fill amber" style="width:49%"></div></div><span class="val">49%</span></div>
<div class="bar-row"><span class="lab">Vida social</span><div class="bar-track"><div class="bar-fill amber" style="width:44%"></div></div><span class="val">44%</span></div>
<div class="bar-row"><span class="lab">Desarrollo profesional</span><div class="bar-track"><div class="bar-fill" style="width:33%"></div></div><span class="val">33%</span></div>
</div>
<div class="card c4">
<h3>Soledad del cuidado</h3>
<div class="qref">5.3 · cuida sin red</div>
<div class="big warn">21%</div>
<div class="big-sub">no tiene quién lo releve mientras trabaja</div>
</div>
<div class="card c4">
<h3>Cuidado permanente</h3>
<div class="qref">6.4 · guardia sin horario</div>
<div class="big warn">28%</div>
<div class="big-sub">debe estar disponible de forma permanente</div>
</div>
<div class="card c4">
<h3>Inclusión laboral de PcD</h3>
<div class="qref">3.1 · discapacidad propia</div>
<div class="big">9%</div>
<div class="big-sub">de la plantilla tiene discapacidad propia</div>
</div>
<div class="card c4">
<h3>Disposición solidaria</h3>
<div class="qref">9.3 + 9.4 · voluntariado</div>
<div class="big">43%</div>
<div class="big-sub">participaría en redes de apoyo o voluntariado</div>
</div>
<div class="card c12 req">
<h3><i class="ti ti-circle-plus"></i> Escolarización de niñez cuidada — requiere nueva pregunta</h3>
<div class="qref">propuesta: 4.4 (instance_of 4.1, condicional a vínculo niño)</div>
<div class="big-sub" style="color:#f0975a">Para la lectura que dialoga con UNICEF/UNESCO sobre educación inclusiva, faltaría capturar si la niñez cuidada está escolarizada y enfrenta barreras de aprendizaje. La arquitectura de instancias ya existente lo soporta: sería una pregunta de instancia que aparece solo cuando el vínculo es un niño/a. Sugerencia para validar con el equipo.</div>
</div>
</div>
<div class="k">k≥5 aplicado · datos sensibles agregados</div>
</div>
<div class="footnote">
<strong>Nota de honestidad metodológica.</strong> Todos los números son datos de ejemplo ilustrativos, NO datos reales. Cada métrica está anclada a preguntas reales del instrumento v3 (referencia bajo cada título) y es construible con queries sobre la tabla <code>answers</code>, respetando k≥5. Las métricas marcadas en naranja <strong>requieren agregar una pregunta</strong> al instrumento. Los marcos internacionales (OIT 5R, CEPAL, PNCUPA, UNICEF/UNFPA) se citan como referencia conceptual; el alineamiento formal con cualquier organismo debe validarlo el equipo. Propuesta para discusión, no especificación final.
</div>
</div>
<script>
function showLens(id, el){
document.querySelectorAll('.lens-content').forEach(c=>c.style.display='none');
document.getElementById(id).style.display='block';
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
el.classList.add('active');
}
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado # ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 20 junio 2026 ## Documento de continuidad — 25 junio 2026
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación > Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce > Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
@@ -7,125 +7,115 @@
--- ---
## 1. Versionado y ramas ## 1. Versionado y ramas
- **Producción:** 2.1.0 (formulario lineal) — los 3 surveys en `draft`
- **Desarrollo:** 3.0.0 (wizard + dashboard nuevo) en `main`, NO desplegado aún
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
- **Versión en producción:** 2.1.0 (formulario lineal, dashboard multitenante) ⚠️ Los 3 surveys están en `draft` a propósito (el frontend v2 desplegado no entiende seed v3).
- **Versión en desarrollo:** 3.0.0 (wizard, en `main`) Se reactivan a `live` al desplegar v3 completo.
- **Rama de preservación:** `v2.1.0-stable` (código + dump DB) + tag `v2.1.0`
- Convención: SemVer + CHANGELOG.md (raíz) + tags en cada versión estable
- ADRs en `docs/adr/`. ADR-005 = esquema declarativo + estrategia dos fases.
--- ---
## 2. Sprint 4 — Wizard v3 — EN CURSO ## 2. Sprint 4 — Wizard v3 — Formulario COMPLETO, falta release
Arquitectura (ADR-005): el formulario es un **esquema declarativo** (datos); el frontend | Etapa | Estado |
es un **renderer** sin reglas de negocio; el motor interpreta el esquema. |---|---|
Estrategia dos fases: Fase 1 (este sprint) formulario-como-esquema; Fase 2 (BACKLOG-002) | 4.0 Preservación v2 | ✅ |
motor genérico universal. | 4A instance_id | ✅ prod |
| 4B esquema declarativo + seed v3 | ✅ prod |
| 4C motor de flujo (lib/form-engine.ts, puro) | ✅ |
| 4D wizard renderer (layout estable) | ✅ |
| 4E instancias (emergente del motor) | ✅ |
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
| 4F autocomplete barrios (UX) | ⏳ pendiente |
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ⏳ pendiente |
### Etapas El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
| Etapa | Nombre | Estado | server-side (RPC `rpc_submit_response`, único camino de escritura, verificado).
|---|---|---|
| 4.0 | Preservación v2 (tag, rama, dump, CHANGELOG) | ✅ |
| 4A | Persistencia `instance_id` en answers | ✅ aplicada en prod |
| 4B | Esquema declarativo + seed v3 (10 secciones, 150 questions) | ✅ aplicada en prod |
| 4C | Motor de resolución de flujo (puro, lib/, 16 tests) | ✅ commiteada |
| 4D | Frontend renderer (consume el motor, una preg/pantalla) | ⏳ siguiente |
| 4E | Instancias de vínculo (mini-wizard familiares) | pendiente |
| 4F | Autocomplete barrios (requiere geo_paraguay_full.json) | pendiente |
| 4G | Submit con instance_id + **validación server-side (no negociable)** | pendiente |
| 4H | RPCs de vínculo (agregados por instancia) | pendiente |
| 4I | Validación e2e + tag v3.0.0 + ADR + BACKLOG Fase 2 | pendiente |
Orden: 4D → (4E ∥ 4F) → 4G → 4H → 4I
### Estado técnico v3 en la DB (ya aplicado en prod)
- `answers.instance_id text DEFAULT 'default'`, constraint `(response_id, question_id, instance_id)`
- `questions` extendida: `instance_generator`, `instance_of`, `hint`
- 150 questions (50 × 3 surveys), instrumento v3 de 10 secciones
- 4.1 = `instance_generator`; 4.2/4.3 = `instance_of='4.1'`
- Datos de prueba v2 borrados (TRUNCATE); v3 arranca limpio
### Motor de flujo (4C) — lib/form-engine.ts
- Módulo puro, sin React/DOM. Tests en `lib/form-engine.test.mts` (16/16).
- Operadores: `eq`, `eq_any`, `not_in` (escalares); `contains`, `eq_sole` (array multiple_choice)
- Acciones: `show`, `hide` (hide = veto absoluto sobre show del mismo array)
- Reglas múltiples en un array = OR
- ID de instancia: `familiar_${posición fija de opción}` (determinista, estable)
- Secciones = pantallas propias; se ocultan solo si su fila type='section' tiene regla
### Recorridos del formulario (validados en mockup y tests)
- Completo: todos los vínculos → todas las secciones
- Solo niño sin discapacidad (`eq_sole` op 1): liviano → 5.3, 8.4, 9.1, 9.4
- Ninguna (`contains` op 8): salta a Sección 10
- 3.1='No': oculta 3.2-3.5
--- ---
## 3. Decisiones arquitectónicas clave (no reabrir) ## 3. Dashboard v3 — DECISIÓN MAYOR: rediseño de "tres miradas"
1. Lógica de flujo en datos (esquema), no en frontend (ADR-005) **Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar,
2. Frontend = renderer sin reglas de negocio dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
3. Motor de flujo (4C) corre en cliente, módulo puro testeable
4. **Validación de integridad del submit = server-side, NO negociable (4G)** ### Estado
La validación de cliente es UX, no seguridad. Datos de salud sensibles. - **Mirada Empresa:** ✅ RPCs implementadas y VERIFICADAS contra 50 respuestas reales.
5. Secciones como pantallas propias (reversible — es comportamiento del motor) E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación
6. Se extiende `questions`, no se crea tabla de definición (ADR-005) con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
7. Migraciones vía CLI del VPS (`docker exec -i supabase-db psql`) Widgets del frontend: PENDIENTE (etapa siguiente).
8. consent_records inmutable (ADR-003); el seed nunca toca datos de respuesta - **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
sprint de dashboard post-deadline.
### Documentos (en docs/dashboard/)
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados honestamente
- `dashboard_tres_miradas_mockup.html` — mockup funcional 3 pestañas
- `SPEC_MIRADA_EMPRESA.md` — contrato técnico de las 10 métricas + estrella
### Decisiones del dashboard
- CUIDADOR_ACTIVO incluye "hijo menor sin discapacidad" (criterio UNFPA, validado por Maria).
Definido en helper `_dash_es_cuidador`.
- Mirada Empresa NO expone datos sensibles (3.x, 4.2, 4.3). Solo 4.1 existencia, 6.x, 7.x, 9.x, 2.6, 2.7.
- Métrica por persona vs por instancia: presentar ambas rotuladas; k≥5 sobre count(DISTINCT response_id).
- 2 gaps de datos marcados [REQUIERE PREGUNTA]: horas cuidado no remunerado (País),
escolarización niñez (Humana). Para el sprint de dashboard.
--- ---
## 4. Deuda técnica y seguridad ## 4. Deuda técnica y seguridad
| ID | Descripción | Prioridad | | ID | Descripción | Prioridad |
|---|---|---| |---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales | | TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales | | TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
| SEC-002 | Sprint de compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado | | TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
| SEC-003 | **Agujero TRUNCATE evade inmutabilidad ADR-003** — cerrar antes de datos reales | Antes de datos reales | | SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline | | SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales |
| SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
COMPLIANCE.md documenta estado de seguridad, roadmap de auditabilidad, y la decisión | BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
fundamentada de NO usar blockchain.
--- ---
## 5. Infraestructura (sin cambios) ## 5. Infraestructura
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11) - Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
- App: `economia-cuidado.inqualityhq.com` · API: `db.inqualityhq.com` - App: economia-cuidado.inqualityhq.com · API: db.inqualityhq.com
- Repos Gitea: `git.inquality.com.py/marcos/motor-de-encuestas` - Repos Gitea: git.inquality.com.py/marcos/motor-de-encuestas
- Backups GFS locales 3AM - Para sembrar datos de prueba: `seed_prueba_50.sql` (en docs/dashboard/), 50 respuestas
deterministas vía RPC. Usa question_id (UUIDs), NO question_code. Incluye su limpieza.
--- ---
## 6. Contexto comercial ## 6. Próximos pasos
- 2 clientes inminentes en 10-20 días (motivan el deadline de v3) 1. **Widgets de Mirada Empresa** — el frontend del dashboard que consume las RPCs ya verificadas.
- InQuality y Fundación Solidaridad ya en joint venture del producto La spec (SPEC_MIRADA_EMPRESA.md) define shapes de retorno; el mockup define el aspecto.
- Datos de prueba descartables (existe dump en v2.1.0-stable) 2. **4F autocomplete barrios** — subir `geo_paraguay_full.json` (8152 barrios) a public/data/.
3. **4I release** — validación e2e, tag v3.0.0, desplegar v3, reactivar surveys a live.
4. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad.
Orden sugerido para go-live: widgets Mirada Empresa → 4F → 4I.
--- ---
## 7. Próximo paso inmediato ## 7. Contexto comercial
**Etapa 4D — Frontend renderer.** El componente consume el motor (4C) y renderiza - 2 clientes inminentes en ~10-20 días (deadline v3). Mirada Empresa es la que le habla al cliente que paga.
una pregunta por pantalla. Incluye la tarea 4.5 pospuesta de 4C (query de app/page.tsx - InQuality y Fundación Solidaridad en joint venture.
con hint/instance_generator/instance_of). Reemplaza lib/conditional.ts y la lógica
de secciones hardcodeada de survey-form.tsx v2.
--- ---
## 8. Prompt para nueva sesión ## 8. Prompt para nueva sesión
``` ```
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de formularios
formularios soberano (Paraguay). Yo soy el product owner. Estamos en Sprint 4 soberano (Paraguay). Yo soy el product owner. Sprint 4: wizard v3 (ADR-005, esquema declarativo).
construyendo el formulario wizard v3 (esquema declarativo, ADR-005).
Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md en el repo. Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md.
Sprint: sprints/sprint-4/ (BRIEF + prompts de etapa + reportes). Formulario v3: COMPLETO y verificado (motor, wizard, submit con validación server-side).
Completadas: 4.0, 4A, 4B, 4C. Siguiente: 4D (frontend renderer). Dashboard: rediseño de "tres miradas". Mirada Empresa = RPCs implementadas y verificadas
contra datos reales; faltan sus widgets. País/Humana diferidas a sprint posterior.
El motor de flujo ya existe y está testeado (lib/form-engine.ts, 16 tests). Pendiente go-live: widgets Mirada Empresa → 4F (barrios) → 4I (release).
La lógica vive en datos; el frontend es renderer sin reglas. Los 3 surveys en draft (v3 no desplegado aún).
Próximo paso: [COMPLETAR] Próximo paso: [COMPLETAR]
``` ```

View File

@@ -4,21 +4,43 @@
import type { DashboardFilters } from './dashboard-types' import type { DashboardFilters } from './dashboard-types'
import { filtersToQuery } from './dashboard-utils' import { filtersToQuery } from './dashboard-utils'
async function fetchWidget<T>(widget: string, surveyId: string, filters: DashboardFilters): Promise<T> { async function fetchWidget<T>(
widget: string,
surveyId: string,
filters: DashboardFilters,
extra?: Record<string, string>
): Promise<T> {
const qs = filtersToQuery(surveyId, filters) const qs = filtersToQuery(surveyId, filters)
const res = await fetch(`/api/dashboard/${widget}?${qs}`, { cache: 'no-store' }) const extraQs = extra ? `&${new URLSearchParams(extra).toString()}` : ''
const res = await fetch(`/api/dashboard/${widget}?${qs}${extraQs}`, { cache: 'no-store' })
if (!res.ok) throw new Error(`Widget ${widget}: ${res.status}`) if (!res.ok) throw new Error(`Widget ${widget}: ${res.status}`)
return res.json() return res.json()
} }
// Mirada Empresa — una entrada por métrica (E1-E10 + estrella).
export const queries = { export const queries = {
kpis: (sid: string, f: DashboardFilters) => fetchWidget('kpis', sid, f), mePrevalencia: (sid: string, f: DashboardFilters) =>
prevalencia: (sid: string, f: DashboardFilters) => fetchWidget('prevalencia', sid, f), fetchWidget('me-prevalencia', sid, f),
heatmap: (sid: string, f: DashboardFilters) => fetchWidget('heatmap', sid, f), meRiesgoRotacion: (sid: string, f: DashboardFilters) =>
distribucion: (sid: string, f: DashboardFilters) => fetchWidget('distribucion', sid, f), fetchWidget('me-riesgo-rotacion', sid, f),
apoyos: (sid: string, f: DashboardFilters) => fetchWidget('apoyos', sid, f), meAusentismo: (sid: string, f: DashboardFilters) =>
score: (sid: string, f: DashboardFilters) => fetchWidget('score', sid, f), fetchWidget('me-ausentismo', sid, f),
piramide: (sid: string, f: DashboardFilters) => fetchWidget('piramide', sid, f), mePresentismo: (sid: string, f: DashboardFilters) =>
intensidad: (sid: string, f: DashboardFilters) => fetchWidget('intensidad', sid, f), fetchWidget('me-presentismo', sid, f),
saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f), mePuntoCiego: (sid: string, f: DashboardFilters) =>
fetchWidget('me-punto-ciego', sid, f),
meApoyos: (sid: string, f: DashboardFilters) =>
fetchWidget('me-apoyos', sid, f),
// p_umbral_senales es parametrizable en la RPC — el widget puede pasarlo,
// si no se pasa nada la RPC usa su propio default (2 de 3).
meTalentoRiesgo: (sid: string, f: DashboardFilters, umbral?: number) =>
fetchWidget('me-talento-riesgo', sid, f, umbral !== undefined ? { umbral: String(umbral) } : undefined),
meCarreraCongelada: (sid: string, f: DashboardFilters) =>
fetchWidget('me-carrera-congelada', sid, f),
meIntensidadCarga: (sid: string, f: DashboardFilters) =>
fetchWidget('me-intensidad-carga', sid, f),
meSegmentacion: (sid: string, f: DashboardFilters) =>
fetchWidget('me-segmentacion', sid, f),
meDisposicionRed: (sid: string, f: DashboardFilters) =>
fetchWidget('me-disposicion-red', sid, f),
} }

View File

@@ -9,51 +9,6 @@ export interface DashboardFilters {
// Estado de cada widget // Estado de cada widget
export type WidgetStatus = 'loading' | 'ok' | 'suppressed' | 'error' export type WidgetStatus = 'loading' | 'ok' | 'suppressed' | 'error'
// Payloads de cada RPC
export interface KpiData {
status: 'ok'
n: number
pct_cuidadores: number
score_bienestar: number | null // avg raw 4.3 (1.05.0)
pct_ausencias: number
}
export interface DeptoData {
depto: string
n: number | null
pct_cuidadores: number | null
suppressed: boolean
}
export interface PrevalenciaData { status: 'ok'; n_total: number; data: DeptoData[] }
export interface HeatmapCell { horas: string; ausencias: string; n: number | null; suppressed: boolean }
export interface HeatmapData { status: 'ok'; n_total: number; data: HeatmapCell[] }
export interface DistribucionData {
status: 'ok'; n: number
disc_propia: number; familiar: number; adulto_mayor: number; ninguno: number
}
export interface ApoyoItem { politica: string; n: number; pct: number }
export interface ApoyosData { status: 'ok'; n_total: number; data: ApoyoItem[] }
export interface ScoreData { status: 'ok'; n: number; score: number; zona: 'baja' | 'desarrollo' | 'consciente' }
export interface PiramideCell { etario: string; genero: string; n: number | null; suppressed: boolean }
export interface PiramideData { status: 'ok'; n_total: number; data: PiramideCell[] }
export interface IntensidadCell { horas: string; bienestar: string; n: number | null; suppressed: boolean }
export interface IntensidadData { status: 'ok'; n_total: number; data: IntensidadCell[] }
export interface SaturacionSegmento {
segmento: string
n: number | null
pct: number | null
apoyo: string | null
suppressed: boolean
}
export interface SaturacionData { status: 'ok'; n_total: number; segmentos: SaturacionSegmento[] }
export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number } export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number }
export type RpcResult<T> = T | SuppressedPayload export type RpcResult<T> = T | SuppressedPayload
@@ -61,3 +16,123 @@ export type RpcResult<T> = T | SuppressedPayload
export function isSuppressed(r: unknown): r is SuppressedPayload { export function isSuppressed(r: unknown): r is SuppressedPayload {
return typeof r === 'object' && r !== null && (r as Record<string, unknown>)['status'] === 'suppressed' return typeof r === 'object' && r !== null && (r as Record<string, unknown>)['status'] === 'suppressed'
} }
// ════════════════════════════════════════════════════════════
// Mirada Empresa — shapes tal cual los devuelven las rpc_me_*
// (migración 20260625000002_dashboard_mirada_empresa.sql, verificada
// contra Postgres real). NO copiar del SPEC_MIRADA_EMPRESA.md a ciegas:
// el SQL real difiere un poco del shape ilustrativo de la spec (ej. E6
// agrega apoyo_percibido_suppressed; E7 usa "apoyos", no "data").
// ════════════════════════════════════════════════════════════
// E1 — Prevalencia de cuidadores
export interface PrevalenciaCuidadoresData {
status: 'ok'
n: number
pct_cuidadores: number
}
// E2 — Riesgo de rotación
export interface RiesgoRotacionData {
status: 'ok'
n_cuidadores: number
pct_riesgo: number
desglose: { algo: number; muy: number }
}
// E3 — Ausentismo
export interface AusentismoData {
status: 'ok'
n: number
pct_ausentismo_recurrente: number
desglose: { nunca: number; pocas: number; varias: number; frecuente: number }
}
// E4 — Presentismo
export interface PresentismoData {
status: 'ok'
n: number
pct_presentismo: number
desglose: {
nunca_o_casi_nunca: number
algunas_al_mes: number
varias_por_semana: number
casi_todos_los_dias: number
}
}
// E6 — Punto ciego organizacional
// apoyo_percibido_pct puede ser null con status='ok' si SU población (9.1)
// no llega a 5, aunque el total (carga_real) sí — dos poblaciones, dos
// supresiones independientes (ver comentario en la RPC).
export interface PuntoCiegoData {
status: 'ok'
n: number
carga_real_pct: number
apoyo_percibido_pct: number | null
apoyo_percibido_suppressed: boolean
brecha_pp: number | null
}
// E7 — Apoyos demandados
export interface ApoyoMEItem { apoyo: string; n: number; pct: number }
export interface ApoyosMEData {
status: 'ok'
n: number
apoyos: ApoyoMEItem[]
}
// ★ Talento en riesgo
export interface TalentoRiesgoData {
status: 'ok'
n_cuidadores: number
umbral_senales: number
pct_talento_riesgo: number
criterios_promedio: number
}
// E5 — Carrera congelada [SEC]
export interface CarreraCongeladaItem { opcion: string; n: number; pct: number }
export interface CarreraCongeladaData {
status: 'ok'
n: number
pct_carrera_congelada: number
top_opciones: CarreraCongeladaItem[]
}
// E8 — Intensidad de carga [SEC]
export interface IntensidadCargaCell {
dias_semana: string
horas_dia: string
n: number | null
suppressed: boolean
}
export interface IntensidadCargaData {
status: 'ok'
n: number
distribucion_intensidad: IntensidadCargaCell[]
pct_guardia_permanente: number
}
// E9 — Segmentación por nivel y modalidad [SEC]
export interface SegmentoNivelItem { nivel: string; n: number; pct: number }
export interface SegmentoModalidadItem { modalidad: string; n: number; pct: number }
export interface SegmentacionData {
status: 'ok'
n: number
por_nivel: SegmentoNivelItem[]
por_modalidad: SegmentoModalidadItem[]
}
// E10 — Disposición a red de apoyo [SEC]
// Shape "suppressed" de esta RPC difiere del genérico (usa
// n_grupo_apoyo/n_voluntariado, no min_n/n) — isSuppressed() solo mira
// `status`, así que sigue funcionando; no leer .n de un resultado
// suprimido de esta RPC en particular.
export interface DisposicionRedData {
status: 'ok'
n_grupo_apoyo: number
pct_grupo_apoyo: number | null
n_voluntariado: number
pct_voluntariado: number | null
}

View File

@@ -33,18 +33,4 @@ export function filtersToRpc(filters: DashboardFilters): Record<string, unknown>
} }
} }
export const HORAS_ORDER = ['Menos de 1h','Entre 1 y 3h','Entre 3 y 6h','Más de 6h','Varía mucho']
export const AUSENCIAS_ORDER = ['Nunca','Pocas veces (13 veces al año)','Varias veces (410 veces al año)','Con frecuencia (más de 10 veces al año)']
export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir'] export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir']
export function sortByOrder<T extends Record<string, unknown>>(
arr: T[],
key: keyof T,
order: string[]
): T[] {
return [...arr].sort((a, b) => {
const ia = order.indexOf(a[key] as string)
const ib = order.indexOf(b[key] as string)
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
})
}

View File

@@ -15,6 +15,8 @@ import {
resolveScreens, resolveScreens,
initialState, initialState,
setAnswer, setAnswer,
applyAnswer,
isRequiredAndUnanswered,
next, next,
back, back,
} from './form-engine.ts' } from './form-engine.ts'
@@ -342,3 +344,92 @@ describe('resolveVisibility — combinación show/hide', () => {
assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false) assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false)
}) })
}) })
// ── applyAnswer — integridad de datos al remover una instancia ──────────
// Esta lógica vivía en wizard-form.tsx (handleChange) hasta ahora; se movió
// al motor para que sea testeable como módulo puro (ver conversación 4E).
describe('applyAnswer — limpieza de respuestas al remover una instancia', () => {
test('quitar un vínculo de 4.1 borra las respuestas de esa instancia y deja intactas las del resto', () => {
const schema = buildFixtureSchema()
let state = { answers: {} as FormAnswers, currentScreenId: 'question:4.1' }
// Selecciona 2 vínculos → familiar_2 (índice 1) y familiar_5 (índice 4)
state = applyAnswer(state, schema, '4.1', [VINCULOS[1], VINCULOS[4]])
// Responde 4.2/4.3 para AMBAS instancias
state = applyAnswer(state, schema, '4.2', ['Visual'], 'familiar_2')
state = applyAnswer(state, schema, '4.3', ['Bastón blanco'], 'familiar_2')
state = applyAnswer(state, schema, '4.2', ['Auditiva'], 'familiar_5')
state = applyAnswer(state, schema, '4.3', ['Audífonos'], 'familiar_5')
assert.deepEqual(state.answers['4.2:familiar_2'], ['Visual'])
assert.deepEqual(state.answers['4.3:familiar_2'], ['Bastón blanco'])
assert.deepEqual(state.answers['4.2:familiar_5'], ['Auditiva'])
assert.deepEqual(state.answers['4.3:familiar_5'], ['Audífonos'])
// Quita el vínculo de familiar_2 — deja solo familiar_5 en 4.1
state = applyAnswer(state, schema, '4.1', [VINCULOS[4]])
// Las respuestas de la instancia removida desaparecen del estado
assert.equal('4.2:familiar_2' in state.answers, false)
assert.equal('4.3:familiar_2' in state.answers, false)
// Las del familiar que queda permanecen intactas, sin reindexarse
assert.deepEqual(state.answers['4.2:familiar_5'], ['Auditiva'])
assert.deepEqual(state.answers['4.3:familiar_5'], ['Audífonos'])
})
test('aplicar la misma respuesta sin cambiar visibilidad no borra nada (caso sin disappeared)', () => {
const schema = buildFixtureSchema()
let state = { answers: {} as FormAnswers, currentScreenId: 'question:3.1' }
state = applyAnswer(state, schema, '3.1', 'Sí')
state = applyAnswer(state, schema, '3.2', ['Física / motora'])
state = applyAnswer(state, schema, '3.1', 'Sí') // mismo valor, no cambia nada visible
assert.deepEqual(state.answers['3.2:default'], ['Física / motora'])
})
})
// ── isRequiredAndUnanswered — incluye preguntas de instancia ─────────────
// El seed v3 actual no marca ninguna pregunta de instancia (4.2/4.3) como
// required — este test usa un esquema sintético mínimo para cubrir el
// camino igual, ya que el código no debe asumir que nunca va a pasar.
function buildRequiredInstanceSchema() {
const rows: Question[] = [
q({
code: 'G1', type: 'multiple_choice', prompt: 'Generador', position: 1,
instance_generator: true,
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }],
}),
q({ code: 'G1.1', type: 'text_short', prompt: 'Dato requerido por instancia', position: 2, instance_of: 'G1', required: true }),
]
return buildFormSchema(rows)
}
describe('isRequiredAndUnanswered — cubre preguntas de instancia required', () => {
test('cada instancia de una pregunta required se evalúa por separado', () => {
const schema = buildRequiredInstanceSchema()
const answers: FormAnswers = { 'G1:default': ['a', 'b'] }
const screens = resolveScreens(schema, answers)
const screenA = screens.find((s) => s.id === 'question:G1.1:familiar_1')
const screenB = screens.find((s) => s.id === 'question:G1.1:familiar_2')
assert.ok(screenA && screenB, 'deben existir 2 pantallas de instancia para G1.1')
// Sin responder ninguna instancia → unanswered en ambas
assert.equal(isRequiredAndUnanswered(screenA!, answers), true)
assert.equal(isRequiredAndUnanswered(screenB!, answers), true)
// Responder SOLO la instancia 1 → deja de exigirse esa, la otra sigue pendiente
const partial = { ...answers, 'G1.1:familiar_1': 'dato' }
assert.equal(isRequiredAndUnanswered(screenA!, partial), false)
assert.equal(isRequiredAndUnanswered(screenB!, partial), true)
})
test('preguntas no-required nunca bloquean, tengan o no respuesta', () => {
const schema = buildFixtureSchema()
const screens = resolveScreens(schema, {})
const screen = screens.find((s) => s.id === 'question:3.1')
assert.ok(screen)
assert.equal(isRequiredAndUnanswered(screen!, {}), false)
})
})

View File

@@ -176,6 +176,53 @@ export function setAnswer(
} }
} }
/** Exportada porque también la usa el validador de submit server-side (4G). */
export function isAnswerEmpty(v: AnswerValue | undefined): boolean {
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
}
/**
* Aplica una respuesta y limpia las respuestas de pantallas que dejaron de
* ser visibles como CONSECUENCIA de ese cambio (ej.: sacar un vínculo de 4.1
* borra las respuestas de 4.2/4.3 de esa instancia puntual; las de las demás
* instancias quedan intactas porque conservan su propio answerKey). Esto es
* consecuencia de flujo (ADR-005), no presentación — por eso vive en el
* motor y no en el componente.
*/
export function applyAnswer(
state: WizardState,
schema: FormSchema,
code: string,
value: AnswerValue,
instanceId: string = DEFAULT_INSTANCE
): WizardState {
const updated = setAnswer(state, code, value, instanceId)
const before = resolveScreens(schema, state.answers)
const after = resolveScreens(schema, updated.answers)
const afterIds = new Set(after.map((s) => s.id))
const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question)
if (disappeared.length === 0) return updated
const clearedAnswers = { ...updated.answers }
for (const s of disappeared) {
delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)]
}
return { ...updated, answers: clearedAnswers }
}
/**
* true si la pantalla es una pregunta `required` y todavía no tiene
* respuesta — incluye preguntas de instancia (cada instancia se evalúa por
* su propio answerKey, independiente de las demás). El seed v3 actual no
* marca ninguna pregunta de instancia como required, pero el camino queda
* cubierto para cuando exista una.
*/
export function isRequiredAndUnanswered(screen: Screen, answers: FormAnswers): boolean {
if (!screen.question?.required) return false
return isAnswerEmpty(answers[answerKey(screen.question.code, screen.instanceId ?? undefined)])
}
/** /**
* Avanza una pantalla. Recalcula la lista de pantallas visibles desde las * Avanza una pantalla. Recalcula la lista de pantallas visibles desde las
* respuestas actuales y busca la pantalla actual por ID (nunca por índice * respuestas actuales y busca la pantalla actual por ID (nunca por índice

View File

@@ -0,0 +1,232 @@
// Tests Node puros (sin navegador, sin DB) del validador server-side de submit.
// Ejecutar: node --test lib/submit-validation.test.mts
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import { buildFormSchema } from './form-engine.ts'
import { validateSubmission } from './submit-validation.ts'
import type { ConditionalRule, Question } from './form-engine.types.ts'
import type { SubmitAnswerEntry } from './submit-validation.ts'
function q(partial: Partial<Question> & Pick<Question, 'code' | 'type' | 'prompt' | 'position'>): Question {
return {
id: partial.code,
is_sensitive: false,
required: false,
hint: null,
options: null,
max_select: null,
conditional_rules: null,
instance_generator: false,
instance_of: null,
...partial,
}
}
const r_show_si_3_1: ConditionalRule[] = [
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
]
const VINCULOS = ['Hijo menor con discapacidad', 'Familiar mayor con discapacidad', 'Otro / Especificar']
function buildFixtureSchema() {
const rows: Question[] = [
q({ code: 'S2', type: 'section', prompt: 'Datos generales', position: 20 }),
q({
code: '2.5', type: 'single_choice', prompt: 'Género', position: 25, required: true,
options: [{ value: 'Femenino', label: 'Femenino' }, { value: 'Masculino', label: 'Masculino' }],
}),
q({ code: 'S3', type: 'section', prompt: 'Prevalencia', position: 30 }),
q({
code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31,
options: [{ value: 'Sí', label: 'Sí' }, { value: 'No', label: 'No' }],
}),
q({
code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_3_1,
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
}),
q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }),
q({
code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41, instance_generator: true,
options: VINCULOS.map((v) => ({ value: v, label: v })),
}),
q({
code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1',
max_select: 2,
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
}),
]
return buildFormSchema(rows)
}
// ── Happy path: 2 familiares ──────────────────────────────────────────────
test('happy path: submit válido con 2 familiares — payload completo con instance_id', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: 'No' }, // oculta 3.2 — no se manda
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0], VINCULOS[1]] },
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual'] },
{ code: '4.2', instanceId: 'familiar_2', value: ['Auditiva'] },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, true)
if (!result.ok) return
// ── Dato concreto pedido en el reporte: filas reales con instance_id ──
console.log('\nPayload validado → filas listas para la RPC (questionId/instanceId/value):')
console.log(JSON.stringify(result.rows, null, 2))
assert.equal(result.rows.length, 5)
const f1 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_1')
const f2 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_2')
assert.deepEqual(f1?.value, ['Visual'])
assert.deepEqual(f2?.value, ['Auditiva'])
})
describe('rechazos — las 7 validaciones', () => {
test('rechaza respuesta a una pregunta que el flujo nunca debió mostrar (3.2 con 3.1=No)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: 'No' },
{ code: '3.2', instanceId: 'default', value: ['Visual'] }, // 3.1='No' la oculta
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('question_should_be_hidden:3.2')))
})
test('rechaza valor fuera de las opciones válidas', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: 'Tal vez' }, // no es 'Sí' ni 'No'
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.includes('value_not_in_options') && e.includes('3.1')))
})
test('rechaza max_select excedido (4.2 tiene max_select=2)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] },
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual', 'Auditiva', 'Visual'] }, // 3 > max_select(2)
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.includes('exceeds_max_select')))
})
test('rechaza instancia inventada (familiar que 4.1 no generó)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] }, // genera solo familiar_1
{ code: '4.2', instanceId: 'familiar_2', value: ['Visual'] }, // familiar_2 NO existe
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('invalid_instance:4.2:familiar_2')))
})
test('rechaza instance_id en una pregunta que no es de instancia', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'familiar_1', value: 'Femenino' }, // 2.5 no es instance_of nada
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('unexpected_instance_id:2.5')))
})
test('rechaza sin consentimiento operativo', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [{ code: '2.5', instanceId: 'default', value: 'Femenino' }]
const result = validateSubmission(schema, entries, false)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.includes('consent_operativo_required'))
})
test('rechaza pregunta requerida visible sin responder', () => {
const schema = buildFixtureSchema()
// 2.5 es required=true y no se manda
const entries: SubmitAnswerEntry[] = [{ code: '3.1', instanceId: 'default', value: 'No' }]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('required_missing:2.5')))
})
test('rechaza pregunta que no existe en el esquema (code inventado)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('unknown_question:99.9')))
})
test('rechaza respuesta a una fila type=section', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: 'S4', instanceId: 'default', value: 'algo' },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.startsWith('section_is_not_answerable:S4')))
})
test('rechaza valor de tipo incorrecto (string donde se espera array)', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '4.1', instanceId: 'default', value: 'Hijo menor con discapacidad' as unknown as string[] },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.some((e) => e.includes('expected_array_value')))
})
test('reporta TODOS los errores encontrados, no solo el primero', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
{ code: 'S4', instanceId: 'default', value: 'algo' },
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, false)
if (result.ok) return
assert.ok(result.errors.length >= 2, `esperaba >=2 errores, hubo ${result.errors.length}`)
})
})
describe('valores vacíos', () => {
test('una entrada con valor vacío no se valida ni se persiste, pero no bloquea el submit', () => {
const schema = buildFixtureSchema()
const entries: SubmitAnswerEntry[] = [
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
{ code: '3.1', instanceId: 'default', value: '' }, // vacío — se ignora, no se evalúa su visibilidad
]
const result = validateSubmission(schema, entries, true)
assert.equal(result.ok, true)
if (!result.ok) return
assert.equal(result.rows.some((r) => r.questionId === '3.1'), false)
})
})

150
lib/submit-validation.ts Normal file
View File

@@ -0,0 +1,150 @@
// Validación de integridad server-side del submit (Etapa 4G).
// REUSA lib/form-engine.ts (resolveVisibility, resolveScreens, expandInstances) —
// no reimplementa lógica de flujo. Corre en el servidor (Server Action), nunca
// en el cliente: "la validación de cliente es UX, NO seguridad" (BRIEF decisión 15).
//
// Lo que esta función NO hace: persistir nada. Solo decide si el payload recibido
// es coherente con el esquema + el propio flujo declarado por el payload, y si es
// así, devuelve las filas listas para insertar (mapeadas a question_id).
import type { AnswerValue, FormAnswers, FormSchema, Question } from './form-engine.types'
// Extensión .ts explícita (no solo en .mts): a diferencia de form-engine.ts —
// que solo importa *tipos* de form-engine.types (se borran en runtime, Node
// nunca necesita resolverlos) — este archivo importa FUNCIONES reales y
// corre tanto bajo Next.js (Server Action) como bajo Node puro (tests). Node
// exige extensión explícita para resolver imports de valor; tsconfig.json
// habilita allowImportingTsExtensions para permitir esto sin romper tsc.
import { answerKey, expandInstances, isAnswerEmpty, resolveScreens, resolveVisibility } from './form-engine.ts'
export interface SubmitAnswerEntry {
code: string
/** 'default' para preguntas normales; 'familiar_N' para preguntas de instancia. */
instanceId: string
value: AnswerValue
}
export interface ValidatedRow {
questionId: string
instanceId: string
value: AnswerValue
}
export type ValidationResult =
| { ok: true; rows: ValidatedRow[] }
| { ok: false; errors: string[] }
function validateValueShape(question: Question, value: AnswerValue): string | null {
switch (question.type) {
case 'yes_no':
return value === 'true' || value === 'false' ? null : 'invalid_yes_no_value'
case 'single_choice': {
if (typeof value !== 'string') return 'expected_string_value'
if (question.options && !question.options.some((o) => o.value === value)) {
return 'value_not_in_options'
}
return null
}
case 'multiple_choice': {
if (!Array.isArray(value)) return 'expected_array_value'
if (!value.every((v) => typeof v === 'string')) return 'expected_string_array'
if (question.max_select !== null && value.length > question.max_select) {
return 'exceeds_max_select'
}
if (question.options) {
const valid = new Set(question.options.map((o) => o.value))
if (!value.every((v) => valid.has(v as string))) return 'value_not_in_options'
}
return null
}
case 'text_short':
case 'text_long':
return typeof value === 'string' ? null : 'expected_string_value'
// rating/nps/matrix/ranking/date/slider: no usados por el instrumento v3 hoy.
// No se valida su forma específica — si en el futuro se usan, agregar el caso.
default:
return null
}
}
/**
* Corre las 7 validaciones del punto 4 de ETAPA_4G_PROMPT.md contra el
* esquema cargado de la DB. Si CUALQUIERA falla, devuelve ok:false con la
* lista completa de errores encontrados (no solo el primero) — el caller
* rechaza el submit entero, sin insertar nada.
*/
export function validateSubmission(
schema: FormSchema,
entries: SubmitAnswerEntry[],
consentOperativo: boolean
): ValidationResult {
const errors: string[] = []
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
// Reconstruye el mismo FormAnswers que tuvo el wizard — las funciones del
// motor lo necesitan para evaluar reglas/instancias/visibilidad.
const answers: FormAnswers = {}
for (const e of entries) answers[answerKey(e.code, e.instanceId)] = e.value
// 6. Consentimiento operativo requerido presente
if (!consentOperativo) errors.push('consent_operativo_required')
const nonEmpty = entries.filter((e) => !isAnswerEmpty(e.value))
// 2. Preguntas legítimas · 3. Valores válidos · 5. Instancias legítimas
for (const e of nonEmpty) {
const q = byCode.get(e.code)
if (!q) { errors.push(`unknown_question:${e.code}`); continue }
if (q.type === 'section') { errors.push(`section_is_not_answerable:${e.code}`); continue }
if (q.instance_of) {
const generator = byCode.get(q.instance_of)
const instances = generator ? expandInstances(generator, answers) : []
if (!instances.some((i) => i.instanceId === e.instanceId)) {
errors.push(`invalid_instance:${e.code}:${e.instanceId}`)
continue
}
} else if (e.instanceId !== 'default') {
errors.push(`unexpected_instance_id:${e.code}:${e.instanceId}`)
continue
}
const shapeError = validateValueShape(q, e.value)
if (shapeError) errors.push(`${shapeError}:${e.code}`)
}
if (errors.length > 0) return { ok: false, errors }
// 4. Visibilidad coherente — cada respuesta corresponde a algo que el
// flujo (con las respuestas recibidas) debía mostrar. Usa resolveVisibility
// del motor directamente — ninguna lógica de show/hide se repite acá.
for (const e of nonEmpty) {
const q = byCode.get(e.code)!
if (!resolveVisibility(q, answers)) {
errors.push(`question_should_be_hidden:${e.code}:${e.instanceId}`)
}
}
if (errors.length > 0) return { ok: false, errors }
// 7. Requeridas presentes — recorre las pantallas que el motor resuelve
// como visibles dadas estas respuestas, exige valor donde required=true.
const screens = resolveScreens(schema, answers)
for (const s of screens) {
if (s.kind !== 'question' || !s.question?.required) continue
const v = answers[answerKey(s.question.code, s.instanceId ?? undefined)]
if (isAnswerEmpty(v)) {
errors.push(`required_missing:${s.question.code}:${s.instanceId ?? 'default'}`)
}
}
if (errors.length > 0) return { ok: false, errors }
const rows: ValidatedRow[] = nonEmpty.map((e) => ({
questionId: byCode.get(e.code)!.id,
instanceId: e.instanceId,
value: e.value,
}))
return { ok: true, rows }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
# ETAPA 4G — Submit con instance_id + validación de integridad server-side
## 1. Objetivo
Escribir el submit de v3: persiste las respuestas (incluyendo instancias) y VALIDA la
integridad en el servidor contra el esquema antes de insertar. La validación server-side
es REQUISITO NO NEGOCIABLE (BRIEF decisión 15). El cliente no es confiable; datos de salud.
## 2. Contexto previo
- El submit de v2 (`app/actions/submit-survey.ts`) NO está conectado y está desactualizado:
- `WITH CHECK (true)` en RLS de answers → acepta cualquier insert (sin validación real).
- payload plano `Record<code,value>` — sin noción de `instance_id`.
- códigos qi_* apuntan a A1/A4/A5 (v2), no existen en v3 (son 2.1/2.4/2.5).
- 3 INSERTs sueltos sin transacción → si answers falla, responses/consent quedan huérfanos.
- El motor `lib/form-engine.ts` es PURO → se puede correr SERVER-SIDE para validar.
NO reescribir la lógica de flujo: reusar el motor.
- `answers.instance_id` existe (4A), constraint UNIQUE(response_id, question_id, instance_id).
- El estado del wizard ya tiene claves `code:instanceId` (4C/4D).
- Se usa anon key respetando RLS (regla 9 de CLAUDE.md) — NUNCA service_role.
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Validación de integridad SERVER-SIDE, no negociable.
- Reusar `lib/form-engine.ts` en el servidor para validar coherencia de flujo — no duplicar lógica.
- El submit rechaza el envío ENTERO si algo no valida — no inserta datos parciales ni inyectados.
- El payload del cliente pasa a ser consciente de instancia: `{code, instanceId, value}[]`
(o equivalente que preserve la clave compuesta).
- Transaccionalidad: las inserciones (consent_records → responses → answers) deben ser atómicas.
Evaluar función RPC transaccional (SECURITY DEFINER con validaciones) vs. inserts en el
Server Action. Proponé el enfoque más robusto dado que anon no puede hacer múltiples
inserts atómicos fácilmente — una RPC transaccional es probablemente lo correcto.
- Códigos qi_* actualizados a v3 (2.1 zona, 2.4 edad, 2.5 sector — verificar contra el seed).
## 4. Validaciones server-side mandatorias
Por cada submit recibido, el servidor valida CONTRA EL ESQUEMA de la DB (no contra el cliente):
1. **Survey válido y activo:** el `survey_id` existe y está en estado que admite respuestas.
2. **Preguntas legítimas:** cada `code` recibido existe y pertenece a ESE survey.
3. **Valores válidos:** cada valor es una opción válida de su pregunta (para single/multiple_choice),
respeta `max_select`, tipo correcto.
4. **Visibilidad coherente:** usando el motor (`resolveScreens`/`resolveVisibility`) con las
respuestas recibidas, cada respuesta enviada corresponde a una pregunta que DEBÍA estar
visible. Rechazar respuestas a preguntas que el flujo nunca debió mostrar (ej. responder
3.2 cuando 3.1='No' las oculta).
5. **Instancias legítimas:** cada `instance_id` (familiar_N) corresponde a una instancia
realmente generada por su `instance_generator` (4.1) según la respuesta real recibida para 4.1.
Rechazar instancias inventadas.
6. **Consentimiento requerido presente:** el consentimiento operativo (requerido) está.
7. **Requeridas presentes:** las preguntas required visibles tienen respuesta.
Si CUALQUIERA falla: rechazar el submit completo, no insertar nada, devolver error claro.
## 5. Tareas
### 5.1 Definir el contrato del payload v3
Shape consciente de instancia. El wizard ya tiene `state.answers` con claves `code:instanceId`.
Definir el payload que el cliente envía y el validador consume.
### 5.2 Validador server-side (reusando el motor)
Módulo/función que recibe el payload + carga el esquema desde la DB + corre las 7 validaciones
del punto 4, reusando `lib/form-engine.ts`. Puro y testeable donde se pueda.
### 5.3 Persistencia transaccional
Implementar la inserción atómica (consent_records → responses → answers con instance_id).
Proponer e implementar RPC transaccional o mecanismo equivalente. Si una RPC SECURITY DEFINER
es el camino, documentar por qué y qué valida internamente.
### 5.4 Conectar el wizard al submit
El botón "Enviar (próximamente)" pasa a enviar de verdad. Manejar estados éxito/error
(los stage que 4D dejó sin implementar). Mensajes de error accionables, nunca trace crudo.
### 5.5 Tests
- Validación: submit con respuesta a pregunta oculta → rechazado.
- Validación: valor fuera de opciones → rechazado.
- Validación: instancia inventada → rechazada.
- Validación: sin consentimiento requerido → rechazado.
- Happy path: submit completo válido (con instancias) → persiste correctamente con instance_id.
- Atomicidad: fallo a mitad → no quedan datos parciales.
## 6. Entregables (DoD)
- [ ] Validación server-side completa (7 validaciones) reusando el motor.
- [ ] Submit rechaza envío entero si algo no valida; sin datos parciales.
- [ ] Persistencia transaccional (sin huérfanos ante fallo).
- [ ] instance_id persistido correctamente por instancia.
- [ ] Códigos qi_* actualizados a v3.
- [ ] Wizard conectado al submit, estados éxito/error funcionando.
- [ ] Tests de las validaciones + happy path + atomicidad.
- [ ] anon key, nunca service_role (salvo dentro de RPC SECURITY DEFINER justificada).
- [ ] **Validación del director** antes de aplicar a producción.
## 7. Qué reportar
```
Verificado:
- Validación server-side: 7/7 implementadas, reusando lib/form-engine.ts ✓
- Casos rechazados probados: pregunta oculta, valor inválido, instancia falsa, sin consentimiento
- Persistencia transaccional vía [RPC / mecanismo] ✓ — atomicidad probada
- instance_id persistido: [ejemplo real de filas answers con familiar_1/familiar_2]
- Códigos qi_* actualizados a v3 ✓
- Wizard conectado, estados éxito/error ✓
- Tests: N/N
Asumido / decisiones:
- [enfoque transaccional y por qué]
Hallazgos:
- [lo que aparezca]
```
Reporte con datos concretos (C.4): mostrá filas reales de answers con instance_id tras un
submit de prueba con 2 familiares.
## 8. Qué NO hacer
- ❌ No confiar en validación de cliente para integridad (es UX, no seguridad).
- ❌ No duplicar la lógica de flujo — reusar el motor server-side.
- ❌ No insertar datos parciales si la validación falla.
- ❌ No usar service_role fuera de una RPC SECURITY DEFINER justificada y documentada.
- ❌ No aplicar a producción sin validación del director.
- ❌ No tocar v2.1.0-stable, el seed, ni el motor (salvo para CONSUMIRLO en validación).
## 9. Versionado
```
feat(submit): submit v3 con validación de integridad server-side [4G]
Validación server-side (no negociable) reusando el motor: rechaza respuestas
a preguntas ocultas, valores inválidos, instancias falsas, sin consentimiento.
Persistencia transaccional con instance_id por instancia. Conecta el wizard.
Cierra la falla de WITH CHECK(true): el cliente ya no es fuente de verdad.
```

View File

@@ -0,0 +1,98 @@
# ETAPA 4H — Migrar las RPCs del dashboard de códigos v2 a v3
## 1. Objetivo
Las 9 RPCs del dashboard referencian códigos de pregunta de v2 (`1.6`, `1.13`, `A6`,
`A7`, `3.4`, `6.2`, etc.) que NO existen en el instrumento v3. Corriendo contra el seed
v3 devuelven 0 en casi todo. Esta etapa migra los códigos v2→v3 para que el dashboard
vuelva a funcionar con el instrumento nuevo. Es REPARACIÓN, no funcionalidad nueva.
## 2. Contexto previo
- Las RPCs viven en `supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql`
+ parche `20260611000001_dash_responses_all.sql` (flag p_all).
- 9 RPCs públicas + 2 helpers (`_dash_responses`, `_dash_parse_filters`).
- k-anonimato k≥5 en dos niveles (total y por-segmento) — NO tocar la lógica de k,
solo los códigos de pregunta.
- Flag p_all = UUID sentinela `00000000-...` en `_dash_responses` — NO tocar.
- El instrumento v3 está en el seed (códigos 2.1-10.1, 10 secciones). El mapeo de
códigos v2→v3 hay que derivarlo del seed v3 y del mockup validado.
- Esta etapa NO incluye los datos de vínculo por instancia (4.2/4.3) — eso es la etapa
siguiente (vínculo). Acá solo se migran los códigos de las preguntas NO instanciadas.
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Solo se migran CÓDIGOS de pregunta v2→v3 y los VALORES esperados si cambiaron
(ej. opciones de respuesta que se reescribieron en v3).
- NO se cambia la lógica de k-anonimato, ni el flag p_all, ni la estructura de las RPCs.
- NO se agregan las RPCs de vínculo (es la etapa siguiente).
- Si una métrica de v2 ya no tiene pregunta equivalente en v3, se REPORTA (no se inventa
un mapeo) — decisión del director/metodología.
- Migración nueva, no se editan las migraciones viejas de RPCs.
## 4. Tareas
### 4.1 Construir la tabla de mapeo v2→v3
Antes de tocar SQL, derivar del seed v3 y documentar el mapeo de cada código v2 usado
en las RPCs a su equivalente v3. Ejemplos a resolver (verificar contra el seed real):
- `1.6`/`1.13` (triggers de cuidado v2) → ¿qué los representa en v3? (probablemente 4.1
con sus opciones de vínculo, o las preguntas de cuidado de S5-S7)
- `A6`/`A7` (demográficas v2) → 2.x en v3
- `3.4` (salario v2) → 8.4 en v3
- `6.2` (apoyos v2) → 9.2 en v3
- etc. — mapear TODOS los códigos que aparezcan en las 9 RPCs.
Reportar esta tabla ANTES de escribir el SQL, porque algunos mapeos requieren confirmación
(las métricas de "cuidador activo" cambian: v2 usaba 3 triggers, v3 usa 4.1 multi-opción).
### 4.2 Migración nueva con las RPCs actualizadas
Crear `supabase/migrations/2026XXXXXXXXXX_dashboard_rpc_v3.sql` que recree las 9 RPCs
con los códigos v3. Mantener idéntica: estructura, lógica de k≥5, manejo de p_all,
shape del JSON de retorno (para no romper los widgets).
### 4.3 Verificar valores de respuesta esperados
Algunas RPCs comparan contra valores literales (`a.value #>> '{}' = 'Sí'`). Si las opciones
de respuesta cambiaron en v3 (wording), actualizar esos literales. Verificar contra el seed.
### 4.4 Reportar métricas sin equivalente
Si alguna métrica v2 no tiene pregunta v3 equivalente, listarla para decisión — no inventar.
## 5. Entregables (DoD)
- [ ] Tabla de mapeo v2→v3 documentada y reportada ANTES del SQL.
- [ ] Migración que recrea las 9 RPCs con códigos v3.
- [ ] Lógica de k≥5, p_all y shape de retorno SIN cambios.
- [ ] Literales de valores de respuesta actualizados donde el wording cambió.
- [ ] Métricas sin equivalente v3 reportadas (no inventadas).
- [ ] NO incluye RPCs de vínculo (etapa siguiente).
- [ ] NO aplicada a producción (solo archivo) hasta validación del director.
## 6. Qué reportar
```
Verificado:
- Tabla de mapeo v2→v3: [código v2 → código v3, por cada uno]
- 9 RPCs migradas ✓
- k≥5 sin cambios ✓ / p_all sin cambios ✓ / shape de retorno sin cambios ✓
- Literales de valores actualizados: [cuáles]
Métricas sin equivalente v3 (requieren decisión):
- [lista o "ninguna"]
Asumido:
- [mapeos que asumió y conviene confirmar]
NO aplicado a producción.
```
## 7. Qué NO hacer
- ❌ No cambiar la lógica de k-anonimato, p_all, ni el shape del JSON de retorno.
- ❌ No agregar las RPCs de vínculo por instancia (es la etapa siguiente).
- ❌ No inventar mapeos para métricas sin equivalente — reportar.
- ❌ No editar las migraciones viejas de RPCs.
- ❌ No aplicar a producción sin validación.
- ❌ No tocar v2.1.0-stable ni el motor.
## 8. Versionado
```
fix(dashboard): migrar RPCs de códigos v2 a v3 [4H]
Las 9 RPCs del dashboard referenciaban códigos v2 inexistentes en v3
(devolvían 0 con el seed nuevo). Migrados a códigos v3. k-anonimato,
flag p_all y shape de retorno sin cambios. No incluye datos de vínculo
por instancia (etapa siguiente).
```

View File

@@ -0,0 +1,62 @@
# ETAPA — Dashboard tema claro (marca Re)
## 1. Objetivo
Convertir el dashboard de tema oscuro a tema claro usando la paleta oficial de marca Re.
Un solo archivo modificado: `app/globals.css`.
## 2. Decisiones ya tomadas — no consultar
- Fondo página: #E7E6E5 (gris claro Re)
- Cards/widgets: #FFFFFF
- Texto principal: #494F4F (carbón Re)
- Acento teal, colores de alerta inline y barras de pct: SIN CAMBIO
- Texto de tabs activos sobre teal (.db-survey-link-active): #FFFFFF
## 3. Tarea
En `app/globals.css`, sección DASHBOARD (línea 710 en adelante),
aplicar los siguientes reemplazos SOLO en esa sección:
FONDOS:
- background: #0F1923 → background: #E7E6E5 (.db-page, línea 716)
- background: #1A2535 → background: #FFFFFF (.db-widget, línea 741)
- background: #14202e → background: #F0EFEE (.db-sidebar, línea 887)
- background: #101a30 → background: #F0EFEE (.db-lens-intro, .db-placeholder)
- background: #2a3547 → background: rgba(73,79,79,0.10) (.db-bar-track)
- background: #1e2d42 → background: #FFFFFF (.db-filter-select)
- background: #1e2d42 → background: rgba(73,79,79,0.08) (.db-skeleton-block)
TEXTO:
- color: #E7E6E5 → color: #494F4F (en TODAS las clases .db-* de esa sección)
- color: #9ca3af → color: #6B7878
- color: #7A8FA0 → color: #8A9A9A
- color: #7d8da5 → color: #8A9A9A
- color: #9fb0cc → color: #8A9A9A
- color: #6b7280 → color: #9CA8A8
- color: #0F1923 → color: #FFFFFF (solo .db-survey-link-active)
- color: #fff → color: #494F4F (solo .db-page-title, línea 721)
NO TOCAR:
- color: var(--color-re-teal) — sin cambio en ninguna clase
- color: #ef4444, #f0b429, #f06b6b, #f06b6b — sin cambio
- Todo lo que esté fuera de la sección DASHBOARD (línea 710 en adelante)
- Cualquier archivo .tsx
## 4. DoD (checklist antes de reportar)
- [ ] Solo `app/globals.css` modificado
- [ ] `git diff --name-only HEAD` lista únicamente `app/globals.css`
- [ ] Ningún valor de acento (teal, rojos, ámbar) fue tocado
- [ ] Ninguna clase fuera de la sección DASHBOARD fue modificada
- [ ] Build sin errores: `npm run build`
## 5. Qué reportar
- Cantidad de líneas modificadas
- Lista de clases tocadas con valor anterior → valor nuevo
- Output de `git diff --name-only HEAD`
- Output de `npm run build` (últimas 5 líneas)
- Hallazgo visual: ¿`.db-gap-brecha` (#f0b429 ámbar) tiene contraste visible
sobre el nuevo fondo #F0EFEE? Verificar si el valor en pantalla sería legible.
## 6. Qué NO hacer
- No tocar variables :root
- No tocar archivos .tsx
- No modificar colores de acento ni alertas
- No hacer deploy — solo commit y push a Gitea

View File

@@ -0,0 +1,100 @@
# ETAPA — Widgets de Mirada Empresa (frontend del dashboard v3)
## 1. Objetivo
Construir el frontend del dashboard de Mirada Empresa: la estructura de tres miradas
(Empresa funcional, País/Humana como "próximamente") y los widgets que consumen las RPCs
`rpc_me_*` ya implementadas y verificadas contra datos reales. El cliente ve las métricas.
## 2. Contexto previo
- Las RPCs de Mirada Empresa están hechas y VERIFICADAS contra 50 respuestas reales
(migración `20260625000002_dashboard_mirada_empresa.sql`): E1-E10 + estrella "Talento en riesgo".
- Contrato de cada RPC (shapes de retorno): `docs/dashboard/SPEC_MIRADA_EMPRESA.md`.
- Aspecto objetivo: `docs/dashboard/dashboard_tres_miradas_mockup.html`.
- Patrón de widget existente a REUTILIZAR (no reinventar):
- `components/dashboard/widget-shell.tsx` — estados loading/ok/suppressed/error.
- hooks `useEffect` + `queries.X(surveyId, filters)` (lib/dashboard-queries.ts).
- `isSuppressed()` type guard (lib/dashboard-types.ts).
- Gráficos en SVG/CSS a mano (db-bar-*, db-donut-*, db-kpi-*) — SIN librería de charts.
- Tema oscuro db-*, acento único teal `#43BBC8` (--color-re-teal).
- Tres archivos de plomería a extender (mapeos mecánicos): `lib/dashboard-types.ts` (tipos
de retorno), `lib/dashboard-queries.ts` (entradas queries), `app/api/dashboard/[widget]/route.ts`
(RPC_MAP con los `rpc_me_*`).
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- **Opción A:** reemplazar el dashboard actual por la estructura de tres miradas. Los widgets
v2 rotos (que consumen RPCs v2 inexistentes en v3) se RETIRAN. País y Humana = placeholder
"Disponible próximamente".
- Reutilizar WidgetShell, el patrón de hooks, isSuppressed, y los gráficos SVG/CSS a mano.
- NO traer librería de charts nueva — replicar el patrón existente.
- Mantener el tema db-* y el teal como acento.
- NO tocar las RPCs (están verificadas). Solo el frontend + la plomería de tipos/queries/route.
- Mirada Empresa NO muestra datos sensibles (ya garantizado en las RPCs; el frontend solo refleja lo que viene).
## 4. Tareas
### 4.1 Plomería (mecánica)
- `lib/dashboard-types.ts`: tipos de retorno de cada `rpc_me_*` según los shapes de la SPEC.
- `lib/dashboard-queries.ts`: una entrada por métrica (E1-E10 + estrella).
- `app/api/dashboard/[widget]/route.ts`: agregar los `rpc_me_*` al RPC_MAP.
### 4.2 Estructura de tres miradas
- Tabs/secciones Empresa | País | Humana (como el mockup).
- Empresa: funcional. País/Humana: placeholder "Disponible próximamente" (no widgets vacíos).
- El selector de survey/p_all y los filtros del sidebar siguen funcionando como hoy.
### 4.3 Widgets de Mirada Empresa
Implementar los widgets del NÚCLEO primero (los que el cliente necesita):
- **Talento en riesgo** (estrella) — KPI destacado, métrica compuesta.
- **E1 Prevalencia de cuidadores** — KPI.
- **E6 Punto ciego organizacional** — el widget de contraste (carga real vs apoyo percibido),
dos barras enfrentadas como en el mockup.
- **E2/E3/E4 Señales de impacto** — barras (riesgo, ausentismo, presentismo).
- **E7 Apoyos demandados** — barras horizontales (igual patrón que apoyos-bar viejo).
Después, si el alcance lo permite, los [SEC]: E5, E8, E9 (segmentación), E10.
### 4.4 Manejo de estados
- Cada widget usa WidgetShell. Estado `suppressed` (k<5) muestra el mensaje estándar 🔒.
- Las métricas que segmentan (E7, E9) suprimen filas individuales con n<5 (las RPCs ya lo hacen;
el widget solo no muestra lo que no viene).
## 5. Entregables (DoD)
- [ ] Estructura de tres miradas; Empresa funcional, País/Humana placeholder.
- [ ] Widgets del núcleo consumiendo las RPCs reales vía la plomería.
- [ ] Widgets v2 rotos retirados.
- [ ] WidgetShell y patrón existente reutilizados (no librería nueva, no reinvención).
- [ ] Estados loading/ok/suppressed manejados.
- [ ] Build sin errores.
- [ ] **Validación visual del director** contra el mockup + datos reales sembrados.
## 6. Qué reportar
```
Verificado:
- Estructura 3 miradas ✓ — Empresa funcional, País/Humana placeholder
- Widgets implementados: [lista con qué RPC consume cada uno]
- Plomería: tipos + queries + RPC_MAP extendidos ✓
- Widgets v2 retirados: [lista]
- WidgetShell reutilizado ✓ / sin librería de charts nueva ✓
- Build: [resultado]
- Descripción visual: cómo se ve cada widget núcleo con datos
Asumido / decisiones de diseño:
- [lo que haya]
```
Reporte con datos concretos (C.4). Para validación visual necesito ver el dashboard con datos:
indicá cómo levantarlo (el seed_prueba_50.sql siembra los datos).
## 7. Qué NO hacer
- ❌ No tocar las RPCs (verificadas).
- ❌ No traer librería de charts — SVG/CSS a mano como el resto.
- ❌ No mostrar datos sensibles (3.x, 4.2, 4.3).
- ❌ No implementar widgets de País/Humana (placeholder solo).
- ❌ No aplicar a producción sin validación visual.
- ❌ No tocar v2.1.0-stable, el motor, el seed del formulario.
## 8. Versionado
```
feat(dashboard): widgets de Mirada Empresa + estructura tres miradas [dashboard]
Reemplaza el dashboard v2 (widgets rotos contra v3) por la estructura de tres
miradas. Mirada Empresa funcional: talento en riesgo, prevalencia, punto ciego,
señales de impacto, apoyos demandados. País/Humana como placeholder. Reutiliza
WidgetShell y el patrón SVG/CSS existente. Consume las rpc_me_* verificadas.
```

View File

@@ -0,0 +1,136 @@
-- ============================================================
-- Sprint 4 (v3.0.0) — Etapa 4G: submit transaccional + cierre de WITH CHECK(true)
--
-- PROBLEMA QUE CIERRA: answers_insert/responses_insert/consent_records_insert
-- permitían INSERT directo a anon con casi ninguna restricción real
-- (answers_insert: WITH CHECK(true) literal) — el cliente era la única
-- fuente de verdad sobre qué se guardaba. Closes BRIEF decisión 15.
--
-- DISEÑO — dos capas, cada una validando lo que le corresponde:
-- 1) lib/form-engine.ts + lib/submit-validation.ts, en el Server Action
-- (Next.js, TypeScript): valida COHERENCIA DE FLUJO — visibilidad,
-- instancias legítimas, opciones válidas, requeridas. Esto NO puede
-- vivir en SQL sin reimplementar el motor (prohibido explícitamente).
-- 2) Esta RPC (Postgres, SECURITY DEFINER): valida ESTRUCTURA — el
-- question_id pertenece a esta encuesta, la encuesta está viva, el
-- consentimiento llegó — y persiste todo de forma ATÓMICA (una
-- llamada a función = una transacción; cualquier RAISE EXCEPTION
-- revierte TODO, sin huérfanos).
--
-- POR QUÉ SECURITY DEFINER: se revoca el INSERT directo de anon sobre las
-- 3 tablas (paso 1 abajo). Sin SECURITY DEFINER, esta función correría
-- con los privilegios de quien la llama (anon) y esas mismas policies
-- recién revocadas la bloquearían a ELLA también. SECURITY DEFINER la
-- hace correr con los privilegios de quien la creó (el owner de la
-- migración, no anon) — por eso puede insertar después de que anon ya
-- no puede hacerlo directo. Mismo patrón que las RPCs de dashboard
-- existentes (rpc_dashboard_kpis, etc.), aplicado por primera vez a
-- escritura en vez de lectura.
--
-- LOS GRANTS DE TABLA (migration 006: GRANT INSERT ON answers TO anon...)
-- NO se tocan acá — siguen el "diseño de capas" que esa migración ya
-- documenta: Capa 1 (GRANT) da la capacidad de intentar el INSERT,
-- Capa 2 (RLS, esta migración) lo deniega por default al no quedar
-- ninguna policy permisiva. anon puede seguir "intentando" un INSERT
-- directo — RLS lo rechaza igual, con RLS habilitado (migration 002) y
-- cero policies de INSERT restantes en estas 3 tablas.
--
-- RIESGO RESIDUAL DOCUMENTADO (no resuelto en esta etapa, ver reporte):
-- cualquiera que llame a esta RPC directamente vía la REST API de
-- Supabase (no a través de nuestro Server Action) puede mandar un
-- payload que pase las validaciones ESTRUCTURALES de acá pero sea
-- incoherente de flujo — porque el motor de flujo es TypeScript y no
-- corre dentro de Postgres. Es un problema de calidad de datos de
-- investigación, no de privacidad/k-anonimato (el dato sigue siendo
-- anónimo, is_sensitive, sujeto al umbral k en agg_segment). Cerrarlo
-- del todo excede el scope de 4G — candidato a BACKLOG.md si se decide
-- perseguirlo (ej. firma del payload por el Server Action).
-- ============================================================
-- ── 1. Revoca el INSERT directo — el único camino pasa a ser la RPC ───────
DROP POLICY IF EXISTS "consent_records_insert" ON public.consent_records;
DROP POLICY IF EXISTS "responses_insert" ON public.responses;
DROP POLICY IF EXISTS "answers_insert" ON public.answers;
-- ── 2. RPC de submit ────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.rpc_submit_response(
p_survey_id uuid,
p_ip_hash text,
p_qi_age_bucket text,
p_qi_sector text,
p_qi_zone text,
p_consent_operativo boolean,
p_consent_macro_n1 boolean,
p_answers jsonb -- [{"question_id":"...","instance_id":"familiar_1","value":...}, ...]
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_org_id uuid;
v_cv_id uuid;
v_consent_id uuid := gen_random_uuid();
v_response_id uuid := gen_random_uuid();
v_bad_refs int;
BEGIN
-- Defensa en profundidad: el Server Action ya confirmó esto antes de
-- llamar, pero esta función no debe confiar en el caller.
SELECT org_id, consent_version_id INTO v_org_id, v_cv_id
FROM public.surveys
WHERE id = p_survey_id AND status = 'live';
IF v_org_id IS NULL THEN
RAISE EXCEPTION 'survey_not_found_or_not_live' USING ERRCODE = 'check_violation';
END IF;
IF NOT p_consent_operativo THEN
RAISE EXCEPTION 'consent_operativo_required' USING ERRCODE = 'check_violation';
END IF;
-- Chequeo ESTRUCTURAL (no de flujo): cada question_id recibido existe
-- y pertenece a ESTA encuesta; cada instance_id viene no-vacío.
-- Visibilidad/instancia-legítima/opciones-válidas ya se validó en TS.
SELECT count(*) INTO v_bad_refs
FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb)
LEFT JOIN public.questions q ON q.id = r.question_id AND q.survey_id = p_survey_id
WHERE q.id IS NULL OR r.instance_id IS NULL OR r.instance_id = '';
IF v_bad_refs > 0 THEN
RAISE EXCEPTION 'invalid_answer_reference' USING ERRCODE = 'check_violation';
END IF;
-- consent_record (inmutable, ADR-003)
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes, granted_at)
VALUES (
v_consent_id, v_cv_id,
jsonb_build_object('operativo', p_consent_operativo, 'macro_n1', p_consent_macro_n1),
now()
);
-- response (respondent_id SIEMPRE NULL — encuesta anónima, regla #2 CLAUDE.md)
INSERT INTO public.responses (
id, survey_id, company_id, respondent_id, ip_hash,
consent_record_id, qi_age_bucket, qi_sector, qi_zone
)
VALUES (
v_response_id, p_survey_id, v_org_id, NULL, p_ip_hash,
v_consent_id, p_qi_age_bucket, p_qi_sector, p_qi_zone
);
-- answers — un row por entrada validada, con su instance_id real
INSERT INTO public.answers (response_id, question_id, instance_id, value)
SELECT v_response_id, r.question_id, r.instance_id, r.value
FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb);
RETURN jsonb_build_object('response_id', v_response_id);
END;
$$;
REVOKE ALL ON FUNCTION public.rpc_submit_response(
uuid, text, text, text, text, boolean, boolean, jsonb
) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.rpc_submit_response(
uuid, text, text, text, text, boolean, boolean, jsonb
) TO anon, authenticated;

View File

@@ -0,0 +1,46 @@
-- Sprint 4 (v3.0.0) — Fix puntual: _dash_responses filtraba género/modalidad
-- contra códigos v2 ('A6'/'A7') que no existen en el esquema v3. El filtro
-- devolvía 0 filas SIN error — silencioso, indistinguible de "no hay nadie
-- con ese filtro". Es una trampa: alguien podría leer "ningún hombre es
-- cuidador" cuando en realidad el filtro nunca encontró la pregunta.
--
-- Mapeo: A6 (género, v2) → 2.5 (género, v3) · A7 (modalidad, v2) → 2.7 (modalidad, v3).
--
-- Alcance: SOLO este helper compartido. Las 9 RPCs viejas de v2 (migración
-- 20260605140001_012_dashboard_rpc_v2.sql) NO se tocan — sus propias
-- comparaciones de código (1.1, 1.6, 1.13, 4.3, 6.1, 6.2, 6.5, etc.) siguen
-- sin migrar (Etapa 4H, pendiente de confirmación aparte, no se resuelve
-- acá). Este fix no las rompe: ninguna de esas 9 RPCs compara 'A6'/'A7'
-- directamente, solo le pasan arrays de filtro (p_genero/p_modalidad) a esta
-- función — que ahora los evalúa contra preguntas que SÍ existen en v3.
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 (p_survey_id = '00000000-0000-0000-0000-000000000000'
OR 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 = '2.5'
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 = '2.7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;

View File

@@ -0,0 +1,716 @@
-- ============================================================
-- Sprint 4 (v3.0.0) — Dashboard: RPCs de la Mirada Empresa
-- Fuente: docs/dashboard/SPEC_MIRADA_EMPRESA.md (contrato validado)
--
-- Patrón: idéntico al de la migración 012 (rpc_dashboard_kpis, etc.) —
-- SECURITY DEFINER, filtro survey_id + flag p_all delegado a
-- _dash_responses (sin tocar), k≥5 en dos niveles, shape {status, n, ...}.
-- No se modifican las 9 RPCs viejas de v2 (quedan como están).
--
-- Mapeo métrica de la spec → función:
-- E1 prevalencia de cuidadores → rpc_me_prevalencia_cuidadores
-- E2 riesgo de rotación → rpc_me_riesgo_rotacion
-- E3 ausentismo → rpc_me_ausentismo
-- E4 presentismo → rpc_me_presentismo
-- E6 punto ciego organizacional → rpc_me_punto_ciego
-- E7 demanda de apoyos → rpc_me_apoyos_demandados
-- ★ talento en riesgo → rpc_me_talento_en_riesgo
-- E5 carrera congelada → rpc_me_carrera_congelada
-- E8 intensidad de carga → rpc_me_intensidad_carga
-- E9 segmentación nivel/modalidad → rpc_me_segmentacion
-- E10 disposición a red de apoyo → rpc_me_disposicion_red
-- ============================================================
-- ════════════════════════════════════════════════════════════
-- HELPER — CUIDADOR_ACTIVO (único punto de la definición, ver spec)
-- "Hijo menor sin discapacidad" SÍ cuenta como cuidador (criterio UNFPA,
-- validado por Maria, jun 2026) — ensancha la población respecto a v2.
-- Toda métrica que use "cuidador" como población llama a este helper;
-- si la definición cambia, se cambia ACÁ y nada más.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public._dash_es_cuidador(p_response_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
-- CASE (no AND) a propósito: Postgres no garantiza orden de evaluación en
-- AND/OR, solo en CASE — esto asegura que el operador jsonb "-" (solo
-- válido sobre arrays) nunca se ejecute sobre un valor que no lo sea.
SELECT EXISTS (
SELECT 1
FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = p_response_id
AND q.code = '4.1'
AND CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(
a.value - 'Ninguna de las anteriores / No tengo vinculación de cuidado'
) > 0
ELSE false
END
)
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E1 — Prevalencia de cuidadores en la plantilla
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_prevalencia_cuidadores(
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_cuid 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(*) INTO v_cuid
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 public._dash_es_cuidador(dr.response_id);
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'pct_cuidadores', ROUND(v_cuid::numeric / v_total * 100, 1)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E2 — Riesgo de rotación por cuidado
-- Denominador: cuidadores que RESPONDIERON 7.6 — no "todos los cuidadores",
-- porque 7.6 vive en Sección 7, oculta para soloNino (solo "Hijo menor sin
-- discapacidad") y para "Ninguna". Si el denominador fuera "todos los
-- cuidadores" sin este matiz, se diluiría el % con gente a la que nunca se
-- le mostró la pregunta — no es lo mismo "no está en riesgo" que "no se le
-- preguntó". El JOIN a la pregunta concreta resuelve esto solo.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_riesgo_rotacion(
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_cuid bigint;
v_algo bigint;
v_muy bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Algo probable'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Muy probable')
INTO v_n_cuid, v_algo, v_muy
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 AND q.code = '7.6'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n_cuid < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_cuidadores',v_n_cuid);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n_cuidadores', v_n_cuid,
'pct_riesgo', ROUND((v_algo + v_muy)::numeric / v_n_cuid * 100, 1),
'desglose', jsonb_build_object(
'algo', ROUND(v_algo::numeric / v_n_cuid * 100, 1),
'muy', ROUND(v_muy::numeric / v_n_cuid * 100, 1)
)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E3 — Ausentismo atribuible al cuidado
-- Mismo matiz de denominador que E2 (7.3 vive en Sección 7, oculta para
-- soloNino/Ninguna) — denominador = cuidadores que respondieron 7.3.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_ausentismo(
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_nunca bigint;
v_pocas bigint;
v_varias bigint;
v_frec bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Nunca'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Pocas veces (13 al año)'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces (410 al año)'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Con frecuencia (más de 10 al año)')
INTO v_n, v_nunca, v_pocas, v_varias, v_frec
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 AND q.code = '7.3'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_ausentismo_recurrente', ROUND((v_varias + v_frec)::numeric / v_n * 100, 1),
'desglose', jsonb_build_object(
'nunca', ROUND(v_nunca::numeric / v_n * 100, 1),
'pocas', ROUND(v_pocas::numeric / v_n * 100, 1),
'varias', ROUND(v_varias::numeric / v_n * 100, 1),
'frecuente', ROUND(v_frec::numeric / v_n * 100, 1)
)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E4 — Presentismo (productividad reducida)
-- Mismo matiz de denominador (7.2, Sección 7) que E2/E3.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_presentismo(
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_nunca bigint;
v_algunas bigint;
v_varias bigint;
v_casi bigint;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Nunca o casi nunca'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Algunas veces al mes'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces por semana'),
count(*) FILTER (WHERE a.value #>> '{}' = 'Casi todos los días')
INTO v_n, v_nunca, v_algunas, v_varias, v_casi
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 AND q.code = '7.2'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_presentismo', ROUND((v_varias + v_casi)::numeric / v_n * 100, 1),
'desglose', jsonb_build_object(
'nunca_o_casi_nunca', ROUND(v_nunca::numeric / v_n * 100, 1),
'algunas_al_mes', ROUND(v_algunas::numeric / v_n * 100, 1),
'varias_por_semana', ROUND(v_varias::numeric / v_n * 100, 1),
'casi_todos_los_dias',ROUND(v_casi::numeric / v_n * 100, 1)
)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E6 — Punto ciego organizacional
-- carga_real_pct: sobre TODA la plantilla encuestada (= E1).
-- apoyo_percibido_pct: sobre quienes respondieron 9.1 (oculta solo para
-- "Ninguna" — soloNino SÍ la ve, a diferencia de 7.x). Son denominadores
-- distintos a propósito (carga real de TODOS vs. percepción de quienes
-- tienen vínculo) — es justamente la brecha que la métrica busca mostrar.
-- Cada población se suprime de forma independiente si n<5.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_punto_ciego(
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_cuid bigint;
v_n_apoyo bigint;
v_apoyo_si bigint;
v_carga_pct numeric;
v_apoyo_pct numeric;
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(*) INTO v_cuid
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 public._dash_es_cuidador(dr.response_id);
v_carga_pct := ROUND(v_cuid::numeric / v_total * 100, 1);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Sí, me siento acompañado/a')
INTO v_n_apoyo, v_apoyo_si
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 AND q.code = '9.1';
IF v_n_apoyo >= 5 THEN
v_apoyo_pct := ROUND(v_apoyo_si::numeric / v_n_apoyo * 100, 1);
ELSE
v_apoyo_pct := NULL;
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'carga_real_pct', v_carga_pct,
'apoyo_percibido_pct', v_apoyo_pct,
'apoyo_percibido_suppressed', (v_n_apoyo < 5),
'brecha_pp', CASE WHEN v_apoyo_pct IS NOT NULL THEN ROUND(v_carga_pct - v_apoyo_pct, 1) ELSE NULL END
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] E7 — Demanda de apoyos priorizada
-- Denominador = TODA la plantilla encuestada (igual molde que
-- rpc_apoyos_demandados de v2: divide por v_total, no por quienes
-- respondieron 9.2). 9.2 ya está oculta para Ninguna/soloNino, así que en
-- la práctica solo cuidadores "completos" la responden — coherente con
-- cómo se comportaba 6.2 en v2.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_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(
'apoyo', 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 = '9.2' AND jsonb_typeof(a.value) = 'array'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY cnt DESC
) t;
RETURN jsonb_build_object('status','ok','n',v_total,'apoyos', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [NÚCLEO] ★ Métrica estrella — "Talento en riesgo"
-- Umbral PARAMETRIZABLE (p_umbral_senales, default 2 de 3) — cambiarlo no
-- requiere tocar el cuerpo de la función. ADVERTENCIA: esto resuelve
-- "cambiar el número de corte"; NO resuelve "pasar a un esquema ponderado"
-- (eso sería una fórmula distinta — pesos por señal, no un conteo — y
-- necesitaría otra firma/cuerpo de función, no solo este parámetro).
--
-- Población: cuidadores que respondieron 7.6 (ancla la Sección 7 visible;
-- 7.3/7.5 se traen con LEFT JOIN porque comparten la misma regla de
-- ocultamiento que 7.6 — en la práctica siempre van juntas, pero LEFT JOIN
-- es defensivo ante una respuesta parcial real).
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_talento_en_riesgo(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb,
p_umbral_senales int DEFAULT 2
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_riesgo bigint;
v_avg numeric;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
WITH base AS (
SELECT
dr.response_id,
CASE WHEN s76.value #>> '{}' IN ('Algo probable','Muy probable') THEN 1 ELSE 0 END
AS sig_riesgo,
CASE WHEN s73.value #>> '{}' IN ('Varias veces (410 al año)','Con frecuencia (más de 10 al año)') THEN 1 ELSE 0 END
AS sig_ausentismo,
CASE WHEN jsonb_typeof(s75.value) = 'array' THEN
CASE WHEN jsonb_array_length(s75.value - 'Ninguna de las anteriores') > 0
THEN 1 ELSE 0 END
ELSE 0
END AS sig_carrera
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 s76 ON s76.response_id = dr.response_id
JOIN public.questions q76 ON q76.id = s76.question_id AND q76.code = '7.6'
LEFT JOIN public.questions q73 ON q73.code = '7.3' AND q73.survey_id = q76.survey_id
LEFT JOIN public.answers s73 ON s73.response_id = dr.response_id AND s73.question_id = q73.id
LEFT JOIN public.questions q75 ON q75.code = '7.5' AND q75.survey_id = q76.survey_id
LEFT JOIN public.answers s75 ON s75.response_id = dr.response_id AND s75.question_id = q75.id
WHERE public._dash_es_cuidador(dr.response_id)
)
SELECT
count(*),
count(*) FILTER (WHERE (sig_riesgo + sig_ausentismo + sig_carrera) >= p_umbral_senales),
avg(sig_riesgo + sig_ausentismo + sig_carrera)
INTO v_n, v_riesgo, v_avg
FROM base;
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_cuidadores',v_n);
END IF;
RETURN jsonb_build_object(
'status', 'ok',
'n_cuidadores', v_n,
'umbral_senales', p_umbral_senales,
'pct_talento_riesgo', ROUND(v_riesgo::numeric / v_n * 100, 1),
'criterios_promedio', ROUND(v_avg, 2)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E5 — Carrera congelada
-- Mismo matiz de denominador (7.5, Sección 7) que E2/E3/E4.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
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_cong bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
ELSE false
END
)
INTO v_n, v_cong
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 AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 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 AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id)
AND jsonb_typeof(a.value) = 'array'
AND a.value #>> '{}' != 'Ninguna de las anteriores'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY cnt DESC
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E8 — Intensidad de carga
-- Población: cuidadores que respondieron 6.1 (ancla Sección 6, misma regla
-- de ocultamiento que 6.2/6.4 — comparten r_hide_ning_sn).
-- Son rangos categóricos (no números) — se reporta distribución por
-- combinación, no un promedio, tal como pide la spec.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_intensidad_carga(
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_guardia bigint;
v_rows jsonb;
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) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '6.1'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row) INTO v_rows FROM (
SELECT jsonb_build_object(
'dias_semana', dias_bin,
'horas_dia', horas_bin,
'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END,
'suppressed', (cnt < 5)
) AS row
FROM (
SELECT
ad.value #>> '{}' AS dias_bin,
ah.value #>> '{}' AS horas_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 ad ON ad.response_id = dr.response_id
JOIN public.questions qd ON qd.id = ad.question_id AND qd.code = '6.1'
JOIN public.answers ah ON ah.response_id = dr.response_id
JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '6.2'
WHERE public._dash_es_cuidador(dr.response_id)
GROUP BY 1, 2
) sub
ORDER BY dias_bin, horas_bin
) t;
SELECT count(*) INTO v_guardia
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 AND q.code = '6.4'
WHERE public._dash_es_cuidador(dr.response_id)
AND a.value #>> '{}' = 'De forma permanente / sin horario';
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'distribucion_intensidad', COALESCE(v_rows, '[]'::jsonb),
'pct_guardia_permanente', ROUND(v_guardia::numeric / v_n * 100, 1)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E9 — Segmentación por nivel y modalidad
-- pct_cuidadores cruzado con 2.6 (nivel organizacional) y 2.7 (modalidad).
-- k≥5 por fila — segmento con n<5 se omite (no se devuelve null disfrazado
-- de 0%, se OMITE la fila, igual criterio que rpc_prevalencia_por_depto).
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_segmentacion(
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_por_nivel jsonb;
v_por_modal 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;
-- Nivel organizacional (2.6) ya se promueve a responses.qi_sector en el
-- submit (Etapa 4G) — se lee directo de ahí, mismo patrón que
-- rpc_prevalencia_por_depto (v2) lee qi_zone. NO se lee de answers/2.6:
-- esa fue la causa del bug original (la RPC buscaba la respuesta cruda
-- en vez de la columna ya denormalizada).
SELECT jsonb_agg(row) INTO v_por_nivel FROM (
SELECT jsonb_build_object(
'nivel', nivel_val,
'n', cnt,
'pct', ROUND(cnt_cuid::numeric / cnt * 100, 1)
) AS row
FROM (
SELECT
r.qi_sector AS nivel_val,
count(DISTINCT dr.response_id) AS cnt,
count(DISTINCT dr.response_id) FILTER (WHERE public._dash_es_cuidador(dr.response_id)) AS cnt_cuid
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.responses r ON r.id = dr.response_id
WHERE r.qi_sector IS NOT NULL
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY nivel_val
) t;
-- Modalidad (2.7) NO tiene columna qi_modalidad — decisión consciente
-- para no reabrir rpc_submit_response, ya verificada en 4G. Se lee por
-- JOIN a answers/questions en tiempo de consulta. Ver TECH-DEBT-010.
SELECT jsonb_agg(row) INTO v_por_modal FROM (
SELECT jsonb_build_object(
'modalidad', modal_val,
'n', cnt,
'pct', ROUND(cnt_cuid::numeric / cnt * 100, 1)
) AS row
FROM (
SELECT
a.value #>> '{}' AS modal_val,
count(DISTINCT dr.response_id) AS cnt,
count(DISTINCT dr.response_id) FILTER (WHERE public._dash_es_cuidador(dr.response_id)) AS cnt_cuid
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 AND q.code = '2.7'
GROUP BY 1
) sub
WHERE cnt >= 5
ORDER BY modal_val
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_total,
'por_nivel', COALESCE(v_por_nivel, '[]'::jsonb),
'por_modalidad', COALESCE(v_por_modal, '[]'::jsonb)
);
END;
$$;
-- ════════════════════════════════════════════════════════════
-- [SEC] E10 — Disposición a red de apoyo interna
-- 9.3 (oculta para Ninguna Y soloNino) y 9.4 (oculta solo para Ninguna)
-- tienen reglas de ocultamiento DISTINTAS → poblaciones distintas →
-- cada porcentaje se calcula y se suprime de forma independiente,
-- mismo criterio que E6.
-- ════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION public.rpc_me_disposicion_red(
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_grupo bigint;
v_grupo_si bigint;
v_n_vol bigint;
v_vol_si bigint;
v_pct_grupo numeric;
v_pct_vol numeric;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' IN ('Sí, definitivamente','Tal vez, si hay más personas en la misma situación'))
INTO v_n_grupo, v_grupo_si
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 AND q.code = '9.3';
SELECT
count(*),
count(*) FILTER (WHERE a.value #>> '{}' = 'Sí')
INTO v_n_vol, v_vol_si
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 AND q.code = '9.4';
IF v_n_grupo < 5 AND v_n_vol < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_grupo_apoyo',v_n_grupo,'n_voluntariado',v_n_vol);
END IF;
v_pct_grupo := CASE WHEN v_n_grupo >= 5 THEN ROUND(v_grupo_si::numeric / v_n_grupo * 100, 1) ELSE NULL END;
v_pct_vol := CASE WHEN v_n_vol >= 5 THEN ROUND(v_vol_si::numeric / v_n_vol * 100, 1) ELSE NULL END;
RETURN jsonb_build_object(
'status', 'ok',
'n_grupo_apoyo', v_n_grupo,
'pct_grupo_apoyo', v_pct_grupo,
'n_voluntariado', v_n_vol,
'pct_voluntariado', v_pct_vol
);
END;
$$;

View File

@@ -0,0 +1,110 @@
-- Fix puntual: column "cnt" does not exist en jsonb_agg(row ORDER BY cnt DESC).
-- El alias cnt vive en el subquery interno; el nivel externo del jsonb_agg solo
-- ve `row`. Solución (Opción B): exponer cnt en el SELECT externo para que
-- jsonb_agg(row ORDER BY cnt DESC) pueda referenciarlo en ese nivel.
-- Solo se tocan estas dos funciones; las otras 9 rpc_me_* no se modifican.
CREATE OR REPLACE FUNCTION public.rpc_me_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(
'apoyo', opt,
'n', cnt,
'pct', ROUND(cnt::numeric / v_total * 100, 1)
) AS row,
cnt
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 = '9.2' AND jsonb_typeof(a.value) = 'array'
GROUP BY 1
) sub
WHERE cnt >= 5
) t;
RETURN jsonb_build_object('status','ok','n',v_total,'apoyos', COALESCE(v_rows,'[]'::jsonb));
END;
$$;
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
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_cong bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
ELSE false
END
)
INTO v_n, v_cong
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 AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row,
cnt
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 AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id)
AND jsonb_typeof(a.value) = 'array'
AND a.value #>> '{}' != 'Ninguna de las anteriores'
GROUP BY 1
) sub
WHERE cnt >= 5
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
);
END;
$$;

View File

@@ -0,0 +1,327 @@
-- ============================================================
-- Tests pgTAP: Dashboard v3 — RPCs de la Mirada Empresa
--
-- Ejecutar con: supabase test db
-- Requiere: Docker + supabase start + extensión pgtap
--
-- Cubre:
-- 1. Existencia de las 11 RPCs + el helper _dash_es_cuidador (firma correcta)
-- 2. _dash_es_cuidador: las 4 combinaciones de la definición UNFPA
-- (solo Ninguna / Ninguna+otro / solo "hijo sin discapacidad" / sin responder)
-- 3. k≥5 en dos niveles: n=4 → suppressed, n=5 → ok con pct correcto (E1)
-- 4. Fix de _dash_responses: el filtro de género ya encuentra '2.5' (no '"A6"')
-- 5. Umbral parametrizable de la métrica estrella: mismo dataset, dos
-- resultados distintos según p_umbral_senales (prueba que el parámetro
-- realmente cambia el comportamiento, no es decorativo)
-- 6. E9 (rpc_me_segmentacion) — bug real encontrado en VPS: por_nivel leía
-- answers/2.6 en vez de responses.qi_sector. Prueba que ahora lee
-- qi_sector, que por_modalidad sigue leyendo por JOIN a 2.7, y que el
-- k≥5 por segmento sigue omitiendo filas chicas.
--
-- NO escrito por mí pero sí ejecutable por el director: no hay Docker en el
-- entorno donde se generó este archivo — no se corrió contra Postgres real.
-- ============================================================
BEGIN;
SELECT plan(28);
-- ============================================================
-- SECCIÓN 1: las funciones existen con la firma esperada
-- ============================================================
SELECT has_function('public', '_dash_es_cuidador', ARRAY['uuid'],
'1.1 _dash_es_cuidador(uuid) existe');
SELECT has_function('public', 'rpc_me_prevalencia_cuidadores', ARRAY['uuid','jsonb'],
'1.2 rpc_me_prevalencia_cuidadores existe');
SELECT has_function('public', 'rpc_me_riesgo_rotacion', ARRAY['uuid','jsonb'],
'1.3 rpc_me_riesgo_rotacion existe');
SELECT has_function('public', 'rpc_me_ausentismo', ARRAY['uuid','jsonb'],
'1.4 rpc_me_ausentismo existe');
SELECT has_function('public', 'rpc_me_presentismo', ARRAY['uuid','jsonb'],
'1.5 rpc_me_presentismo existe');
SELECT has_function('public', 'rpc_me_punto_ciego', ARRAY['uuid','jsonb'],
'1.6 rpc_me_punto_ciego existe');
SELECT has_function('public', 'rpc_me_apoyos_demandados', ARRAY['uuid','jsonb'],
'1.7 rpc_me_apoyos_demandados existe');
SELECT has_function('public', 'rpc_me_talento_en_riesgo', ARRAY['uuid','jsonb','int4'],
'1.8 rpc_me_talento_en_riesgo(uuid,jsonb,int) existe — confirma el parámetro de umbral');
SELECT has_function('public', 'rpc_me_carrera_congelada', ARRAY['uuid','jsonb'],
'1.9 rpc_me_carrera_congelada existe');
SELECT has_function('public', 'rpc_me_intensidad_carga', ARRAY['uuid','jsonb'],
'1.10 rpc_me_intensidad_carga existe');
SELECT has_function('public', 'rpc_me_segmentacion', ARRAY['uuid','jsonb'],
'1.11 rpc_me_segmentacion existe');
SELECT has_function('public', 'rpc_me_disposicion_red', ARRAY['uuid','jsonb'],
'1.12 rpc_me_disposicion_red existe');
-- ============================================================
-- SECCIÓN 2: fixture — 1 org, 1 survey v3, preguntas mínimas necesarias
-- ============================================================
DO $$
DECLARE
v_org_id uuid := '10000000-1111-0000-0000-000000000001';
v_cv_id uuid := '20000000-1111-0000-0000-000000000001';
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
BEGIN
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_me_org');
INSERT INTO public.consent_versions (id, version, body, scopes)
VALUES (v_cv_id, '_test-cv-me', 'body', '["operativo"]'::jsonb);
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, consent_version_id)
VALUES (v_survey_id, v_org_id, '_test_me_survey', true, 'sensible', 'confidencial', v_cv_id);
INSERT INTO public.questions (id, survey_id, code, type, prompt, position) VALUES
('40000000-1111-0000-0000-000000000041', v_survey_id, '4.1', 'multiple_choice', 'vínculos', 41),
('40000000-1111-0000-0000-000000000072', v_survey_id, '7.2', 'single_choice', 'presentismo', 72),
('40000000-1111-0000-0000-000000000073', v_survey_id, '7.3', 'single_choice', 'ausentismo', 73),
('40000000-1111-0000-0000-000000000075', v_survey_id, '7.5', 'multiple_choice', 'carrera', 75),
('40000000-1111-0000-0000-000000000076', v_survey_id, '7.6', 'single_choice', 'riesgo rotación', 76),
('40000000-1111-0000-0000-000000000091', v_survey_id, '9.1', 'single_choice', 'conciencia', 91),
('40000000-1111-0000-0000-000000000025', v_survey_id, '2.5', 'single_choice', 'género', 25);
END;
$$;
-- ============================================================
-- SECCIÓN 3: _dash_es_cuidador — las 4 combinaciones de la definición UNFPA
-- ============================================================
DO $$
DECLARE
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
v_q41 uuid := '40000000-1111-0000-0000-000000000041';
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
v_r_ninguna uuid := '60000000-1111-0000-0000-000000000001'; -- solo "Ninguna..."
v_r_mixto uuid := '60000000-1111-0000-0000-000000000002'; -- "Ninguna..." + otra opción
v_r_solonin uuid := '60000000-1111-0000-0000-000000000003'; -- solo "Hijo menor sin discapacidad"
v_r_sinresp uuid := '60000000-1111-0000-0000-000000000004'; -- no respondió 4.1
BEGIN
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
VALUES (v_cr_id, '20000000-1111-0000-0000-000000000001', '{"operativo":true}'::jsonb);
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id) VALUES
(v_r_ninguna, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r_mixto, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r_solonin, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r_sinresp, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
INSERT INTO public.answers (response_id, question_id, instance_id, value) VALUES
(v_r_ninguna, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado"]'::jsonb),
(v_r_mixto, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado","Otro / Especificar"]'::jsonb),
(v_r_solonin, v_q41, 'default', '["Hijo menor sin discapacidad"]'::jsonb);
-- v_r_sinresp: sin fila en answers para 4.1 (a propósito)
END;
$$;
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000001'), false,
'3.1 solo "Ninguna..." → NO es cuidador');
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000002'), true,
'3.2 "Ninguna..." + otra opción → SÍ es cuidador (la otra opción cuenta)');
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000003'), true,
'3.3 solo "Hijo menor sin discapacidad" → SÍ es cuidador (criterio UNFPA)');
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000004'), false,
'3.4 sin respuesta a 4.1 → NO es cuidador');
-- ============================================================
-- SECCIÓN 4: k≥5 en rpc_me_prevalencia_cuidadores (E1)
-- 4 cuidadores → suppressed; al agregar 1 más (5) → ok, pct correcto
-- ============================================================
SELECT is(
(public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'status'),
'suppressed',
'4.1 con 4 responses (n<5) → status=suppressed'
);
DO $$
DECLARE
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
v_q41 uuid := '40000000-1111-0000-0000-000000000041';
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
v_r5 uuid := '60000000-1111-0000-0000-000000000005'; -- 5ta response, NO cuidador
BEGIN
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id)
VALUES (v_r5, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
INSERT INTO public.answers (response_id, question_id, instance_id, value)
VALUES (v_r5, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado"]'::jsonb);
END;
$$;
-- Ahora hay 5 responses: 3 cuidadores (mixto, solonin) + ... v_r_sinresp NO es
-- cuidador (sin 4.1), v_r_ninguna NO es cuidador, v_r5 NO es cuidador.
-- Cuidadores reales = {v_r_mixto, v_r_solonin} = 2 de 5 → 40.0%
SELECT is(
(public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'status'),
'ok',
'4.2 con 5 responses (n>=5) → status=ok'
);
SELECT is(
((public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'pct_cuidadores')::numeric),
40.0,
'4.3 pct_cuidadores = 2/5 = 40.0%'
);
-- ============================================================
-- SECCIÓN 5: fix de _dash_responses — filtro de género contra 2.5 (no A6)
-- ============================================================
DO $$
DECLARE
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
v_q25 uuid := '40000000-1111-0000-0000-000000000025';
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
v_rg uuid := '60000000-1111-0000-0000-000000000006';
BEGIN
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id)
VALUES (v_rg, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
INSERT INTO public.answers (response_id, question_id, instance_id, value)
VALUES (v_rg, v_q25, 'default', '"Femenino"'::jsonb);
END;
$$;
SELECT is(
(SELECT count(*)::int FROM public._dash_responses(
'30000000-1111-0000-0000-000000000001'::uuid, NULL, ARRAY['Femenino'], NULL, NULL
) WHERE response_id = '60000000-1111-0000-0000-000000000006'),
1,
'5.1 filtro p_genero=[Femenino] encuentra la response con 2.5=Femenino (post-fix)'
);
SELECT is(
(SELECT count(*)::int FROM public._dash_responses(
'30000000-1111-0000-0000-000000000001'::uuid, NULL, ARRAY['Masculino'], NULL, NULL
) WHERE response_id = '60000000-1111-0000-0000-000000000006'),
0,
'5.2 filtro p_genero=[Masculino] NO encuentra esa response (control negativo)'
);
-- ============================================================
-- SECCIÓN 6: umbral parametrizable — métrica estrella
-- 1 cuidador con exactamente 1 de 3 señales (riesgo de rotación, vía 7.6)
-- ============================================================
DO $$
DECLARE
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
v_q41 uuid := '40000000-1111-0000-0000-000000000041';
v_q76 uuid := '40000000-1111-0000-0000-000000000076';
v_q73 uuid := '40000000-1111-0000-0000-000000000073';
v_q75 uuid := '40000000-1111-0000-0000-000000000075';
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
v_r1 uuid := '60000000-2222-0000-0000-000000000001';
v_r2 uuid := '60000000-2222-0000-0000-000000000002';
v_r3 uuid := '60000000-2222-0000-0000-000000000003';
v_r4 uuid := '60000000-2222-0000-0000-000000000004';
v_r5 uuid := '60000000-2222-0000-0000-000000000005';
BEGIN
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id)
VALUES (v_r1, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r2, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r3, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r4, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
(v_r5, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
-- Las 5 son cuidadoras (4.1 con un vínculo real) y respondieron 7.6
-- (ancla de la población), pero NINGUNA cumple 2+ señales — cada una
-- cumple EXACTAMENTE 1 (riesgo de rotación), 0 en ausentismo/carrera.
INSERT INTO public.answers (response_id, question_id, instance_id, value)
SELECT r, v_q41, 'default', '["Otro / Especificar"]'::jsonb
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
INSERT INTO public.answers (response_id, question_id, instance_id, value)
SELECT r, v_q76, 'default', '"Muy probable"'::jsonb
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
INSERT INTO public.answers (response_id, question_id, instance_id, value)
SELECT r, v_q73, 'default', '"Nunca"'::jsonb
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
INSERT INTO public.answers (response_id, question_id, instance_id, value)
SELECT r, v_q75, 'default', '["Ninguna de las anteriores"]'::jsonb
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
END;
$$;
-- Con el umbral default (2 de 3): nadie llega → 0%
SELECT is(
((public.rpc_me_talento_en_riesgo('30000000-1111-0000-0000-000000000001'::uuid)->>'pct_talento_riesgo')::numeric),
0.0,
'6.1 umbral default (2 de 3): 0% — nadie acumula 2 señales'
);
-- Bajando el umbral a 1 de 3: las 5 cumplen (todas tienen la señal de riesgo) → 100%
SELECT is(
((public.rpc_me_talento_en_riesgo(
'30000000-1111-0000-0000-000000000001'::uuid, '{}'::jsonb, 1
)->>'pct_talento_riesgo')::numeric),
100.0,
'6.2 umbral=1 (parámetro explícito): 100% — el parámetro SÍ cambia el resultado'
);
SELECT is(
((public.rpc_me_talento_en_riesgo('30000000-1111-0000-0000-000000000001'::uuid)->>'criterios_promedio')::numeric),
1.00,
'6.3 criterios_promedio = 1.00 (cada cuidadora cumple exactamente 1 de 3 señales)'
);
-- ============================================================
-- SECCIÓN 7: E9 (rpc_me_segmentacion) — por_nivel via qi_sector,
-- por_modalidad via JOIN a 2.7, k≥5 por segmento
-- ============================================================
DO $$
DECLARE
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
v_q27 uuid := '40000000-1111-0000-0000-000000000027';
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
v_r uuid;
i int;
BEGIN
INSERT INTO public.questions (id, survey_id, code, type, prompt, position)
VALUES (v_q27, v_survey_id, '2.7', 'single_choice', 'modalidad', 27);
-- 6 responses "Operativo"/"Presencial" (n=6 >= 5 → visible)
FOR i IN 1..6 LOOP
v_r := ('60000000-3333-0000-0000-' || lpad(i::text, 12, '0'))::uuid;
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id, qi_sector)
VALUES (v_r, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id, 'Operativo');
INSERT INTO public.answers (response_id, question_id, instance_id, value)
VALUES (v_r, v_q27, 'default', '"Presencial"'::jsonb);
END LOOP;
-- 3 responses "Gerencial"/"Remoto (home office)" (n=3 < 5 → omitido)
FOR i IN 1..3 LOOP
v_r := ('60000000-4444-0000-0000-' || lpad(i::text, 12, '0'))::uuid;
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id, qi_sector)
VALUES (v_r, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id, 'Gerencial');
INSERT INTO public.answers (response_id, question_id, instance_id, value)
VALUES (v_r, v_q27, 'default', '"Remoto (home office)"'::jsonb);
END LOOP;
END;
$$;
SELECT is(
jsonb_path_query_first(
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
'$.por_nivel[*] ? (@.nivel == "Operativo")'
)->>'n',
'6',
'7.1 por_nivel lee qi_sector — "Operativo" aparece con n=6 (post-fix)'
);
SELECT is(
jsonb_path_query_first(
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
'$.por_nivel[*] ? (@.nivel == "Gerencial")'
),
NULL::jsonb,
'7.2 "Gerencial" (n=3 < 5) está omitido de por_nivel — k-anonimato por segmento'
);
SELECT is(
jsonb_path_query_first(
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
'$.por_modalidad[*] ? (@.modalidad == "Presencial")'
)->>'n',
'6',
'7.3 por_modalidad lee 2.7 por JOIN a answers — "Presencial" aparece con n=6'
);
SELECT is(
jsonb_path_query_first(
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
'$.por_modalidad[*] ? (@.modalidad == "Remoto (home office)")'
),
NULL::jsonb,
'7.4 "Remoto (home office)" (n=3 < 5) está omitido de por_modalidad'
);
SELECT * FROM finish();
ROLLBACK;

View File

@@ -11,6 +11,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"allowImportingTsExtensions": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "incremental": true,
"plugins": [{ "name": "next" }], "plugins": [{ "name": "next" }],