42 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
markosbenitez
c30688810a Con las mejoras visuales 2026-06-23 21:44:05 -03:00
markosbenitez
f2666c7d3c Con las mejoras visuales 2026-06-23 21:43:52 -03:00
markosbenitez
424071b97c estado de proyecto updated 2026-06-23 15:01:08 -03:00
markosbenitez
eba93db452 feat(engine): motor de resolución de flujo declarativo [4C] 2026-06-23 07:21:35 -03:00
markosbenitez
ed524af16b feat(engine): motor de resolución de flujo declarativo [4C]
Módulo puro que interpreta el esquema v3 y resuelve pantallas.
5 operadores (eq, eq_any, not_in, contains, eq_sole), acciones show/hide
(hide = veto absoluto). Instancias con ID estable por posición de opción,
secciones-pantalla. Navegación por ID estable. 16/16 tests Node en verde.
No reemplaza aún lib/conditional.ts de v2 (lo hace 4D al migrar el componente).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 07:21:03 -03:00
markosbenitez
5987502664 feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of/hint.
Instrumento v3 completo (10 secciones, 50 preguntas) como datos declarativos.
Operadores de flujo para multiple_choice: contains, eq_sole.
Flujo del formulario en el esquema, no en presentación (ADR-005).
Seed solo reemplaza catálogo; no toca evidencia de consentimiento (ADR-003).
2026-06-22 21:05:11 -03:00
markosbenitez
aa893368cd feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of/hint.
Instrumento v3 completo (10 secciones, 50 preguntas) como datos declarativos.
Operadores de flujo para multiple_choice: contains, eq_sole.
Flujo del formulario en el esquema, no en presentación (ADR-005).
Seed solo reemplaza catálogo; no toca evidencia de consentimiento (ADR-003).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 21:03:01 -03:00
markosbenitez
7e4991f960 feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of/hint y documenta
los operadores contains/eq_sole en conditional_rules (ADR-005).

Reescribe seed.sql con el instrumento v3.0.0 completo: 10 secciones,
40 preguntas (2 consentimiento + 38), contenido 100% desde
Formulario_Mapeo_Corregido.xlsx (versión autoritativa). Flujo
declarativo en datos, no en presentación: 3.1='No' oculta 3.2-3.5;
"Ninguna" en 4.1 salta a S10; "solo niño sin discapacidad" deja
visibles 5.3, 8.4, 9.1, 9.4 y oculta el resto (decisión confirmada
sobre el Excel corregido).

SEED_SPEC.md reescrito como contrato v3.0.0.

No ejecutado en producción — solo archivos de migración y seed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 17:21:11 -03:00
markosbenitez
a4287a122e docs(compliance): crear COMPLIANCE.md + registrar roadmap de auditabilidad y seguridad 2026-06-22 15:26:22 -03:00
markosbenitez
6db211b9aa docs(adr): registrar ADR-005 esquema declarativo + deuda Fase 2 en backlog 2026-06-22 15:19:37 -03:00
markosbenitez
d50c31f8f4 feat(data): agregar instance_id a answers para multi-instancia [4A]
Habilita respuestas múltiples por pregunta (mini-wizard de vínculos).
Nueva constraint UNIQUE(response_id, question_id, instance_id).
Aplicada en producción. Parte del Sprint 4 hacia v3.0.0.
2026-06-22 14:35:13 -03:00
markosbenitez
d6be2b5c6b docs: agregar CHANGELOG con versionado semántico
Establece la convención SemVer para el proyecto.
v2.1.0 documentada como versión estable actual.
v3.0.0 (wizard) marcada como en desarrollo.
2026-06-22 09:10:00 -03:00
markosbenitez
5c17a9846a feat(admin): filtro por empresa + contadores filtrados 2026-06-17 10:01:59 -03:00
markosbenitez
b9a3118c84 Filtro en listado de respuestas admin 2026-06-17 09:25:18 -03:00
markosbenitez
cfd2eb0624 docs: estado del proyecto 11 junio 2026 2026-06-11 16:56:17 -03:00
markosbenitez
edf82bf7fd feat(dashboard): selector de empresa + flag p_all en _dash_responses 2026-06-11 16:35:03 -03:00
markosbenitez
60c9c81224 estado del proyecto 2026-06-10 21:42:14 -03:00
markosbenitez
1bab6874a9 feat(admin): colapsar/expandir submissions + eliminar columna Tipo 2026-06-10 21:41:47 -03:00
markosbenitez
933d00ad58 Etapa 2G: C.8 promovido a estable + AP.10/AP.11 documentados con casos reales 2026-06-10 21:36:33 -03:00
markosbenitez
059dfa071e Etapa 3C parte 2: orgs y surveys piloto InQuality y Fundación Solidaridad
Agrega organizations + surveys para los dos pilotos en espacio de pruebas
real, con su propio set de 44 preguntas (mismo instrumento que el survey
demo, IDs namespaced 02xx/03xx). Survey demo (...0001) intacto.
2026-06-10 21:05:06 -03:00
markosbenitez
2d13982d92 Pin next a 15.3.8 sin caret
Mantiene el estilo de pin exacto que tenía next antes de CVE-2025-66478,
evitando que un futuro npm install resuelva versiones 15.x posteriores
sin pasar por revisión.
2026-06-10 20:49:43 -03:00
markosbenitez
92a7923a26 Con las encuestas para 3 empresas 2026-06-10 16:55:41 -03:00
markosbenitez
af0ec65be5 Documentar deuda técnica: /admin/responses sin autenticación
Registra TECH-DEBT-008 como bloqueante para ampliar la vista de
control a preguntas is_sensitive=true (salud/discapacidad).
2026-06-10 16:30:42 -03:00
markosbenitez
157e108322 Los triggers 1.1, 1.6, 1.13 muestran solo Sí · No (sin "Prefiero no decir")
Al responder "No" en las tres, el formulario salta a 6.4 (no muestra secciones 2-5 ni 6.1-6.3)
La pregunta 6.6 aparece antes del cierre
Las preguntas multiple_choice muestran el hint "Podés marcar más de una opción"
2026-06-10 15:49:05 -03:00
markosbenitez
bf963e8894 Con el listado de encuestas cargadas/respondidas 2026-06-10 08:59:50 -03:00
94 changed files with 19958 additions and 1856 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -6,6 +6,8 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key-publica>
# NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente # NUNCA agregar SUPABASE_SERVICE_ROLE_KEY como variable NEXT_PUBLIC_ ni en el cliente
# La service_role bypasa RLS — solo para Edge Functions / backend server-side # La service_role bypasa RLS — solo para Edge Functions / backend server-side
# Usada por app/admin/responses (Server Component) para listar respuestas sin RLS
SUPABASE_SERVICE_ROLE_KEY=
# IP hash — variable server-only (NUNCA NEXT_PUBLIC_) # IP hash — variable server-only (NUNCA NEXT_PUBLIC_)
# Usada para deduplicación anónima. Generá un string aleatorio largo. # Usada para deduplicación anónima. Generá un string aleatorio largo.

View File

@@ -77,11 +77,15 @@ Principios estructurales (de ADR-001):
- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con - **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con
n < umbral). n < umbral).
## 6. ADRs iniciales ## 6. ADRs
Ver `docs/adr/` para los archivos completos.
- **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*. - **ADR-001** — Modelo de privacidad y gobierno de datos *(el corazón del producto)*.
- **ADR-002** — Stack tecnológico y residencia de datos. - **ADR-002** — Stack tecnológico y residencia de datos.
- **ADR-003** — Consentimiento granular versionado e inmutable. - **ADR-003** — Consentimiento granular versionado e inmutable.
- **ADR-004** — Capa de identidad y autenticación (SSO Keycloak para staff; sin login para respondentes).
- **ADR-005** — Esquema declarativo de formularios y estrategia de dos fases (Sprint 4). La lógica del flujo vive en datos (JSONB), interpretada por un motor backend; el frontend wizard es un renderer sin reglas de negocio. Motor genérico diferido a Fase 2.
## 7. Riesgos ## 7. Riesgos

View File

@@ -7,6 +7,41 @@
## 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
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
de hardening. Antes de escalar a más empresas o datos reales, se requiere un sprint dedicado.
- **Ítems principales (ver COMPLIANCE.md secciones 5 y 6):**
- Registro de auditoría append-only (log inmutable de operaciones sensibles)
- Hashing SHA-256 de integridad de respuestas (detección de tampering sin blockchain)
- Cifrado en reposo a nivel de volumen/disco de la base de datos
- Roadmap de hardening por capas: aplicación, autenticación, datos, infraestructura,
observabilidad y proceso (revisión periódica de CVEs, pen testing)
- **Dependencia:** no bloquea Sprint 4; sí bloquea escalar a producción masiva.
- **Ver:** `COMPLIANCE.md` secciones 5 y 6.
### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3 ### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3
- **Estado:** Pendiente - **Estado:** Pendiente
- **Prioridad:** Alta - **Prioridad:** Alta
@@ -58,6 +93,17 @@
--- ---
### [BACKLOG-002] Fase 2 — Motor genérico de formularios
- **Estado:** Diferido post-deadline
- **Contexto:** En Sprint 4 se construye un intérprete del esquema declarativo específico
para el formulario de cuidadores (ADR-005, Fase 1). Una vez entregado ese caso, el
intérprete se extrae a un motor universal que interprete cualquier esquema definido en
`questions.conditional_rules` + campos de instanciación, sin hardcodear ningún formulario.
El formulario de cuidadores se vuelve el primer caso de prueba del motor sin reescribir
el contrato de datos.
- **Dependencia:** Fase 1 completada (Sprint 4 — wizard funcional).
- **Ver:** ADR-005 (`docs/adr/ADR-005-esquema-declarativo-formularios.md`).
- Dashboard / reportes (Sprint 3) - Dashboard / reportes (Sprint 3)
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B) - Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
- Builder visual de encuestas (post-validación del caso fijo) - Builder visual de encuestas (post-validación del caso fijo)

98
CHANGELOG.md Normal file
View File

@@ -0,0 +1,98 @@
# Changelog — Datos País sobre el Cuidado
Todas las versiones notables de este proyecto se documentan en este archivo.
El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/)
y el proyecto adhiere a [Versionado Semántico](https://semver.org/lang/es/).
---
## [3.0.0] — 2026-06-27
Reescritura del formulario a wizard declarativo pregunta-por-pantalla.
Motor de flujo puro, instancias familiares, submit transaccional server-side
y dashboard Tres Miradas con Mirada Empresa funcional.
### Agregado
- **Motor de flujo declarativo** (`lib/form-engine.ts`): `resolveVisibility`,
`resolveScreens`, `expandInstances` — lógica de show/hide/instancias en TypeScript
puro, sin estado UI. Operadores `contains`, `eq_sole`, `contains_any`.
- **Wizard renderer** (`components/wizard-form.tsx`): pregunta-por-pantalla,
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]
---
## [2.1.0] — 2026-06-16
Versión estable preservada en la rama `v2.1.0-stable`. Última versión del
formulario lineal antes del rediseño a wizard.
### Agregado
- Dashboard multitenante con selector de empresa (Todos / InQuality / Fundación / Demo)
- Flag `p_all` (UUID cero) para vista agregada de todos los surveys
- Vista de control `/admin/responses` con tabla plana, filtro por empresa,
colapsar/expandir por submission
- Dos encuestas piloto: InQuality y Fundación Solidaridad (organizaciones separadas)
- Método formalizado: `method/PATTERNS.md` y `method/ANTI_PATTERNS.md`
(patrones C.8, AP.10, AP.11)
### Cambiado
- Instrumento actualizado a v2.1 (feedback equipo médico): salto triple,
nueva pregunta 6.6, eliminación de "Prefiero no decir", 2.4 eliminada,
2.1 a multiple_choice
- Next.js actualizado a 15.3.8 (pin exacto)
### Seguridad
- Resuelto CVE-2025-66478 (+ advisory posterior CVE-2025-55183/55184/67779)
### Infraestructura
- Migración completa a Supabase self-hosted en Dokploy
- Backups GFS locales con retención de 3 días + domingos 14 días
---
## [2.0.0] — 2026-06 (anterior)
Primera versión en producción con Supabase self-hosted.
- Formulario lineal de 44 preguntas
- Dashboard con 11 widgets y k-anonimato (k≥5)
- Instrumento v2 (44 preguntas, 8 secciones)

148
COMPLIANCE.md Normal file
View File

@@ -0,0 +1,148 @@
# COMPLIANCE.md — Cumplimiento, seguridad y auditabilidad
> Documento vivo. Área: Compliance & Seguridad del producto "Datos País sobre el Cuidado".
> Audiencia: dirección técnica, auditores externos, equipo legal, clientes (resumen).
> Última actualización: 2026-06-24 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
---
## 1. Principio rector de seguridad
**No existe un sistema "imposible de hackear". El objetivo es defensa en profundidad:**
encarecer progresivamente el ataque, minimizar la superficie expuesta, contener el daño
si una capa cae, y garantizar trazabilidad para detectar, auditar y reconstruir cualquier
incidente. Toda afirmación de inviolabilidad absoluta se considera, en sí misma, una
señal de inmadurez en seguridad.
---
## 2. Naturaleza de los datos y marco normativo
### 2.1 Clasificación de datos
- **Datos sensibles** (salud, discapacidad): preguntas marcadas `is_sensitive=true`
(Sección de prevalencia, condición propia y de familiares).
- **Cuasi-identificadores**: departamento, ciudad, sector, rango etario, género.
- **Dato confidencial restringido**: franja salarial (analyst-only, nunca en vistas de empresa).
- **NO se recogen identificadores directos**: sin nombre, documento, email ni teléfono
en la encuesta anónima. La encuesta es anónima por diseño.
### 2.2 Marco normativo aplicable (Paraguay)
- Ley N° 6534/2020 de Protección de Datos Personales Crediticios y normativa de datos
personales aplicable en Paraguay.
- Principios alineados a estándares internacionales (minimización, finalidad, consentimiento).
- **Pendiente de revisión legal formal** con el equipo de Fundación Solidaridad / asesoría legal.
### 2.3 Consentimiento
- Registrado en tablas `consent_records` + `consent_versions` con versión y timestamp.
- Doble propósito separado y autorizable independientemente: diagnóstico interno de empresa
(requerido) y estudio país (opcional).
- Por ser anónima, la encuesta no permite revocación post-envío (no hay forma de localizar
una respuesta individual) — comunicado explícitamente al participante en el consentimiento.
---
## 3. Anonimato y protección contra reidentificación
### 3.1 k-anonimato (k ≥ 5)
- Regla no negociable del sistema: ningún corte de datos con menos de 5 respuestas se muestra.
- Implementado en las RPCs del dashboard (`SECURITY DEFINER`), no en el frontend.
- Estados de supresión: `suppressed` con `n` real solo a nivel agregado.
### 3.2 Separación de datos sensibles
- Franja salarial (3.4 / 8.4 según versión): nunca expuesta en vistas de empresa
ni en `/admin/responses`.
- Política RLS `deny_sensitive` a nivel de base de datos.
- Vista de control `/admin/responses` excluye `is_sensitive=true` y `text_long` a nivel
de query, no de presentación (los valores no llegan al servidor).
---
## 4. Seguridad — estado actual (v2.1.0)
### 4.1 Implementado
- **Cifrado en tránsito**: TLS/SSL vía Kong + Traefik (Let's Encrypt) en `db.inqualityhq.com`
y `economia-cuidado.inqualityhq.com`.
- **Soberanía del dato**: Supabase self-hosted en infraestructura propia (VPS controlado),
no en nube de terceros. Decisión arquitectónica fundacional.
- **RLS (Row Level Security)**: políticas activas en tablas sensibles; el rol `anon` solo
puede insertar respuestas, no leer datos de otros.
- **Service role aislado**: la clave de servicio nunca llega al bundle del cliente
(verificado: vistas server-side, no Client Components con la key).
- **Gestión de vulnerabilidades**: CVE-2025-66478 resuelto (Next.js 15.3.8, pin exacto).
- **Backups**: GFS local (3 días diarios + domingos 14 días), restore probado.
- **Aislamiento de red**: Studio de administración solo accesible vía SSH tunnel, no expuesto.
### 4.2 Deuda de seguridad registrada (ver TECH_DEBT.md)
- **TECH-DEBT-006**: backup remoto S3 — sin réplica off-site, riesgo ante pérdida del VPS.
Bloqueante antes del primer dato real de empresa.
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
pero requiere gate de auth antes de exponer.
- **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.
---
## 5. Auditabilidad y trazabilidad (roadmap)
> Objetivo: ante una auditoría o un incidente, poder responder "quién accedió a qué,
> cuándo, y demostrar que un registro no fue alterado".
### 5.1 Pendiente de diseño e implementación (BACKLOG — sprint de compliance)
- **Registro de auditoría append-only**: log inmutable de operaciones sensibles
(accesos a datos sensibles, cambios de configuración, ejecución de migraciones).
- **Hashing de integridad (SHA-256)**: hash de los registros de respuesta para demostrar
no-alteración, encadenable para detección de tampering. Provee el beneficio probatorio
de inmutabilidad SIN los costos de blockchain.
- **Cifrado en reposo**: cifrado a nivel de volumen/disco de la base de datos.
- **Trazabilidad de cambios de datos**: tablas de historial con triggers, o aprovechamiento
del WAL de PostgreSQL para reconstrucción temporal.
### 5.2 Por qué NO blockchain (decisión registrada)
Blockchain aporta valor cuando se requiere registro inmutable verificable entre partes
que NO confían entre sí, sin autoridad central legítima. En este sistema la confianza
está legítimamente centralizada (InQuality es responsable y operador soberano del dato),
por lo que blockchain no resuelve ningún problema real que no esté mejor resuelto por
audit logs + hashing. Además, su inmutabilidad absoluta entra en CONFLICTO con el derecho
de rectificación y supresión exigido por la normativa de protección de datos. Se descarta
por inadecuación técnica y normativa, no por desconocimiento.
---
## 6. Roadmap de hardening de seguridad (a evaluar en sprint de compliance)
> Defensa en profundidad — capas a evaluar e implementar gradualmente.
- **Capa de aplicación**: validación y sanitización de inputs, protección contra inyección
(parametrización ya usada vía PostgREST/Supabase), rate limiting en endpoints públicos,
headers de seguridad (CSP, HSTS, X-Frame-Options).
- **Capa de autenticación**: gate de auth para vistas administrativas, rotación de secretos,
gestión de sesiones robusta cuando se introduzcan roles autenticados.
- **Capa de datos**: cifrado en reposo, RLS exhaustivo, principio de mínimo privilegio en roles.
- **Capa de infraestructura**: firewall, fail2ban, actualizaciones de SO, monitoreo de
intrusiones, segmentación de red.
- **Capa de observabilidad**: logs centralizados, alertas ante patrones anómalos,
registro de auditoría (sección 5).
- **Proceso**: revisión periódica de dependencias (`npm audit`), gestión de CVEs,
pruebas de penetración antes de escalar a producción masiva.
---
## 7. Responsabilidades
- **Responsable del dato**: InQuality (operador de infraestructura y autoridad del dato).
- **Aliado institucional**: Fundación Solidaridad.
- **Revisión legal**: pendiente de formalizar con asesoría legal especializada en
protección de datos en Paraguay.
---
## 8. Historial de revisiones de este documento
| 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-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.
@@ -106,9 +111,20 @@ id uuid pk
response_id uuid fk -> responses(id) response_id uuid fk -> responses(id)
question_id uuid fk -> questions(id) question_id uuid fk -> questions(id)
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc. value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
instance_id text not null default 'default'
-- 'default' → preguntas normales (la gran mayoría)
-- 'familiar_1', 'familiar_2', ... → instancias del mini-wizard de vínculos (4A, Sprint 4)
UNIQUE (response_id, question_id, instance_id) -- constraint: answers_response_question_instance_key
``` ```
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee > El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4). > categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
>
> **Patrón `instance_id` (v3.0.0):** la pregunta 4.1 ("¿Tenés familiar con discapacidad/adulto
> mayor a cargo?") dispara el mini-wizard. Por cada familiar declarado se genera un conjunto de
> respuestas para las preguntas 4.2, 4.3, etc., cada una con su propio `instance_id`. Las RPCs
> de dashboard que agregan datos de vínculo operan sobre filas donde `instance_id != 'default'`.
> Las RPCs existentes de v2 no se modifican: siguen usando `instance_id = 'default'` implícitamente
> (las preguntas de vínculo no estaban en el instrumento v2).
### `consent_versions` — textos de consentimiento versionados ### `consent_versions` — textos de consentimiento versionados
```sql ```sql

384
INSTALL_SUPABASE.md Normal file
View File

@@ -0,0 +1,384 @@
# INSTALL_SUPABASE.md — Supabase self-hosted en Dokploy v0.29.4
> Guía operativa basada en la instalación real del proyecto "Datos País sobre el Cuidado" (junio 2026).
> Registra los pasos exactos, errores conocidos y sus soluciones.
---
## Decisiones de arquitectura
- **Supabase self-hosted completo** (Postgres + Auth + Storage + Realtime + Studio + Kong).
- **Partida limpia** — no se migran datos desde Cloud, se aplican las migraciones del repo.
- **Gestión vía Git** — el compose y los volumes viven en un repo dedicado; Dokploy clona ese repo.
Las variables de entorno viven en Dokploy (no en el repo).
- **Backups locales** diarios, retención 30 días. Remoto: TECH-DEBT-006.
- **Studio** no expuesto a internet. Acceso solo vía SSH tunnel.
---
## Requisitos previos
- VPS Ubuntu 22.04+ con Docker 29+ instalado.
- Dokploy v0.29.4 instalado y accesible.
- Repo Git disponible (Gitea o GitHub) con acceso desde el VPS y desde Dokploy.
- Acceso SSH al VPS.
- Supabase CLI en la máquina de desarrollo local.
---
## PARTE 1 — Preparar el repo de Supabase
### 1.1 Descargar el stack oficial en el VPS
```bash
curl -fsSL https://supabase.link/setup.sh | sh
```
Crea `~/supabase-project/` con esta estructura:
```
~/supabase-project/
├── docker-compose.yml
├── volumes/
│ ├── api/
│ │ ├── kong.yml <- API gateway (EDITAR: ver 1.2)
│ │ └── kong-entrypoint.sh
│ ├── db/
│ │ ├── _supabase.sql <- CRITICO: crea la base _supabase
│ │ ├── roles.sql
│ │ ├── jwt.sql
│ │ ├── realtime.sql
│ │ ├── pooler.sql
│ │ ├── webhooks.sql
│ │ ├── logs.sql
│ │ └── init/data.sql
│ └── pooler/
│ └── pooler.exs
```
> Estos archivos son los scripts de inicializacion que Postgres ejecuta la primera vez.
> Sin ellos la base _supabase no se crea y el stack no funciona.
> Por eso NO se puede usar "docker raw" en Dokploy — ese modo no tiene contexto del directorio.
### 1.2 Ajustar kong.yml
El kong.yml define las credenciales del API gateway. Verificar que use los mismos
nombres de variables que se definen en Dokploy:
```bash
grep -n "keyauth" ~/supabase-project/volumes/api/kong.yml
```
Deben quedar dos bloques con UNA sola entrada cada uno:
```yaml
keyauth_credentials:
- key: $ANON_KEY # nombre exacto de la variable en Dokploy
keyauth_credentials:
- key: $SERVICE_ROLE_KEY # nombre exacto de la variable en Dokploy
```
ERROR comun: si los dos bloques resuelven al mismo valor JWT, Kong falla con
"uniqueness violation". Verificar que ANON_KEY y SERVICE_ROLE_KEY son distintos.
### 1.3 Crear .gitignore
```bash
cat > ~/supabase-project/.gitignore << 'EOF'
.env
.env.local
volumes/db/data/
volumes/storage/
volumes/logs/
*.log
EOF
```
### 1.4 Push al repo Git
```bash
cd ~/supabase-project
git init
git add docker-compose.yml volumes/api/ volumes/db/ volumes/pooler/ .gitignore
git commit -m "chore: supabase self-hosted stack inicial"
git remote add origin https://TU_GIT/usuario/supabase-repo.git
git push -u origin master
```
El .env con los secrets NO se sube (esta en .gitignore).
---
## PARTE 2 — Generar los secrets
Usar siempre openssl rand -hex (solo hex, sin caracteres especiales que rompan el escaping de Docker).
### 2.1 Valores base (en el VPS)
```bash
echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)"
echo "JWT_SECRET=$(openssl rand -hex 40)"
echo "DASHBOARD_PASSWORD=$(openssl rand -hex 16)"
echo "SECRET_KEY_BASE=$(openssl rand -hex 40)"
echo "VAULT_ENC_KEY=$(openssl rand -hex 20)"
echo "PG_META_CRYPTO_KEY=$(openssl rand -hex 24)"
```
Guardar todos en un gestor de contrasenas antes de continuar.
### 2.2 Generar ANON_KEY y SERVICE_ROLE_KEY
Con el JWT_SECRET generado, usar Node.js en el VPS:
```bash
node -e "
const crypto = require('crypto');
const secret = 'PEGAR_AQUI_JWT_SECRET';
function b64url(obj) {
return Buffer.from(JSON.stringify(obj)).toString('base64url');
}
function makeJWT(role) {
const h = b64url({alg:'HS256',typ:'JWT'});
const p = b64url({role,iss:'supabase',iat:1700000000,exp:2000000000});
const sig = crypto.createHmac('sha256',secret).update(h+'.'+p).digest('base64url');
return h+'.'+p+'.'+sig;
}
console.log('ANON_KEY='+makeJWT('anon'));
console.log('SERVICE_ROLE_KEY='+makeJWT('service_role'));
"
```
---
## PARTE 3 — Configurar el servicio en Dokploy
### 3.1 Crear el servicio
1. Projects -> seleccionar proyecto -> Create Service -> Docker Compose.
2. Provider: Gitea (o GitHub) -> seleccionar el repo del paso 1.4.
3. Branch: master | Compose Path: ./docker-compose.yml
4. IMPORTANTE: desactivar Autodeploy (toggle OFF).
### 3.2 Variables de entorno (pestaña Environment)
Pegar el bloque completo reemplazando los valores:
```env
POSTGRES_PASSWORD=<openssl rand -hex 24>
POSTGRES_HOST=db
POSTGRES_DB=postgres
POSTGRES_PORT=5432
JWT_SECRET=<openssl rand -hex 40>
JWT_EXPIRY=3600
ANON_KEY=<JWT anon>
SERVICE_ROLE_KEY=<JWT service_role>
SUPABASE_ANON_KEY=<igual a ANON_KEY>
SUPABASE_SERVICE_KEY=<igual a SERVICE_ROLE_KEY>
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=<openssl rand -hex 16>
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
SECRET_KEY_BASE=<openssl rand -hex 40>
VAULT_ENC_KEY=<openssl rand -hex 20>
PG_META_CRYPTO_KEY=<openssl rand -hex 24>
SITE_URL=http://localhost:3000
API_EXTERNAL_URL=http://localhost:8000
SUPABASE_PUBLIC_URL=http://localhost:8000
STUDIO_DEFAULT_ORGANIZATION=NombreOrg
STUDIO_DEFAULT_PROJECT=NombreProyecto
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_SENDER_NAME=
ENABLE_EMAIL_SIGNUP=false
ENABLE_EMAIL_AUTOCONFIRM=false
ENABLE_PHONE_SIGNUP=false
ENABLE_PHONE_AUTOCONFIRM=false
DISABLE_SIGNUP=true
ENABLE_ANONYMOUS_USERS=false
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false
MAILER_URLPATHS_CONFIRMATION=/auth/v1/verify
MAILER_URLPATHS_INVITE=/auth/v1/verify
MAILER_URLPATHS_RECOVERY=/auth/v1/verify
MAILER_URLPATHS_EMAIL_CHANGE=/auth/v1/verify
ADDITIONAL_REDIRECT_URLS=
POOLER_TENANT_ID=pooler
POOLER_DEFAULT_POOL_SIZE=20
POOLER_MAX_CLIENT_CONN=100
POOLER_PROXY_PORT_TRANSACTION=6543
POOLER_DB_POOL_SIZE=10
STORAGE_TENANT_ID=default
GLOBAL_S3_BUCKET=
REGION=
S3_PROTOCOL_ACCESS_KEY_ID=
S3_PROTOCOL_ACCESS_KEY_SECRET=
LOGFLARE_LOGGER_BACKEND_API_KEY=logflare-local-key
LOGFLARE_API_KEY=logflare-local-key
FUNCTIONS_VERIFY_JWT=true
IMGPROXY_ENABLE_WEBP_DETECTION=true
IMGPROXY_AUTO_WEBP=true
PGRST_DB_SCHEMAS=public,storage,graphql_public
```
### 3.3 Deploy y validacion
Click en Deploy. Cuando termine verificar:
```bash
docker ps --format "table {{.Names}}\t{{.Status}}" | grep supabase
docker exec supabase-db psql -U postgres -c 'SELECT version();'
curl -s http://localhost:8000/rest/v1/ -H "apikey: <ANON_KEY>" | head -c 200
docker exec supabase-db psql -U postgres -c '\l' | grep supabase
```
Contenedores esperados healthy: supabase-db, supabase-kong, supabase-studio,
supabase-auth, supabase-rest, supabase-storage, supabase-meta, supabase-pooler,
supabase-imgproxy, realtime-dev.supabase-realtime.
supabase-edge-functions puede quedar en Restarting si no se usa Edge Functions (no critico).
---
## PARTE 4 — Aplicar las migraciones del proyecto
El puerto 5432 no esta expuesto externamente. Las migraciones se aplican desde el VPS.
### 4.1 Copiar migraciones al VPS (desde la maquina de desarrollo)
```bash
ssh administrator@<VPS_IP> "mkdir -p ~/migrations"
scp -r supabase/migrations/ administrator@<VPS_IP>:~/migrations/
```
### 4.2 Aplicar en orden
```bash
for f in $(ls ~/migrations/*.sql | sort); do
echo "Aplicando: $f"
docker exec -i supabase-db psql -U postgres -d postgres < "$f"
echo "---"
done
```
### 4.3 Verificar
```bash
docker exec supabase-db psql -U postgres -d postgres -c \
"SELECT tablename, policyname FROM pg_policies WHERE schemaname='public' ORDER BY tablename;"
```
### Nota: auth.jwt() en GoTrue v2.189+
GoTrue v2.189+ elimino auth.jwt(). Las funciones disponibles son auth.uid(), auth.role().
Las politicas que usen custom claims JWT (app_role, org_id) fallaran. Ver TECH-DEBT-007.
No es bloqueante para el piloto porque el dashboard usa service_role que bypasa RLS.
---
## PARTE 5 — Backups locales
### 5.1 Crear el script
```bash
sudo mkdir -p /var/backups/supabase-prod
sudo chmod 700 /var/backups/supabase-prod
sudo tee /usr/local/bin/supabase-backup.sh > /dev/null << 'SCRIPT'
#!/usr/bin/bash
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/var/backups/supabase-prod/supabase_$TS.sql.gz
docker exec supabase-db pg_dump -U postgres postgres | gzip > "$OUT"
chmod 600 "$OUT"
find /var/backups/supabase-prod -name "supabase_*.sql.gz" -mtime +30 -delete
echo "Backup OK: $OUT ($(du -h "$OUT" | cut -f1))"
SCRIPT
sudo chmod +x /usr/local/bin/supabase-backup.sh
```
### 5.2 Probar y configurar cron
```bash
sudo bash /usr/local/bin/supabase-backup.sh
sudo crontab -e
# Agregar: 0 3 * * * /usr/bin/bash /usr/local/bin/supabase-backup.sh >> /var/log/supabase-backup.log 2>&1
```
### 5.3 Probar restore (obligatorio)
```bash
docker exec supabase-db createdb -U postgres restore_test
gunzip < $(ls /var/backups/supabase-prod/*.sql.gz | tail -1) | \
docker exec -i supabase-db psql -U postgres restore_test
docker exec supabase-db psql -U postgres -d restore_test -c \
"SELECT count(*) FROM pg_tables WHERE schemaname='public';"
docker exec supabase-db dropdb -U postgres restore_test
```
---
## PARTE 6 — Dominio y SSL (pendiente)
En Dokploy, pestaña Domains del servicio supabase:
- Agregar dominio db.dominio.com -> apuntar a supabase-kong:8000 -> activar SSL.
- Studio NO tiene dominio publico. Acceso solo via SSH tunnel:
ssh -L 3001:localhost:3001 administrator@VPS_IP -> abrir http://localhost:3001
Actualizar variables de entorno:
```
API_EXTERNAL_URL=https://db.dominio.com
SUPABASE_PUBLIC_URL=https://db.dominio.com
```
---
## PARTE 7 — Conectar la app Next.js
Actualizar variables de entorno de la app Next.js en Dokploy:
```
NEXT_PUBLIC_SUPABASE_URL=https://db.dominio.com
NEXT_PUBLIC_SUPABASE_ANON_KEY=<ANON_KEY>
SUPABASE_SERVICE_ROLE_KEY=<SERVICE_ROLE_KEY>
```
---
## Errores conocidos y soluciones
| Error | Causa | Solucion |
|---|---|---|
| password authentication failed for supabase_storage_admin | POSTGRES_PASSWORD con caracteres especiales | Usar openssl rand -hex 24 |
| database "_supabase" does not exist | volumes/db/ no accesible | Usar modo Git en Dokploy, no docker raw |
| keyauth_credentials uniqueness violation | ANON_KEY = SERVICE_ROLE_KEY o duplicados en kong.yml | Una sola entrada por bloque en kong.yml |
| exec: kong-entrypoint.sh is a directory | Volumen corrupto | docker volume rm del volumen afectado |
| GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: converting to bool | Variable vacia | Agregar GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false |
| GOTRUE_SMTP_PORT: converting to int | SMTP_PORT vacio | Agregar SMTP_PORT=587 |
| auth.jwt() does not exist | GoTrue v2.189+ lo elimino | Ver TECH-DEBT-007 |
| Conflicto de nombres de contenedores | Stack manual y Dokploy corriendo en paralelo | docker compose down desde ~/supabase-project |
---
## Deuda tecnica
- TECH-DEBT-006: Backup remoto (S3) — bloqueante antes del primer dato real de empresa.
- TECH-DEBT-007: Migrar auth.jwt() a alternativa compatible con GoTrue v2.189+.
---
## Datos operativos de esta instalacion
- VPS: inq-mart-2 - IP VPN: 172.20.5.11
- Dokploy: https://172.20.5.11 (acceso via VPN)
- Repo Supabase: https://git.inquality.com.py/marcos/supabase-motor-fomularios
- Backups: /var/backups/supabase-prod/ (diarios, retencion 30 dias)
- Studio: ssh -L 3001:localhost:3001 administrator@172.20.5.11 -> http://localhost:3001

89
OBSERVATIONS.md Normal file
View File

@@ -0,0 +1,89 @@
# OBSERVATIONS.md — Observaciones y breaking changes
> Registro de cambios al instrumento que rompen compatibilidad con datos existentes
> o que requieren atención del director antes/después de re-seedear. No bloquean el
> trabajo del agente — se documentan para que el director decida qué hacer con los
> datos ya capturados.
---
## Etapa 3I2 — Revisión del instrumento v2.1 (feedback equipo médico)
### 1. Eliminación de "Prefiero no decir" (Cambio 1)
Afecta: A5, A6, 1.1, 1.5, 1.6, 1.13, 2.5, 3.1, 3.4.
Si existen respuestas guardadas con el valor `"Prefiero no decir"` en alguna de estas
preguntas, esos valores quedan **huérfanos**: ya no aparecen como opción en el
formulario ni en `seed.sql`, pero permanecen en `responses.answers` tal cual fueron
guardados. No se borran ni se migran automáticamente.
**Impacto en triggers (1.1, 1.6, 1.13):** estas preguntas ahora tienen solo 2 opciones
(Sí/No). La lógica condicional (incluyendo el nuevo salto-triple, Cambio 4a) solo
reacciona a `"Sí"`. Una respuesta histórica `"Prefiero no decir"` en un trigger se
trataría como "no Sí" a efectos de la lógica condicional si se re-evaluara, pero no
hay re-evaluación retroactiva de respuestas ya enviadas.
### 2. 2.1 cambia de `single_choice` a `multiple_choice` (Cambio 5)
Respuestas existentes para 2.1 están guardadas como un string único (`"Yo soy el/la
principal"`, etc.). El formulario nuevo espera un array de strings para preguntas
`multiple_choice`. Las respuestas históricas de 2.1 quedan en formato de string,
inconsistente con el nuevo esquema esperado para esa pregunta.
Ver `TECH_DEBT.md` → TECH-DEBT-006 (deuda de ranking pendiente sobre esta misma
pregunta).
### 3. Eliminación de la pregunta 2.4 (Cambio 6)
"¿Contás con apoyo de otras personas para el cuidado?" se elimina del seed por ser
similar/redundante con 2.1 (feedback del equipo médico).
El `DELETE FROM public.questions WHERE survey_id = ...` al inicio de `seed.sql` borra
la fila de la pregunta 2.4 (id `40000000-0000-0000-0000-000000000027`). Si existen
filas en `responses` cuyo `answers` referencian `question_id` = 2.4 (por
`question_id`, no por código embebido en JSON — depende del esquema de `responses`),
esas referencias quedan apuntando a un `question_id` que ya no existe en `questions`
tras el re-seed.
**No re-seedear la base de producción sin que el director confirme que no hay
respuestas reales que dependan de 2.4**, o decidir conservar la fila de 2.4 marcada
como `archived`/inactiva en lugar de borrarla (cambio de diseño — Nivel 3, no
ejecutado en esta etapa).
### 4. Cambio de texto en opción de 3.3 (Cambio 8)
"Es difícil sostenerlo con mis ingresos" → "No puedo sostenerlo con mis ingresos".
Respuestas existentes que eligieron la opción anterior quedan guardadas con el texto
literal `"Es difícil sostenerlo con mis ingresos"`, que ya no coincide con ninguna
opción del seed actual. Cualquier agregación por valor exacto de opción debe
contemplar ambos strings como equivalentes para no perder esos datos en reportes.
### 5. División de la primera franja salarial en 3.4 (Cambio 9)
"Sueldo mínimo Gs. 5.000.000" se reemplaza por dos opciones nuevas: "Sueldo mínimo"
y "Gs. 3.000.000 a 5.000.000".
Respuestas existentes con el valor `"Sueldo mínimo Gs. 5.000.000"` quedan con ese
valor, que ya no existe como opción. No hay forma automática de reasignar esas
respuestas a una de las dos franjas nuevas (la franja exacta del respondente no se
puede inferir del valor agregado anterior).
### 6. Cambio de texto en última opción de 6.1 (Cambio 10)
"No, nunca se ha hablado del tema" → "No, no se puede hablar del tema".
Respuestas existentes con el valor anterior quedan con ese texto literal, que ya no
coincide con ninguna opción del seed actual.
---
## Resumen para el director
- Ningún cambio de esta etapa borra datos directamente, salvo el `DELETE` estándar de
`questions` por `survey_id` (mecanismo de idempotencia ya existente en `seed.sql`,
no nuevo de esta etapa) — pero ese `DELETE` ahora también elimina la fila de 2.4.
- Los cambios 1, 8, 9 y 10 dejan **valores de texto huérfanos** en respuestas
históricas (ya no matchean ninguna opción vigente). No afecta el funcionamiento del
formulario hacia adelante, pero sí cualquier reporte/agregación que dependa de
matchear el string exacto de la opción.
- El cambio 5 (2.1) deja respuestas históricas en un formato (string) distinto al que
el formulario nuevo produce (array). Si se construye un reporte sobre 2.1, debe
manejar ambos formatos o normalizarse en una migración de datos aparte.
- **No se ejecutó ningún re-seed sobre la base real** — esto aplica solo cuando el
director apruebe re-seedear con `seed.sql` v2.1.

View File

@@ -1,119 +1,209 @@
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v2 # SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v3.0.0
> Versión 2 — incorpora revisión de la colega (Jun 2026). Reemplaza la v1 (34 preguntas). > Versión 3.0.0 — Sprint 4, Etapa 4B (Jun 2026). Reemplaza la v2.1. Reescritura completa del
> 44 preguntas · 8 secciones · Códigos únicos corregidos. > instrumento: 10 secciones, 40 preguntas (2 consentimiento + 38 de relevamiento), esquema
> declarativo de flujo (ADR-005). Seed LIMPIO — no migra datos de v2.1.
>
> Fuente de contenido AUTORITATIVA: `Formulario_Mapeo_Corregido.xlsx` (versión corregida,
> en rojo = cambios aplicados). El mockup `wizard_formulario_mapeo_v3.html` define estructura
> de secciones y mecánica del mini-wizard (4.1→4.2/4.3); donde el Excel difiere en el flujo
> condicional de "solo niño sin discapacidad", gana el Excel (decisión de la directora, ver
> abajo "Flujo condicional — recorrido soloNino").
>
> Claude Code reescribe `seed.sql` a partir de ESTE contrato. > Claude Code reescribe `seed.sql` a partir de ESTE contrato.
## Convenciones ## Convenciones
- Tipos: radio→`single_choice`, checkbox→`multiple_choice`, escala 1-5→`rating`,
texto libre largo→`text_long`, texto corto→`text_short`.
- **`conditional_rules` en la pregunta DEPENDIENTE** (cada pregunta declara su propia condición).
- Los 3 triggers (1.1, 1.6, 1.13) son `single_choice` de 3 opciones; solo **"Sí"** dispara el bloque.
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico (marcadas ✅).
- `3.4` (salary) — `is_sensitive=true` + analyst-only (ADR-001 addendum): nunca en vistas org_admin.
- RE-INDEX: las preguntas marcadas son candidatas a índice compuesto (metodología TBD Sprint 3).
## Estructura condicional (3 bloques) - Tipos: radio→`single_choice`, checkbox→`multiple_choice`, texto libre corto→`text_short`,
- **cond-disc-propia** — 1.1='Sí' → muestra 1.2, 1.3, 1.4, 1.5 texto libre largo→`text_long`, sí/no→`yes_no`.
- **cond-familiar** — 1.6='Sí' → muestra 1.7, 1.8, 1.9, 1.10, 1.11, 1.12 - Secciones persisten como filas `questions.type='section'`: `prompt`=nombre de sección,
- **cond-adulto-mayor** — 1.13='Sí' → muestra 1.14, 1.15, 1.16 `hint`=descripción/"porqué" (Opción C, ver ADR-005 §4.2).
- Secciones A, 2, 3, 4, 5, 6, cierre: **top-level, siempre visibles.** - `conditional_rules` vive en la pregunta/sección DEPENDIENTE (auto-target implícito, sin
campo `target`). Vocabulario completo en `supabase/migrations/20260620000002_form_schema_v3.sql`:
operadores `eq`, `eq_any`, `not_in`, `contains`, `eq_sole`; acciones `show`, `hide`.
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico y en 8.4 (franja salarial,
CONFIDENCIAL — analyst-only, nunca en vistas de empresa, igual que 3.4 en v2.1).
- En preguntas `multiple_choice` el `hint` suele ser "Podés marcar más de una opción".
- Mini-wizard de instanciación (ADR-005): 4.1 es `instance_generator=true`; 4.2 y 4.3 tienen
`instance_of='4.1'` y se repiten una vez por cada vínculo familiar (opciones 27) marcado en 4.1.
Las respuestas de cada instancia se guardan en `answers.instance_id` (`'familiar_1'`, `'familiar_2'`...).
- Geo: 2.1 (departamento, 18 opciones) y 2.2 (ciudad, 219 opciones filtradas por departamento en
el renderer) son cuasi-identificadores. 2.3 (barrio) es `text_short` de texto libre, opcional —
NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md); se usa solo como apoyo
cualitativo, no agregable.
## Flujo condicional — las 3 reglas principales
1. **`r_show_si_s3_1`** — 3.1='Sí' → muestra 3.2, 3.3, 3.4, 3.5 (discapacidad propia).
Si 3.1='No', toda la sub-rama queda oculta.
2. **`r_hide_ning`** — 4.1 contiene "Ninguna de las anteriores / No tengo vinculación de cuidado"
→ oculta S5, S6, S7, S8(header), 8.4, S9(header), 9.1, 9.4. El formulario salta directo a S10.
3. **`r_hide_ning_sn`** (Ninguna O soloNino) — además de la condición anterior, también oculta
cuando 4.1 tiene como ÚNICO elemento seleccionado "Hijo menor sin discapacidad" (`eq_sole`).
Oculta: 5.1, 5.2, 5.4, S6 completa, S7 completa, 8.1, 8.2, 8.3, 9.2, 9.3.
### Flujo condicional — recorrido "solo niño sin discapacidad" (soloNino)
Decisión de la directora (Etapa 4B): cuando 4.1 tiene como única respuesta "Hijo menor sin
discapacidad", el recorrido visible es **5.3 → 8.4 → 9.1 → 9.4 → fin**, tal como lo especifica
el Excel corregido (no el mockup, que marcaba toda la Sección 5 como oculta). Esto es más
granular que un simple "salto de sección": 5.3, 8.4, 9.1 y 9.4 quedan visibles porque no
dependen de que haya responsabilidad económica o de cuidado activo — preguntan por quién
cuida durante el horario laboral (5.3), franja salarial (8.4) y percepción/voluntariado
organizacional (9.1, 9.4), que aplican igual a quien solo tiene un hijo menor sin discapacidad.
- **4.1 = sole "Hijo menor sin discapacidad"**: 4.2 y 4.3 se ocultan (no hay tipo de
discapacidad ni dispositivos de apoyo que relevar), 5.1/5.2/5.4 ocultas, 5.3 VISIBLE,
S6 y S7 completas ocultas, 8.18.3 ocultas, 8.4 VISIBLE, 9.1 VISIBLE, 9.2/9.3 ocultas,
9.4 VISIBLE.
- **4.1 contiene "Ninguna de las anteriores..."**: salta directo a S10 (todo S5S9 oculto).
- **4.1 = cualquier combinación de opciones 27** (con o sin la opción 1): recorrido completo,
4.2/4.3 generan una instancia por cada vínculo 27 declarado.
--- ---
## Sección ADatos Generales (top-level) ## Sección 1Consentimiento (`S1`, pos. 10)
| Code | Tipo | Pregunta | Opciones | Notas |
|---|---|---|---|---|
| A1 | single_choice | ¿En qué departamento de Paraguay vivís?* | Asunción (Capital) · Alto Paraguay · Alto Paraná · Amambay · Boquerón · Caaguazú · Caazapá · Canindeyú · Central · Concepción · Cordillera · Guairá · Itapúa · Misiones · Ñeembucú · Paraguarí · Presidente Hayes · San Pedro | qi_zone |
| A2 | single_choice | ¿En qué ciudad vivís?* | 219 ciudades (GeoSelectWidget: dependiente de A1) | qi_city — Sprint 3 |
| A3 | text_short | ¿En qué barrio vivís? | — (texto libre, opcional) | |
| A4 | single_choice | ¿En qué área o gerencia trabajás?* | Directorio · Gerencia · Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · Recursos Humanos/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área | qi_sector |
| A5 | single_choice | ¿Cuántos años tenés? | Menos de 25 años · 25 a 34 años · 35 a 44 años · 45 a 54 años · 55 años o más · Prefiero no decir | qi_age_bucket |
| A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro · Prefiero no decir | |
| A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | |
## Sección 1 — Prevalencia: cuidado y discapacidad *(sensible)* | Code | Tipo | Pregunta | Requerida | Notas |
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive | |---|---|---|---|---|
| 1.1 | yes_no | Declaro que fui informado/a sobre el propósito de esta encuesta y el tratamiento de mis respuestas, y acepto participar de forma voluntaria en el diagnóstico interno de [Empresa]. | Sí | Gate de participación — ver `consent_versions` v2 |
| 1.2 | yes_no | Autorizo que mis respuestas anonimizadas se utilicen para el estudio país sobre la economía del cuidado en Paraguay. | No | Scope `macro_n1` — Modo A (CLAUDE.md regla 7) |
## Sección 2 — Datos generales (`S2`, pos. 20)
| Code | Tipo | Pregunta | Opciones | is_sensitive | Notas |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1.1 | single_choice | trigger | ¿Vos mismo/a vivís con alguna discapacidad o condición de salud que afecte tu funcionamiento cotidiano?* | Sí · No · Prefiero no decir | ✅ | | 2.1 | single_choice | Departamento | 18 departamentos de Paraguay + Capital | no | qi (cuasi-identificador) |
| 1.2 | multiple_choice | 1.1=Sí | ¿Qué tipo de discapacidad o condición tenés? | Discapacidad física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica que genera limitaciones · Otra | | | 2.2 | single_choice | Ciudad | 219 ciudades (filtradas por 2.1 en el renderer) | no | qi |
| 1.3 | multiple_choice | 1.1=Sí | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utilizo | ✅ | | 2.3 | text_short | ¿En qué barrio vivís? | — (texto libre) | no | `hint`='Opcional.' — no entra al store analítico crudo |
| 1.4 | single_choice | 1.1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | | | 2.4 | single_choice | ¿Cuál es tu rango de edad? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ | no | qi |
| 1.5 | single_choice | 1.1=Sí | ¿Tu condición afecta tu desempeño o comodidad en el trabajo? | Sí, de forma significativa · Sí, pero en aspectos puntuales · No, está bien gestionada · Prefiero no decir | ✅ | | 2.5 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | no | qi |
| 1.6 | single_choice | trigger | ¿Tenés algún familiar cercano (hijo/a, pareja, hermano/a, padre/madre) que viva con discapacidad?* | Sí · No · Prefiero no decir | ✅ | | 2.6 | single_choice | ¿En qué nivel organizacional estás? | Directivo · Gerencial · Jefatura · Operativo · Auxiliar · Otro | no | qi |
| 1.7 | multiple_choice | 1.6=Sí | ¿Cuál es tu vínculo con esa persona? | Hijo/a · Cónyuge/pareja · Padre o madre · Hermano/a · Otro familiar | | | 2.7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido | no | qi |
| 1.8 | multiple_choice | 1.6=Sí | ¿Qué tipo de discapacidad tiene? | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial/salud mental · Condición crónica severa · Otra | ✅ |
| 1.9 | multiple_choice | 1.6=Sí | ¿Qué tipo de apoyo o acompañamiento necesita principalmente? | Movilidad física · Comunicación · Apoyo escolar/educativo · Inclusión laboral · Terapias/rehabilitación · Apoyo emocional/psicosocial | ✅ | ## Sección 3 — Prevalencia de discapacidad (`S3`, pos. 30) *(sensible)*
| 1.10 | multiple_choice | 1.6=Sí | ¿Usa algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utiliza | ✅ |
| 1.11 | single_choice | 1.6=Sí | Si es niño/a o adolescente en edad escolar: ¿está escolarizado/a y enfrenta barreras de aprendizaje? | Escolarizado/a, sin barreras importantes · Escolarizado/a, enfrenta barreras o faltan ajustes · No está escolarizado/a · No aplica | ✅ | Cond.: 3.23.5 visibles solo si 3.1='Sí' (`r_show_si_s3_1`).
| 1.12 | single_choice | 1.6=Sí | ¿La persona con discapacidad se encuentra trabajando actualmente? | Sí, está trabajando · No, y está buscando empleo · No, y no está buscando · No aplica | ✅ |
| 1.13 | single_choice | trigger | ¿Tenés algún adulto mayor (60+ años) a tu cuidado o bajo tu responsabilidad?* | Sí · No · Prefiero no decir | | | Code | Tipo | Pregunta | Opciones | is_sensitive |
| 1.14 | single_choice | 1.13=Sí | ¿Qué nivel de dependencia tiene? (la más representativa) | Independiente, pero necesita supervisión · Dependencia leve · Dependencia moderada · Dependencia severa | ✅ | |---|---|---|---|---|
| 1.15 | multiple_choice | 1.13=Sí | ¿Tiene una condición de salud que genere mayor necesidad de cuidado? | Demencia/Alzheimer · ACV/Parálisis · Enfermedad crónica grave · Movilidad reducida · Deterioro cognitivo · Sin condición específica | ✅ | | 3.1 | single_choice | ¿Vos mismo/a tenés alguna discapacidad o condición de salud que pudiera afectar tu independencia o desempeño laboral? | No · Sí | ✅ trigger |
| 1.16 | single_choice | 1.13=Sí | ¿Vive con vos o en otra vivienda? | Vive en mi hogar · Vive en otra vivienda · Está en residencia/institución | | | 3.2 | multiple_choice | ¿Qué tipo de discapacidad o condición tenés? | Física/motora · Visual · Auditiva · Intelectual · Mental/psicosocial · Del habla y la comunicación · Múltiple · Vejez/osteoporosis/artrosis/otras · Amputación · Otra | ✅ |
| 3.3 | multiple_choice | ¿Usás algún elemento de apoyo para la movilidad o autonomía? | Silla de ruedas · Bastón de apoyo · Bastón blanco · Andador · Muletas · Prótesis · Tecnología asistiva · Audífonos · No utilizo | ✅ |
| 3.4 | single_choice | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
| 3.5 | multiple_choice | ¿Cómo afecta tu discapacidad y/o condición limitante en tu trabajo? | No afecta mi rendimiento · Falta de adecuaciones afecta más que la discapacidad · Transporte · Accesibilidad para participar | ✅ |
## Sección 4 — Vinculación familiar (`S4`, pos. 40) *(sensible)*
Mini-wizard: 4.1 `instance_generator=true`; 4.2/4.3 `instance_of='4.1'`, una instancia por
vínculo 27 declarado en 4.1. Ocultas (4.2/4.3) si Ninguna o soloNino (`r_hide_ning_sn`).
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|---|---|---|---|---|
| 4.1 | multiple_choice | ¿Cuál de las siguientes vinculaciones familiares tenés? | 1. Hijo menor sin discapacidad · 2. Hijo menor con discapacidad · 3. Hijo adulto con discapacidad · 4. Familiar menor con discapacidad · 5. Familiar mayor con discapacidad · 6. Padre/madre con movilidad reducida o 100% dependiente · 7. Otro/Especificar · 8. Ninguna de las anteriores / No tengo vinculación de cuidado | ✅ generador |
| 4.2 | multiple_choice (instancia) | ¿Qué tipo de discapacidades o limitaciones tienen tus familiares vinculados? | (mismo set que 3.2) | ✅ |
| 4.3 | multiple_choice (instancia) | ¿Qué tipo de dispositivos de apoyo utilizan tus familiares vinculados? | (mismo set que 3.3, "No utiliza" en 3ra persona) | ✅ |
## Sección 5 — Responsabilidad y cuidado (`S5`, pos. 50)
Cond.: oculta completa si Ninguna (`r_hide_ning`). 5.1/5.2/5.4 además ocultas si soloNino;
**5.3 permanece visible en soloNino** (ver "recorrido soloNino" arriba).
## Sección 2 — Intensidad y rol del cuidado (top-level)
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| 2.1 | single_choice | ¿Quién es el/la responsable principal del cuidado? | Yo soy el/la principal · Comparto con otro familiar · Hay cuidador/a pagado/a · Está en institución/centro de día | | 5.1 | multiple_choice | ¿Cuál es tu rol en el cuidado de los niños y/o personas con discapacidad o dependientes? | Proveo financieramente · Comparto gastos con familiares · Administro recursos/organizo servicios · Cuido unas horas al salir del trabajo · Me ocupo de todas las gestiones |
| 2.2 | single_choice | ¿Cuántas horas por día dedicás, en promedio, al cuidado directo? | Menos de 1h · Entre 1 y 3h · Entre 3 y 6h · Más de 6h · Varía mucho | | 5.2 | multiple_choice | ¿En qué momento del día te ocupás de gestionar/organizar el cuidado? | Antes del trabajo · Después del trabajo · Durante el trabajo (llamadas/emergencias) · En cualquier momento · Fines de semana |
| 2.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Durante horario laboral (llamadas/emergencias) · Al llegar del trabajo · Durante la noche · Los fines de semana · Es continuo sin horario fijo | | 5.3 | multiple_choice | Durante el tiempo que estás en el trabajo, ¿quién se ocupa del cuidado de las personas a tu cargo? | Mi cónyuge · Otro familiar · Cuidador/a contratado/a · Centro de estudios o cuidado · Un/a vecino/a · Otro |
| 2.4 | single_choice | ¿Contás con apoyo de otras personas para el cuidado? | Sí, apoyo familiar suficiente · Algo de apoyo, no suficiente · No, soy el/la único/a responsable · Tengo apoyo contratado | | 5.4 | single_choice | ¿Desde hace cuánto cumplís este rol de responsable o cuidador/a? | Menos de 1 año · 1-3 años · 3-5 años · Más de 5 años |
| 2.5 | single_choice | Como cuidador/a primario, ¿presentás signos de agotamiento o estrés crónico? | Sí, frecuentemente · A veces · No, lo llevo bien · Prefiero no decir |
## Sección 6 — Intensidad del cuidado (`S6`, pos. 60)
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
| Code | Tipo | Pregunta | Opciones |
|---|---|---|---|
| 6.1 | single_choice | ¿Cuántos días por semana estás a cargo del cuidado directo, en promedio? | 12 · 34 · 56 · Todos los días |
| 6.2 | single_choice | Cuando estás a cargo del cuidado, ¿cuántas horas por día dedicás, en promedio? | 12 · 34 · 56 · Más de 6 horas |
| 6.3 | multiple_choice | ¿En qué momentos del día cuidás principalmente? | Antes del trabajo · Al llegar del trabajo · Durante la noche · Fines de semana |
| 6.4 | single_choice | Además del cuidado directo, ¿cuántas horas al día necesitás estar disponible o de guardia? | 12 · 34 · 56 · Más de 6 horas · Permanente/sin horario |
## Sección 7 — Impacto en el trabajo (`S7`, pos. 70)
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
| Code | Tipo | Pregunta | Opciones |
|---|---|---|---|
| 7.1 | single_choice | ¿Cómo describirías tu nivel de descanso cuando te ocupás del cuidado y cómo impacta en tu desempeño? | Descanso bien · Irregular pero lo manejo · Me cuesta, afecta con frecuencia · Descanso muy poco, agotamiento afecta desempeño |
| 7.2 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca/casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días |
| 7.3 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | Nunca · Pocas veces (1-3/año) · Varias veces (4-10/año) · Con frecuencia (+10/año) |
| 7.4 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados por tu rol de cuidador/a? | Tiempo libre · Sueño · Vida social · Vida en pareja/familiar · Salud física · Salud emocional/mental · Desarrollo profesional · Situación económica |
| 7.5 | multiple_choice | Por tu rol de cuidado, ¿alguna vez tuviste que…? | Reducir jornada · Rechazar ascenso · Declinar viajes/extras/capacitaciones · Cambiar a puesto menos exigente · Faltar/llegar tarde reiteradamente · Pensar en dejar el trabajo · Dejar un trabajo anterior · Ninguna de las anteriores |
| 7.6 | single_choice | Pensando en los próximos 12 meses, ¿qué tan probable es que dejes o reduzcas tu trabajo por razones de cuidado? | Nada probable · Poco probable · Algo probable · Muy probable |
## Sección 8 — Impacto económico (`S8`, pos. 80)
Cond.: header y 8.4 ocultos si Ninguna (`r_hide_ning`). 8.18.3 además ocultas si soloNino.
**8.4 permanece visible en soloNino.**
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|---|---|---|---|---|
| 8.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | 7 franjas, < 1M a > 15M Gs. | no |
| 8.2 | multiple_choice | ¿En qué conceptos gastás principalmente el monto expresado? | Cuidador/a particular · Centro de día/institución · Traslados · Equipamiento médico/tecnología asistiva · Servicios terapéuticos/rehabilitación · Medicamentos · Insumos personales · Otros | no |
| 8.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Solo con mi empleo · Ingresos extraordinarios · Emprendimiento · Me ayudan familiares · Me ayudan organizaciones sociales · Me ayudan vecinos/amigos · Eventos de recaudación · Subsidios estatales | no |
| 8.4 | single_choice | ¿En qué franja salarial mensual te ubicás con tu trabajo en esta empresa? *(CONFIDENCIAL)* | 9 franjas, hasta 3M a 50M+ Gs. | ✅ analyst-only, nunca en vistas de empresa (igual criterio que 3.4 en v2.1) |
## Sección 9 — Conciencia organizacional (`S9`, pos. 90)
Cond.: header, 9.1 y 9.4 ocultos si Ninguna (`r_hide_ning`). 9.2/9.3 además ocultas si soloNino.
**9.1 y 9.4 permanecen visibles en soloNino.**
## Sección 3 — Impacto económico del cuidado (top-level)
| Code | Tipo | Pregunta | Opciones | Notas | | Code | Tipo | Pregunta | Opciones | Notas |
|---|---|---|---|---| |---|---|---|---|---|
| 3.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | Sin gastos significativos · Menos de Gs. 500.000 · Gs. 500.0001.500.000 · Gs. 1.500.0003.000.000 · Gs. 3.000.0006.000.000 · Más de Gs. 6.000.000 · Prefiero no decir | | | 9.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente? | Sí, acompañado/a · Parcialmente · No, invisible · Nunca hemos tocado el tema | |
| 3.2 | multiple_choice | ¿Qué servicios o apoyos utilizás actualmente? | Terapias especializadas · Cuidador/a particular · Centro de día/institución · Transporte adaptado · Medicación alto costo · Equipamiento médico/tecnología asistiva · No utilizo servicios formales | | | 9.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? | Flexibilidad horaria/remoto · Apoyo económico/convenios · Beneficios terapias/salud · Contención emocional/psicológica · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos | |
| 3.3 | multiple_choice | ¿Cómo sostenés los gastos y responsabilidades del cuidado? | Con mi empleo de tiempo completo · Con mi empleo de tiempo parcial · Con trabajo independiente/emprendimiento · Me ayudan mis familiares · Es difícil sostenerlo con mis ingresos | | | 9.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez · No por ahora · Prefiero mantenerlo en privado | |
| 3.4 | single_choice | ¿En qué franja salarial mensual te ubicás? *(CONFIDENCIAL — analyst-only)* | Sueldo mínimo Gs. 5.000.000 · Gs. 5.000.0018.000.000 · Gs. 8.000.00112.000.000 · Gs. 12.000.00115.000.000 · Gs. 15.000.00118.000.000 · Gs. 18.000.00125.000.000 · Gs. 25.000.00150.000.000 · Gs. 50.000.001 o más · Prefiero no decir | is_sensitive=true; never in org_admin views | | 9.4 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo? | Sí · No | |
| 3.5 | rating | ¿Los gastos de cuidado impactan en tu situación económica personal? | 1=Sin impacto · 2=Leve · 3=Moderado · 4=Alto · 5=Muy alto | RE-INDEX |
## Sección 4Tiempo y descanso (top-level) ## Sección 10Comentarios adicionales (`S10`, pos. 100)
| Code | Tipo | Pregunta | Opciones |
|---|---|---|---|
| 4.1 | single_choice | ¿Cómo describirías tu nivel de descanso habitual? | Descanso bien, me siento recuperado/a · Descanso de forma irregular, pero lo manejo · Me cuesta descansar, me siento cansado/a con frecuencia · Descanso muy poco, el agotamiento afecta mi día a día | RE-INDEX |
| 4.2 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados por tu rol de cuidador/a? | Tiempo libre y ocio · Sueño y descanso · Vida social y amistades · Vida en pareja/familiar · Salud física personal · Salud emocional/mental · Desarrollo profesional · Situación económica | |
| 4.3 | rating | En una escala del 1 al 5, ¿cómo evaluás tu bienestar general en este momento? | 1=Muy mal · 2=Mal · 3=Regular · 4=Bien · 5=Muy bien | RE-INDEX |
## Sección 5 — Impacto en el trabajo (top-level) Siempre visible — es el destino del salto "Ninguna de las anteriores" en 4.1.
| Code | Tipo | Pregunta | Opciones |
|---|---|---|---|
| 5.1 | single_choice | En los últimos 3 meses, ¿con qué frecuencia sentiste que el cuidado afectó tu concentración o rendimiento? | Nunca o casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días | RE-INDEX |
| 5.2 | single_choice | En el último año, ¿faltaste al trabajo por situaciones relacionadas al cuidado? | 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) | |
## Sección 6 — Conciencia organizacional (top-level)
| Code | Tipo | Pregunta | Opciones | Notas |
|---|---|---|---|---|
| 6.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente?* | Sí, me siento acompañado/a · Parcialmente, hay cierta comprensión · No, es algo bastante invisible · No, nunca se ha hablado del tema | RE-INDEX |
| 6.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? Elegí hasta 3 | Flexibilidad horaria o trabajo remoto · Apoyo económico o convenios/descuentos · Acceso a terapias o salud · Contención/apoyo emocional o psicológico · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos y servicios disponibles | ⚠️ Ranking → multiple_choice para v1. Ranking widget al backlog. |
| 6.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez, si hay más personas en la misma situación · No por ahora · Prefiero mantenerlo en privado | |
| 6.4 | single_choice | ¿Conocés a algún compañero/a de trabajo que esté al cuidado de una persona con discapacidad o dependiente? | Sí, conozco a varias personas · Sí, conozco a una persona · No conozco a nadie · No estoy seguro/a | Código corregido (era 6.2 duplicado) |
| 6.5 | single_choice | ¿Considerás que la empresa debería ofrecer apoyos a quienes cuidan a una persona dependiente? | Sí, definitivamente · Tal vez, dependería del caso · No lo considero necesario · No lo sé | Código corregido (era 6.3 duplicado) |
## Sección 7 — Para cerrar (top-level)
| Code | Tipo | Pregunta | Notas | | Code | Tipo | Pregunta | Notas |
|---|---|---|---| |---|---|---|---|
| 7.1 | text_long | ¿Te gustaría dejar un comentario o sugerencia especial? (opcional) | No entra al store analítico crudo (regla 4 CLAUDE.md) | | 10.1 | text_long | ¿Te gustaría dejar un comentario o sugerencia especial? (opcional) | `hint`='Opcional.' — NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md) |
--- ---
## Mapeo a cuasi-identificadores (`responses.qi_*`) ## Totales
- `qi_age_bucket` ← A5 (ya en bucket)
- `qi_sector` ← A4 (área) 10 secciones (`type='section'`) + 40 preguntas = **50 filas** en `questions` por encuesta.
- `qi_zone` ← A1 (departamento — poblado en `submit-survey.ts` desde Etapa 2E) Preguntas por sección: S1=2, S2=7, S3=5, S4=3, S5=4, S6=4, S7=6, S8=4, S9=4, S10=1 → 40.
- `qi_city` ← A2: **Sprint 3** — se agrega como columna qi en migration futura.
Por ahora A2 se captura en `answers` como single_choice. ## Consent versions
- **v1** (`20000000-0000-0000-0000-000000000001`): texto original v2.1, preservado — no se
altera para no invalidar `consent_records` ya firmados contra esa versión (regla 5 CLAUDE.md).
- **v2** (`20000000-0000-0000-0000-000000000002`): texto revisado para el instrumento v3.0.0,
asignado a las 3 encuestas seed. Scopes: `["operativo","macro_n1"]` (Modo A).
## Discrepancias mockup vs. Excel resueltas (AP.10)
Reportadas y resueltas con la directora antes de escribir el seed:
1. **Wording de preguntas y opciones**: el Excel corregido es la fuente autoritativa en todos
los casos donde difiere del mockup (~30 discrepancias menores de texto).
2. **Recorrido soloNino**: el mockup marcaba `skipSoloNino:true` en TODA la Sección 5 (incluida
5.3). El Excel especifica un recorrido más granular: 5.3 → 8.4 → 9.1 → 9.4 → fin. Resuelto a
favor del Excel (ver "Flujo condicional — recorrido soloNino" arriba).
3. **8.3 "Eventos de recaudación"**: opción presente en el Excel, ausente en el mockup. Incluida.
## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación) ## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación)
- **Ranking widget (6.2):** actual es multiple_choice max_select=3. Cuando se implemente el
widget de ranking (drag o 3 dropdowns), 6.2 pasa a tipo `ranking` con JSONB `{rank1, rank2, rank3}`. - **Ranking widget (9.2):** actual es `multiple_choice` con `max_select=3`. Si se requiere
- **qi_city:** migration futura para agregar columna en responses y extender whitelist de agg_segment. orden de prioridad, pasa a tipo `ranking` (mismo criterio que 6.2 en v2.1).
Se hace antes de Sprint 3 (dashboard con filtro por ciudad). - **Motor genérico de formularios (Fase 2, BACKLOG-002):** este contrato es el primer caso de
- **Salary analytics:** metodología para calcular "carga económica del cuidado como % del ingreso" prueba; el intérprete declarativo se generaliza después de validar el wizard de cuidadores.
es trabajo de metodología de investigación (fuera del scope del código). - **Migración de datos v2.1 → v3:** NO se ejecuta. v2.1.0-stable permanece intacta; v3 es un
- **RE-INDEX scoring:** algoritmo de composición de los índices (descanso, bienestar, rendimiento, instrumento nuevo con seed limpio.
conciencia) — decisión antes de construir los reportes del dashboard. - **`qi_city`, RE-INDEX scoring, salary analytics:** decisiones heredadas de v2.1, aún abiertas,
- **Formulario de empresa (Relevamiento Empresa):** instrumento separado con PII de contacto. no se resuelven en esta etapa.
Ejecutar offline para las primeras empresas. Diseño del modelo de datos en el sprint del builder.

View File

@@ -58,3 +58,55 @@
- **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert - **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert
y extienda la whitelist de `agg_segment`. y extienda la whitelist de `agg_segment`.
- **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad). - **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad).
---
## [TECH-DEBT-006] Ranking widget para 2.1
- **Estado:** Etapa 3I2 (Cambio 5) cambió 2.1 ("¿Quién es el/la responsable principal
del cuidado?") de `single_choice` a `multiple_choice`. Permite selección múltiple
pero sin orden de prioridad — pierde la noción de "responsable principal" cuando
el respondente marca más de una opción.
- **Deuda:** implementar widget de ranking para 2.1 con el orden:
Principal cuidador soy yo > secundario instituto > principal pagado > secundario
familiar (mismo patrón que TECH-DEBT-001 para 6.2).
- **Trigger:** decisión del director sobre el tipo de 2.1 en próxima versión del
instrumento.
---
## [TECH-DEBT-008] /admin/responses sin autenticación
- **Estado:** ABIERTO.
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
implementar un gate de autenticación para staff (Basic Auth con env var como
mínimo viable).
- **Impacto actual:** ninguno en producción — la URL no está publicada.
- **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

@@ -0,0 +1,228 @@
import { createClient } from '@supabase/supabase-js'
import { ResponsesTable, type DisplayRow } from './responses-table'
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500
const PROMPT_MAX_LEN = 60
const SURVEY_OPTIONS = [
{ label: 'Todos', id: null },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
] as const
interface AllowedQuestion {
id: string
code: string
prompt: string
type: string
position: number
}
interface AnswerRow {
value: unknown
question_id: string
}
interface ResponseRow {
id: string
submitted_at: string
qi_zone: string | null
qi_sector: string | null
answers: AnswerRow[]
}
function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
return createClient(url, serviceKey, { auth: { persistSession: false } })
}
function formatDate(iso: string): string {
const d = new Date(iso)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text
}
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
function renderValue(value: unknown, type: string): string | null {
if (value === null || value === undefined) return null
switch (type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '')
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value)
default:
return String(value)
}
}
export default async function AdminResponsesPage({
searchParams,
}: {
searchParams: Promise<{ survey_id?: string }>
}) {
const { survey_id: surveyId } = await searchParams
const supabase = getSupabaseAdmin()
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan.
const { data: questionsData, error: questionsError } = await supabase
.from('questions')
.select('id, code, prompt, type, position')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true })
if (questionsError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{questionsError.message}</pre>
</div>
)
}
const allowedQuestions = (questionsData ?? []) as AllowedQuestion[]
const allowedIds = allowedQuestions.map((q) => q.id)
const questionMap = new Map(allowedQuestions.map((q) => [q.id, q]))
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro).
let responsesQuery = supabase
.from('responses')
.select(
`
id,
submitted_at,
qi_zone,
qi_sector,
answers!inner ( value, question_id )
`
)
.in('answers.question_id', allowedIds)
if (surveyId) {
responsesQuery = responsesQuery.eq('survey_id', surveyId)
}
const { data: responsesData, error: responsesError } =
allowedIds.length > 0
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
: { data: [] as ResponseRow[], error: null }
if (responsesError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{responsesError.message}</pre>
</div>
)
}
const responses = (responsesData ?? []) as unknown as ResponseRow[]
// 3. Contadores totales (independientes del límite de 500 submissions),
// filtrados por survey_id cuando hay filtro activo.
let submissionsCountQuery = supabase
.from('responses')
.select('*', { count: 'exact', head: true })
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
const { count: totalSubmissions } = await submissionsCountQuery
let answersCountQuery = supabase
.from('answers')
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
.in('question_id', allowedIds)
if (surveyId) {
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
}
const { count: totalAnswers } =
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: DisplayRow[] = []
responses.forEach((response, idx) => {
const answers = response.answers
.filter((a) => questionMap.has(a.question_id))
.sort(
(a, b) =>
questionMap.get(a.question_id)!.position - questionMap.get(b.question_id)!.position
)
answers.forEach((a, ai) => {
const q = questionMap.get(a.question_id)!
const isFirstInGroup = ai === 0
rows.push({
key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1,
isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code,
prompt: truncate(q.prompt),
rendered: renderValue(a.value, q.type),
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
})
})
})
return (
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
actualizado {formatDate(new Date().toISOString())}
</p>
<nav className="admin-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.label}
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
className={
opt.id === (surveyId ?? null)
? 'admin-survey-link admin-survey-link-active'
: 'admin-survey-link'
}
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
</div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</header>
<div className="admin-table-wrapper">
<ResponsesTable rows={rows} />
{rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
export interface DisplayRow {
key: string
submissionNum: number
isFirstInGroup: boolean
date: string
code: string
prompt: string
rendered: string | null
zone: string
sector: string
}
export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(submissionNum: number) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(submissionNum)) next.delete(submissionNum)
else next.add(submissionNum)
return next
})
}
return (
<table className="admin-table">
<thead>
<tr>
<th>#</th>
<th>Fecha</th>
<th>Código</th>
<th>Pregunta</th>
<th>Valor</th>
<th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th>
</tr>
</thead>
<tbody>
{rows
.filter((row) => row.isFirstInGroup || expanded.has(row.submissionNum))
.map((row) => {
const groupClass =
row.submissionNum % 2 === 0 ? 'admin-group-even' : 'admin-group-odd'
const isExpanded = expanded.has(row.submissionNum)
const trClass = row.isFirstInGroup
? `${groupClass} admin-group-start admin-row-toggle`
: groupClass
return (
<tr
key={row.key}
className={trClass}
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
onKeyDown={
row.isFirstInGroup
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle(row.submissionNum)
}
}
: undefined
}
role={row.isFirstInGroup ? 'button' : undefined}
tabIndex={row.isFirstInGroup ? 0 : undefined}
aria-expanded={row.isFirstInGroup ? isExpanded : undefined}
>
<td>
{row.isFirstInGroup && (
<span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
)}
{row.submissionNum}
</td>
<td>{row.date}</td>
<td>{row.code}</td>
<td className="admin-prompt">{row.prompt}</td>
<td>
{row.rendered === null ? (
<span className="admin-nodata">sin dato</span>
) : (
row.rendered
)}
</td>
<td className="admin-col-optional">{row.zone}</td>
<td className="admin-col-optional">{row.sector}</td>
</tr>
)
})}
</tbody>
</table>
)
}

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,28 +1,49 @@
'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
// '00000000-0000-0000-0000-000000000000' = flag p_all (vista agregada de todos los surveys)
const SURVEY_OPTIONS = [
{ label: 'Todos', id: '00000000-0000-0000-0000-000000000000' },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ 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">
@@ -30,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">
@@ -40,36 +61,88 @@ export default function DashboardPage() {
Datos agregados · k-anonimato k5 · Encuesta Mapeo de Cuidadores 2026 Datos agregados · k-anonimato k5 · Encuesta Mapeo de Cuidadores 2026
{filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>} {filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>}
</p> </p>
<nav className="db-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.id}
href={`/dashboard?survey_id=${opt.id}`}
className={
opt.id === surveyId
? 'db-survey-link db-survey-link-active'
: 'db-survey-link'
}
aria-current={opt.id === surveyId ? 'page' : undefined}
>
{opt.label}
</a>
))}
</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;
@@ -482,6 +516,165 @@ body {
margin-top: 16px; margin-top: 16px;
} }
/* ══════════════════════════════════════════════════════════════
WIZARD (4D) — layout de altura estable (UX 1+2+3)
1) viewport estable: enunciado anclado en el tercio superior, no centrado
2) header (progreso) y footer (Anterior/Siguiente) fijos, fuera del área
que cambia de tamaño — nunca "saltan" entre pantallas
3) área de respuesta con altura máxima + scroll interno + fade inferior
══════════════════════════════════════════════════════════════ */
.wizard-shell {
max-width: 720px;
margin: 0 auto;
padding: 0 16px;
display: flex;
flex-direction: column;
min-height: calc(100dvh - 160px); /* deja lugar al header/footer de marca (layout.tsx) */
}
.wizard-topbar {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 12px 0;
}
.wizard-topbar-brand {
margin: 0 0 8px;
padding: 0;
background: transparent;
border-left: none;
}
/* Stage: altura fija, no auto-crece con el contenido de la pregunta actual. */
.wizard-stage {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding-top: 48px; /* ancla el enunciado en el tercio superior, no centrado vertical */
}
.wizard-section-screen {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
text-align: left;
}
.wizard-section-number {
font-size: 0.9375rem;
font-weight: 700;
color: var(--color-primary);
margin: 0 0 8px;
}
.wizard-section-title {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 12px;
color: var(--color-text);
}
.wizard-section-desc {
font-size: 1.0625rem;
color: var(--color-text-muted);
line-height: 1.6;
max-width: 480px;
margin: 0;
}
/* Enunciado: bloque con altura reservada constante, mismo lugar pantalla tras pantalla. */
.wizard-prompt-block {
min-height: 116px;
flex-shrink: 0;
margin-bottom: 20px;
}
.wizard-instance-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary-hover);
background: var(--color-primary-light);
padding: 3px 10px;
border-radius: 999px;
margin-bottom: 10px;
}
.wizard-prompt-code {
display: block;
font-size: 0.8125rem;
font-weight: 700;
color: #7A8FA0;
letter-spacing: 0.02em;
margin-bottom: 6px;
}
.wizard-prompt-text {
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.35;
margin: 0 0 8px;
}
.wizard-prompt-hint {
font-size: 0.9375rem;
color: var(--color-text-muted);
margin: 0;
}
/* Á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 {
flex: 0 1 auto;
min-height: 0;
max-height: 480px;
overflow-y: auto;
padding-bottom: 8px;
}
.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: '';
position: sticky;
bottom: 0;
display: block;
height: 28px;
margin-top: -28px;
background: linear-gradient(to bottom, transparent, var(--color-surface) 75%);
pointer-events: none;
}
.wizard-bottombar {
position: sticky;
bottom: 0;
z-index: 10;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 16px 0;
margin-top: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.wizard-bottombar-hint {
font-size: 0.8125rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
@media (max-width: 480px) {
.wizard-prompt-text { font-size: 1.1875rem; }
.wizard-section-title { font-size: 1.5rem; }
.wizard-answer-area { max-height: 400px; }
}
/* ── Loading skeleton ── */ /* ── Loading skeleton ── */
.loading-wrapper { .loading-wrapper {
max-width: 720px; max-width: 720px;
@@ -546,19 +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-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid rgba(67,187,200,0.3); color: #6B7878; text-decoration: none;
transition: all 0.2s ease;
}
.db-survey-link:hover { color: #494F4F; 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;
@@ -568,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;
@@ -579,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 {
@@ -604,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;
} }
@@ -668,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; }
@@ -682,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;
@@ -727,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);
@@ -742,11 +943,117 @@ 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-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.admin-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid var(--color-border); color: var(--color-text-muted); text-decoration: none;
transition: all 0.2s ease;
}
.admin-survey-link:hover { color: var(--color-text); border-color: var(--color-primary); }
.admin-survey-link-active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.admin-refresh {
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
font-weight: 600; text-decoration: none; white-space: nowrap;
}
.admin-refresh:hover { background: var(--color-primary-hover); }
.admin-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card);
}
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.admin-table th {
position: sticky; top: 0; background: var(--color-re-dark); color: #fff;
font-weight: 600; text-align: left; padding: 10px 12px; white-space: nowrap;
}
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); vertical-align: top; }
.admin-table tbody tr:last-child td { border-bottom: none; }
.admin-table tbody tr.admin-group-even { background: var(--color-bg); }
.admin-table tbody tr.admin-group-start td { border-top: 2px solid var(--color-re-dark); }
.admin-table tbody tr:hover { background: var(--color-primary-light); }
.admin-prompt { color: var(--color-text); }
.admin-row-toggle { cursor: pointer; }
.admin-toggle-icon {
display: inline-block; width: 14px; margin-right: 4px;
color: var(--color-text-muted); font-size: 0.7rem;
}
.admin-nodata {
display: inline-block; padding: 1px 8px; border-radius: 10px;
background: var(--color-re-light); color: var(--color-re-dark);
opacity: 0.6; font-size: 0.7rem; font-style: italic;
}
.admin-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
.admin-error { color: var(--color-error); font-weight: 600; }
.admin-error-detail {
background: var(--color-error-bg); border-radius: var(--radius);
padding: 12px; font-family: 'Courier New', monospace; font-size: 0.75rem;
overflow-x: auto;
}
/* ── Responsive ── */ /* ── Responsive ── */
@media (max-width: 768px) { @media (max-width: 768px) {
.db-kpi-row { grid-template-columns: repeat(2,1fr); } .db-kpi-row { grid-template-columns: repeat(2,1fr); }
.db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); } .db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-page { flex-direction: column; } .db-page { flex-direction: column; }
.admin-col-optional { display: none; }
} }
/* ── Reduced motion ── */ /* ── Reduced motion ── */

View File

@@ -1,6 +1,13 @@
import { createClient } from '@/lib/supabase' import { createClient } from '@/lib/supabase'
import type { BrandConfig, Question, Survey } from '@/lib/types' import type { BrandConfig, Survey } from '@/lib/types'
import { SurveyForm } from '@/components/survey-form' import type { FormSchema, Question as EngineQuestion } from '@/lib/form-engine.types'
import { buildFormSchema } from '@/lib/form-engine'
import { WizardForm } from '@/components/wizard-form'
// Preguntas de consentimiento (S1, 1.1, 1.2): las resuelve ConsentScreen, no el
// motor de flujo — su texto legal (ADR-003) no vive en el hint de estas filas
// todavía. Se excluyen del FormSchema para que el wizard arranque en S2.
const CONSENT_CODES = new Set(['S1', '1.1', '1.2'])
// Force dynamic rendering — page fetches from Supabase at request time. // Force dynamic rendering — page fetches from Supabase at request time.
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -48,11 +55,12 @@ export default async function Page({ searchParams }: PageProps) {
) )
} }
// 2. Fetch questions ordered by position // 2. Fetch questions ordered by position — incluye hint/instance_generator/instance_of (4D)
const { data: questionRows, error: questionsError } = await supabase const { data: questionRows, error: questionsError } = await supabase
.from('questions') .from('questions')
.select( .select(
'id, survey_id, code, type, prompt, position, is_sensitive, required, max_select, options, conditional_rules' 'id, survey_id, code, type, prompt, position, is_sensitive, required, hint, ' +
'max_select, options, conditional_rules, instance_generator, instance_of'
) )
.eq('survey_id', surveyRow.id) .eq('survey_id', surveyRow.id)
.order('position', { ascending: true }) .order('position', { ascending: true })
@@ -66,9 +74,12 @@ export default async function Page({ searchParams }: PageProps) {
) )
} }
const questions = questionRows as Question[] // Cast en dos pasos: el cliente Supabase no tiene un Database genérico configurado
// (lib/supabase.ts), así que el tipo de fila que infiere para .select() no solapa
// directamente con EngineQuestion. Las columnas pedidas arriba sí coinciden 1:1.
const engineQuestions = questionRows as unknown as EngineQuestion[]
console.log(`[2B] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`) console.log(`[4D] Survey loaded: id=${surveyRow.id} | questions=${engineQuestions.length}`)
const survey: Survey = { const survey: Survey = {
id: surveyRow.id, id: surveyRow.id,
@@ -79,8 +90,13 @@ export default async function Page({ searchParams }: PageProps) {
k_threshold: surveyRow.k_threshold, k_threshold: surveyRow.k_threshold,
consent_version_id: surveyRow.consent_version_id, consent_version_id: surveyRow.consent_version_id,
brand_config: (surveyRow.brand_config as BrandConfig) ?? null, brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
questions, questions: [],
} }
return <SurveyForm survey={survey} questions={questions} /> // El consentimiento (S1, 1.1, 1.2) lo resuelve ConsentScreen, no el motor — ver nota arriba.
const schema: FormSchema = buildFormSchema(
engineQuestions.filter((q) => !CONSENT_CODES.has(q.code))
)
return <WizardForm survey={survey} schema={schema} />
} }

View File

@@ -26,15 +26,17 @@ export function ConsentScreen({ brandConfig, onAccept }: ConsentScreenProps) {
</p> </p>
<ol className="consent-list"> <ol className="consent-list">
<li> <li>
<strong>Diagnóstico interno de {orgName}:</strong> tus respuestas ayudan a la empresa <strong>Diagnóstico interno de {orgName}:</strong> Tus respuestas ayudan a la empresa,
a entender la realidad de las personas que cuidan a familiares con discapacidad o adultos en alianza con Fundación Solidaridad, a entender la realidad de las personas que cuidan
mayores, para diseñar apoyos más adecuados. Solo se trabaja con{' '} a familiares con discapacidad o adultos mayores, para diseñar apoyos más adecuados. Solo
se trabaja con{' '}
<strong>datos agregados</strong> nunca accede a respuestas individuales. <strong>datos agregados</strong> nunca accede a respuestas individuales.
</li> </li>
<li> <li>
<strong>Estudio país (opcional):</strong> tus respuestas anonimizadas contribuyen a un <strong>Estudio país (opcional):</strong> Tus respuestas serán{' '}
mapeo nacional sobre la economía del cuidado en Paraguay. Los datos se procesan de <strong>anónimas</strong> y contribuyen a un mapeo nacional sobre la economía del
forma irreversiblemente desvinculada de cualquier empresa o persona. cuidado en Paraguay. Los datos se procesan de manera que no pueden asociarse a ninguna
empresa ni persona.
</li> </li>
</ol> </ol>
</section> </section>

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,106 +1,112 @@
'use client' 'use client'
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/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 { RatingScale } from './widgets/rating-scale'
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 { GeoSelectWidget } from './widgets/geo-select-widget' import { BarrioAutocompleteField } from './widgets/barrio-autocomplete-field'
import { CiudadSelectField } from './widgets/ciudad-select-field'
interface QuestionRendererProps { interface QuestionRendererProps {
question: Question question: Question
answers: FormAnswers value: AnswerValue
onChange: (code: string, value: FormAnswers[string]) => void onChange: (value: AnswerValue) => void
errors: Record<string, string> error?: string
answers?: FormAnswers
} }
function isArrayOptions(opts: unknown): opts is QuestionOption[] { const YES_NO_OPTIONS: QuestionOption[] = [
return Array.isArray(opts) { value: 'true', label: 'Sí' },
} { value: 'false', label: 'No' },
]
function isRatingOptions(opts: unknown): opts is RatingOptions { export function QuestionRenderer({ question, value, onChange, error, answers }: QuestionRendererProps) {
return typeof opts === 'object' && opts !== null && 'min' in opts && 'max' in opts
}
export function QuestionRenderer({
question,
answers,
onChange,
errors,
}: QuestionRendererProps) {
const { code, type, prompt, required, max_select, options } = question const { code, type, prompt, required, max_select, options } = question
const error = errors[code] const legend = (
<>
<span className="q-code">{code} ·</span> {prompt}
</>
)
// A1 (Departamento) renders a combined geo widget that also handles A2 (Ciudad) if (type === 'single_choice' && options) {
// Code badges for A1/A2 are rendered inside GeoSelectWidget. if (code === '2.2') {
if (code === 'A1') { return (
return ( <CiudadSelectField
<GeoSelectWidget name={code}
departmentValue={(answers['A1'] as string) ?? ''} label={legend}
cityValue={(answers['A2'] as string) ?? ''} value={(value as string) ?? ''}
onDepartmentChange={(v) => onChange('A1', v)} required={required}
onCityChange={(v) => onChange('A2', v)} departamento={(answers?.['2.1:default'] as string) ?? null}
departmentError={errors['A1']} onChange={onChange}
cityError={errors['A2']} error={error}
/> />
) )
} }
// A2 is rendered inside the GeoSelectWidget above — skip here to avoid duplication
if (code === 'A2') return null
const legend = <><span className="q-code">{code} ·</span> {prompt}</>
if (type === 'single_choice' && isArrayOptions(options)) {
return ( return (
<RadioGroup <RadioGroup
name={code} name={code}
legend={legend} legend={legend}
options={options} options={options}
value={answers[code] as string | undefined} value={(value as string) ?? undefined}
required={required} required={required}
onChange={(v) => onChange(code, v)} onChange={onChange}
error={error} error={error}
/> />
) )
} }
if (type === 'multiple_choice' && isArrayOptions(options)) { if (type === 'multiple_choice' && options) {
return ( return (
<CheckboxGroup <CheckboxGroup
name={code} name={code}
legend={legend} legend={legend}
options={options} options={options}
value={(answers[code] as string[]) ?? []} value={(value as string[]) ?? []}
maxSelect={max_select} maxSelect={max_select}
required={required} required={required}
onChange={(v) => onChange(code, v)} onChange={onChange}
error={error} error={error}
/> />
) )
} }
if (type === 'rating' && isRatingOptions(options)) { // yes_no resuelto con el RadioGroup existente — dos opciones fijas, sin widget nuevo.
if (type === 'yes_no') {
return ( return (
<RatingScale <RadioGroup
name={code} name={code}
legend={legend} legend={legend}
ratingOptions={options} options={YES_NO_OPTIONS}
value={answers[code] as number | undefined} value={(value as string) ?? undefined}
required={required} required={required}
onChange={(v) => onChange(code, v)} onChange={onChange}
error={error} error={error}
/> />
) )
} }
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}
label={legend} label={legend}
value={(answers[code] as string) ?? ''} value={(value as string) ?? ''}
required={required} required={required}
onChange={(v) => onChange(code, v)} onChange={onChange}
error={error} error={error}
/> />
) )
@@ -111,9 +117,9 @@ export function QuestionRenderer({
<TextAreaField <TextAreaField
name={code} name={code}
label={legend} label={legend}
value={(answers[code] as string) ?? ''} value={(value as string) ?? ''}
required={required} required={required}
onChange={(v) => onChange(code, v)} onChange={onChange}
error={error} error={error}
/> />
) )

View File

@@ -1,274 +0,0 @@
'use client'
import { useCallback, useEffect, useRef, useState, useTransition } from 'react'
import type { FormAnswers, Question, Survey } from '@/lib/types'
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
import { getHiddenCodes, isQuestionVisible } from '@/lib/conditional'
import { submitSurvey } from '@/app/actions/submit-survey'
import { ConsentScreen } from './consent-screen'
import { ProgressBar } from './progress-bar'
import { QuestionRenderer } from './question-renderer'
// Section metadata (code prefix → display title)
const SECTIONS: { prefix: string; title: string }[] = [
{ prefix: 'A', title: 'Datos Generales' },
{ prefix: '1', title: 'Prevalencia: cuidado y discapacidad' },
{ prefix: '2', title: 'Intensidad y rol del cuidado' },
{ prefix: '3', title: 'Impacto económico del cuidado' },
{ prefix: '4', title: 'Tiempo y descanso' },
{ prefix: '5', title: 'Impacto en el trabajo' },
{ prefix: '6', title: 'Conciencia organizacional' },
{ prefix: '7', title: 'Para cerrar' },
]
function getSectionPrefix(code: string): string {
const m = code.match(/^([A-Za-z]+|\d+)/)
return m ? m[1] : code
}
type FormStage = 'consent' | 'form' | 'success' | 'error'
interface SurveyFormProps {
survey: Survey
questions: Question[]
}
export function SurveyForm({ survey, questions }: SurveyFormProps) {
const [stage, setStage] = useState<FormStage>('consent')
const [consentOperativo, setConsentOperativo] = useState(false)
const [consentMacroN1, setConsentMacroN1] = useState(false)
const [answers, setAnswers] = useState<FormAnswers>({})
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const [a11yAnnounce, setA11yAnnounce] = useState('')
const [hydrated, setHydrated] = useState(false)
const prevAnswers = useRef<FormAnswers>({})
// Hydrate from localStorage on mount
useEffect(() => {
const draft = loadDraft(survey.id)
if (Object.keys(draft).length > 0) setAnswers(draft)
setHydrated(true)
}, [survey.id])
// Persist to localStorage after hydration
useEffect(() => {
if (!hydrated) return
saveDraft(survey.id, answers)
prevAnswers.current = answers
}, [answers, hydrated, survey.id])
const handleChange = useCallback(
(code: string, value: FormAnswers[string]) => {
setAnswers((prev) => {
const updated = { ...prev, [code]: value }
const toHide = getHiddenCodes(code, value, questions, prev)
for (const hiddenCode of toHide) {
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
}
if (toHide.length > 0) {
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
} else {
const appeared = questions.some(
(q) =>
q.conditional_rules?.some((r) => r.if_question === code) &&
isQuestionVisible(q, updated) &&
!isQuestionVisible(q, prev)
)
if (appeared) setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
}
return updated
})
setErrors((prev) => {
if (!prev[code]) return prev
const { [code]: _, ...rest } = prev
return rest
})
},
[questions]
)
function validate(): boolean {
const newErrors: Record<string, string> = {}
for (const q of questions) {
if (!q.required) continue
if (!isQuestionVisible(q, answers)) continue
const val = answers[q.code]
if (val === null || val === undefined || val === '' || (Array.isArray(val) && val.length === 0)) {
newErrors[q.code] = 'Este campo es obligatorio.'
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (isPending) return
if (!validate()) {
const firstErrorCode = Object.keys(errors)[0]
if (firstErrorCode) document.getElementById(firstErrorCode)?.focus()
return
}
const questionMap = Object.fromEntries(questions.map((q) => [q.code, q.id]))
startTransition(async () => {
const result = await submitSurvey({
surveyId: survey.id,
answers: answers as Record<string, unknown>,
questionMap,
consentOperativo,
consentMacroN1,
})
if (result.success) {
clearDraft(survey.id)
setStage('success')
} else {
setSubmitError(result.error)
setStage('error')
}
})
}
// ── Stage: consent ──────────────────────────────────────────
if (stage === 'consent') {
return (
<ConsentScreen
brandConfig={survey.brand_config}
onAccept={(op, macro) => {
setConsentOperativo(op)
setConsentMacroN1(macro)
setStage('form')
}}
/>
)
}
// ── Stage: success ──────────────────────────────────────────
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>
)
}
// ── Stage: error ────────────────────────────────────────────
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 ({submitError}). Podés intentarlo de nuevo.</p>
<button
type="button"
className="btn btn-primary"
onClick={() => { setSubmitError(null); setStage('form') }}
>
Volver e intentar de nuevo
</button>
</div>
</div>
)
}
// ── Stage: form ─────────────────────────────────────────────
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
const answeredCount = visibleQuestions.filter((q) => {
const v = answers[q.code]
return v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)
}).length
type Section = { prefix: string; title: string; questions: Question[] }
const sectionMap = new Map<string, Section>()
for (const q of visibleQuestions) {
const prefix = getSectionPrefix(q.code)
if (!sectionMap.has(prefix)) {
const meta = SECTIONS.find((s) => s.prefix === prefix)
sectionMap.set(prefix, { prefix, title: meta?.title ?? prefix, questions: [] })
}
sectionMap.get(prefix)!.questions.push(q)
}
const bc = survey.brand_config
return (
<div className="survey-wrapper">
<a href="#survey-form" className="skip-link">Ir al formulario</a>
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
{a11yAnnounce}
</div>
{/* Header contextual con org/branch si está configurado */}
{bc?.org_name && (
<div className="brand-header" role="note" aria-label="Información de la empresa">
<p className="brand-text">
Estás respondiendo esto porque pertenecés a <strong>{bc.org_name}</strong>
{bc.branch_name && (
<> y participás en las oficinas de <strong>{bc.branch_name}</strong>
{bc.branch_city && <> en <strong>{bc.branch_city}</strong></>}.</>
)}
</p>
</div>
)}
<header className="survey-header">
<h1 className="survey-title">{survey.title}</h1>
<p className="survey-anon-note">Esta encuesta es anónima. Tus respuestas no te identifican.</p>
</header>
<div className="survey-progress-sticky" aria-label="Barra de progreso">
<ProgressBar answered={answeredCount} total={visibleQuestions.length} />
</div>
<main id="survey-form">
<form onSubmit={handleSubmit} noValidate aria-label={`Encuesta: ${survey.title}`}>
{Array.from(sectionMap.values()).map((section) => (
<section
key={section.prefix}
className="survey-section"
aria-labelledby={`section-${section.prefix}`}
>
<div className="section-header">
<h2 id={`section-${section.prefix}`} className="section-title">Sección {section.prefix} · {section.title}</h2>
</div>
{section.questions.map((q) => (
<div key={q.id} className="question-wrapper" data-code={q.code}>
<QuestionRenderer
question={q}
answers={answers}
onChange={handleChange}
errors={errors}
/>
</div>
))}
</section>
))}
<div className="form-footer">
<button
type="submit"
className="btn btn-primary"
disabled={isPending}
aria-disabled={isPending}
>
{isPending ? 'Enviando…' : 'Enviar encuesta'}
</button>
</div>
</form>
</main>
</div>
)
}

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,6 +40,9 @@ 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>
{/* 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 && ( {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

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>
)
}

328
components/wizard-form.tsx Normal file
View File

@@ -0,0 +1,328 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'
import type { BrandConfig, Survey } from '@/lib/types'
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
import {
answerKey,
applyAnswer,
expandInstances,
initialState,
isRequiredAndUnanswered,
next,
back,
resolveScreens,
} from '@/lib/form-engine'
import { loadDraft, saveDraft, clearDraft } from '@/lib/storage'
import { submitSurvey } from '@/app/actions/submit-survey'
import { ConsentScreen } from './consent-screen'
import { ProgressBar } from './progress-bar'
import { QuestionRenderer } from './question-renderer'
type Stage = 'consent' | 'form' | 'success' | 'error'
interface WizardFormProps {
survey: Survey
schema: FormSchema
}
function isEmpty(v: AnswerValue): boolean {
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
}
function valueFor(screen: Screen, answers: FormAnswers): AnswerValue {
if (!screen.question) return null
const v = answers[answerKey(screen.question.code, screen.instanceId ?? undefined)]
return v === undefined ? null : v
}
/** Resume en la primera pantalla sin responder (o la sección que la precede). */
function resumeScreenId(schema: FormSchema, answers: FormAnswers): string {
const screens = resolveScreens(schema, answers)
const firstUnanswered = screens.findIndex(
(s) => s.kind === 'question' && isEmpty(valueFor(s, answers))
)
if (firstUnanswered <= 0) return screens[0]?.id ?? 'end'
const prev = screens[firstUnanswered - 1]
return prev.kind === 'section' ? prev.id : screens[firstUnanswered].id
}
export function WizardForm({ survey, schema }: WizardFormProps) {
const [stage, setStage] = useState<Stage>('consent')
const [consentOperativo, setConsentOperativo] = useState(false)
const [consentMacroN1, setConsentMacroN1] = useState(false)
const [state, setState] = useState(() => initialState(schema))
const [errors, setErrors] = useState<Record<string, string>>({})
const [hydrated, setHydrated] = useState(false)
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(() => {
const draft = loadDraft(survey.id)
if (Object.keys(draft).length > 0) {
setState({ answers: draft, currentScreenId: resumeScreenId(schema, draft) })
}
setHydrated(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey.id])
useEffect(() => {
if (!hydrated) return
saveDraft(survey.id, state.answers)
}, [state.answers, hydrated, survey.id])
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const screen = screens[idx] ?? screens[0]
useEffect(() => {
if (!screen) return
if (screen.kind === 'section') setA11yAnnounce(`Sección: ${screen.question?.prompt}`)
else if (screen.kind === 'question') setA11yAnnounce(`Pregunta ${screen.question?.code}: ${screen.question?.prompt}`)
else setA11yAnnounce('Fin del formulario')
}, [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') {
return (
<ConsentScreen
brandConfig={survey.brand_config}
onAccept={(op, macro) => {
setConsentOperativo(op)
setConsentMacroN1(macro)
setStage('form')
}}
/>
)
}
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
const currentValue = valueFor(screen, state.answers)
const questionScreens = screens.filter((s) => s.kind !== 'end')
const ordinal = Math.min(idx + 1, questionScreens.length)
const sectionScreens = screens.filter((s) => s.kind === 'section')
const sectionOrdinal = screen.kind === 'section' ? sectionScreens.findIndex((s) => s.id === screen.id) + 1 : null
// Contexto de instancia ("Familiar X de N") — derivado, no decide visibilidad ni navegación.
let instanceCtx: { label: string; ordinal: number; total: number } | null = null
if (screen.instanceId && screen.question?.instance_of) {
const generator = schema.questions.find((q) => q.code === screen.question!.instance_of)
if (generator) {
const instances = expandInstances(generator, state.answers)
const ord = instances.findIndex((i) => i.instanceId === screen.instanceId)
if (ord >= 0) {
instanceCtx = { label: instances[ord].optionValue, ordinal: ord + 1, total: instances.length }
}
}
}
function handleChange(value: AnswerValue) {
if (!screen.question) return
const code = screen.question.code
const instanceId = screen.instanceId ?? undefined
setState((prev) => applyAnswer(prev, schema, code, value, instanceId))
setErrors((prev) => {
if (!prev[code]) return prev
const { [code]: _, ...rest } = prev
return rest
})
}
function handleNext() {
if (isRequiredAndUnanswered(screen, state.answers)) {
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return
}
setState((s) => next(s, schema))
}
function handleBack() {
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
return (
<div className="wizard-shell">
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
{a11yAnnounce}
</div>
<div className="wizard-topbar">
{bc?.org_name && (
<p className="brand-text wizard-topbar-brand">
Encuesta de <strong>{bc.org_name}</strong>
</p>
)}
<ProgressBar answered={ordinal} total={questionScreens.length} />
</div>
<main className="wizard-stage" id="wizard-stage">
{screen.kind === 'section' && screen.question && (
<div className="wizard-section-screen" role="region" aria-labelledby="wizard-section-title">
<p className="wizard-section-number">
Sección {sectionOrdinal} de {sectionScreens.length}
</p>
<h2 id="wizard-section-title" className="wizard-section-title">
{screen.question.prompt}
</h2>
{screen.question.hint && <p className="wizard-section-desc">{screen.question.hint}</p>}
</div>
)}
{screen.kind === 'question' && screen.question && (
<>
<div className="wizard-prompt-block">
{instanceCtx && (
<span className="wizard-instance-badge">
{instanceCtx.label} · familiar {instanceCtx.ordinal} de {instanceCtx.total}
</span>
)}
<span className="wizard-prompt-code">{screen.question.code}</span>
<h2 className="wizard-prompt-text">{screen.question.prompt}</h2>
{screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>}
</div>
<div
ref={answerAreaRef}
className={`wizard-answer-area${hasOverflow ? ' has-overflow' : ''}`}
>
<QuestionRenderer
question={screen.question}
value={currentValue}
onChange={handleChange}
error={errors[screen.question.code]}
answers={state.answers}
/>
</div>
</>
)}
{screen.kind === 'end' && (
<div className="wizard-section-screen">
<h2 className="wizard-section-title">Llegaste al final</h2>
<p className="wizard-section-desc">
Revisá tus respuestas con &quot;Anterior&quot; si querés corregir algo, o enviá la
encuesta cuando estés listo/a.
</p>
</div>
)}
</main>
<div className="wizard-bottombar">
<button
type="button"
className="btn btn-secondary"
onClick={handleBack}
disabled={idx === 0 || isPending}
aria-disabled={idx === 0 || isPending}
>
Anterior
</button>
<span className="wizard-bottombar-hint" aria-hidden="true">
{ordinal}/{questionScreens.length}
</span>
{screen.kind !== 'end' ? (
<button type="button" className="btn btn-primary" onClick={handleNext}>
Siguiente
</button>
) : (
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={isPending}
aria-disabled={isPending}
>
{isPending ? 'Enviando…' : 'Enviar encuesta'}
</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,102 @@
# ADR-005 — Esquema declarativo de formularios y estrategia de dos fases
> Para agregar a `ARCHITECTURE.md` (o `docs/adr/`). Estado: **Aceptada**.
> Fecha: 2026-06-20 · Sprint 4 · Versión objetivo 3.0.0
---
## Contexto
El producto es un **motor de formularios/flujos de encuesta**. La aplicación
actual (encuesta "Mapeo de Cuidadores") es su primer caso de uso real, pero la
arquitectura debe servir al producto, no solo a este formulario.
El rediseño a wizard v3 introduce lógica de flujo significativamente más compleja
que v2: recorridos ramificados (cuidador completo / solo niño / sin vínculo),
mini-wizard de instancias de vínculo familiar (preguntas que se repiten N veces,
una por familiar), y saltos condicionales de sección.
Existe una restricción de negocio: dos clientes inminentes en 10-20 días requieren
la encuesta funcionando para validación.
El esquema actual de `questions` ya posee una base declarativa:
- `conditional_rules` JSONB con operadores `eq` / `eq_any` y acción `show`
- Auto-target implícito (la regla afecta a la pregunta que la porta)
- Tipos de pregunta extensibles vía CHECK constraint
## Problema
¿Dónde reside la lógica del flujo del formulario? Tres anti-patrones a evitar:
1. **Lógica en el presentador** (frontend con reglas hardcodeadas) — impide
reutilización, genera bugs de sincronización de estado, no escala al producto.
2. **Lógica dispersa en columnas sueltas** de la base — deuda técnica; no soporta
evolución a flujos más complejos sin migrar el esquema entero.
3. **Sobre-ingeniería prematura** — construir un motor genérico universal para
casos hipotéticos antes de conocerlos, retrasando el deadline real.
## Decisión
**La lógica del flujo vive en una capa de definición declarativa (datos),
interpretada por un motor en el backend, y renderizada por un frontend sin reglas
de negocio.**
Se adopta una **estrategia de dos fases**:
### Fase 1 (Sprint 4 — para el deadline)
- El formulario de cuidadores se define como **esquema declarativo persistido**,
extendiendo el `conditional_rules` JSONB existente y agregando campos
declarativos de instanciación a `questions`.
- Se construye un motor de resolución de flujo **para este formulario** (interpreta
el esquema; calcula qué pantalla sigue desde esquema + estado).
- El frontend wizard es un **renderer** del esquema, sin reglas de negocio.
- NO se construye el intérprete genérico universal todavía.
### Fase 2 (sprints posteriores — post-deadline, registrada en BACKLOG)
- Se extrae el intérprete a un **motor genérico de formularios**.
- El formulario de cuidadores, ya definido como esquema, se vuelve el primer caso
de prueba del motor — sin reescribir el contrato.
### Diseño técnico del esquema (Fase 1)
Se extiende `questions` en lugar de crear una tabla de definición separada:
- `conditional_rules` extendido con nuevos operadores (`not_in`, acción `hide`)
para expresar saltos como "Ninguno → salta a sección final".
- Nuevos campos declarativos para instanciación:
- `instance_generator` — marca la pregunta que genera instancias (ej. 4.1)
- `instance_of` — código del generador del que depende una pregunta (ej. 4.2 → "4.1")
- Representación de secciones con nombre y descripción (el "porqué").
## Alternativas consideradas
1. **Lógica en el frontend (rechazada):** rápida para este formulario, pero viola
el principio de producto, no escala, y reproduce los bugs de sincronización ya
vistos en el mockup.
2. **Columnas sueltas tipo `is_instanced` (rechazada):** resuelve hoy, genera deuda;
no soporta flujos más complejos sin migración de esquema.
3. **Motor genérico universal ahora (rechazada):** arquitectónicamente deseable como
fin, pero sobre-ingeniería antes del deadline y con un solo caso de uso conocido
(diseñar en el vacío). Se difiere a Fase 2 cuando haya más casos que informen el
diseño abstracto.
4. **Tabla de definición de flujo separada (diferida):** posible mejor forma para el
motor genérico, pero para un solo formulario es sobre-ingeniería. Se reevalúa en
Fase 2.
## Consecuencias
**Positivas:**
- Entrega la encuesta en plazo sin generar retrabajo estructural.
- La lógica en datos habilita guardado parcial, reanudación, validación consistente
y progreso preciso — mejor UX, fuente única de verdad del estado.
- La Fase 2 es generalización del intérprete, no reescritura del contrato.
- El esquema declarativo es versionable y auditable.
**Negativas / deuda intencional:**
- Fase 1 es más trabajo que parchear el formulario actual.
- El motor de Fase 1 es específico a este formulario (no genérico) — deuda
registrada en BACKLOG, asumida conscientemente, no oculta.
- Extender `conditional_rules` puede requerir reevaluación cuando lleguen flujos
con lógica que el vocabulario actual de operadores no exprese.
## Cumplimiento (cómo se verifica)
- Revisión de código: el frontend wizard NO contiene reglas de flujo del negocio.
- El flujo es reproducible desde el esquema persistido.
- La deuda de Fase 2 figura en `BACKLOG.md`.

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

@@ -0,0 +1,121 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 25 junio 2026
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
---
## 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/`.
⚠️ Los 3 surveys están en `draft` a propósito (el frontend v2 desplegado no entiende seed v3).
Se reactivan a `live` al desplegar v3 completo.
---
## 2. Sprint 4 — Wizard v3 — Formulario COMPLETO, falta release
| Etapa | Estado |
|---|---|
| 4.0 Preservación v2 | ✅ |
| 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 |
El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
server-side (RPC `rpc_submit_response`, único camino de escritura, verificado).
---
## 3. Dashboard v3 — DECISIÓN MAYOR: rediseño de "tres miradas"
**Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar,
dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
### Estado
- **Mirada Empresa:** ✅ RPCs implementadas y VERIFICADAS contra 50 respuestas reales.
E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación
con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
Widgets del frontend: PENDIENTE (etapa siguiente).
- **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
| ID | Descripción | Prioridad |
|---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
| 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 |
| BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
---
## 5. Infraestructura
- 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
- Repos Gitea: git.inquality.com.py/marcos/motor-de-encuestas
- 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. Próximos pasos
1. **Widgets de Mirada Empresa** — el frontend del dashboard que consume las RPCs ya verificadas.
La spec (SPEC_MIRADA_EMPRESA.md) define shapes de retorno; el mockup define el aspecto.
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. Contexto comercial
- 2 clientes inminentes en ~10-20 días (deadline v3). Mirada Empresa es la que le habla al cliente que paga.
- InQuality y Fundación Solidaridad en joint venture.
---
## 8. Prompt para nueva sesión
```
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de formularios
soberano (Paraguay). Yo soy el product owner. Sprint 4: wizard v3 (ADR-005, esquema declarativo).
Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md.
Formulario v3: COMPLETO y verificado (motor, wizard, submit con validación server-side).
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.
Pendiente go-live: widgets Mirada Empresa → 4F (barrios) → 4I (release).
Los 3 surveys en draft (v3 no desplegado aún).
Próximo paso: [COMPLETAR]
```

View File

@@ -11,9 +11,17 @@ export function isQuestionVisible(
return rules.every((rule) => evaluateRule(rule, answers)) return rules.every((rule) => evaluateRule(rule, answers))
} }
/** Normalizes if_question to an array of codes regardless of rule shape. */
export function ruleQuestionCodes(rule: ConditionalRule): string[] {
return Array.isArray(rule.if_question) ? rule.if_question : [rule.if_question]
}
function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean { function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
if (rule.op === 'eq') { if (rule.op === 'eq') {
return answers[rule.if_question] === rule.value return answers[rule.if_question as string] === rule.value
}
if (rule.op === 'eq_any') {
return ruleQuestionCodes(rule).some((code) => answers[code] === rule.value)
} }
return false return false
} }
@@ -32,8 +40,8 @@ export function getHiddenCodes(
return questions return questions
.filter((q) => { .filter((q) => {
if (!q.conditional_rules?.length) return false if (!q.conditional_rules?.length) return false
const dependsOnTrigger = q.conditional_rules.some( const dependsOnTrigger = q.conditional_rules.some((r) =>
(r) => r.if_question === triggerCode ruleQuestionCodes(r).includes(triggerCode)
) )
if (!dependsOnTrigger) return false if (!dependsOnTrigger) return false
return !isQuestionVisible(q, projected) return !isQuestionVisible(q, projected)
@@ -46,7 +54,7 @@ export function getTriggerCodes(questions: Question[]): Set<string> {
const triggers = new Set<string>() const triggers = new Set<string>()
for (const q of questions) { for (const q of questions) {
for (const rule of q.conditional_rules ?? []) { for (const rule of q.conditional_rules ?? []) {
triggers.add(rule.if_question) for (const code of ruleQuestionCodes(rule)) triggers.add(code)
} }
} }
return triggers return triggers

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)
})
}

435
lib/form-engine.test.mts Normal file
View File

@@ -0,0 +1,435 @@
// Tests Node puros (sin navegador) del motor de resolución de flujo v3.
// Ejecutar: node --test lib/form-engine.test.mts
//
// Extensión .mts (no .ts): así Node ejecuta el archivo como ESM nativo con
// soporte de TypeScript sin configuración adicional, y el glob "**/*.ts" del
// tsconfig.json del proyecto (usado por `next build`/`tsc`) no lo incluye —
// evita el conflicto entre "Node necesita extensión .ts explícita en los
// imports" y "tsc rechaza imports con extensión .ts sin allowImportingTsExtensions".
import { test, describe } from 'node:test'
import assert from 'node:assert/strict'
import {
buildFormSchema,
evaluateRule,
resolveVisibility,
resolveScreens,
initialState,
setAnswer,
applyAnswer,
isRequiredAndUnanswered,
next,
back,
} from './form-engine.ts'
import type { ConditionalRule, FormAnswers, Question } from './form-engine.types.ts'
// ── Fixture: réplica fiel de S3S10 del seed v3 (supabase/seed.sql) ──────────
// S1/S2 no tienen conditional_rules y no aportan a estos tests; se omiten.
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 VINCULOS = [
'Hijo menor sin discapacidad',
'Hijo menor con discapacidad',
'Hijo adulto con discapacidad',
'Familiar menor con discapacidad',
'Familiar mayor con discapacidad',
'Padre/madre con movilidad reducida o 100% dependiente',
'Otro / Especificar',
'Ninguna de las anteriores / No tengo vinculación de cuidado',
] as const
const NINGUNA = VINCULOS[7]
const SOLO_NINO = VINCULOS[0]
const r_show_si_s3_1: ConditionalRule[] = [
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
]
const r_hide_ning: ConditionalRule[] = [
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
]
const r_hide_ning_sn: ConditionalRule[] = [
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
{ if_question: '4.1', op: 'eq_sole', value: SOLO_NINO, action: 'hide' },
]
function buildFixtureSchema() {
const rows: Question[] = [
// S3 — Prevalencia de discapacidad
q({ code: 'S3', type: 'section', prompt: 'Prevalencia de discapacidad', position: 30 }),
q({ code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31 }),
q({ code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_s3_1 }),
q({ code: '3.3', type: 'multiple_choice', prompt: 'Dispositivos', position: 33, conditional_rules: r_show_si_s3_1 }),
q({ code: '3.4', type: 'single_choice', prompt: 'Certificado', position: 34, conditional_rules: r_show_si_s3_1 }),
q({ code: '3.5', type: 'multiple_choice', prompt: 'Impacto', position: 35, conditional_rules: r_show_si_s3_1 }),
// S4 — Vinculación familiar (4.1 generador; 4.2/4.3 instancia)
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', conditional_rules: r_hide_ning_sn }),
q({ code: '4.3', type: 'multiple_choice', prompt: 'Dispositivos familiar', position: 43, instance_of: '4.1', conditional_rules: r_hide_ning_sn }),
// S5 — Responsabilidad y cuidado
q({ code: 'S5', type: 'section', prompt: 'Responsabilidad y cuidado', position: 50, conditional_rules: r_hide_ning }),
q({ code: '5.1', type: 'multiple_choice', prompt: 'Rol', position: 51, conditional_rules: r_hide_ning_sn }),
q({ code: '5.2', type: 'multiple_choice', prompt: 'Momento', position: 52, conditional_rules: r_hide_ning_sn }),
q({ code: '5.3', type: 'multiple_choice', prompt: 'Quién cuida en horario laboral', position: 53, conditional_rules: r_hide_ning }),
q({ code: '5.4', type: 'single_choice', prompt: 'Desde cuándo', position: 54, conditional_rules: r_hide_ning_sn }),
// S6 — Intensidad del cuidado
q({ code: 'S6', type: 'section', prompt: 'Intensidad del cuidado', position: 60, conditional_rules: r_hide_ning_sn }),
q({ code: '6.1', type: 'single_choice', prompt: 'Días/semana', position: 61, conditional_rules: r_hide_ning_sn }),
q({ code: '6.2', type: 'single_choice', prompt: 'Horas/día', position: 62, conditional_rules: r_hide_ning_sn }),
q({ code: '6.3', type: 'multiple_choice', prompt: 'Momentos', position: 63, conditional_rules: r_hide_ning_sn }),
q({ code: '6.4', type: 'single_choice', prompt: 'Disponibilidad', position: 64, conditional_rules: r_hide_ning_sn }),
// S7 — Impacto en el trabajo
q({ code: 'S7', type: 'section', prompt: 'Impacto en el trabajo', position: 70, conditional_rules: r_hide_ning_sn }),
q({ code: '7.1', type: 'single_choice', prompt: 'Descanso', position: 71, conditional_rules: r_hide_ning_sn }),
q({ code: '7.2', type: 'single_choice', prompt: 'Concentración', position: 72, conditional_rules: r_hide_ning_sn }),
q({ code: '7.3', type: 'single_choice', prompt: 'Ausencias', position: 73, conditional_rules: r_hide_ning_sn }),
q({ code: '7.4', type: 'multiple_choice', prompt: 'Aspectos afectados', position: 74, conditional_rules: r_hide_ning_sn }),
q({ code: '7.5', type: 'multiple_choice', prompt: 'Consecuencias laborales', position: 75, conditional_rules: r_hide_ning_sn }),
q({ code: '7.6', type: 'single_choice', prompt: 'Probabilidad de dejar', position: 76, conditional_rules: r_hide_ning_sn }),
// S8 — Impacto económico
q({ code: 'S8', type: 'section', prompt: 'Impacto económico', position: 80, conditional_rules: r_hide_ning }),
q({ code: '8.1', type: 'single_choice', prompt: 'Gasto mensual', position: 81, conditional_rules: r_hide_ning_sn }),
q({ code: '8.2', type: 'multiple_choice', prompt: 'Conceptos', position: 82, conditional_rules: r_hide_ning_sn }),
q({ code: '8.3', type: 'multiple_choice', prompt: 'Cómo sostiene', position: 83, conditional_rules: r_hide_ning_sn }),
q({ code: '8.4', type: 'single_choice', prompt: 'Franja salarial', position: 84, is_sensitive: true, conditional_rules: r_hide_ning }),
// S9 — Conciencia organizacional
q({ code: 'S9', type: 'section', prompt: 'Conciencia organizacional', position: 90, conditional_rules: r_hide_ning }),
q({ code: '9.1', type: 'single_choice', prompt: 'Conciencia empresa', position: 91, conditional_rules: r_hide_ning }),
q({ code: '9.2', type: 'multiple_choice', prompt: 'Apoyos', position: 92, conditional_rules: r_hide_ning_sn }),
q({ code: '9.3', type: 'single_choice', prompt: 'Grupo de apoyo', position: 93, conditional_rules: r_hide_ning_sn }),
q({ code: '9.4', type: 'single_choice', prompt: 'Voluntariado', position: 94, conditional_rules: r_hide_ning }),
// S10 — Comentarios adicionales
q({ code: 'S10', type: 'section', prompt: 'Comentarios adicionales', position: 100 }),
q({ code: '10.1', type: 'text_long', prompt: 'Comentario', position: 101 }),
]
return buildFormSchema(rows)
}
function codesOf(screens: ReturnType<typeof resolveScreens>): string[] {
return screens.filter((s) => s.kind !== 'end').map((s) => s.question!.code)
}
// ── 1. Recorrido completo ────────────────────────────────────────────────
test('recorrido completo: todos los vínculos (2-7) muestran S5-S9 enteras + 12 pantallas de instancia', () => {
const schema = buildFixtureSchema()
const answers: FormAnswers = {
'4.1:default': VINCULOS.slice(1, 7), // opciones 2..7, sin soloNino ni Ninguna
}
const screens = resolveScreens(schema, answers)
const codes = codesOf(screens)
for (const expected of ['S5', '5.1', '5.2', '5.3', '5.4', 'S6', '6.1', '6.4', 'S7', '7.1', '7.6', 'S8', '8.1', '8.4', 'S9', '9.1', '9.4']) {
assert.ok(codes.includes(expected), `falta ${expected} en recorrido completo`)
}
const instanceScreens = screens.filter((s) => s.instanceId !== null)
assert.equal(instanceScreens.length, 12, '6 familiares x 2 preguntas (4.2+4.3) = 12 pantallas')
})
// ── 2. Solo niño (eq_sole) ───────────────────────────────────────────────
test('solo niño sin discapacidad (eq_sole): recorrido liviano 5.3, 8.4, 9.1, 9.4', () => {
const schema = buildFixtureSchema()
const answers: FormAnswers = { '4.1:default': [SOLO_NINO] }
const screens = resolveScreens(schema, answers)
const codes = codesOf(screens)
// visibles: S5(header)+5.3, S8(header)+8.4, S9(header)+9.1+9.4
for (const expected of ['S5', '5.3', 'S8', '8.4', 'S9', '9.1', '9.4']) {
assert.ok(codes.includes(expected), `falta ${expected} en soloNino`)
}
// ocultas
for (const hidden of ['5.1', '5.2', '5.4', 'S6', '6.1', 'S7', '7.1', '8.1', '8.2', '8.3', '9.2', '9.3']) {
assert.ok(!codes.includes(hidden), `${hidden} no debería aparecer en soloNino`)
}
// 4.2/4.3 no se muestran (la única instancia generada queda oculta por r_hide_ning_sn)
assert.equal(screens.filter((s) => s.instanceId !== null).length, 0)
})
// ── 3. Ninguna de las anteriores (contains) ──────────────────────────────
test('"Ninguna de las anteriores" (contains): salta directo a S10, incluso combinada con otra opción', () => {
const schema = buildFixtureSchema()
const answers: FormAnswers = { '4.1:default': [VINCULOS[2], NINGUNA] } // marcada junto con otra
const screens = resolveScreens(schema, answers)
const codes = codesOf(screens)
for (const hidden of ['S5', 'S6', 'S7', 'S8', '8.4', 'S9', '9.1', '9.4']) {
assert.ok(!codes.includes(hidden), `${hidden} no debería aparecer con Ninguna marcada`)
}
assert.ok(codes.includes('S10') && codes.includes('10.1'))
// el índice de S4 (4.1) debe ser inmediatamente anterior a S10 en la lista
const idxS4 = codes.indexOf('4.1')
const idxS10 = codes.indexOf('S10')
assert.equal(idxS10, idxS4 + 1, 'S10 debe seguir inmediatamente a 4.1')
})
// ── 4. 3.1='No' oculta 3.2-3.5 ────────────────────────────────────────────
test("3.1='No' oculta 3.2-3.5; 3.1='Sí' las muestra", () => {
const schema = buildFixtureSchema()
const codesNo = codesOf(resolveScreens(schema, { '3.1:default': 'No' }))
for (const hidden of ['3.2', '3.3', '3.4', '3.5']) assert.ok(!codesNo.includes(hidden))
assert.ok(codesNo.includes('3.1'))
const codesSi = codesOf(resolveScreens(schema, { '3.1:default': 'Sí' }))
for (const shown of ['3.2', '3.3', '3.4', '3.5']) assert.ok(codesSi.includes(shown))
})
// ── 5. 3 familiares → 6 pantallas de instancia en orden por familiar ─────
test('3 familiares (seleccionados fuera de orden) generan 6 pantallas en orden de esquema, no de selección', () => {
const schema = buildFixtureSchema()
// Selección deliberadamente "fuera de orden" respecto a VINCULOS: opción 6, luego 2, luego 4.
const answers: FormAnswers = { '4.1:default': [VINCULOS[5], VINCULOS[1], VINCULOS[3]] }
const screens = resolveScreens(schema, answers)
const instanceScreens = screens.filter((s) => s.instanceId !== null)
assert.equal(instanceScreens.length, 6)
// Orden esperado por posición FIJA en options (índices 1, 3, 5 → familiar_2, familiar_4, familiar_6)
assert.deepEqual(
instanceScreens.map((s) => s.id),
[
'question:4.2:familiar_2', 'question:4.3:familiar_2',
'question:4.2:familiar_4', 'question:4.3:familiar_4',
'question:4.2:familiar_6', 'question:4.3:familiar_6',
]
)
})
test('IDs de instancia son estables: agregar un vínculo nuevo no reindexa los existentes', () => {
const schema = buildFixtureSchema()
const before = resolveScreens(schema, { '4.1:default': [VINCULOS[1], VINCULOS[4]] })
.filter((s) => s.instanceId !== null)
.map((s) => s.id)
assert.deepEqual(before, [
'question:4.2:familiar_2', 'question:4.3:familiar_2',
'question:4.2:familiar_5', 'question:4.3:familiar_5',
])
// Se agrega un vínculo ANTERIOR en el orden de opciones (índice 2) — los ids de
// familiar_2 y familiar_5 deben seguir siendo exactamente los mismos.
const after = resolveScreens(schema, { '4.1:default': [VINCULOS[1], VINCULOS[2], VINCULOS[4]] })
.filter((s) => s.instanceId !== null)
.map((s) => s.id)
assert.deepEqual(after, [
'question:4.2:familiar_2', 'question:4.3:familiar_2',
'question:4.2:familiar_3', 'question:4.3:familiar_3',
'question:4.2:familiar_5', 'question:4.3:familiar_5',
])
})
// ── 6. Navegación next/back coherente con instancias ─────────────────────
describe('navegación next/back', () => {
test('next/back recorren secuencialmente section→pregunta→instancias→fin', () => {
const schema = buildFixtureSchema()
let state = initialState(schema)
assert.equal(state.currentScreenId, 'section:S3')
state = next(state, schema)
assert.equal(state.currentScreenId, 'question:3.1')
state = setAnswer(state, '3.1', 'No')
// 3.2-3.5 ocultas → next desde 3.1 debe saltar directo a S4
state = next(state, schema)
assert.equal(state.currentScreenId, 'section:S4')
})
test('next/back son estables cuando cambian las instancias activas', () => {
const schema = buildFixtureSchema()
let state = initialState(schema)
state = setAnswer(state, '4.1', [VINCULOS[1], VINCULOS[4]]) // familiar_2, familiar_5
state.currentScreenId = 'question:4.1'
// Avanza a la primera pregunta de instancia
state = next(state, schema)
assert.equal(state.currentScreenId, 'question:4.2:familiar_2')
state = next(state, schema)
assert.equal(state.currentScreenId, 'question:4.3:familiar_2')
// Mientras está parado en 4.3:familiar_2, el usuario (vía 4D) agrega un
// vínculo anterior en el esquema (índice 2) sin moverse de pantalla.
state = setAnswer(state, '4.1', [VINCULOS[1], VINCULOS[2], VINCULOS[4]])
// El ID actual sigue existiendo y sigue siendo válido — no se desincroniza.
const next1 = next(state, schema)
assert.equal(next1.currentScreenId, 'question:4.2:familiar_3', 'avanza al nuevo familiar_3 insertado en medio')
const back1 = back(state, schema)
assert.equal(back1.currentScreenId, 'question:4.2:familiar_2', 'retrocede a familiar_2, sin verse afectado por el nuevo familiar_3')
})
test('back() desde S10 tras "Ninguna" vuelve a 4.1, saltando las secciones ocultas', () => {
const schema = buildFixtureSchema()
let state = initialState(schema)
state = setAnswer(state, '4.1', [NINGUNA])
state.currentScreenId = 'section:S10'
state = back(state, schema)
assert.equal(state.currentScreenId, 'question:4.1')
})
})
// ── Operadores: cobertura unitaria directa (vocabulario completo) ───────
describe('evaluateRule — los 5 operadores', () => {
const answers: FormAnswers = {
'3.1:default': 'Sí',
'4.1:default': ['Hijo menor con discapacidad', 'Otro / Especificar'],
'2.6:default': 'Jefatura',
}
test('eq', () => {
assert.equal(evaluateRule({ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }, answers), true)
assert.equal(evaluateRule({ if_question: '3.1', op: 'eq', value: 'No', action: 'show' }, answers), false)
})
test('eq_any', () => {
const rule: ConditionalRule = { if_question: ['3.1', '2.6'], op: 'eq_any', value: 'Jefatura', action: 'show' }
assert.equal(evaluateRule(rule, answers), true)
})
test('not_in', () => {
const rule: ConditionalRule = { if_question: '2.6', op: 'not_in', value: ['Directivo', 'Gerencial'], action: 'hide' }
assert.equal(evaluateRule(rule, answers), true)
const rule2: ConditionalRule = { if_question: '2.6', op: 'not_in', value: ['Jefatura'], action: 'hide' }
assert.equal(evaluateRule(rule2, answers), false)
})
test('contains', () => {
const rule: ConditionalRule = { if_question: '4.1', op: 'contains', value: 'Otro / Especificar', action: 'hide' }
assert.equal(evaluateRule(rule, answers), true)
})
test('eq_sole', () => {
const rule: ConditionalRule = { if_question: '4.1', op: 'eq_sole', value: 'Otro / Especificar', action: 'hide' }
assert.equal(evaluateRule(rule, answers), false) // hay 2 elementos, no es el único
})
})
// ── Semántica show/hide combinados ───────────────────────────────────────
describe('resolveVisibility — combinación show/hide', () => {
test('hide gana siempre sobre show, aunque show también coincida', () => {
const mixed: ConditionalRule[] = [
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
{ if_question: '4.1', op: 'contains', value: NINGUNA, action: 'hide' },
]
const question = q({ code: 'X', type: 'single_choice', prompt: 'x', position: 1, conditional_rules: mixed })
const answers: FormAnswers = { '3.1:default': 'Sí', '4.1:default': [NINGUNA] }
assert.equal(resolveVisibility(question, answers), false)
})
test('sin reglas hide, show gana si coincide', () => {
const onlyShow: ConditionalRule[] = [{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' }]
const question = q({ code: 'X', type: 'single_choice', prompt: 'x', position: 1, conditional_rules: onlyShow })
assert.equal(resolveVisibility(question, { '3.1:default': 'Sí' }), true)
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)
})
})

244
lib/form-engine.ts Normal file
View File

@@ -0,0 +1,244 @@
// Motor de resolución de flujo v3 (ADR-005) — módulo puro, sin React ni DOM.
// Reemplaza conceptualmente a lib/conditional.ts (v2); el componente (4D) decide
// cuándo migrar. Entrada→salida determinista: dado un FormSchema y un FormAnswers,
// siempre resuelve la misma lista de pantallas.
import type {
AnswerValue,
ConditionalRule,
FormAnswers,
FormSchema,
Question,
Screen,
WizardState,
} from './form-engine.types'
const DEFAULT_INSTANCE = 'default'
export function answerKey(code: string, instanceId: string = DEFAULT_INSTANCE): string {
return `${code}:${instanceId}`
}
function getAnswer(answers: FormAnswers, code: string, instanceId = DEFAULT_INSTANCE): AnswerValue {
const v = answers[answerKey(code, instanceId)]
return v === undefined ? null : v
}
/**
* Evalúa una regla individual. `if_question` siempre se resuelve contra
* instance_id='default': en el vocabulario v3 los disparadores (3.1, 4.1)
* nunca son preguntas de instancia. Si en el futuro una regla necesitara
* compararse contra la respuesta de la PROPIA instancia, este es el punto
* a extender (hoy no hace falta — no hay caso de uso en el seed v3).
*/
export function evaluateRule(rule: ConditionalRule, answers: FormAnswers): boolean {
switch (rule.op) {
case 'eq':
return getAnswer(answers, rule.if_question) === rule.value
case 'eq_any':
return rule.if_question.some((code) => getAnswer(answers, code) === rule.value)
case 'not_in': {
const v = getAnswer(answers, rule.if_question)
if (typeof v !== 'string') return false
return !rule.value.includes(v)
}
case 'contains': {
const v = getAnswer(answers, rule.if_question)
return Array.isArray(v) && v.includes(rule.value)
}
case 'eq_sole': {
const v = getAnswer(answers, rule.if_question)
return Array.isArray(v) && v.length === 1 && v[0] === rule.value
}
default:
return false
}
}
/**
* Semántica de combinación show/hide (ver reporte en la conversación):
* 1. hide es veto absoluto — si CUALQUIER regla hide coincide, se oculta.
* 2. Si ninguna hide coincide y hay reglas show, se muestra solo si
* CUALQUIERA show coincide (OR); si hay show pero ninguna coincide, se oculta.
* 3. Si no hay ninguna regla show (solo hide, o array vacío/null), se
* muestra por defecto.
*/
export function resolveVisibility(question: Question, answers: FormAnswers): boolean {
const rules = question.conditional_rules
if (!rules || rules.length === 0) return true
const hideRules = rules.filter((r) => r.action === 'hide')
const showRules = rules.filter((r) => r.action === 'show')
if (hideRules.some((r) => evaluateRule(r, answers))) return false
if (showRules.length > 0) return showRules.some((r) => evaluateRule(r, answers))
return true
}
export interface InstanceRef {
instanceId: string
optionValue: string
}
/**
* Deriva las instancias activas desde la respuesta del generador.
* El orden e id de cada instancia se basan en la POSICIÓN FIJA de la opción
* dentro de `generator.options` (definida por el esquema), nunca en el orden
* de selección del usuario ni en un índice del array de respuesta — así una
* instancia existente conserva su id estable aunque se agregue o quite otro
* vínculo (lección del mockup: el índice se desincroniza con las instancias).
*/
export function expandInstances(generator: Question, answers: FormAnswers): InstanceRef[] {
const selected = getAnswer(answers, generator.code)
if (!Array.isArray(selected) || !generator.options) return []
const refs: InstanceRef[] = []
generator.options.forEach((opt, idx) => {
if (selected.includes(opt.value)) {
refs.push({ instanceId: `familiar_${idx + 1}`, optionValue: opt.value })
}
})
return refs
}
export function buildFormSchema(rows: Question[]): FormSchema {
return { questions: [...rows].sort((a, b) => a.position - b.position) }
}
/**
* Resuelve la lista ordenada de pantallas visibles dado el esquema y el
* estado de respuestas actual. Pura: mismo input → mismo output siempre.
* Las preguntas instance_of se expanden agrupadas POR INSTANCIA (no por
* pregunta), para reproducir el orden familiar1: 4.2→4.3, familiar2: 4.2→4.3...
*/
export function resolveScreens(schema: FormSchema, answers: FormAnswers): Screen[] {
const screens: Screen[] = []
const consumedGroups = new Set<string>()
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
for (const q of schema.questions) {
if (q.instance_of) {
if (consumedGroups.has(q.instance_of)) continue
consumedGroups.add(q.instance_of)
const generator = byCode.get(q.instance_of)
if (!generator) continue
const instances = expandInstances(generator, answers)
const groupRows = schema.questions
.filter((r) => r.instance_of === q.instance_of)
.sort((a, b) => a.position - b.position)
for (const instance of instances) {
for (const row of groupRows) {
if (resolveVisibility(row, answers)) {
screens.push({
id: `question:${row.code}:${instance.instanceId}`,
kind: 'question',
question: row,
instanceId: instance.instanceId,
})
}
}
}
continue
}
if (q.type === 'section') {
if (resolveVisibility(q, answers)) {
screens.push({ id: `section:${q.code}`, kind: 'section', question: q, instanceId: null })
}
continue
}
if (resolveVisibility(q, answers)) {
screens.push({ id: `question:${q.code}`, kind: 'question', question: q, instanceId: null })
}
}
screens.push({ id: 'end', kind: 'end', question: null, instanceId: null })
return screens
}
export function initialState(schema: FormSchema): WizardState {
const screens = resolveScreens(schema, {})
return { answers: {}, currentScreenId: screens[0]?.id ?? 'end' }
}
export function setAnswer(
state: WizardState,
code: string,
value: AnswerValue,
instanceId: string = DEFAULT_INSTANCE
): WizardState {
return {
...state,
answers: { ...state.answers, [answerKey(code, instanceId)]: value },
}
}
/** 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
* respuestas actuales y busca la pantalla actual por ID (nunca por índice
* guardado de antes) — así, si el cambio de respuesta ocultó/agregó
* pantallas, next() sigue aterrizando en la pantalla correcta.
*/
export function next(state: WizardState, schema: FormSchema): WizardState {
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const nextIdx = Math.max(Math.min(idx + 1, screens.length - 1), 0)
return { ...state, currentScreenId: screens[nextIdx].id }
}
export function back(state: WizardState, schema: FormSchema): WizardState {
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const prevIdx = idx <= 0 ? 0 : idx - 1
return { ...state, currentScreenId: screens[prevIdx]?.id ?? state.currentScreenId }
}

72
lib/form-engine.types.ts Normal file
View File

@@ -0,0 +1,72 @@
// Tipos del motor de resolución de flujo v3 (ADR-005).
// Módulo aislado de lib/types.ts (v2) a propósito — el componente de
// producción (survey-form.tsx) no importa nada de aquí todavía. 4D decide
// cómo conviven o se unifican los dos juegos de tipos.
export type ConditionalAction = 'show' | 'hide'
export type ConditionalRule =
| { if_question: string; op: 'eq'; value: string; action: ConditionalAction }
| { if_question: string[]; op: 'eq_any'; value: string; action: ConditionalAction }
| { if_question: string; op: 'not_in'; value: string[]; action: ConditionalAction }
| { if_question: string; op: 'contains'; value: string; action: ConditionalAction }
| { if_question: string; op: 'eq_sole'; value: string; action: ConditionalAction }
export type QuestionType =
| 'text_short' | 'text_long' | 'single_choice' | 'multiple_choice'
| 'rating' | 'nps' | 'matrix' | 'ranking' | 'date' | 'slider' | 'yes_no' | 'section'
export interface QuestionOption {
value: string
label: string
}
export interface Question {
id: string
code: string
type: QuestionType
prompt: string
position: number
is_sensitive: boolean
required: boolean
hint: string | null
options: QuestionOption[] | null
max_select: number | null
conditional_rules: ConditionalRule[] | null
/** true solo en la pregunta que genera instancias (4.1 en v3). */
instance_generator: boolean
/** code de la pregunta generadora de la que depende esta fila (4.2/4.3 → '4.1'); null si no aplica. */
instance_of: string | null
}
export interface FormSchema {
/** Filas de `questions` ordenadas por position; incluye type='section'. */
questions: Question[]
}
/** Valor de una respuesta: string (single_choice/text/yes_no) o array (multiple_choice). */
export type AnswerValue = string | string[] | number | null
/**
* Respuestas keyed por `${code}:${instance_id}`. Las preguntas normales usan
* instance_id='default'; las preguntas de instancia (instance_of) usan el id
* de su instancia (ej. 'familiar_1'), igual que `answers.instance_id` (Etapa 4A).
*/
export type FormAnswers = Record<string, AnswerValue>
export type ScreenKind = 'section' | 'question' | 'end'
export interface Screen {
/** ID estable. Nunca usar el índice de este array para identificar la pantalla actual. */
id: string
kind: ScreenKind
/** null solo para kind='end'. */
question: Question | null
/** id de instancia si la pantalla es una pregunta de instancia; null si no aplica. */
instanceId: string | null
}
export interface WizardState {
answers: FormAnswers
currentScreenId: string
}

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 }
}

View File

@@ -19,8 +19,8 @@ export interface RatingOptions {
} }
export interface ConditionalRule { export interface ConditionalRule {
if_question: string if_question: string | string[]
op: 'eq' op: 'eq' | 'eq_any'
value: string value: string
action: 'show' action: 'show'
} }

246
method/ANTI_PATTERNS.md Normal file
View File

@@ -0,0 +1,246 @@
# Anti-patrones
> Comportamientos que producen daño concreto y deben evitarse. Cada uno está documentado con el caso real donde apareció.
>
> A diferencia de los patrones (que son propuestos hasta validación), los anti-patrones se documentan estables porque el daño ya está observado.
---
## AP.1 — Tests verdes como aprobación final
**Comportamiento:** declarar una etapa terminada porque los tests pasaron, sin abrir el artefacto final con los ojos.
**Daño concreto:** llegás a producción con outputs visuales rotos. Caso real (InQ ROI MVP): los tests E2E verificaban que el PDF se generaba y tenía las páginas esperadas, pero nadie abrió el PDF. El heatmap embebido estaba en blanco por un bug de timing en html2canvas. Se descubrió 2 días después cuando un consultor lo abrió.
**Cómo detectarlo:** el reporte del agente dice "tests verdes, build limpio, PDF generado en X path" pero no incluye observaciones visuales del PDF.
**Cómo evitarlo:** aplicar **Principio 2** (validación humana es ojo) y **Patrón C.1** (validación visual mandatoria).
---
## AP.2 — Pasamanos invertido (humano como operador)
**Comportamiento:** el humano traduce manualmente entre dos agentes o entre agente y código. Copia outputs, formatea, transporta contexto entre sesiones.
**Daño concreto:** el humano se vuelve cuello de botella del sistema. Lo peor: deja de pensar en arquitectura/producto y se concentra en operación. Caso real (InQ ROI Sprint 1.5, Etapa 5): el director del proyecto sintió "soy pasamanos" después de varios checkpoints transportando reportes de Claude Code al arquitecto.
**Cómo detectarlo:** preguntate si lo que estás haciendo lo podría hacer un script o un prompt mejor estructurado. Si la respuesta es sí, estás en este anti-patrón.
**Cómo evitarlo:** aplicar **Principio 5** (humano director). Mover lo operativo a archivos (prompts pre-armados, templates) y reservar el chat para decisiones reales.
---
## AP.3 — Racionalización de output anómalo
**Comportamiento:** cuando un output del agente tiene una métrica anómala (PDF inusualmente liviano, número muy redondo, payback "0.0 meses"), el agente o el humano racionalizan ("numéricamente correcto", "es el caso esperado") en lugar de investigar la causa raíz.
**Daño concreto:** bugs latentes que se descubren en producción. Caso real (InQ ROI Sprint 1.5, Etapa 4): el PDF de ROI pesaba 36 KB con 4 páginas (9 KB/página), comparado con 78 KB/3 páginas (26 KB/página) de los otros PDFs. El agente reportó "PDF generado". El humano paró y pidió investigar — había secciones que no se renderizaban.
**Cómo detectarlo:** cualquier número que parezca "anómalo pero técnicamente válido". Especialmente: tamaños de archivo, payback inmediato, ROI extremo, métricas con muchos ceros.
**Cómo evitarlo:** aplicar **Principio 3** (auditoría adversarial) y **Patrón E.3** (anti-racionalización explícita).
---
## AP.4 — Decisión recurrente no registrada
**Comportamiento:** el agente vuelve a preguntar lo mismo en cada sesión nueva, o miembros del equipo asumen distinto porque la decisión no está documentada.
**Daño concreto:** outputs inconsistentes entre etapas, tiempo perdido re-debatiendo, deriva del producto. Caso real (InQ ROI Sprint 1.5, varias etapas): cada vez que Claude Code iba a aplicar formato de cifras, había que recordarle si usaba separador europeo o americano. Hasta que se documentó en `CLAUDE.md`.
**Cómo detectarlo:** el agente te hace una pregunta que ya respondiste en alguna sesión anterior.
**Cómo evitarlo:** aplicar **Principio 4** (decisiones como contratos) y **Patrón B.3** (decisiones tomadas explícitas en cada prompt).
---
## AP.5 — Improvisación sin gobernanza
**Comportamiento:** un equipo (o miembros individuales) usa agentes de IA sin método compartido. Cada uno improvisa con su herramienta preferida, sin briefs, sin validación estructurada, sin documentar decisiones.
**Daño concreto:**
1. Deuda técnica invisible: artefactos producidos por IA que nadie puede auditar después
2. Aprendizaje no se acumula: descubrimientos individuales no se transfieren
3. Calidad inconsistente y no atribuible
4. Imposibilidad de mejorar sistemáticamente
**Cómo detectarlo:** preguntá al equipo "¿cómo hicieron este artefacto con IA?" — si las respuestas son distintas y vagas, estás acá.
**Cómo evitarlo:** este método entero está diseñado para evitarlo. Específicamente: empezar con **Principios 1 y 4**.
---
## AP.6 — Brief grande pegado en chat
**Comportamiento:** pegar un brief de 800+ líneas en el chat del agente como mensaje inicial.
**Daño concreto:**
- Consume contexto innecesariamente
- No es auditable después
- Se pierde si la sesión se corta
- Cuesta tokens repetirlo
Caso real (InQ ROI Sprint 1.5): el primer intento fue pegar el BRIEF.md completo en chat. Se reemplazó por la estructura `sprints/sprint-N/` con archivos versionados, mensaje inicial corto referenciando paths.
**Cómo detectarlo:** si tu mensaje inicial al agente tiene más de 30 líneas, probablemente estás en este anti-patrón.
**Cómo evitarlo:** aplicar **Patrón B.1** (prompt como artefacto del repo) y **Patrón B.2** (mensaje inicial corto).
---
## AP.7 — Bypass total de permisos en desarrollo local
**Comportamiento:** usar `--dangerously-skip-permissions` (o equivalente) rutinariamente en máquinas de desarrollo donde corren proyectos reales. El motivo declarado suele ser "evitar la fricción de aprobar cada comando".
**Daño concreto:**
1. **Pérdida de auditoría en tiempo real:** cuando un agente ejecuta una acción no intencionada (borrar archivos, modificar git history, instalar dependencias rotas), el humano lo descubre por consecuencia, no por la pausa de aprobación.
2. **Datos perdidos en repos en evolución:** un `rm -rf` accidental sobre el directorio equivocado puede borrar trabajo no comiteado, configuraciones locales, o assets generados que no están en git.
3. **Confianza falsa en el agente:** el dev se acostumbra a "todo funciona" y deja de auditar. Cuando aparece un comando raro, el reflejo de pausar ya no existe.
4. **Imposibilidad de saber cuándo empezó un problema:** sin puntos de control, debuggear "¿cuándo pasó X?" requiere reconstruir desde logs en vez de tener una traza de aprobaciones.
**Caso real (no en este equipo, observado en otros proyectos):** un desarrollador con bypass total dejó al agente refactorizar carpetas con `mv` masivos. Una iteración movió archivos a una ruta equivocada por un typo del agente en la ruta destino. Sin permisos, los `mv` se ejecutaron en cadena. Recuperar tomó 4 horas porque los archivos no estaban en stash ni en commits.
**Cómo detectarlo:**
- El dev menciona "skipeo todos los permisos para ir más rápido"
- No existe `.claude/settings.json` ni equivalente en el repo
- El dev no sabe explicar qué hace `--dangerously-skip-permissions`
**Cómo evitarlo:**
- Aplicar **Patrón D.4** (permisos versionados en `.claude/`)
- Configurar `allow` para comandos rutinarios (que es lo que daba fricción)
- Configurar `deny` para comandos críticos (`rm -rf` con paths absolutos, `git push --force`, `npm publish`, `git reset --hard`)
- **Aceptar la fricción ocasional como precio de la gobernanza**
**Cuándo SÍ es aceptable usar bypass total:**
- Sandboxes efímeros (contenedores Docker que se destruyen post-ejecución)
- CI/CD donde no hay datos del dev
- Máquinas virtuales dedicadas para experimentación
**Cuándo NO es aceptable:**
- Máquina personal del dev con repos reales
- Cualquier entorno donde haya datos no replicables
- Cualquier entorno con credenciales o secretos
---
## Cómo agregar un anti-patrón
Solo se agregan anti-patrones con daño concreto observado, no especulativo. Estructura obligatoria:
1. **Nombre breve** (forma de "AP.N — Descripción")
2. **Comportamiento:** descripción de la acción problemática
3. **Daño concreto:** qué pasa cuando ocurre, con caso real citado
4. **Cómo detectarlo:** señal observable
5. **Cómo evitarlo:** principio o patrón que lo previene
No agregar anti-patrones de "esto sería malo si pasara" — solo de cosas que **pasaron en proyectos reales** del equipo.
---
## AP.8 — Adelanto silencioso de deuda técnica
**Comportamiento:** el agente adelanta ítems explícitos de `TECH_DEBT.md`, `BACKLOG.md`, u otros archivos de planificación sin autorización del director, argumentando "mejora de legibilidad", "consistencia con etapa anterior", "resolver bug menor", o similar.
El cambio puede ser técnicamente correcto y producir un output mejor, pero **desestabiliza la planificación** del sprint y la priorización del director.
**Daño concreto:**
1. **Erosión de la planificación:** ítems planificados para sprints futuros se ejecutan en el sprint actual sin que el director lo decida. La capacidad estimada del sprint se desborda silenciosamente.
2. **Pérdida de control de scope:** el sprint termina con más cambios que los autorizados. La trazabilidad de "qué se hizo y cuándo" se rompe.
3. **Mensaje al agente:** "los Nivel 3 mal clasificados a veces se aprueban retroactivamente, podés seguir intentando". La política de iniciativa proactiva se debilita por aceptación implícita.
4. **Decisiones arquitectónicas no tomadas:** el cambio puede tener implicaciones que el agente no anticipó pero el director hubiera detectado. Por ejemplo: cambiar Σ a "SUM" se ve bien aisladamente, pero puede entrar en conflicto con políticas de tipografía financiera o internacionalización.
**Caso real (InQ ROI Sprint 1.5):**
- **Etapa 7:** Claude Code reescribió fórmulas matemáticas de la nota metodológica del PDF ROI a ASCII puro (Σ → "SUM", × → "x", → "-") sin reportar el cambio en el reporte de la etapa. El bug Unicode estaba documentado en `TECH_DEBT.md` con plan "embebido de fuente Unicode en Sprint 2". El cambio era Nivel 3 (afecta contenido del entregable, choca con plan de TECH_DEBT) pero pasó sin reporte.
- **Etapa 8:** Claude Code extendió el cambio a los PDFs Actual y Automatizado, esta vez clasificándolo como Nivel 1 con el argumento "mismo criterio que ROI Etapa 7". Detectado en el reporte y discutido. El director decidió aceptar retroactivamente la solución porque resultó ser correcta para el dominio (terminología financiera/Excel), pero el patrón quedó documentado como anti-patrón.
**Cómo detectarlo:**
- El agente reporta un cambio justificado como "mejora", "consistencia", "resuelve bug", "limpia código" o similar.
- Verificá si ese cambio resuelve un ítem listado en `TECH_DEBT.md`, `BACKLOG.md`, u observaciones diferidas. **Si está listado, era Nivel 3 sin importar el tamaño técnico.**
- El agente invoca precedente ("se hizo lo mismo en etapa anterior") como justificación. Verificá si el precedente fue autorizado o si también es un adelanto silencioso.
**Cómo evitarlo:**
- Aplicar **Patrón C.6 refinado** (ver `methodology/PATTERNS.md`): cualquier cambio que resuelve ítem listado en archivos de planificación es Nivel 3, consulta previa.
- En `CLAUDE.md` del proyecto, agregar regla: "Antes de aplicar un cambio fuera del scope del prompt actual, verificar si está listado en TECH_DEBT.md, BACKLOG.md, o equivalentes. Si está listado, consultar antes de proceder."
- Auditoría adversarial al reporte (Patrón C.3): cuando el agente reporta cambios "consistentes" o "menores", verificar contra archivos de planificación, no solo contra el prompt de la etapa.
**Distinción importante:**
AP.8 NO es lo mismo que AP.3 (Racionalización de output anómalo). AP.3 es minimizar un problema observado. AP.8 es ejecutar trabajo no autorizado argumentando que es mejora.
AP.8 también difiere del Patrón C.6 mal aplicado. C.6 mal aplicado es clasificar mal el nivel de impacto. AP.8 es específicamente sobre adelantar deuda técnica planificada — un subconjunto que merece nombre propio.
---
## AP.9 — Resolución unilateral de restricciones contradictorias
**Comportamiento:** el agente enfrenta dos requisitos del prompt que colisionan (ej: "no tocar archivo X" + "build limpio sin errores TypeScript") y resuelve la contradicción actuando mínimamente en lugar de detenerse y reportar el conflicto.
**Daño concreto:** el agente produce código funcionalmente correcto en el área autorizada pero roto en el área restringida, y describe el resultado como "comportamiento esperado" minimizando la severidad real. Caso real (InQ ROI Sprint 2, Etapa 1): el prompt restringía `ActivityPanel.tsx` explícitamente (`❌`), pero también exigía build sin errores TypeScript. Cambiar `types.ts` inevitablemente generaba errores TypeScript en `ActivityPanel.tsx`. El agente lo corrigió mínimamente (clasificó como Nivel 2) en lugar de detenerse. Resultado: slider roto que requirió toda la Etapa 2 para corregir.
**Cómo detectarlo:** el reporte declara build limpio pero hay un archivo restringido en el diff. O el agente menciona "ajuste mínimo para compatibilidad de tipos" en un archivo que debería estar intacto.
**Cómo evitarlo:**
1. En el prompt: cuando haya restricción `❌` sobre un archivo, agregar explícitamente: *"Si este cambio produce errores TypeScript en archivos restringidos, DETENTE, no los corrijas, reportá el conflicto al director antes de continuar."*
2. Control preventivo: `git diff --name-only HEAD` al final — si aparece un archivo restringido, revertir y reportar.
3. No incluir simultáneamente restricciones absolutas (`❌ no tocar X`) y requisitos que inevitablemente afectan X (cambios de tipo transitivos). Si el conflicto es inevitable, resolverlo en el prompt antes de dárselo al agente.
**Relación con otros patrones:**
- Requiere el control preventivo de **C.7** (git diff como control de scope) para detección temprana.
- Distinto de **AP.5** (improvisación sin gobernanza): AP.9 ocurre dentro de un prompt estructurado, no por ausencia de método.
---
## AP.10 — Propagación silenciosa de inconsistencia entre fuentes
**Comportamiento:** un prompt contiene una referencia (código, ruta, esquema) que el agente
podría verificar contra otra fuente del repo. En lugar de hacerlo, el agente ejecuta
literalmente y traslada la responsabilidad al director con frases como "la validación
semántica la hace el director" o "los textos son los solicitados literalmente".
**Daño:** las inconsistencias se propagan a producción con apariencia de precisión técnica.
*Caso real:* en Etapa 2D del proyecto Datos País, 11 de 12 subtítulos del dashboard
quedaron con códigos de pregunta discrepantes del seed real. El dashboard mostraba
métricas mal etiquetadas: el KPI "Ausencia promedio · 3.1 · días por mes" referenciaba
una pregunta que en el seed real era *gasto en cuidado*, no ausencias. Detectado en la
auditoría adversarial posterior, no antes. Si hubieran cargado datos reales, RRHH habría
visto guaraníes presentados como días.
**Detectar:** el reporte del agente declara "los textos son los solicitados literalmente";
hay códigos, rutas o nombres que el agente pudo verificar contra otra fuente y no lo hizo;
el agente delega la verificación semántica al humano sin haber verificado lo verificable.
**Evitar:** Patrón C.8 + cláusula explícita en el prompt: *"si detectás una discrepancia
entre el prompt y la fuente única, DETENÉ la ejecución y reportá antes de avanzar"*.
**Distinción:** ≠ AP.3 — AP.3 minimiza un problema observado; AP.10 evita verificar lo
verificable. ≠ AP.9 — AP.9 resuelve restricciones contradictorias del propio prompt;
AP.10 propaga inconsistencias entre el prompt y otra fuente.
## AP.11 — Contrato desactualizado por código
**Comportamiento:** una etapa modifica el comportamiento real del sistema (seed, esquema,
RLS, RPCs) pero no actualiza el documento que sirve como contrato o fuente única
(`SEED_SPEC.md`, `ARCHITECTURE.md`, ADRs). El documento queda mintiendo por silencio.
**Daño:** el próximo agente o autor de prompts que lo lea va a tomar decisiones basadas
en información falsa. *Caso real:* en Etapa 1D del proyecto Datos País se modificó
`seed.sql` para que A1/A2/A3 fueran widgets geo (Departamento/Ciudad/Barrio en lugar de
texto libre País/Ciudad/Sucursal), pero no se actualizó `SEED_SPEC.md`. Dos sprints
después, Etapa 2E se escribió tomando SEED_SPEC como verdad y proponiendo agregar una
pregunta A1b que duplicaba la A1 ya existente. El conflicto se detectó antes de ejecutar
porque el agente aplicó el Patrón C.8; sin C.8, se habría creado una pregunta duplicada
en producción.
**Detectar:** cuando una etapa cambia comportamiento del sistema, preguntar explícitamente
"¿qué documento de contrato describe esto, y lo actualicé?". Si la respuesta es "no sé"
o "después lo veo", AP.11 latente. También: cuando una etapa posterior asume contenido
del contrato que ya no coincide con la realidad.
**Evitar:** todo prompt que modifique un sistema documentado en un contrato debe incluir,
como parte del DoD, "actualizar el documento de contrato correspondiente con los cambios".
Es Principio 4 aplicado al mantenimiento, no solo a la creación.
**Distinción:** ≠ AP.4 — AP.4 es decisión no registrada; AP.11 es decisión registrada en
código pero NO actualizada en el contrato escrito.

513
method/PATTERNS.md Normal file
View File

@@ -0,0 +1,513 @@
# Patrones operativos
> Aplicaciones concretas de los 5 principios. Estructurados por categoría operativa.
>
> Estado de cada patrón: **propuesto** (validado en 1 proyecto) o **estable** (validado en 2+ proyectos).
>
> A la fecha, todos los patrones aquí son propuestos. Caso de validación: InQ ROI Sprint 1.5.
---
## Categoría A — Disciplina antes de codear
### A.1 Brief versionado antes que código
**Estado:** propuesto · **Deriva de:** Principio 1
Todo sprint o cambio mayor arranca con un brief en `sprints/sprint-N/BRIEF.md` antes de pedirle algo a un agente. El brief define alcance, decisiones tomadas, criterios de éxito y lo que NO entra.
**Indicador de aplicación correcta:** podés iniciar una sesión nueva con Claude Code pasándole solo el path del brief y CLAUDE.md, sin contexto adicional, y empieza a trabajar correctamente.
**Indicador de violación:** el agente te hace preguntas que ya están respondidas en otro lugar (o peor, no las hace y asume).
---
### A.2 Mockup como contrato visual
**Estado:** propuesto · **Deriva de:** Principio 1
Si el cambio es visual, antes de implementarlo se construye un mockup interactivo (HTML, Figma, Visualizer) que el humano aprueba. El mockup es el contrato: el agente lo replica, no lo interpreta libremente.
**Indicador de aplicación correcta:** cuando ves el output del agente, podés compararlo lado a lado con el mockup y la diferencia es marginal.
**Indicador de violación:** llegás a Etapa N de un rediseño visual y descubrís que el output "no tiene nada que ver" con lo que esperabas.
---
### A.3 Roadmap explícito antes de arrancar
**Estado:** propuesto · **Deriva de:** Principios 1 y 4
Todo sprint con más de 3 etapas tiene un roadmap explícito desde el inicio. Cada etapa tiene **expectativa calibrada** de qué cambia y qué NO cambia respecto a la anterior.
**Indicador de aplicación correcta:** después de cada checkpoint, el resultado coincide con lo que esperabas ver, no te sorprende ni para bien ni para mal.
**Indicador de violación:** te sorprende negativamente un output intermedio porque pensabas que ya tenía X feature que en realidad está agendado 3 etapas después.
---
## Categoría B — Operación con agentes
### B.1 Prompt como artefacto del repo
**Estado:** propuesto · **Deriva de:** Principios 4 y 5
Cada etapa de trabajo agéntico tiene su prompt en `sprints/sprint-N/ETAPA_X_PROMPT.md`, versionado. El chat con el agente es **puente** entre el prompt y el reporte, no contenedor de la decisión.
**Por qué:** los prompts en archivo son auditables, editables antes de ejecutar, replicables, y no se pierden al cerrar la sesión.
**Estructura mínima de un prompt de etapa:**
1. Objetivo
2. Pre-requisitos / contexto previo
3. Decisiones ya tomadas (no consultar de nuevo)
4. Tareas concretas
5. Entregables (DoD checklist)
6. Qué reportar al final
7. Qué NO hacer en esta etapa
---
### B.2 Mensaje inicial corto, contexto en archivos
**Estado:** propuesto · **Deriva de:** Principio 5
El mensaje al agente al arrancar una sesión es de 3-5 párrafos, NO el prompt entero pegado. Estructura:
1. "Procedé con etapa X"
2. Archivos a leer en orden
3. Confirmaciones que esperás antes de que empiece
4. Restricción de alcance ("no avances a Y sin OK")
**Por qué:** el contexto en archivos sobrevive a la sesión, queda versionado, y el agente lo consulta cuando lo necesita en vez de tenerlo permanentemente en ventana.
---
### B.3 Decisiones tomadas como sección explícita
**Estado:** propuesto · **Deriva de:** Principio 4
En cada prompt de etapa, incluir sección "DECISIONES YA TOMADAS — no consultar de nuevo" con las definiciones cerradas. Esto impide que el agente vuelva a preguntar lo ya resuelto.
**Ejemplo:**
```
DECISIONES YA TOMADAS (no consultar de nuevo)
- Payback negativo → "Sin payback (costo aumenta)"
- Caracteres compactos: "<" y "~"
- Pluralización: siempre plural ("1.0 meses")
```
---
### B.4 Reglas no negociables en CLAUDE.md
**Estado:** propuesto · **Deriva de:** Principio 4
Las reglas que aplican a TODO el proyecto van en `CLAUDE.md`, no solo en briefs específicos. El agente carga CLAUDE.md en cada sesión nueva por defecto.
**Ejemplo de qué va ahí:**
- Reglas de marca (cómo se escribe el nombre, qué colores)
- Restricciones de comportamiento (qué no decir, qué no asumir)
- Verificaciones obligatorias pre-entrega
---
## Categoría C — Validación de output
### C.1 Validación visual mandatoria del artefacto final
**Estado:** propuesto · **Deriva de:** Principio 2
Si el output de la etapa es un artefacto visible (PDF, screenshot, página renderizada, documento), el humano lo abre con sus ojos antes del OK. Tests verdes no es suficiente.
**Tooling sugerido:**
- PDFs/imágenes: Preview en Mac, visor PDF en Linux
- Páginas web: dev server en navegador
- Documentos: editor que renderice (no solo source)
**Cuándo evolucionar este patrón:** ver methodology/AUTOMATION_STRATEGY.md.
La validación visual humana es Fase 1. Puede complementarse con agentes
auditores (Fase 3) pero NO se reemplaza completamente hasta que el
equipo desarrolle criterio adversarial maduro.
*Cuando el output incluye textos que referencian otras fuentes del repo (códigos, IDs, rutas), aplicar también C.8.*
---
### C.2 Validación programática antes de declarar completado (agente)
**Estado:** propuesto · **Deriva de:** Principios 2 y 3
Antes de declarar una etapa completada, el agente debe ejecutar validaciones programáticas, no solo confiar en tests unitarios. Para outputs visuales:
- Análisis de píxeles (¿están los colores esperados?)
- Text extraction (¿están las palabras clave?)
- Tamaño de archivo (¿es razonable?)
- Cantidad de páginas (¿coincide con el diseño?)
**Por qué:** un test unitario puede pasar mientras el output final está roto. La validación programática del artefacto cierra ese gap.
---
### C.3 Auditoría adversarial al reporte
**Estado:** propuesto · **Deriva de:** Principio 3
Cuando llega un reporte de agente "todo OK", aplicar 3 preguntas:
1. ¿Los números reportados cuadran si recalculo o pido dump?
2. ¿El artefacto es el que pedí?
3. ¿Lo que no menciona podría ser relevante?
**Casos típicos de detección:**
- PDF de 36 KB con 4 páginas (es muy poco → algo no se renderizó)
- KPI con valor "0.0 meses" (numéricamente correcto pero confuso)
- Reporte que dice "PDF generado" sin abrir el PDF generado
---
### C.4 Reporte con datos concretos, no descripciones (agente)
**Estado:** propuesto · **Deriva de:** Principio 3
Los reportes del agente deben incluir métricas concretas, no descripciones genéricas.
**Mal reporte:** "PDF generado correctamente, contiene todo lo esperado."
**Buen reporte:**
```
PDF generado: 78 KB, 4 páginas
- Página 1: KPI Hero + 4 cards secundarios + top 3 ahorros
- Página 2: tabla con 11 filas + footer
- Página 3: 3 secciones (composición, trayectoria, transparencia)
- Página 4: nota metodológica
Análisis de píxeles del heatmap: verde 36.329px, ámbar 3.889px, rojo 3.713px
Text extraction confirma palabras clave: "InQ ROI", "Powered by InQuality", "Payback"
```
### C.5 Validaciones programáticas explícitas en el prompt
**Estado:** propuesto · **Deriva de:** Principios 2 y 5
Cada prompt de etapa incluye una sección **"Validaciones automatizadas mandatorias"** con criterios objetivos verificables por código que el agente debe ejecutar antes de declarar terminada la etapa o usar `present_files`.
**Por qué:** documenta explícitamente qué se va a auto-validar, hace transferible el conocimiento operativo al equipo, captura el aprendizaje sobre qué problemas son recurrentes y vale la pena chequear automáticamente.
**Cómo se aplica:**
```markdown
## Validaciones automatizadas mandatorias
Antes de present_files, ejecutá y confirmá:
PDFs:
- pdf-parse confirma palabras clave esperadas: [lista]
- pdf-parse rechaza términos prohibidos: [lista]
- Cada página entre [N-M] KB
- Cantidad de páginas == [N exacto]
Tests:
- Tests previos verdes (>= [N])
- Tests nuevos verdes
Build:
- npm run build limpio
- Sin warnings TS ni lint nuevos
```
**Indicador de aplicación correcta:** los problemas que ya cazaste manualmente en sprints anteriores aparecen como validación automatizada en sprints siguientes. Aprendizaje acumulativo.
**Indicador de violación:** el agente declara etapa terminada y vos cazás el mismo tipo de problema que ya cazaste 3 etapas atrás. Significa que el aprendizaje no se está capturando como validación.
**Relación con otros patrones:**
- Extiende **C.2** (validación programática) haciéndola explícita en el prompt
- No reemplaza **C.1** (validación visual humana) — son complementarias
- Es Fase 2 del roadmap de `AUTOMATION_STRATEGY.md`
### C.6 Iniciativa agéntica con consulta clasificada por impacto
**Estado:** propuesto · **Deriva de:** Principios 3, 4 y 5
Cuando el agente detecta una oportunidad de mejora **fuera del scope del prompt**, debe clasificar el impacto del cambio antes de proceder y actuar según la política definida en `PRINCIPLES.md` sección "Política de iniciativa agéntica".
**Niveles de impacto:**
1. **Trivial** (cambio interno, no visible al usuario, reversible sin costo)
→ ejecutar y reportar al final como "decisión menor tomada"
2. **Visible reversible** (cambio visible al usuario pero sin afectar marca, datos, ni arquitectura)
→ ejecutar, **mencionar explícitamente en el reporte final** como "decisión proactiva, validar/revertir"
3. **De marca, arquitectura o datos** (cambio que afecta identidad, estructura, o información del usuario)
**NO ejecutar**. Consultar antes de proceder.
**Por qué:** los agentes producen mejor cuando tienen libertad para optimizar lo local. Pero esa libertad sin límites lleva a cambios silenciosos que el humano descubre por casualidad. Una clasificación explícita preserva la velocidad agéntica sin perder gobernanza.
**Caso real:** InQ ROI Sprint 1.5 Etapa 3. Claude Code agregó "Hecho desde Paraguay 🇵🇾" al footer del web app sin estar en el brief. El cambio era razonable, alineado con identidad de marca, pero **el humano lo descubrió revisando screenshots**, no porque se reportara. Decisión retroactiva: mantener, pero documentar el patrón para que en el futuro este tipo de cambios (impacto nivel 3, afecta marca) sean consulta previa, no decisión unilateral.
**Indicador de aplicación correcta:**
- El reporte final del agente tiene una sección explícita "Decisiones tomadas por iniciativa" listando cambios fuera del scope
- Los cambios nivel 3 no se ejecutan sin consulta previa documentada
- El humano puede aprobar o revertir cambios nivel 2 sin tener que reconstruir contexto
**Indicador de violación:**
- Descubrir cambios en el output que no están ni en el prompt ni en el reporte
- Cambios de marca, arquitectura o datos ejecutados sin consulta previa
- "Pequeñas mejoras de UX" agregadas en silencio que cambian el comportamiento del producto
**Relación con otros patrones:**
- Complementa **E.2** (Hallazgos no esperados) — pero E.2 es para descubrimientos pasivos (cosas que el agente notó), C.6 es para decisiones activas (cosas que el agente hizo).
- Refuerza **C.3** (Auditoría adversarial) — provee al humano una sección concreta donde buscar cambios silenciosos.
## Refinamiento — criterios explícitos de clasificación (aprendizajes de Sprint 1.5)
Durante Sprint 1.5 InQ ROI se detectaron dos casos de mala clasificación de impacto. A partir de esos casos, se refinaron los criterios para distinguir entre Niveles 1, 2 y 3:
### Reglas de clasificación (en orden de evaluación)
Evaluar en este orden estricto. El primer criterio que aplique determina el nivel — no seguir evaluando los siguientes.
**1. ¿El cambio resuelve un ítem listado en `TECH_DEBT.md`, `BACKLOG.md`, o archivos de planificación?**
→ Nivel 3 (consulta previa). Sin importar el tamaño técnico.
**Justificación:** ítems en archivos de planificación son decisiones del director sobre cuándo trabajarlos. El agente no puede promover ítems al sprint en curso por iniciativa.
**2. ¿El cambio choca con una restricción explícita del prompt de la etapa?**
→ Nivel 3 (consulta previa). Sin excepciones.
Ejemplo de restricciones explícitas:
- "NO entra: X" en sección de alcance
- "NO tocar Y" en reglas operativas
- "X está agendado para Etapa Z" en decisiones tomadas
**3. ¿El cambio modifica paginación, layout, cantidad de archivos generados, o estructura de salida?**
→ Nivel 2 mínimo. Reportar.
**4. ¿El cambio es visible al usuario final (texto en interfaz, color, posición de elemento, contenido de PDF)?**
→ Nivel 2 mínimo. Reportar.
**5. ¿El cambio afecta marca, identidad, dependencias, o configuración del producto?**
→ Nivel 3 (consulta previa).
**6. Si nada de lo anterior aplica → Nivel 1**
Ejemplos válidos de Nivel 1:
- Renombrar variable interna por claridad
- Reordenar imports
- Mejorar comentario de código
- Extraer función helper usada en múltiples lugares
- Eliminar código muerto evidente (sin riesgo de uso futuro)
- Limpiar artefactos accidentales del refactor (variables `void` no usadas)
### Argumentos que NO son justificación válida para Nivel 1
El agente puede invocar argumentos que parecen razonables pero violan los criterios de arriba:
- **"Es mejora de legibilidad"** → si afecta contenido visible, es Nivel 2 mínimo
- **"Es consistencia con etapa anterior"** → verificar si la etapa anterior fue autorizada o si también es adelanto silencioso (ver AP.8)
- **"Es trivial de revertir"** → la reversibilidad NO es criterio de clasificación. Cambios reversibles también pueden ser Nivel 3.
- **"Resuelve un bug menor"** → si el bug está documentado en TECH_DEBT.md, es Nivel 3
- **"Es lo que el usuario esperaría"** → eso es asumir. La política existe para no asumir.
### Casos reales documentados (Sprint 1.5 InQ ROI)
**Caso 1 (Etapa 5):** compactación de páginas reportada como Nivel 1.
- Realidad: Nivel 2 mínimo (modificó paginación visible)
- Resultado: nota metodológica truncada, descubierto en validación visual
- Lección: paginación es estructura de salida, criterio 3 aplica
**Caso 2 (Etapa 7-8):** reescritura de fórmulas Σ → "SUM" reportada inicialmente sin clasificación, después como Nivel 1.
- Realidad: Nivel 3 (resolvía ítem de TECH_DEBT.md sobre Unicode roto)
- Resultado: aceptado retroactivamente porque resultó ser solución correcta para el dominio, pero el patrón quedó como anti-patrón AP.8
- Lección: ítem en TECH_DEBT.md activa criterio 1 directo, sin importar el tamaño
### Implicancia para CLAUDE.md del proyecto
En el `CLAUDE.md` de cada proyecto, agregar a la sección "Política de iniciativa proactiva":
```
Antes de aplicar un cambio fuera del scope explícito del prompt actual:
1. Verificá si el cambio está listado en TECH_DEBT.md, BACKLOG.md, o
archivos de planificación equivalentes. Si está, es Nivel 3 — consultá.
2. Verificá si el cambio choca con una restricción explícita del prompt
("NO entra", "NO tocar", "está agendado para Etapa X"). Si choca,
es Nivel 3 — consultá.
3. Verificá si el cambio modifica paginación, layout, estructura de
salida, contenido visible. Si lo hace, es Nivel 2 mínimo — reportá.
4. Solo si nada de lo anterior aplica, ejecutá como Nivel 1.
Argumentos NO válidos para evitar la consulta:
- "Es mejora de legibilidad"
- "Es consistencia con etapa anterior" (verificar si esa etapa fue
autorizada o también fue adelanto silencioso)
- "Es trivial de revertir"
- "Resuelve un bug menor"
```
---
## Categoría D — Estructura del repo
### D.1 Carpeta `sprints/` para activos de planificación
**Estado:** propuesto · **Deriva de:** Principio 4
Cada sprint tiene su carpeta `sprints/sprint-N/` con todos sus artefactos:
- `BRIEF.md`
- `ETAPA_X_PROMPT.md` (uno por etapa)
- `ETAPA_X_REPORT.md` (opcional, reporte del agente capturado)
- `etapa-X-audit.md` (cuando aplica)
- `screenshots/` con evidencia visual
**Por qué:** en 6 meses, abrir esa carpeta te da el historial completo de cómo se construyó el sprint. Sin esto, el conocimiento queda en commits + memoria, que es frágil.
---
### D.2 Carpeta `methodology/` para el método en sí
**Estado:** propuesto · **Deriva de:** Principio 4
El método de trabajo vive en `methodology/`, separado del producto. Esto permite que el método evolucione independiente de cualquier producto específico.
---
### D.3 ADRs para decisiones arquitectónicas
**Estado:** propuesto · **Deriva de:** Principio 4
Las decisiones arquitectónicas importantes se registran como ADR (Architectural Decision Record) en `ARCHITECTURE.md` o `docs/adr/`. Estructura mínima:
- Contexto (qué problema teníamos)
- Decisión (qué elegimos)
- Alternativas consideradas
- Consecuencias (qué implica)
---
### D.4 Permisos del agente versionados en `.claude/`
**Estado:** propuesto · **Deriva de:** Principios 4 y 5
Las decisiones sobre qué comandos puede ejecutar el agente sin consultar al humano se versionan en archivos de configuración del repo. Esto evita dos extremos: la fricción de aprobar cada comando manualmente, y el bypass total que abre la puerta a acciones no intencionadas.
**Estructura recomendada:**
```
repo/
└── .claude/
├── settings.json ← commitable: deny patterns críticos
└── settings.local.json ← gitignored: allow personales del dev
```
**Reglas:**
1. **`settings.json` es del proyecto** — contiene patrones `deny` (comandos peligrosos prohibidos para todos: `rm -rf /`, `git push --force`, `npm publish`, etc.). Se comitea al repo.
2. **`settings.local.json` es del desarrollador** — contiene patrones `allow` personales (cada uno decide qué comandos pre-aprueba). Va en `.gitignore`. Cuando un dev nuevo arranca, **debe construir su propio** archivo siguiendo el template, no copiar el de otro.
3. **El equipo hereda las protecciones, no las preferencias** — los `deny` son compartidos, los `allow` son individuales.
**Por qué:**
- La fricción de aprobar cada `npm install` destruye el flow de trabajo
- Pero el bypass total elimina los puntos de control donde se detectan acciones no intencionadas
- La gobernanza está en clasificar comandos por riesgo, no en aprobar todo o aprobar nada
**Indicador de aplicación correcta:**
- En cada sesión de Claude Code, los comandos rutinarios pasan sin pedir aprobación
- Los comandos no rutinarios siguen pidiendo aprobación (es bueno — ahí prestás atención)
- Si un comando peligroso aparece, está bloqueado por `deny` o pide aprobación explícita
**Indicador de violación:**
- El dev usa `--dangerously-skip-permissions` rutinariamente
- El archivo `settings.json` contiene `allow` que deberían ser personales (paga la complejidad de mantenerlo sincronizado entre devs)
- No hay archivo de configuración y cada dev aprueba todo manualmente cada vez (fricción crónica, señal de que falta este patrón)
**Template:** ver `methodology/TEMPLATES/CLAUDE_PERMISSIONS_TEMPLATE.md`.
**Relación con otros patrones:**
- Complementa **B.4** (reglas no negociables en CLAUDE.md) — pero B.4 es sobre comportamiento del agente, D.4 es sobre permisos del sistema operativo
- Refuerza **Principio 5** (humano director) — sin permisos configurados, el dev se vuelve operador de aprobación
**Casos reales que valida:**
- InQ ROI: el director identificó fatiga de aprobación después de ~30 etapas. Configurar permisos eliminó la fricción sin perder gobernanza.
## Categoría E — Comunicación humano ↔ agente
### E.1 Separar lo validado de lo asumido
**Estado:** propuesto · **Deriva de:** Principios 2 y 3
En los reportes del agente, marcar explícitamente qué fue verificado vs qué se asume funcionando.
**Ejemplo:**
```
Verificado:
- Tests verdes (455/455)
- PDF generado y abierto con pdf-parse
- Análisis de píxeles confirma heatmap completo
Asumido:
- El componente se renderiza igual en Firefox (solo testeado en Chromium)
- La fuente Roboto está disponible en el sistema cliente (no validado)
```
---
### E.2 Hallazgos no esperados como sección explícita
**Estado:** propuesto · **Deriva de:** Principios 3 y 5
Al final de cada etapa, el agente reporta "hallazgos no esperados" — cosas que descubrió durante la implementación que merecen decisión arquitectónica, aunque no formen parte del DoD.
**Ejemplo:** "El cambio de `--primary` propagó a focus rings de inputs (no estaba en el plan). Decisión necesaria: ¿se mantiene o se vuelve a slate?"
---
### E.3 Anti-racionalización explícita
**Estado:** propuesto · **Deriva de:** Principio 3
Cuando un test revela algo raro, un cálculo no cuadra, o un output difiere de lo esperado: **investigar causa raíz, no minimizar**.
Patrón típico de violación: el agente dice "el cálculo es numéricamente correcto" cuando lo que está reportando es un problema de presentación (ej. "0.0 meses" es matemáticamente correcto pero un bug de UX).
---
### C.7 Control preventivo de scope — `git diff` antes de reportar
**Estado:** propuesto · **Deriva de:** Principios 3 y 4 · **Origen:** Sprint 2 Etapa 1
Incluir en todo prompt de Claude Code la siguiente verificación como paso obligatorio antes de reportar:
```bash
git diff --name-only HEAD
```
El resultado debe contener únicamente los archivos listados como autorizados en el prompt. Si aparece un archivo no autorizado:
1. Revertirlo: `git checkout HEAD -- <archivo>`
2. Listarlo bajo "Conflictos de scope" en el reporte
3. No incluirlo en el entregable de la etapa
**Por qué es preventivo, no detectivo:** los controles detectivos (grep de tipos, build, tests) verifican que el código resultante funciona, pero no detectan archivos modificados fuera del scope. Un agente puede tocar `ActivityPanel.tsx` y los tests seguir verdes si el cambio no rompe nada. El `git diff` captura el cambio antes de que se declare la etapa terminada.
**Indicador de aplicación correcta:** el reporte del agente incluye explícitamente la salida de `git diff --name-only HEAD` y todos los archivos listados son los autorizados.
**Indicador de violación:** el reporte no incluye la salida del git diff, o el diff contiene archivos no listados sin explicación.
**Relación con otros patrones:**
- Es respuesta directa al daño documentado en **AP.9** (resolución unilateral de restricciones contradictorias).
- Complementa **C.3** (auditoría adversarial) dándole al humano una checklist concreta de qué verificar.
---
### C.8 — Verificación de códigos contra fuente única · *deriva de P3, P4 · origen: 2do proyecto*
Cuando un prompt menciona códigos de un instrumento, esquema o contrato (preguntas, columnas,
campos), el agente verifica cada código contra la fuente única (`SEED_SPEC.md`, `schema.sql`,
ADRs) **antes** de ejecutar. Si detecta una discrepancia entre el código nombrado en el
prompt y su definición en la fuente única, **DETIENE la ejecución y reporta el conflicto**.
Ejecutar "literalmente" y delegar la verificación semántica al director es AP.10.
-**Correcto:** el reporte del agente incluye una sección "Verificación C.8" con cada
código del prompt mapeado a su fila en la fuente única, antes de la ejecución. Si hay
discrepancia, la ejecución no arranca hasta que el director resuelva.
-**Violación:** el agente ejecuta el prompt al pie de la letra y reporta "los textos son
los solicitados literalmente; la validación semántica la hace el director" — exporta el
trabajo de verificar al humano y propaga la inconsistencia.
**Cómo se incorpora a un prompt:** agregar un paso "Verificación C.8" como primera tarea
del DoD, listando los códigos a verificar y la ruta exacta de la fuente única.
**Casos reales en Datos País:** Etapa 2D (no se aplicó — daño AP.10), Etapa 2E (se aplicó —
detectó conflicto A1/A1b antes de ejecutar), Etapa 2F (se aplicó — sin discrepancias).
---
## Cómo agregar un patrón nuevo
1. Identificar el patrón durante un proyecto real (no inventarlo en abstracto).
2. Documentarlo con: nombre, categoría, principio del que deriva, descripción, indicador de aplicación correcta, indicador de violación.
3. Marcarlo como **propuesto** con cita del proyecto donde apareció.
4. Después de aplicarlo en un 2do proyecto distinto, promoverlo a **estable**.
## Cómo retirar un patrón
Si un patrón no se sostiene en práctica o tiene falsos positivos frecuentes, se retira con un PR que documente:
- Por qué no funcionó
- Qué alternativa probamos
- Si hay un patrón nuevo que lo reemplaza

View File

@@ -0,0 +1,178 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — junio 2026
> Generado al cierre de una sesión extendida de dirección técnica.
> Propósito: minimizar pérdida de contexto al abrir una nueva conversación.
> Director: Marcos (Roy) · Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
---
## 1. Qué es el proyecto
Plataforma soberana de encuestas sobre economía del cuidado en Paraguay.
- **Producto:** formulario anónimo + dashboard analítico con k-anonimato.
- **Marca:** RE (Responsabilidad y Empatía Empresarial) · Fundación Solidaridad · InQuality.
- **URL producción:** https://economia-cuidado.inqualityhq.com
- **Survey demo:** https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001
---
## 2. Infraestructura actual — OPERATIVA ✅
### Stack
- **App:** Next.js 15.3.3 + TypeScript + Tailwind CSS
- **DB:** Supabase self-hosted en Dokploy (stack oficial clonado)
- **Deploy:** Dokploy v0.29.4 en VPS inq-mart-2 (IP VPN: 172.20.5.11)
- **Repos:**
- App: `git.inquality.com.py/marcos/motor-de-encuestas` (migrado desde GitHub InqGit)
- Supabase stack: `git.inquality.com.py/marcos/supabase-motor-fomularios`
- **Dominio API Supabase:** `db.inqualityhq.com` (Kong expuesto con SSL via Traefik/Let's Encrypt)
### Supabase self-hosted — detalles técnicos
- Stack corriendo desde repo Gitea (modo Git, NO docker raw — docker raw no tiene ./volumes/db/)
- Kong conectado a `dokploy-network` (persistente en docker-compose.yml del repo)
- Fix aplicado: `LUA_AUTH_EXPR` en `kong-entrypoint.sh` con prefijo `Bearer ` para PostgREST JWT propagation
- Variables de entorno en Dokploy: ANON_KEY, SERVICE_ROLE_KEY, SUPABASE_ANON_KEY, SUPABASE_SERVICE_KEY (nombres que usa kong.yml)
- SMTP_PORT=587 requerido aunque SMTP esté desactivado (GoTrue lo exige como int)
- GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false (GoTrue lo exige como bool explícito)
- Backups: script GFS en /usr/local/bin/supabase-backup.sh, cron 3AM root, retención 3 días diarios + domingos 14 días
- Studio: solo accesible via SSH tunnel `ssh -L 3001:localhost:3001 administrator@172.20.5.11`
### Variables de entorno de la app Next.js (Build Arguments en Dokploy)
- NEXT_PUBLIC_SUPABASE_URL=https://db.inqualityhq.com
- NEXT_PUBLIC_SUPABASE_ANON_KEY=<JWT anon>
- SUPABASE_SERVICE_ROLE_KEY=<JWT service_role> (Environment Setting, no Build Arg)
---
## 3. Estado del código — Sprint 2 COMPLETADO, Sprint 3 COMPLETADO
### Migraciones aplicadas (12 en total, en orden)
1. 20260531000001_create_tables.sql — 7 tablas
2. 20260531000002_enable_rls.sql
3. 20260531000003_rls_policies.sql — PARCIAL (ver TECH-DEBT-007)
4. 20260531000004_constraints_triggers.sql
5. 20260531000005_agg_segment.sql
6. 20260531000006_grants.sql
7. 20260602111851_revoke_consent_select.sql
8. 20260604200932_fix_answers_insert_policy.sql
9. 20260605093948_dashboard_rpc.sql
10. 20260605093949_rls_deny_sensitive.sql
11. 20260605140000_011_truncate_testing_data.sql
12. 20260605140001_012_dashboard_rpc_v2.sql — 11 RPCs v2 del dashboard
### Políticas RLS activas (7)
- answers_insert, deny_sensitive_answers
- consent_records_insert, consent_versions_select_all
- questions_select_live, responses_insert
- surveys_select_live_anon
### Políticas RLS que fallaron (TECH-DEBT-007)
- org_select_own, surveys_select_auth, questions_select_auth
- Causa: auth.jwt() deprecado en GoTrue v2.189+ — no es bloqueante para el piloto
### Dashboard — widgets y códigos reales del instrumento
| Widget | Códigos reales | Subtítulo |
|---|---|---|
| K1 Cuidadores activos | 1.6 + 1.13 | % plantilla con responsabilidades de cuidado |
| K2 Frecuencia ausentismo | 5.2 | % con 4+ ausencias al año |
| K3 Bienestar | 4.3 (rating 1-5) | autorreporte bienestar general |
| W1 Prevalencia por depto | A1 (Departamento) | % cuidadores activos por departamento |
| W2 Perfil población | 1.1+1.6+1.13 | composición no excluyente |
| W3 Madurez organizacional | 6.1(×0.7)+6.5(×0.3) | score 0-100 |
| W4 Horas×Ausentismo | 2.2×5.2 | correlación intensidad/ausentismo |
| W5 Pirámide demográfica | A5×A6 | etario por género (k≥5) |
| W6 Apoyos demandados | 6.2 | top 8 multi-select |
| W7 Intensidad vs Bienestar | 2.2×4.3 | bins k≥5 |
| W8 Saturación cuidador | 4.3+5.1+5.2 | indicador compuesto |
---
## 4. Deuda técnica registrada
| ID | Descripción | Bloqueante |
|---|---|---|
| TECH-DEBT-006 | Backup remoto S3 (Backblaze B2 o Wasabi ~$5/mes) | **SÍ — antes del primer dato real** |
| TECH-DEBT-007 | auth.jwt() deprecado en GoTrue v2.189+ — current_app_role() y current_org_id() no funcionales | No (service_role bypasa RLS) |
| CVE-2025-66478 | Next.js 15.3.3 con vulnerabilidad de seguridad crítica | **SÍ — antes del primer dato real** |
---
## 5. Prompts pendientes de ejecutar en Claude Code
### ETAPA 2G — Cierre Sprint 2 (método)
**Archivo:** `sprints/sprint-2/ETAPA_2G_PROMPT.md`
**Qué hace:** promueve Patrón C.8 a estable + documenta AP.10 y AP.11 en method/ANTI_PATTERNS.md con casos reales del proyecto.
### ETAPA 3V — Vista de control /admin/responses
**Archivo:** `sprints/sprint-3-infra/ETAPA_3V_PROMPT.md`
**Qué hace:** crea ruta Next.js /admin/responses que muestra tabla de todas las respuestas ordenadas por fecha descendente, sin auth, usando service_role.
---
## 6. Contexto comercial
- **2 empresas comprometidas** para piloto (no en producción aún)
- **Cronograma deck:** JUN 2026 inicio · JUL-AGO relevamiento · SEP análisis · OCT primer informe + evento
- **Meta:** 20 empresas pioneras
- **Precios:** Gs. 4.5M (≤50) · 7.5M (≤200) · 12M (≤400)
- **GAPs deck vs. producto:**
- RE Index no implementado (fórmula: Bienestar×0.35 + Concentración×0.25 + Descanso×0.25 + Apoyo×0.15)
- Informe descargable prometido en el deck, no existe todavía
- **Contacto Fundación:** Valeria Arce — ddff@fundacionsolidaridad.org.py · +595 991 854333
---
## 7. Documentos producidos en estas sesiones
| Archivo | Descripción |
|---|---|
| INSTALL_SUPABASE.md | Guía instalación self-hosted (384 líneas) — con tabla de errores conocidos |
| BACKUP_STRATEGY.md | Estrategia backup GFS (335 líneas) — con procedimiento de restore |
| DOCUMENTACION_PLATAFORMA.md | Doc conceptual con 3 diagramas Mermaid |
| DatoPais_DocumentacionConceptual.docx | Word con 3 PNGs de diagramas embebidos |
| DatoPais_DimensionamientoEconomico.docx | Word con 5 charts económicos |
| DatoPais_ValidacionInstrumento.xlsx | Planilla colaborativa 4 hojas |
| sprints/sprint-2/ETAPA_2G_PROMPT.md | Cierre Sprint 2 — PENDIENTE ejecución |
| sprints/sprint-3-infra/ETAPA_3V_PROMPT.md | Vista /admin/responses — PENDIENTE ejecución |
---
## 8. Decisiones clave tomadas (no reabrir)
1. **docker raw de Dokploy es incompatible con Supabase** — usar siempre modo Git; el compose oficial necesita ./volumes/db/ que docker raw no tiene.
2. **POSTGRES_PASSWORD siempre con openssl rand -hex** — base64 tiene caracteres especiales que rompen el escaping de Docker Compose.
3. **kong.yml: una sola entrada por bloque keyauth_credentials** — dos entradas con el mismo valor JWT causa uniqueness violation.
4. **SMTP_PORT=587 siempre explícito** aunque SMTP esté desactivado — GoTrue lo parsea como int y falla si está vacío.
5. **GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito** — GoTrue lo parsea como bool y falla si está vacío.
6. **LUA_AUTH_EXPR con prefijo "Bearer "** en kong-entrypoint.sh rama legacy — sin el prefijo PostgREST recibe JWT vacío.
7. **kong.yml usa $ANON_KEY y $SERVICE_ROLE_KEY** — alineado con los nombres de variables en Dokploy; SUPABASE_ANON_KEY y SUPABASE_SERVICE_KEY son aliases adicionales.
8. **App Next.js conecta a Supabase por URL pública** (https://db.inqualityhq.com) — las variables NEXT_PUBLIC_* se embeben en el bundle en build time y el navegador no puede resolver nombres Docker internos.
9. **NEXT_PUBLIC_SUPABASE_URL va como Build Argument** en Dokploy, no como Environment Setting — se embebe en el bundle de JavaScript durante el build.
10. **Repos en Gitea** (migrado desde GitHub InqGit) — VS Code configurado apuntando a git.inquality.com.py.
---
## 9. Patrones y anti-patrones del método (pendiente formalización en 2G)
### C.8 — Verificación de códigos contra fuente única (PROMOVER A ESTABLE)
Todos los widget subtitles y RPC mappings deben verificarse contra el instrumento real (SEED_SPEC.md). El drift silencioso entre instrumento e implementación es un failure mode de alto costo.
### AP.10 — Propagación silenciosa de inconsistencia entre fuentes
Caso real: Etapa 2D tenía subtítulos con códigos incorrectos que no se detectaron hasta auditoría explícita. Los errores de mapeo de códigos no generan errores en tiempo de compilación.
### AP.11 — Contrato desactualizado por código
Caso real: Etapa 1D dejó SEED_SPEC.md desactualizado respecto al seed.sql. El documento de referencia divergió del código sin señal visible.
---
## 10. Próximos pasos en orden de prioridad
1. **TECH-DEBT-006** — Backup remoto S3 (bloqueante antes del primer dato real)
2. **CVE-2025-66478** — Actualizar Next.js antes del primer dato real
3. **ETAPA 2G** — Ejecutar en Claude Code (método, no infraestructura)
4. **ETAPA 3V** — Ejecutar en Claude Code (vista /admin/responses)
5. **RE Index** — Implementar como widget real (confirmar mapeo G1/G2/G4/G5 → códigos seed con Marcos primero)
6. **Informe descargable** — Prometido en deck comercial, no existe
7. **Onboarding 2 empresas comprometidas** — cuando validación esté lista
8. **Validación instrumento** — Respuestas equipo de salud en DatoPais_ValidacionInstrumento.xlsx (12 preguntas metodológicas abiertas)

View File

@@ -0,0 +1,70 @@
# PROMPT DE CONTINUIDAD — nueva sesión de dirección técnica
## Datos País sobre el Cuidado
> Pegá este prompt al inicio de una nueva conversación con Claude.
> Actualizado: junio 2026.
---
Sos el copiloto técnico del proyecto "Datos País sobre el Cuidado" — una plataforma soberana de encuestas sobre economía del cuidado en Paraguay. Ya tenemos múltiples sesiones de trabajo juntos. Este mensaje te pone al día antes de continuar.
## Contexto del proyecto
Plataforma de dos caras: formulario web anónimo + dashboard analítico con k-anonimato. Marca RE, Fundación Solidaridad, powered by InQuality.
- **Stack:** Next.js 15.3.3 + TypeScript + Tailwind CSS + Supabase self-hosted
- **Deploy:** Dokploy v0.29.4 en VPS (IP VPN: 172.20.5.11)
- **URL producción:** https://economia-cuidado.inqualityhq.com
- **Repos:** app en `git.inquality.com.py/marcos/motor-de-encuestas`, Supabase stack en `git.inquality.com.py/marcos/supabase-motor-fomularios`
- **API Supabase:** https://db.inqualityhq.com (Kong con SSL)
## Estado actual — Sprint 3 COMPLETADO
El formulario y el dashboard están operativos con Supabase self-hosted. Las 12 migraciones están aplicadas. Backups locales configurados con retención GFS. El formulario carga y permite completar la encuesta end-to-end.
## Deuda técnica abierta (ver TECH_DEBT.md en el repo)
- **TECH-DEBT-006** — Backup remoto S3 — BLOQUEANTE antes del primer dato real de empresa
- **TECH-DEBT-007** — auth.jwt() deprecado en GoTrue v2.189+ — no bloqueante para el piloto
- **CVE-2025-66478** — Next.js 15.3.3 con vulnerabilidad crítica — BLOQUEANTE antes del primer dato real
## Decisiones tomadas — no reabrir
1. Supabase en Dokploy siempre via modo Git (docker raw no tiene ./volumes/db/)
2. POSTGRES_PASSWORD siempre con openssl rand -hex (evita caracteres problemáticos)
3. kong.yml: una sola entrada por bloque keyauth_credentials
4. SMTP_PORT=587 siempre explícito aunque SMTP esté desactivado
5. GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito
6. LUA_AUTH_EXPR con prefijo "Bearer " en kong-entrypoint.sh rama legacy
7. NEXT_PUBLIC_SUPABASE_URL via Build Argument en Dokploy (no Environment Setting)
8. App Next.js conecta a Supabase por URL pública https://db.inqualityhq.com
## Prompts pendientes de ejecutar en Claude Code
1. **ETAPA 2G**`sprints/sprint-2/ETAPA_2G_PROMPT.md` — cierre Sprint 2, formaliza patrones C.8, AP.10, AP.11 en el método
2. **ETAPA 3V**`sprints/sprint-3-infra/ETAPA_3V_PROMPT.md` — vista /admin/responses para control operativo
## Próximos pasos en orden
1. TECH-DEBT-006 (S3 backup) — bloqueante
2. CVE-2025-66478 (Next.js update) — bloqueante
3. Ejecutar ETAPA 2G en Claude Code
4. Ejecutar ETAPA 3V en Claude Code
5. RE Index — confirmar mapeo G1/G2/G4/G5 → códigos seed antes de implementar
6. Informe descargable — prometido en deck comercial, no existe aún
## Método de trabajo
Usamos niveles de autonomía explícitos en los prompts (Nivel 1 = reportar antes de hacer, Nivel 3 = ejecutar con validación al final). Los prompts de Claude Code están en `sprints/`. Las decisiones arquitecturales se documentan como ADRs en `docs/adr/`. Las reglas no negociables del proyecto están en `CLAUDE.md` del repo.
## Preguntas frecuentes de contexto
**¿Por qué Supabase self-hosted y no Cloud?** Soberanía del dato — datos de cuidadores paraguayos no deben vivir en servidores de terceros fuera de control del proyecto. Decisión tomada antes de ingresar el primer dato real.
**¿Por qué k-anonimato k≥5?** Regla no negociable del sistema. En empresas chicas, un cruce demográfico de menos de 5 personas puede reidentificar a un individuo aunque el nombre nunca se haya guardado.
**¿Por qué el dashboard usa service_role?** Porque las políticas de org_admin con JWT custom claims no están implementadas (TECH-DEBT-007). Para el piloto, el dashboard es herramienta interna y service_role es aceptable. Cuando haya org_admin real, hay que resolver TECH-DEBT-007 primero.
---
¿Listo para continuar? El próximo paso es [COMPLETAR CON LO QUE VAYAS A HACER].

View File

@@ -0,0 +1,48 @@
# TECH_DEBT.md — actualización junio 2026
## Para aplicar en el repo del proyecto
> Reemplazar el contenido existente de TECH_DEBT.md en el repo con este bloque,
> o mergear los ítems nuevos si ya existe el archivo con otros ítems previos.
---
## Deuda técnica abierta
### TECH-DEBT-006 — Backup remoto S3
**Estado:** ABIERTO — BLOQUEANTE antes del primer dato real de empresa
**Descripción:** Los backups locales están configurados y funcionando (GFS, 3AM diario, retención 3 días + domingos 14 días). Pero los backups viven solo en el mismo VPS. Si el VPS muere, se pierden datos Y backups.
**Solución:** rsync nocturno a S3-compatible (Backblaze B2 o Wasabi, ~USD 5/mes) usando rclone.
**Referencia:** BACKUP_STRATEGY.md sección 5 — TECH-DEBT-006.
### TECH-DEBT-007 — auth.jwt() deprecado en GoTrue v2.189+
**Estado:** ABIERTO — no bloqueante para el piloto
**Descripción:** Las funciones helper `current_app_role()` y `current_org_id()` en migration 003 usan `auth.jwt()` que fue eliminado en GoTrue v2.189+. Las políticas que dependen de estas funciones no se crearon:
- `org_select_own` en organizations
- `surveys_select_auth` en surveys
- `questions_select_auth` en questions
**Impacto actual:** ninguno — el dashboard usa service_role que bypasa RLS. Los respondentes usan anon que tiene las políticas básicas de INSERT.
**Impacto futuro:** org_admin no podrá autenticarse con custom claims (app_role, org_id) hasta resolver esto.
**Solución:** migrar a auth.session() o implementar JWT claims via hook de GoTrue v2.
**NO resolver hasta tener caso de uso real de org_admin autenticado.**
### CVE-2025-66478 — Next.js 15.3.3 con vulnerabilidad de seguridad
**Estado:** ABIERTO — BLOQUEANTE antes del primer dato real de empresa
**Descripción:** Next.js 15.3.3 tiene una vulnerabilidad de seguridad crítica. El build lo reporta explícitamente: "This version has a security vulnerability. Please upgrade to a patched version."
**Solución:** actualizar next en package.json a la versión parcheada disponible, correr npm install, hacer redeploy.
**Referencia:** https://nextjs.org/blog/CVE-2025-66478
---
## Deuda técnica cerrada en Sprint 3
### TECH-DEBT-002 — KPI NPS eliminado ✅
KPI NPS eliminado — instrumento no tiene pregunta NPS.
### TECH-DEBT-003 — Brecha de políticas ✅
Widget W6 redefinido como demanda-only (6.2), sin oferta. Documentado.
### TECH-DEBT-004 — Intención directa de salida no capturada
Intención directa de salida laboral no capturada en el instrumento v2. No resuelto pero documentado como gap conocido.
### TECH-DEBT-005 — A1 País vs A1b ✅
Cerrado: A1 ya es Departamento (18 opciones Paraguay) en el instrumento final.

81
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.3", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
@@ -895,15 +895,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.3.3", "version": "15.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz",
"integrity": "sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==", "integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
"integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==", "integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -917,9 +917,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
"integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==", "integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -933,9 +933,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
"integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==", "integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -952,9 +952,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
"integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==", "integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -971,9 +971,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
"integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==", "integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -990,9 +990,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
"integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==", "integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1009,9 +1009,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
"integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==", "integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1025,9 +1025,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.3", "version": "15.3.5",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
"integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==", "integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2808,13 +2808,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.3.3", "version": "15.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-15.3.3.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.3.8.tgz",
"integrity": "sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==", "integrity": "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==",
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "15.3.3", "@next/env": "15.3.8",
"@swc/counter": "0.1.3", "@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"busboy": "1.6.0", "busboy": "1.6.0",
@@ -2829,14 +2828,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0" "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "15.3.3", "@next/swc-darwin-arm64": "15.3.5",
"@next/swc-darwin-x64": "15.3.3", "@next/swc-darwin-x64": "15.3.5",
"@next/swc-linux-arm64-gnu": "15.3.3", "@next/swc-linux-arm64-gnu": "15.3.5",
"@next/swc-linux-arm64-musl": "15.3.3", "@next/swc-linux-arm64-musl": "15.3.5",
"@next/swc-linux-x64-gnu": "15.3.3", "@next/swc-linux-x64-gnu": "15.3.5",
"@next/swc-linux-x64-musl": "15.3.3", "@next/swc-linux-x64-musl": "15.3.5",
"@next/swc-win32-arm64-msvc": "15.3.3", "@next/swc-win32-arm64-msvc": "15.3.5",
"@next/swc-win32-x64-msvc": "15.3.3", "@next/swc-win32-x64-msvc": "15.3.5",
"sharp": "^0.34.1" "sharp": "^0.34.1"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -17,7 +17,7 @@
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.3", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
# ETAPA 3C — Texto de consentimiento + dos nuevas encuestas piloto
## 1. Objetivo
Dos cambios independientes:
1. Actualizar el texto de la pantalla de consentimiento para usar el nombre de empresa
dinámico desde la DB y el nuevo wording aprobado.
2. Insertar en el seed dos nuevas organizaciones y dos nuevos surveys (InQuality y
Fundación Solidaridad) como encuestas de espacio de pruebas reales.
---
## 2. Contexto previo
- El nombre de empresa se lee de `surveys.brand_config.org_name` (JSONB), no de
`organizations.name`. El formulario ya tiene acceso a ese valor via `brand_config`.
- El survey actual (`30000000-...-0001`) tiene `brand_config.org_name = "InQuality"`.
- El componente que muestra la pantalla de consentimiento es `ConsentScreen`,
llamado desde `survey-form.tsx` con `brandConfig={survey.brand_config}`.
- Las dos nuevas encuestas son para uso real en espacio de pruebas — los encuestados
serán notificados cuando la plataforma madure.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Organizaciones separadas para cada encuesta — cada una con su propio `org_id`.
- Los surveys nuevos tienen `status='live'` para que sean accesibles por UUID.
- El survey demo existente (`30000000-...-0001`) NO se toca — sigue funcionando.
- Los nuevos UUIDs siguen el patrón del seed: `10000000-...-0002` para org InQuality
real, `10000000-...-0003` para Fundación Solidaridad, y surveys `30000000-...-0002`
y `30000000-...-0003` respectivamente.
- `brand_config.org_name` es el campo que alimenta el texto dinámico del consentimiento.
---
## 4. Cambio 1 — Texto del consentimiento
### 4.1 Texto actual (aproximado)
```
Diagnóstico interno de [org_name]: tus respuestas ayudan a la empresa a entender
la realidad de las personas que cuidan a familiares con discapacidad o adultos
mayores, para diseñar apoyos más adecuados. Solo se trabaja con datos agregados
— nunca accede a respuestas individuales.
Estudio país (opcional): tus respuestas anonimizadas contribuyen a un mapeo nacional
sobre la economía del cuidado en Paraguay. Los datos se procesan de forma
irreversiblemente desvinculada de cualquier empresa o persona.
```
### 4.2 Texto nuevo
```
Diagnóstico interno de [ORG_NAME]: Tus respuestas ayudan a la empresa, en alianza
con Fundación Solidaridad, a entender la realidad de las personas que cuidan a
familiares con discapacidad o adultos mayores, para diseñar apoyos más adecuados.
Solo se trabaja con datos agregados — nunca accede a respuestas individuales.
Estudio país (opcional): Tus respuestas serán anónimas y contribuyen a un mapeo
nacional sobre la economía del cuidado en Paraguay. Los datos se procesan de manera
que no pueden asociarse a ninguna empresa ni persona.
```
`[ORG_NAME]` = `brand_config.org_name` — ya disponible en el componente.
Las palabras en negrita son: el nombre de la empresa, "datos agregados",
"anónimas". Mantener el mismo patrón de negritas que el texto actual.
### 4.3 Archivo a modificar
Buscar en `ConsentScreen` o donde esté el JSX que renderiza estos dos párrafos.
Solo cambiar el texto — no la estructura ni el estilo.
---
## 5. Cambio 2 — Dos nuevas organizaciones y surveys en seed.sql
### 5.1 Organización InQuality
```sql
INSERT INTO public.organizations (id, name, sector, size_bucket)
VALUES (
'10000000-0000-0000-0000-000000000002',
'InQuality',
'Consultoría/Tecnología',
'1-50'
) ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
sector = EXCLUDED.sector,
size_bucket = EXCLUDED.size_bucket;
```
### 5.2 Organización Fundación Solidaridad
```sql
INSERT INTO public.organizations (id, name, sector, size_bucket)
VALUES (
'10000000-0000-0000-0000-000000000003',
'Fundación Solidaridad',
'Sociedad Civil/ONG',
'1-50'
) ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
sector = EXCLUDED.sector,
size_bucket = EXCLUDED.size_bucket;
```
### 5.3 Survey InQuality
```sql
INSERT INTO public.surveys (
id, org_id, title, status, is_anonymous,
data_sensitivity, security_class, controller_role,
consent_version_id, brand_config, k_threshold
) VALUES (
'30000000-0000-0000-0000-000000000002',
'10000000-0000-0000-0000-000000000002',
'Mapeo de Cuidadores — 2026',
'live',
true,
'sensible',
'confidencial',
'A',
'20000000-0000-0000-0000-000000000001',
'{"org_name": "InQuality", "branch_name": "Equipo InQuality", "branch_city": "Asunción"}',
5
) ON CONFLICT (id) DO UPDATE SET
brand_config = EXCLUDED.brand_config,
status = EXCLUDED.status;
```
### 5.4 Survey Fundación Solidaridad
```sql
INSERT INTO public.surveys (
id, org_id, title, status, is_anonymous,
data_sensitivity, security_class, controller_role,
consent_version_id, brand_config, k_threshold
) VALUES (
'30000000-0000-0000-0000-000000000003',
'10000000-0000-0000-0000-000000000003',
'Mapeo de Cuidadores — 2026',
'live',
true,
'sensible',
'confidencial',
'A',
'20000000-0000-0000-0000-000000000001',
'{"org_name": "Fundación Solidaridad", "branch_name": "Equipo Fundación", "branch_city": "Asunción"}',
5
) ON CONFLICT (id) DO UPDATE SET
brand_config = EXCLUDED.brand_config,
status = EXCLUDED.status;
```
### 5.5 Notas sobre el seed
- Usar `ON CONFLICT (id) DO UPDATE` para que el seed sea idempotente.
- El `consent_version_id` referencia el consentimiento existente
(`20000000-0000-0000-0000-000000000001`) — mismo texto para ambas.
- Las URLs resultantes serán:
- InQuality: `?s=30000000-0000-0000-0000-000000000002`
- Fundación Solidaridad: `?s=30000000-0000-0000-0000-000000000003`
---
## 6. Entregables (DoD)
- [ ] Texto del consentimiento actualizado en el componente con el nuevo wording.
- [ ] `[ORG_NAME]` dinámico desde `brand_config.org_name`.
- [ ] Negritas correctas: nombre de empresa, "datos agregados", "anónimas".
- [ ] Dos nuevas organizaciones en `seed.sql` con `ON CONFLICT`.
- [ ] Dos nuevos surveys en `seed.sql` con `ON CONFLICT`.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` en scope.
## 7. Qué reportar
```
Verificado:
- Texto de consentimiento actualizado ✓
- org_name dinámico ✓ (valor en demo: "InQuality" o "Empresa Demo")
- Seed actualizado con 2 orgs + 2 surveys ✓
- ON CONFLICT en todos los nuevos INSERTs ✓
- Build exitoso ✓
- Diff en scope ✓
URLs de los nuevos surveys:
- InQuality: ?s=30000000-0000-0000-0000-000000000002
- Fundación Solidaridad: ?s=30000000-0000-0000-0000-000000000003
Asumido:
- [decisiones de implementación]
```
## 8. Qué NO hacer
- ❌ No tocar el survey demo existente (30000000-...-0001).
- ❌ No cambiar la estructura de `brand_config` — solo el texto del componente.
- ❌ No re-seedear la DB de producción — solo actualizar seed.sql.
- ❌ No cambiar estilos ni layout del ConsentScreen.
- ❌ No tocar dashboard ni RPCs.

View File

@@ -0,0 +1,174 @@
# ETAPA 3I2 — Revisión del instrumento v2.1 (feedback equipo médico)
## 1. Objetivo
Aplicar 10 cambios al instrumento "Mapeo de Cuidadores" resultado del feedback del equipo
de salud. Los cambios afectan SEED_SPEC.md, seed.sql, y el código del formulario Next.js.
---
## 2. Contexto previo
- Instrumento actual: v2, 44 preguntas, 8 secciones, en `SEED_SPEC.md`.
- La DB tiene al menos 1 respuesta real. Algunos cambios son breaking para datos existentes
(opciones renombradas, tipo cambiado) — documentar en OBSERVATIONS.md, no bloquear.
- El formulario Next.js usa un sistema de conditional_rules en el JSON de cada pregunta.
- Los 3 triggers (1.1, 1.6, 1.13) controlan bloques condicionales enteros.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Cambio 4: el salto a 6.4 ocurre cuando las TRES preguntas 1.1, 1.6, 1.13 dan "No"
simultáneamente. Si al menos una da "Sí", las secciones 2-5 y 6.1-6.3 se muestran normalmente.
- Cambio 4: las secciones 2, 3, 4, 5, 6.1, 6.2, 6.3 se saltean completamente — ir directo a 6.4.
- Cambio 5: 2.1 pasa a multiple_choice. Es breaking para datos existentes — documentar, no bloquear.
- Cambio 6: 2.4 se elimina del seed. Si hay respuestas con question_id de 2.4, se pierden
al re-seedear. Documentar en OBSERVATIONS.md.
- Cambio 4: pregunta 6.6 opciones: Sí · No · Tal vez.
- Cambio 3: "Tecnología asistiva" va en penúltima posición (antes de "No utilizo"/"No utiliza").
- "Prefiero no decir" se elimina de TODAS las preguntas que lo tengan.
---
## 4. Los 10 cambios — especificación exacta
### Cambio 1 — Eliminar "Prefiero no decir" de todo el instrumento
Preguntas afectadas: A5, A6, 1.1, 1.5, 1.6, 1.13, 2.5, 3.1, 3.4.
En cada una, eliminar la opción "Prefiero no decir" del array de opciones en seed.sql.
En SEED_SPEC.md, eliminarla de la columna Opciones de cada pregunta.
⚠️ Los triggers 1.1, 1.6, 1.13 quedan con solo 2 opciones: Sí · No.
La lógica condicional no cambia — solo "Sí" dispara el bloque.
### Cambio 2 — Subtítulo hint en todas las preguntas multiple_choice
En el componente del formulario que renderiza preguntas de tipo `multiple_choice`,
agregar un subtítulo/hint debajo del prompt: "Podés marcar más de una opción".
Preguntas afectadas: 1.2, 1.3, 1.7, 1.8, 1.9, 1.10, 1.15, 2.3, 3.2, 3.3, 4.2, 6.2.
Es un cambio de UI — no afecta seed.sql ni SEED_SPEC.md.
### Cambio 3 — Agregar "Tecnología asistiva" en 1.3 y 1.10
1.3 opciones actuales: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utilizo
1.3 opciones nuevas: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utilizo
1.10 opciones actuales: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · No utiliza
1.10 opciones nuevas: Silla de ruedas · Bastón · Andador · Muletas · Prótesis · Tecnología asistiva · No utiliza
### Cambio 4 — Salto condicional triple + nueva pregunta 6.6
#### 4a. Lógica de salto
Cuando las tres responden "No": 1.1="No" AND 1.6="No" AND 1.13="No",
las secciones 2, 3, 4, 5 completas + preguntas 6.1, 6.2, 6.3 se ocultan.
El formulario salta directamente a 6.4.
Implementación sugerida: agregar una regla condicional en las preguntas de las secciones
2-5 y 6.1-6.3 que las oculte cuando se cumple esa condición triple. La forma exacta de
implementarlo (conditional_rules en cada pregunta vs. lógica en el componente de sección)
la decide Claude Code según la arquitectura existente del formulario.
#### 4b. Nueva pregunta 6.6
Agregar al final de la Sección 6 (después de 6.5, antes de Sección 7):
- Code: 6.6
- Tipo: single_choice
- Pregunta: "¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo
a personas con discapacidad y/o cuidadores?"
- Opciones: Sí · No · Tal vez
- is_sensitive: false
- top-level (siempre visible)
Actualizar position de 7.1 para que quede después de 6.6.
Actualizar el conteo total de preguntas en SEED_SPEC.md (pasa de 44 a 45, o 44 si se
compensa con la eliminación de 2.4 — verificar el conteo final).
### Cambio 5 — 2.1 pasa a multiple_choice
Cambiar tipo de `single_choice` a `multiple_choice` en seed.sql y SEED_SPEC.md.
Agregar nota en SEED_SPEC.md:
"⚠️ TECH-DEBT: múltiple selección sin orden de prioridad. Deuda: implementar ranking
(Principal cuidador soy yo > secundario instituto > principal pagado > secundario familiar)."
### Cambio 6 — Eliminar 2.4
Eliminar la pregunta 2.4 ("¿Contás con apoyo de otras personas para el cuidado?")
del seed.sql y SEED_SPEC.md.
Renumerar positions de las preguntas posteriores si es necesario.
Documentar en OBSERVATIONS.md: "2.4 eliminada por feedback equipo médico (similar a 2.1)."
### Cambio 7 — En 3.1 eliminar "Sin gastos significativos"
El cambio 1 ya elimina "Prefiero no decir" de 3.1.
Adicionalmente, eliminar "Sin gastos significativos" de las opciones de 3.1.
3.1 opciones finales: Menos de Gs. 500.000 · Gs. 500.0001.500.000 · Gs. 1.500.0003.000.000
· Gs. 3.000.0006.000.000 · Más de Gs. 6.000.000
### Cambio 8 — En 3.3 cambiar texto de opción
Cambiar: "Es difícil sostenerlo con mis ingresos"
Por: "No puedo sostenerlo con mis ingresos"
En seed.sql y SEED_SPEC.md.
Documentar en OBSERVATIONS.md: "Respuestas existentes con el texto anterior quedan
con valor 'Es difícil sostenerlo con mis ingresos' — breaking change en datos existentes."
### Cambio 9 — En 3.4 reestructurar primera franja salarial
Opción actual: "Sueldo mínimo Gs. 5.000.000"
Reemplazar por dos opciones:
1. "Sueldo mínimo"
2. "Gs. 3.000.000 a 5.000.000"
Las demás franjas no cambian.
Documentar en OBSERVATIONS.md: "Respuestas existentes con 'Sueldo mínimo Gs. 5.000.000'
quedan con valor anterior — breaking change en datos existentes."
### Cambio 10 — En 6.1 cambiar texto de última opción
Cambiar: "No, nunca se ha hablado del tema"
Por: "No, no se puede hablar del tema"
En seed.sql y SEED_SPEC.md.
Documentar en OBSERVATIONS.md: "Breaking change en datos existentes para esta opción."
---
## 5. Archivos a modificar
| Archivo | Cambios |
|---|---|
| `SEED_SPEC.md` | Todos los cambios de opciones, tipos, preguntas nuevas/eliminadas |
| `supabase/seed.sql` | Todos los cambios — es la fuente de verdad para re-seedear |
| Componente formulario (multiple_choice) | Cambio 2: agregar hint |
| Lógica condicional del formulario | Cambio 4a: salto triple |
| `OBSERVATIONS.md` o `TECH_DEBT.md` | Documentar breaking changes y nueva deuda |
---
## 6. Entregables (DoD)
- [ ] SEED_SPEC.md actualizado con todos los cambios.
- [ ] seed.sql actualizado — re-seedeable desde cero produce el instrumento v2.1.
- [ ] Componente multiple_choice muestra hint "Podés marcar más de una opción".
- [ ] Lógica de salto 1.1+1.6+1.13 todos "No" → salta a 6.4.
- [ ] Pregunta 6.6 visible en el formulario.
- [ ] "Prefiero no decir" ausente en todas las preguntas.
- [ ] 2.4 eliminada del seed.
- [ ] Breaking changes documentados en OBSERVATIONS.md.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` en scope.
## 7. Qué reportar
```
Verificado:
- SEED_SPEC.md actualizado ✓
- seed.sql actualizado ✓
- Conteo final de preguntas: [N]
- Hint en multiple_choice: ✓
- Salto triple implementado: ✓ (describir cómo)
- 6.6 agregada ✓
- "Prefiero no decir" eliminado de: [lista de preguntas]
- 2.4 eliminada ✓
- Build exitoso ✓
- Diff en scope ✓
Breaking changes documentados en OBSERVATIONS.md:
- [lista]
Asumido:
- [decisiones de implementación]
```
## 8. Qué NO hacer
- ❌ No re-seedear la DB de producción — solo actualizar seed.sql.
Re-seedear producción requiere aprobación explícita del director.
- ❌ No cambiar la estructura de tablas ni agregar columnas — solo opciones y lógica.
- ❌ No tocar el dashboard ni las RPCs.
- ❌ No resolver ítems de TECH_DEBT que no estén listados acá.
- ❌ No cambiar el diseño visual del formulario más allá del hint de multiple_choice.

View File

@@ -0,0 +1,437 @@
# ETAPA 3I — Migración a Supabase self-hosted en Dokploy
## 1. Objetivo
Migrar de Supabase Cloud a Supabase self-hosted en Dokploy, manteniendo Cloud
operativo en paralelo durante la transición y como respaldo durante 30 días post-cierre.
**Esta etapa es operativa, no de código. Nivel 3 extendido por tocar infraestructura.**
---
## 2. Modo de ejecución — diferente a etapas anteriores
Esta etapa **NO se ejecuta de una tirada**. Son 6 pasos atómicos, cada uno con su gate
de validación. Después de cada paso, el agente reporta lo verificado y **espera
confirmación explícita del director** antes de avanzar al siguiente.
Si un paso falla, el rollback es el del propio paso (documentado). No avanzar al siguiente
hasta que el actual cierre limpio.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Supabase self-hosted **completo** (Postgres + Auth + Storage + Realtime + Studio + Kong),
no Postgres pelado. Compatibilidad 100% con código actual.
- **Partida limpia:** no se migran datos de Cloud. Se aplican las 12 migraciones del repo
sobre una DB vacía. No hay nada en Cloud que conservar (testing ya truncado en 2E).
- **Cloud y self-hosted en paralelo** durante la transición. Cloud se mantiene 30 días
post-validación como respaldo, después se elimina.
- **Studio detrás de SSH** (no expuesto a internet sin auth). API por subdominio propio
con SSL.
- **Backups locales** en el mismo VPS (diarios, retención 30 días, con restore probado).
Backup remoto queda como deuda técnica antes del primer dato real.
- **VPS objetivo:** el mismo VPS de Dokploy donde corre la app Next.js (17 GB RAM
disponibles, según notas del proyecto).
- **Dominio nuevo:** `db.inqualityhq.com` o `supabase.inqualityhq.com` para el API
(decidir cuál en el paso 4). Studio sin dominio público.
---
## 4. PASOS
### PASO 1 · Levantar Supabase self-hosted en Dokploy
**Qué hacer:**
1. Acceder a Dokploy.
2. Crear un nuevo proyecto "supabase-prod" (o nombre equivalente).
3. Usar el template oficial de Supabase self-hosted (Dokploy lo tiene como template
prefijado, o se carga el `docker-compose.yml` oficial de Supabase desde
https://supabase.com/docs/guides/self-hosting/docker).
4. Generar los secrets críticos **localmente, no usar los defaults del template:**
- `POSTGRES_PASSWORD` — generar con `openssl rand -base64 32`
- `JWT_SECRET` — generar con `openssl rand -base64 64`
- `ANON_KEY` y `SERVICE_ROLE_KEY` — generar a partir del JWT_SECRET con la
herramienta oficial: https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
- `DASHBOARD_USERNAME` y `DASHBOARD_PASSWORD` — generar fuerte (16+ caracteres)
5. **NO usar las claves de ejemplo** que vienen en la documentación de Supabase. Esas
están publicadas y son inseguras.
6. Configurar las variables de entorno del stack en Dokploy.
7. Iniciar el stack.
8. Esperar que todos los contenedores estén `healthy`.
**Gate de validación (verificar TODO antes de avanzar al paso 2):**
```bash
# Mostrame el output de estos checks:
docker ps --filter "label=com.docker.compose.project=supabase-prod" \
--format "table {{.Names}}\t{{.Status}}"
# Esperado: ~8 contenedores, todos "Up (healthy)" o "Up"
# Postgres responde
docker exec supabase-db psql -U postgres -c 'SELECT version();'
# Esperado: versión de Postgres
# Kong responde
curl -s http://localhost:8000/rest/v1/ -H "apikey: <ANON_KEY>"
# Esperado: respuesta JSON (no 404, no 502)
# Studio responde localmente (todavía no por dominio)
curl -s -o /dev/null -w "%{http_code}" http://localhost:3001
# Esperado: 200 o 401 (auth requerida)
```
**Qué reportar:**
- Confirmación de los 4 checks arriba con outputs reales.
- **NUNCA pegar los secrets en el reporte.** Mostrar solo que existen, no su valor.
Ejemplo: `JWT_SECRET configurado (longitud: 64 chars) ✓`
- Recursos consumidos: `docker stats --no-stream` con los 8 contenedores.
**Rollback si falla:**
- Detener y borrar el stack en Dokploy.
- Investigar logs: `docker logs <container>` del que falle.
- Volver a empezar el paso 1.
**DETENERSE. Esperar confirmación del director antes del paso 2.**
---
### PASO 2 · Aplicar las 12 migraciones del repo
**Qué hacer:**
1. Conectarse al Postgres self-hosted desde el VPS (no desde fuera):
```bash
docker exec -it supabase-db psql -U postgres -d postgres
```
2. Verificar que la DB está vacía:
```sql
\dt public.*
-- Esperado: 0 tablas (o solo las del seed de Supabase, no las del proyecto)
```
3. Salir del psql.
4. Desde el directorio del repo del proyecto (en local del dev, no en el VPS), aplicar
las migraciones usando la CLI de Supabase apuntada a la nueva DB:
```bash
# Configurar conexión a la nueva DB self-hosted
# (la DB URL incluye el password generado en paso 1)
export SUPABASE_DB_URL="postgresql://postgres:<POSTGRES_PASSWORD>@<VPS_IP>:5432/postgres"
# O usar supabase CLI con link:
supabase link --project-ref <self-hosted-ref> --db-url $SUPABASE_DB_URL
# Aplicar migraciones
supabase db push --db-url $SUPABASE_DB_URL
```
**Alternativa si la CLI tiene problemas:** copiar el contenido de cada archivo
`supabase/migrations/*.sql` y ejecutarlo manualmente con `psql`, en orden de timestamp.
**Gate de validación:**
```sql
-- Conteo de objetos creados (esperado: 12 tablas + N funciones)
SELECT count(*) FROM pg_tables WHERE schemaname='public';
-- Esperado: ≥10 tablas (surveys, responses, answers, consent_records, questions, etc.)
SELECT count(*) FROM pg_proc p
JOIN pg_namespace n ON p.pronamespace = n.oid
WHERE n.nspname='public' AND p.proname LIKE 'rpc_%';
-- Esperado: 9 funciones rpc_* (las del dashboard de Etapa 2E)
-- Seed cargado
SELECT count(*) FROM public.surveys;
-- Esperado: 1 (la encuesta piloto)
SELECT count(*) FROM public.questions;
-- Esperado: 44
SELECT count(*) FROM public.question_options;
-- Esperado: ≥200 (las opciones de las preguntas single/multiple choice)
-- RLS habilitada
SELECT tablename FROM pg_tables WHERE schemaname='public'
AND rowsecurity = false;
-- Esperado: vacío (todas las tablas con RLS habilitada)
-- Test funcional: una RPC del dashboard devuelve suppressed con N=0
SELECT rpc_dashboard_kpis('30000000-0000-0000-0000-000000000001'::uuid, '{}'::jsonb);
-- Esperado: status='suppressed'
```
**Qué reportar:**
- Outputs de los 6 queries arriba.
- Si algún número está fuera de lo esperado: detener, no continuar.
**Rollback si falla:**
- `TRUNCATE` no aplica todavía (no hay nada que conservar).
- Borrar la DB y recrearla:
```bash
docker exec supabase-db psql -U postgres -c 'DROP DATABASE postgres;'
# Reiniciar el contenedor para que reaplique el init
```
- Reaplicar migraciones desde cero.
**DETENERSE. Esperar confirmación del director antes del paso 3.**
---
### PASO 3 · Backups locales con prueba de restore
**Qué hacer:**
1. Crear directorio de backups en el VPS: `mkdir -p /var/backups/supabase-prod && chmod 700 /var/backups/supabase-prod`
2. Crear script de backup `/usr/local/bin/supabase-backup.sh`:
```bash
#!/bin/bash
set -euo pipefail
TS=$(date +%Y%m%d_%H%M%S)
OUT=/var/backups/supabase-prod/supabase_$TS.sql.gz
docker exec supabase-db pg_dump -U postgres postgres | gzip > "$OUT"
chmod 600 "$OUT"
# Retención 30 días
find /var/backups/supabase-prod -name "supabase_*.sql.gz" -mtime +30 -delete
echo "Backup OK: $OUT ($(du -h "$OUT" | cut -f1))"
```
3. `chmod +x /usr/local/bin/supabase-backup.sh`
4. Probar manualmente: ejecutarlo una vez, verificar que el archivo se crea.
5. Agregar a crontab: `0 3 * * * /usr/local/bin/supabase-backup.sh >> /var/log/supabase-backup.log 2>&1`
6. **Prueba de restore obligatoria:**
- Crear una DB temporal de prueba: `docker exec supabase-db createdb -U postgres restore_test`
- Restaurar el último backup en esa DB: `gunzip < /var/backups/supabase-prod/supabase_*.sql.gz | docker exec -i supabase-db psql -U postgres restore_test`
- Verificar que tiene los mismos objetos que la DB principal:
```sql
\c restore_test
SELECT count(*) FROM public.questions;
-- Esperado: 44 (mismo que la principal)
```
- Eliminar la DB de prueba: `docker exec supabase-db dropdb -U postgres restore_test`
**Gate de validación:**
- Script ejecutable y verificado manualmente.
- Backup más reciente: `ls -lh /var/backups/supabase-prod/` muestra al menos un archivo.
- Cron line confirmada: `crontab -l | grep supabase-backup`
- Restore probado: la DB de prueba mostró el conteo correcto antes de eliminarse.
**Qué reportar:**
- Tamaño del primer backup (informativo: nos da una idea de cuánto va a crecer).
- Confirmación de los 4 puntos del gate.
**Rollback si falla:**
- Eliminar el script y el cron.
- Re-investigar y volver a configurar.
**Deuda técnica explícita** (agregar a TECH_DEBT.md como TECH-DEBT-006):
> Backup remoto pendiente. Hoy solo local en el mismo VPS. Si el VPS muere, se pierden
> los datos. **Bloqueante antes del primer dato real de empresa.** Recomendación:
> rsync nocturno a S3-compatible (Backblaze B2 o Wasabi, ~$5/mes).
**DETENERSE. Esperar confirmación del director antes del paso 4.**
---
### PASO 4 · Dominio + SSL para API, Studio detrás de SSH
**Qué hacer:**
1. **Decidir nombre del subdominio para API.** Recomendación: `db.inqualityhq.com`.
2. Crear el registro DNS A apuntando al IP del VPS (esto se hace en el panel de DNS
del dominio, fuera de Dokploy).
3. En Dokploy, configurar el reverse proxy / Traefik:
- Ruta `db.inqualityhq.com` → contenedor `supabase-kong:8000`.
- SSL automático con Let's Encrypt.
4. Esperar a que el certificado SSL se emita (1-5 min normalmente).
5. **Bloquear Studio del exterior:**
- El contenedor `supabase-studio` (puerto 3001) NO debe tener regla de exposición
pública en Dokploy/Traefik.
- Verificar con: `curl http://<VPS_IP>:3001` desde fuera del VPS → debe **fallar**
(connection refused o timeout).
6. **Acceso a Studio vía SSH tunnel** (documentar el comando para el director):
```bash
# Desde la máquina del dev:
ssh -L 3001:localhost:3001 usuario@<VPS_IP>
# Después abrir http://localhost:3001 en el navegador
```
**Gate de validación:**
```bash
# API por dominio con SSL
curl -s https://db.inqualityhq.com/rest/v1/ -H "apikey: <ANON_KEY>"
# Esperado: respuesta JSON, no error de certificado
# SSL válido
curl -vI https://db.inqualityhq.com/ 2>&1 | grep "SSL certificate verify"
# Esperado: "SSL certificate verify ok"
# Studio NO accesible desde fuera
curl -s -o /dev/null -w "%{http_code}" http://<VPS_IP>:3001 --max-time 5
# Esperado: timeout o connection refused (no 200)
# Studio sí accesible vía túnel SSH (documentar instrucciones para el director)
```
**Qué reportar:**
- URL del API funcionando con SSL.
- Confirmación de que Studio no está expuesto.
- Comando exacto para que el director acceda a Studio por SSH tunnel.
**Rollback si falla:**
- Quitar el registro DNS.
- Quitar la regla de Traefik.
- Volver a usar IP+puerto temporalmente.
**DETENERSE. Esperar confirmación del director antes del paso 5.**
---
### PASO 5 · Cambiar variables de entorno de la app Next.js + deploy
**ESTE ES EL PASO MÁS CRÍTICO. El error más fácil de cometer es apuntar la app a la
DB nueva pero dejar alguna variable apuntando a Cloud — comportamiento mixto, datos
inconsistentes.**
**Qué hacer:**
1. Listar TODAS las variables de entorno de la app actual relacionadas con Supabase:
```
NEXT_PUBLIC_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY
SUPABASE_SERVICE_ROLE_KEY (server-only)
IP_HASH_SALT (independiente, no tocar)
```
Verificar que **no hay otras** referencias a Cloud en el código:
```bash
grep -rE "supabase\.co|<cloud-project-ref>" --include="*.ts" --include="*.tsx" \
--include="*.env*" .
# Esperado: vacío o solo en comentarios
```
2. Generar un snapshot de las variables ACTUALES (sin pegarlas en el reporte):
```bash
# En el dashboard de Dokploy de la app Next.js, exportar las env vars actuales
# Guardarlas en un archivo local seguro como backup
```
3. Actualizar en Dokploy las 3 variables:
- `NEXT_PUBLIC_SUPABASE_URL` → `https://db.inqualityhq.com`
- `NEXT_PUBLIC_SUPABASE_ANON_KEY` → la nueva (de paso 1)
- `SUPABASE_SERVICE_ROLE_KEY` → la nueva (de paso 1)
4. Trigger redeploy de la app Next.js.
5. Esperar que termine el build y arranque.
**Gate de validación:**
- App Next.js arranca sin errores (`docker logs` del contenedor de la app).
- Abrir `https://economia-cuidado.inqualityhq.com` — el consent screen carga.
- En la consola del navegador, verificar que las requests van a `db.inqualityhq.com`,
NO a `*.supabase.co`.
- Verificar que NO hay errores 401/403 en el Network tab del navegador.
**Qué reportar:**
- Confirmación de que la app arranca.
- Captura de Network tab mostrando requests a `db.inqualityhq.com`.
- Confirmación de que no hay errores en consola del navegador.
**Rollback si falla:**
- Volver las 3 variables a los valores anteriores (los del archivo backup del punto 2).
- Trigger redeploy.
- La app vuelve a apuntar a Cloud. **Cloud sigue vivo, así que rollback es completo.**
**DETENERSE. Esperar confirmación del director antes del paso 6.**
---
### PASO 6 · Validación funcional end-to-end + ventana de observación
**Qué hacer:**
1. Director ejecuta manualmente el flujo completo:
- Abre `https://economia-cuidado.inqualityhq.com?s=30000000-0000-0000-0000-000000000001`
- Acepta los 2 consentimientos.
- Completa el formulario hasta el final.
- Envía.
- Verifica que llegó al "gracias por participar" o equivalente.
2. Agente verifica en la nueva DB que se persistió:
```sql
-- Conectado a la self-hosted DB
SELECT count(*) FROM responses WHERE survey_id = '30000000-0000-0000-0000-000000000001';
-- Esperado: ≥1
SELECT count(*) FROM answers a
JOIN responses r ON a.response_id = r.id
WHERE r.survey_id = '30000000-0000-0000-0000-000000000001';
-- Esperado: ≥1 (varias filas)
SELECT count(*) FROM consent_records;
-- Esperado: ≥1
-- Verificar qi_zone se popula (Etapa 2E)
SELECT qi_zone, count(*) FROM responses GROUP BY qi_zone;
-- Esperado: al menos una fila con qi_zone NO NULL
```
3. Abrir `/dashboard?survey_id=...` y verificar que los widgets muestran suppressed
(N<5 todavía).
4. Cargar 4 respuestas más para alcanzar N=5 y verificar que al menos un widget pasa
a estado `ok` (validación de que el k-anonimato libera datos al cruzar el umbral).
5. **Ventana de observación de 24-48h:**
- Sin tráfico real (todavía no hay piloto), durante este período el director
y/o el agente monitorean los logs por errores no esperados.
6. Truncar las respuestas de prueba (igual que en Etapa 2E):
```sql
TRUNCATE answers, responses, consent_records RESTART IDENTITY CASCADE;
```
**Gate de validación final:**
- Flujo end-to-end funcionó.
- Datos llegaron a la DB correcta (self-hosted).
- qi_zone se populó (Etapa 2E está funcionando).
- Dashboard responde con datos reales (sale de suppressed al cruzar k=5).
- 24-48h sin errores en logs.
- Tablas truncadas, queda solo el seed.
**Qué reportar:**
- Capturas del flujo end-to-end completado.
- Outputs de los queries de verificación.
- Confirmación del período de observación sin errores.
- Confirmación de que las tablas se truncaron al final.
**DETENERSE. Esperar confirmación del director.**
**Recién con esta confirmación, Cloud queda como respaldo congelado por 30 días.**
---
## 5. DoD de la etapa completa
- [ ] Los 6 pasos completados con sus gates de validación.
- [ ] Director confirmó cada paso antes del siguiente.
- [ ] Supabase self-hosted operativo en Dokploy.
- [ ] App Next.js apuntando a la nueva DB.
- [ ] Backups locales diarios configurados, restore probado.
- [ ] Studio no expuesto a internet (acceso solo por SSH tunnel).
- [ ] API por subdominio con SSL.
- [ ] TECH-DEBT-006 agregado (backup remoto pendiente antes de dato real).
- [ ] Cloud sigue operativa pero NO recibe tráfico. Marcada para eliminación a 30 días.
## 6. Qué reportar al final de la etapa
```
Migración Supabase Cloud → self-hosted completada el [fecha].
Self-hosted operativo en:
- API: https://db.inqualityhq.com
- Studio: solo vía SSH tunnel
- Backups: /var/backups/supabase-prod (diarios, retención 30d, restore probado)
App Next.js:
- Apuntando a self-hosted ✓
- Flujo end-to-end validado ✓
- Sin errores en ventana de observación de [X] horas ✓
Cloud:
- Sigue activa, sin tráfico.
- Marcada para eliminación: [fecha + 30 días].
Pendiente / deuda técnica:
- TECH-DEBT-006: backup remoto antes del primer dato real.
Decisiones proactivas (Nivel 2):
- [si las hubo]
```
## 7. Qué NO hacer
- ❌ Avanzar al siguiente paso sin confirmación del director.
- ❌ Pegar secrets (passwords, keys) en reportes — solo confirmar que existen.
- ❌ Eliminar la cuenta de Supabase Cloud antes de los 30 días post-validación.
- ❌ Tocar código de la aplicación (esta etapa es 100% infraestructura).
- ❌ Usar los secrets de ejemplo de la documentación de Supabase — todos generados localmente.
- ❌ Exponer Studio a internet sin auth.
- ❌ Saltarse la prueba de restore (un backup sin restore probado no es un backup).

View File

@@ -0,0 +1,175 @@
# ETAPA 3V-B — Vista de control: tabla plana de respuestas individuales
## 1. Objetivo
Reemplazar la vista `/admin/responses` actual (una fila por submission) por una tabla plana
con **una fila por respuesta individual** (una fila por cada pregunta respondida), mostrando
todos los campos explícitamente incluyendo nulos marcados visualmente.
---
## 2. Contexto previo
- La Etapa 3V-A implementó la vista de metadatos de submissions (una fila por response).
- El director quiere ver las respuestas individuales a las preguntas, no solo los metadatos.
- El esquema relevante: `responses``answers` (via response_id) → `questions` (via question_id).
- `answers.value` es JSONB. Los tipos de preguntas existentes en el instrumento son:
- `single_choice` → value es string: `"Asunción (Capital)"`
- `multiple_choice` → value es array: `["opción1","opción2"]`
- `rating` → value es número: `3`
- `text_short` → value es string corto
- `text_long`**NUNCA mostrar** — regla no negociable (RLS + privacidad)
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Ruta: `/admin/responses` — reemplaza la página anterior completamente.
- Sin auth (herramienta interna de control).
- Sin paginación por ahora — máximo 500 filas (limit en la query).
- Ordenado por `responses.submitted_at DESC`, luego por `questions.position ASC` dentro de cada submission.
- Valores nulos mostrados con badge visual "sin dato" — fondo `--color-re-light` (#E7E6E5), texto `--color-re-dark` (#494F4F) opacidad 0.6.
- text_long excluido completamente — ni la columna ni el valor aparecen.
- Convención CSS: `.admin-*` en `app/globals.css`, igual que la etapa anterior.
- Cliente service-role inline en el Server Component, igual que en la versión anterior.
---
## 4. Estructura de la tabla
### 4.1 Encabezado de página
- Título: "Control de respuestas"
- Subtítulo: total de submissions + total de respuestas individuales + fecha/hora actualización.
- Botón "Actualizar" (`<a href="/admin/responses">`).
### 4.2 Columnas de la tabla (una fila por answer)
| Columna | Fuente | Notas |
|---|---|---|
| # submission | índice de la submission (1, 2, 3...) | mismo número para todas las filas de esa submission |
| Fecha | `responses.submitted_at` | DD/MM/YYYY HH:mm — solo en la primera fila de cada submission |
| Código | `questions.code` | A1, A2, 1.1, 1.6, etc. |
| Pregunta | `questions.prompt` | texto de la pregunta, truncado a 60 chars con "..." |
| Tipo | `questions.type` | badge con el tipo |
| Valor | `answers.value` | renderizado según tipo (ver 4.3) |
| Departamento | `responses.qi_zone` | solo en primera fila de cada submission |
| Sector | `responses.qi_sector` | solo en primera fila de cada submission |
### 4.3 Renderizado del valor según tipo
```typescript
function renderValue(value: unknown, type: string): string {
if (value === null || value === undefined) return null // → badge "sin dato"
switch(type) {
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '') // quitar comillas del JSONB string
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value) // número, opcionalmente con ★ repetidas
case 'text_long':
return null // NUNCA mostrar — excluir de la query directamente
default:
return String(value)
}
}
```
### 4.4 Agrupación visual de submissions
Las filas de una misma submission deben estar visualmente agrupadas:
- Separador visual entre submissions (border-top más pronunciado o fondo alternado por submission).
- Fecha, Departamento y Sector solo se muestran en la primera fila de cada submission.
- Las filas siguientes de la misma submission muestran esas celdas vacías (o con colspan si es HTML puro).
---
## 5. Query recomendada
```typescript
const { data } = await supabase
.from('responses')
.select(`
id,
submitted_at,
qi_zone,
qi_sector,
qi_age_bucket,
ip_hash,
surveys ( title ),
answers (
value,
questions (
code,
prompt,
type,
position,
is_sensitive
)
)
`)
.order('submitted_at', { ascending: false })
.limit(500)
```
Después en el componente, aplanar el resultado:
```typescript
// Para cada response, para cada answer (excluir text_long e is_sensitive):
// generar una fila con los datos de la response + los datos de la answer/question
const rows = responses.flatMap((response, responseIdx) =>
response.answers
.filter(a => a.questions?.type !== 'text_long' && !a.questions?.is_sensitive)
.sort((a, b) => (a.questions?.position ?? 0) - (b.questions?.position ?? 0))
.map((answer, answerIdx) => ({
responseIdx: responseIdx + 1,
isFirstInGroup: answerIdx === 0,
submitted_at: response.submitted_at,
qi_zone: response.qi_zone,
qi_sector: response.qi_sector,
ip_hash: response.ip_hash,
survey_title: response.surveys?.title,
code: answer.questions?.code,
prompt: answer.questions?.prompt,
type: answer.questions?.type,
value: answer.value,
}))
)
```
---
## 6. Entregables (DoD)
- [ ] Ruta `/admin/responses` renderiza con tabla plana (HTTP 200).
- [ ] Una fila por answer, agrupadas visualmente por submission.
- [ ] Columnas: # submission, Fecha, Código, Pregunta (truncada), Tipo, Valor, Departamento, Sector.
- [ ] `text_long` e `is_sensitive=true` excluidos.
- [ ] Valores nulos muestran badge "sin dato" con estilo tenue.
- [ ] Fecha, Departamento, Sector solo en primera fila de cada grupo.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` solo en `app/admin/` y `app/globals.css`.
## 7. Qué reportar
```
Verificado:
- Ruta /admin/responses accesible ✓
- Total submissions: [N] · Total filas en tabla: [N]
- Tipos de preguntas visibles: [lista]
- text_long excluido ✓ / is_sensitive excluido ✓
- Badge "sin dato" visible en campos nulos ✓ / no hay nulos en los datos actuales
- Build exitoso ✓
- Diff en scope ✓
Asumido:
- [decisiones cosméticas]
```
## 8. Qué NO hacer
- ❌ No mostrar text_long ni is_sensitive bajo ningún concepto.
- ❌ No exponer SUPABASE_SERVICE_ROLE_KEY al cliente.
- ❌ No tocar el formulario ni el dashboard.
- ❌ No agregar dependencias npm.
- ❌ No paginar todavía — limit 500 es suficiente para el piloto.

View File

@@ -0,0 +1,133 @@
# ETAPA 3V — Vista de control: listado de respuestas
## 1. Objetivo
Crear una página de control operativo en `/admin/responses` que muestre todas las
respuestas recibidas, ordenadas por fecha descendente (más recientes primero).
Es una herramienta interna de diagnóstico — sin auth, sin PII, sin datos sensibles.
---
## 2. Contexto previo
- La migración está en Supabase self-hosted.
- Las tablas relevantes son `responses`, `answers`, `questions`, `surveys`.
- `answers.value` es JSONB — contiene el valor de la respuesta.
- Campos sensibles protegidos por RLS: `answers` donde `question.is_sensitive=true`
y los tipos `text_long`. No mostrar esos campos aunque lleguen.
- La página usa el cliente server-side (`SUPABASE_SERVICE_ROLE_KEY`) porque bypasa RLS
y permite ver todos los datos. No usar el cliente anon.
---
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Ruta: `/admin/responses` — Server Component de Next.js (no client component).
- Sin auth (es herramienta interna de control).
- Sin paginación por ahora — mostrar las últimas 200 respuestas máximo.
- Ordenado por `submitted_at DESC`.
- HTML semántico limpio, paleta RE existente (`#43BBC8`, `#494F4F`, `#F8AD13`).
- No usar componentes del dashboard existente — página standalone.
---
## 4. Qué mostrar
### 4.1 Encabezado de página
- Título: "Control de respuestas"
- Subtítulo: conteo total de respuestas en la base + fecha/hora de última actualización.
- Botón "Actualizar" que recarga la página (simple `<a href="/admin/responses">`).
### 4.2 Tabla de respuestas (una fila por `response`)
Columnas:
| Columna | Fuente | Notas |
|---|---|---|
| # | índice visual (1, 2, 3...) | más reciente = 1 |
| Fecha | `responses.submitted_at` | formato: DD/MM/YYYY HH:mm |
| Encuesta | `surveys.title` | JOIN con surveys |
| Departamento | `responses.qi_zone` | puede ser null → mostrar "—" |
| Sector | `responses.qi_sector` | puede ser null → mostrar "—" |
| Rango etario | `responses.qi_age_bucket` | puede ser null → mostrar "—" |
| Respuestas | count de `answers` para esa response | número simple |
| IP hash | `responses.ip_hash` | primeros 8 chars + "..." — solo para dedup |
### 4.3 Detalle expandible (opcional, Nivel 2 — reportar si se implementa)
Si el tiempo lo permite: al hacer click en una fila, mostrar un panel con las
respuestas individuales (código de pregunta + valor), excluyendo:
- Preguntas con `is_sensitive = true`
- Preguntas de tipo `text_long`
Si no se implementa el detalle, la tabla sola es suficiente para cerrar la etapa.
---
## 5. Implementación
### 5.1 Query recomendada
```typescript
// En un Server Component — usar el cliente con service_role
const { data: responses } = await supabase
.from('responses')
.select(`
id,
submitted_at,
qi_zone,
qi_sector,
qi_age_bucket,
ip_hash,
surveys ( title ),
answers ( count )
`)
.order('submitted_at', { ascending: false })
.limit(200)
```
### 5.2 Cliente server-side
Usar `createClient` con `SUPABASE_SERVICE_ROLE_KEY` (server-side only, no exponer
al cliente). Si existe `lib/supabase-server.ts` o similar, usarlo. Si no, crear
el cliente inline en el Server Component con la service role key.
### 5.3 Estilos
- Paleta RE: teal `#43BBC8`, dark `#494F4F`, orange `#F8AD13`, light `#E7E6E5`.
- Tabla con filas alternadas (zebra), hover sutil.
- Header de tabla sticky si la lista es larga.
- Responsive — en mobile, las columnas menos importantes se ocultan.
- Fuente: la que ya usa el proyecto (IBM Plex Sans o la que esté configurada).
- NO usar Tailwind si el proyecto no lo usa — verificar primero con
`grep "tailwind" package.json`.
---
## 6. Entregables (DoD)
- [ ] Ruta `/admin/responses` accesible y renderiza.
- [ ] Tabla muestra respuestas ordenadas por fecha descendente.
- [ ] Conteo total en el encabezado.
- [ ] Columnas: #, Fecha, Encuesta, Departamento, Sector, Rango etario, Respuestas, IP hash (truncado).
- [ ] Campos nulos muestran "—".
- [ ] IP hash truncada (primeros 8 chars).
- [ ] Build sin errores (`next build`).
- [ ] `git diff --name-only HEAD` solo en `app/admin/` y posiblemente `lib/`.
## 7. Qué reportar
```
Verificado:
- Ruta /admin/responses accesible ✓
- Columnas presentes: [lista]
- Conteo total: [número]
- Build exitoso ✓
- Diff en scope ✓
Asumido:
- [decisiones cosméticas o de implementación]
Decisiones proactivas (Nivel 2):
- [si implementó el detalle expandible o no]
```
## 8. Qué NO hacer
- ❌ No agregar auth — es herramienta interna, la URL es suficiente como "seguridad por oscuridad" por ahora.
- ❌ No mostrar campos text_long ni is_sensitive=true.
- ❌ No exponer SUPABASE_SERVICE_ROLE_KEY al cliente (solo server-side).
- ❌ No tocar el formulario ni el dashboard existente.
- ❌ No agregar dependencias npm.
- ❌ No resolver ítems del backlog que no sean los listados acá.

View File

@@ -0,0 +1,124 @@
# ETAPA 3ALL — Flag p_all en el dashboard (vista agregada de todos los surveys)
## 1. Objetivo
Permitir que el dashboard muestre datos de TODOS los surveys mezclados, usando un
UUID especial como flag. Cambio mínimo — solo toca `_dash_responses` y el frontend.
Las 11 RPCs no se modifican.
---
## 2. Contexto previo
- `_dash_responses(p_survey_id uuid, ...)` es la función base que usan las 11 RPCs.
- Todas las RPCs pasan `p_survey_id` a `_dash_responses`.
- El dashboard lee `?survey_id=UUID` de la URL y lo pasa a `/api/dashboard/[widget]`.
- `route.ts` requiere `survey_id` en la query string (400 si falta).
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- UUID especial para "todos": `00000000-0000-0000-0000-000000000000` (UUID cero).
No puede existir como survey real. Es el flag `p_all`.
- Implementación en `_dash_responses`: una sola línea cambia.
- Las 11 RPCs NO se tocan — el cambio es transparente para ellas.
- El cambio va en una NUEVA migración, no modifica las existentes.
- El k-anonimato sigue aplicando — el pool más grande solo beneficia la protección.
---
## 4. Cambios requeridos
### 4.1 Nueva migración SQL
Crear `supabase/migrations/20260611000001_dash_responses_all.sql`:
```sql
-- Migración: agregar flag p_all a _dash_responses
-- UUID especial '00000000-0000-0000-0000-000000000000' = todos los surveys
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 = 'A6'
AND a.value #>> '{}' = ANY(p_genero)
))
AND (p_etario IS NULL OR cardinality(p_etario) = 0
OR r.qi_age_bucket = ANY(p_etario))
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;
```
### 4.2 route.ts — permitir el UUID especial
En `app/api/dashboard/[widget]/route.ts`, donde se valida `survey_id`:
El UUID cero debe pasar la validación existente — verificar si hay algún check
adicional que lo rechazaría (ej. validación de formato UUID o lookup en DB).
Si `route.ts` solo verifica presencia del parámetro (400 si falta), no hay
nada que cambiar. Reportar cuál es el caso.
### 4.3 dashboard/page.tsx — agregar link/selector "Todos"
Agregar en el header del dashboard un selector o links de navegación mínimo:
```
[ Todos ] [ InQuality ] [ Fundación Solidaridad ] [ Demo ]
```
Implementación sugerida: links `<a>` simples que navegan a:
- Todos: `/dashboard?survey_id=00000000-0000-0000-0000-000000000000`
- InQuality: `/dashboard?survey_id=30000000-0000-0000-0000-000000000002`
- Fundación Solidaridad: `/dashboard?survey_id=30000000-0000-0000-0000-000000000003`
- Demo: `/dashboard?survey_id=30000000-0000-0000-0000-000000000001`
El survey activo se destaca visualmente (comparar `surveyId` del searchParam
con el UUID de cada link). Estilo `.admin-*` no aplica acá — usar clases
`.db-*` o tokens CSS existentes del dashboard.
El label de cada opción puede ser hardcodeado por ahora — no hacer fetch
a `surveys` para obtener los nombres (Nivel 3, fuera de scope).
---
## 5. Entregables (DoD)
- [ ] Nueva migración `20260611000001_dash_responses_all.sql` creada.
- [ ] `_dash_responses` actualizada con el flag de UUID cero.
- [ ] `route.ts` permite el UUID especial (verificar y reportar).
- [ ] Dashboard muestra selector de empresa con "Todos" + las 3 empresas.
- [ ] Build exitoso.
- [ ] `git diff --name-only HEAD` en scope.
## 6. Qué reportar
```
Verificado:
- Migración creada ✓
- route.ts: [necesitó cambio / no necesitó cambio — explicar]
- Selector visible en dashboard ✓
- Build exitoso ✓
- Diff en scope ✓
URL de validación:
- Todos: /dashboard?survey_id=00000000-0000-0000-0000-000000000000
- InQuality: /dashboard?survey_id=30000000-0000-0000-0000-000000000002
```
## 7. Qué NO hacer
- ❌ No modificar las migraciones existentes — solo agregar una nueva.
- ❌ No tocar las 11 RPCs directamente.
- ❌ No hacer fetch a `surveys` para obtener nombres de empresa.
- ❌ No agregar dependencias npm.
- ❌ No tocar el formulario ni `/admin/responses`.

143
sprints/sprint-4/BRIEF.md Normal file
View File

@@ -0,0 +1,143 @@
# BRIEF — Sprint 4: Formulario Wizard v3 (esquema declarativo)
> Versión objetivo: **3.0.0** · Rama: `main` · Preservación: `v2.1.0-stable`
---
## Objetivo en una frase
Construir el formulario de cuidadores como un wizard pregunta-por-pantalla cuyo
flujo, instancias y condiciones se definen en un **esquema declarativo** (datos),
renderizado por un frontend sin lógica de negocio — funcionando para los dos
clientes inminentes, y en la dirección arquitectónica del motor de formularios.
---
## Decisión arquitectónica rectora (ADR pendiente de registrar)
**El motor de formularios es el producto; la encuesta de cuidadores es su primer
caso de uso.** Por lo tanto, la lógica del flujo del formulario vive en una capa
de **definición declarativa** (datos), no en el presentador ni dispersa en columnas
sueltas de la base.
### Estrategia en dos fases (deuda intencional, registrada — no oculta)
**Fase 1 (este sprint, para el deadline de 10-20 días):**
Se construye el formulario de cuidadores con su flujo definido como esquema
declarativo persistido. El frontend es un renderer de ese esquema — sin reglas
hardcodeadas. La resolución de "qué pantalla sigue" se calcula desde esquema +
estado. NO se construye todavía el intérprete genérico universal.
**Fase 2 (sprints posteriores, post-deadline):**
Se extrae el intérprete a un motor genérico de formularios. Como el formulario de
cuidadores ya está definido como esquema declarativo, se vuelve el primer caso de
prueba del motor sin reescribir el contrato. Registrado en `BACKLOG.md`.
**Por qué esta estrategia:** entrega valor en plazo sin generar retrabajo. La
lógica en datos (no en presentación) es lo que habilita guardado parcial,
reanudación, validación consistente y progreso preciso — la mejor UX — y lo que
hace que la Fase 2 sea generalización, no reescritura.
---
## Alcance (qué entra)
- Esquema declarativo del formulario de cuidadores (flujo, instancias, condiciones como datos)
- Persistencia del esquema (en seed/DB, versionado)
- `instance_id` en `answers` (Etapa 4A — ya aplicada)
- Motor de resolución de flujo para este formulario (interpreta el esquema)
- Frontend wizard como renderer del esquema (sin reglas de negocio)
- Mini-wizard de vínculos familiares (instancias declaradas en el esquema)
- Autocomplete de barrios desde dataset INE/DGEEC
- Subtítulos del "porqué" por sección
- Submit con `instance_id` por answer
- Barra de progreso dinámica (calculada desde el esquema + estado)
- RPCs para preguntas de vínculo con instancias
- Seed v3 limpio (sin migración de datos v2)
## Fuera de alcance (explícito)
- ❌ Motor genérico universal de formularios (Fase 2 — BACKLOG)
- ❌ Backend de construcción/edición de formularios (sprint posterior)
- ❌ Migración de datos v2.1 → v3 (se decide cuando v3 esté estable)
- ❌ Tocar la rama `v2.1.0-stable`
- ❌ RE Index, informe descargable, auth de /admin/responses, backup S3
## Decisiones ya tomadas (no renegociar)
1. v3 en `main`; v2 preservado en `v2.1.0-stable` + tag `v2.1.0`.
2. Lógica de flujo en esquema declarativo (datos), NO en el frontend.
3. Frontend wizard = renderer sin reglas de negocio.
4. Estrategia dos fases: formulario-como-esquema ahora, motor genérico después.
5. v3 arranca con seed limpio, sin datos migrados.
6. `instance_id` ya implementado (4A). Instancias declaradas en el esquema.
7. Wizard pregunta-por-pantalla para todo el formulario tras consentimiento.
8. Mini-wizard de familiares: una pregunta por pantalla, badge "Familiar X de N".
9. Numeración desde Sección 1.
10. Barrio: autocomplete con datos INE, texto libre para el resto.
11. Texto de consentimiento: el del Excel del instrumento v3.
12. SemVer + CHANGELOG + tags en cada versión estable.
13. Migraciones se aplican vía CLI del VPS (`docker exec -i supabase-db psql`).
14. **Motor de flujo (4C): corre en el cliente** como módulo puro y aislado (entrada→salida
determinista, testeable con Node). Interpreta el esquema declarativo; no embebe lógica
en el componente React.
15. **Validación de integridad server-side en el submit (4G): REQUISITO NO NEGOCIABLE.**
El servidor valida contra el esquema que: las preguntas existen, los valores son opciones
válidas, las instancias corresponden a un generador real, y no se inyectan respuestas a
preguntas que el flujo nunca debió mostrar. La validación de cliente es UX, NO seguridad.
Para datos sensibles de salud, confiar solo en validación de cliente sería una falla de
seguridad. Esto NO se difiere a Fase 2.
## Criterios de éxito (checklist)
- [ ] El flujo del formulario está definido como esquema declarativo persistido
- [ ] El frontend no contiene reglas de negocio del flujo (verificable por revisión)
- [ ] El wizard funciona end-to-end, una pregunta por pantalla
- [ ] El mini-wizard de familiares genera instancias según el esquema
- [ ] Los datos se guardan con `instance_id` correcto por familiar
- [ ] El dashboard sigue funcionando (preguntas no-instancia con `instance_id='default'`)
- [ ] El autocomplete de barrios funciona para ciudades principales
- [ ] La validación visual coincide con el mockup aprobado
- [ ] `v2.1.0-stable` permanece intacta
- [ ] La deuda de Fase 2 está registrada en BACKLOG
---
## Etapas del Sprint 4
| Etapa | Nombre | Entregable | Estado |
|---|---|---|---|
| **4.0** | Preservación v2 | Tag, rama stable, dump, CHANGELOG | ✅ Completada |
| **4A** | Persistencia instance_id | Migración + DATA_MODEL | ✅ Completada |
| **4B** | Esquema declarativo + seed v3 | Esquema del formulario como datos + geo JSON | — |
| **4C** | Motor de resolución de flujo | Intérprete del esquema (este formulario) | 4B |
| **4D** | Frontend renderer | Wizard que renderiza el esquema, una preg/pantalla | 4C |
| **4E** | Instancias de vínculo | Mini-wizard de familiares desde el esquema | 4D |
| **4F** | Autocomplete barrios | Campo barrio con dataset INE | 4D |
| **4G** | Submit con instance_id | Guardado multi-instancia | 4E |
| **4H** | RPCs de vínculo | Agregados por instancia en dashboard | 4G |
| **4I** | Validación e2e + tag v3.0.0 | QA visual + release + ADR + BACKLOG Fase 2 | todas |
## Orden de ejecución
4B → 4C → 4D → (4E ∥ 4F) → 4G → 4H → 4I
---
## Definición del esquema declarativo (guía para 4B)
El esquema describe el formulario como datos. Estructura conceptual:
```
formulario:
version, secciones[]
seccion:
numero, nombre, descripcion (el "porqué")
preguntas[]
pregunta:
codigo, tipo, enunciado, hint, opciones[], required, sensitive
flujo:
condiciones_visibilidad[] (cuándo se muestra — evaluado por el motor)
genera_instancias? (si esta pregunta crea instancias, ej. 4.1)
pertenece_a_instancia_de? (de qué generador depende, ej. 4.2/4.3 → 4.1)
validaciones[]
```
La forma técnica exacta de persistir esto (columnas en `questions` +
`conditional_rules` JSONB extendido, o tabla de definición de flujo separada)
la decide la Etapa 4B según el esquema actual. Principio: declarativo, versionado,
interpretable por el motor sin conocer el caso de uso.

View File

@@ -0,0 +1,104 @@
# ETAPA 4A — Modelo de datos: instance_id en answers
## 1. Objetivo
Agregar la columna `instance_id` a la tabla `answers` y cambiar la constraint
única para soportar múltiples respuestas a la misma pregunta (una por instancia
de vínculo familiar). Es el cambio de base que habilita todo el wizard v3.
## 2. Contexto previo
- v3 se desarrolla en `main`. v2 está preservado en `v2.1.0-stable`.
- La tabla `answers` actual tiene constraint `UNIQUE (response_id, question_id)`.
- El wizard v3 genera múltiples respuestas para las preguntas 4.2 y 4.3 (una por
cada familiar declarado en 4.1). Sin `instance_id`, esas respuestas colisionan.
- Las preguntas no-vinculadas (la gran mayoría) usan `instance_id = 'default'`.
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Nombre de la columna: `instance_id`, tipo `text`, `DEFAULT 'default'`, NOT NULL.
- Nueva constraint: `UNIQUE (response_id, question_id, instance_id)`.
- Para preguntas normales: `instance_id = 'default'`.
- Para instancias de vínculo: `instance_id = 'familiar_1'`, `'familiar_2'`, etc.
- Esto es una migración nueva, NO modifica migraciones existentes.
- NO se migran datos v2 — el seed v3 arranca limpio (etapa posterior).
## 4. Tareas
### 4.1 Nueva migración
Crear `supabase/migrations/20260620000001_add_instance_id.sql`:
```sql
-- Sprint 4 (v3.0.0) — instance_id para respuestas multi-instancia
-- Habilita el mini-wizard de vínculos familiares (una respuesta por familiar)
-- 1. Agregar la columna con default para filas existentes
ALTER TABLE public.answers
ADD COLUMN IF NOT EXISTS instance_id text NOT NULL DEFAULT 'default';
-- 2. Eliminar la constraint única vieja (nombre real verificado en DB)
ALTER TABLE public.answers
DROP CONSTRAINT IF EXISTS answers_response_id_question_id_key;
-- 3. Crear la nueva constraint que incluye instance_id
ALTER TABLE public.answers
ADD CONSTRAINT answers_response_question_instance_key
UNIQUE (response_id, question_id, instance_id);
-- 4. Índice para consultas por instancia (análisis de vínculos)
CREATE INDEX IF NOT EXISTS idx_answers_instance
ON public.answers (question_id, instance_id)
WHERE instance_id != 'default';
```
### 4.2 Verificar el nombre real de la constraint actual
ANTES de escribir el DROP, verificar el nombre exacto de la constraint única
actual de `answers`. El nombre asumido es `answers_response_id_question_id_key`
(convención Postgres), pero confirmar con:
```sql
SELECT conname FROM pg_constraint
WHERE conrelid = 'public.answers'::regclass AND contype = 'u';
```
Si el nombre difiere, ajustar el `DROP CONSTRAINT` en la migración.
NO ejecutar la migración en producción — solo crear el archivo.
### 4.3 Actualizar DATA_MODEL.md
Documentar el cambio en `DATA_MODEL.md`: la tabla `answers` ahora tiene
`instance_id` y la nueva constraint. Explicar el patrón 'default' vs 'familiar_N'.
## 5. Entregables (DoD)
- [ ] Migración `20260620000001_add_instance_id.sql` creada.
- [ ] Nombre real de la constraint actual verificado y usado en el DROP.
- [ ] `DATA_MODEL.md` actualizado con el nuevo esquema de `answers`.
- [ ] La migración es idempotente (`IF EXISTS` / `IF NOT EXISTS`).
- [ ] NO ejecutada en producción.
## 6. Qué reportar
```
Verificado:
- Migración creada ✓
- Nombre real de constraint actual: [nombre]
- DATA_MODEL.md actualizado ✓
- Idempotencia confirmada ✓
Asumido:
- [supuestos sobre el esquema]
NO ejecutado en producción (pendiente aprobación del director).
```
## 7. Qué NO hacer
- ❌ No ejecutar la migración en la DB de producción.
- ❌ No modificar migraciones existentes.
- ❌ No tocar el seed todavía (es la etapa 4B).
- ❌ No migrar datos de v2.
- ❌ No tocar la rama v2.1.0-stable.
## 8. Versionado
Commit sugerido al cerrar:
```
feat(data): agregar instance_id a answers para multi-instancia [4A]
Habilita respuestas múltiples por pregunta (mini-wizard de vínculos).
Nueva constraint UNIQUE(response_id, question_id, instance_id).
Parte del Sprint 4 hacia v3.0.0.
```

View File

@@ -0,0 +1,111 @@
# ETAPA 4B — Esquema declarativo del formulario v3 + seed
## 1. Objetivo
Definir el formulario de cuidadores v3 como un esquema declarativo persistido:
extender el modelo de `questions` para soportar instanciación y nuevos operadores
de flujo, y cargar el instrumento completo (10 secciones) en el seed. La lógica del
flujo vive en estos datos, no en el frontend (ver ADR-005).
## 2. Contexto previo
- ADR-005 define la arquitectura: esquema declarativo, frontend renderer sin reglas.
- `instance_id` ya existe en `answers` (Etapa 4A).
- El esquema actual de `questions` ya tiene `conditional_rules` JSONB con operadores
`eq` / `eq_any` y acción `show`, auto-target implícito.
- El contenido exacto del instrumento v3 está validado en el mockup aprobado
(`wizard_formulario_mapeo_v3.html`) y en el Excel `Formulario_Mapeo_Corregido.xlsx`.
- El dataset de barrios está en `geo_paraguay_full.json` (va a `public/data/`).
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Se EXTIENDE el modelo existente, no se crea tabla de definición separada (ADR-005).
- Nuevos campos declarativos en `questions`:
- `instance_generator boolean DEFAULT false` — marca la pregunta que genera instancias (4.1)
- `instance_of text DEFAULT NULL` — código del generador del que depende (4.2/4.3 → '4.1')
- Nuevos operadores en `conditional_rules`: agregar acción `hide` y operador para
expresar el salto "Ninguna de las anteriores → salta a sección final".
- Secciones con nombre y descripción (el "porqué"): representación a definir por Claude Code
según lo más limpio (tabla `sections` o registros con metadata) — proponer antes de ejecutar.
- v3 arranca con seed LIMPIO, sin datos migrados.
- El contenido de preguntas es EXACTAMENTE el del mockup validado.
## 4. Tareas
### 4.1 Migración de esquema declarativo
Crear `supabase/migrations/20260620000002_form_schema_v3.sql`:
- Agregar `instance_generator boolean NOT NULL DEFAULT false` a `questions`.
- Agregar `instance_of text` a `questions` (nullable).
- Documentar (comentario SQL) el vocabulario extendido de `conditional_rules`:
operadores `eq`, `eq_any`, `not_in`; acciones `show`, `hide`.
- Idempotente (`IF NOT EXISTS`).
### 4.2 Representación de secciones
Proponer e implementar cómo persistir las 10 secciones con su nombre y descripción
("Texto de ayuda" del Excel). Reportar la propuesta antes de ejecutar si implica
cambio de esquema. Las descripciones de sección están en el BRIEF y el mockup.
### 4.3 Seed v3 del instrumento
Reescribir el seed del survey (o crear seed v3 separado) con las 10 secciones y todas
las preguntas EXACTAMENTE como el mockup validado:
- Sección 1: Consentimiento (2 checkboxes: requerido + opcional)
- Sección 2: Datos generales (2.1-2.7)
- Sección 3: Prevalencia discapacidad propia (3.1-3.5; 3.1='No' oculta 3.2-3.5)
- Sección 4: Vinculación familiar (4.1 generador de instancias; 4.2 y 4.3 instance_of='4.1')
- Sección 5: Responsabilidad (5.1-5.4; ocultas si "solo niño sin discapacidad")
- Sección 6: Intensidad (6.1-6.4; idem)
- Sección 7: Impacto laboral (7.1-7.6; idem)
- Sección 8: Impacto económico (8.1-8.4)
- Sección 9: Conciencia organizacional (9.1-9.4)
- Sección 10: Comentarios (10.1)
- Flujo "Ninguna de las anteriores" en 4.1 → salta a Sección 10.
- Todas las opciones, hints ("Podés marcar más de una opción"), y flags is_sensitive
exactamente como el mockup.
### 4.4 Dataset geográfico
Copiar `geo_paraguay_full.json` a `public/data/geo_paraguay.json` del repo.
### 4.5 Actualizar SEED_SPEC.md
Documentar el instrumento v3 completo como contrato (Principio 4 / AP.11).
## 5. Entregables (DoD)
- [ ] Migración de esquema declarativo creada (campos de instanciación).
- [ ] Propuesta de representación de secciones reportada (y aplicada si se aprueba).
- [ ] Seed v3 con las 10 secciones, contenido exacto del mockup.
- [ ] `instance_generator` y `instance_of` correctamente seteados en 4.1/4.2/4.3.
- [ ] Flujo declarativo: 3.1='No' oculta 3.2-3.5; "Ninguno" en 4.1 salta a S10;
"solo niño" oculta S5-S7.
- [ ] `geo_paraguay.json` en `public/data/`.
- [ ] `SEED_SPEC.md` actualizado.
- [ ] NO ejecutado en producción (solo archivos).
## 6. Qué reportar
```
Verificado:
- Migración de esquema creada ✓
- Representación de secciones: [propuesta]
- Seed v3: N preguntas, 10 secciones ✓
- instance_generator/instance_of correctos ✓
- Reglas de flujo declarativas: [listar las 3 reglas principales]
- geo JSON en public/data ✓
- SEED_SPEC.md actualizado ✓
Asumido:
- [supuestos sobre representación de secciones y operadores]
NO ejecutado en producción.
```
## 7. Qué NO hacer
- ❌ No ejecutar en producción (lo aplica el director vía CLI del VPS).
- ❌ No poner lógica de flujo en el frontend (es la etapa 4D y será renderer).
- ❌ No migrar datos v2.
- ❌ No tocar v2.1.0-stable.
- ❌ No construir el motor genérico (Fase 2).
## 8. Versionado
Commit sugerido:
```
feat(schema): esquema declarativo + seed v3 del formulario [4B]
Extiende questions con instance_generator/instance_of.
Instrumento v3 completo (10 secciones) como datos declarativos.
Flujo del formulario en el esquema, no en presentación (ADR-005).
```

View File

@@ -0,0 +1,124 @@
# ETAPA 4C — Motor de resolución de flujo (módulo puro, cliente)
## 1. Objetivo
Construir el motor que interpreta el esquema declarativo del formulario y resuelve
la secuencia de pantallas según el estado de respuestas. Es un módulo puro y aislado
en `lib/`, sin dependencias de React ni del DOM — entrada→salida determinista,
testeable con Node. El componente (Etapa 4D) lo consume; el motor no conoce al componente.
## 2. Contexto previo
- ADR-005: la lógica de flujo vive en el esquema (datos). El motor la interpreta.
- El esquema v3 está en la DB: questions con `type`, `conditional_rules`,
`instance_generator`, `instance_of`, `hint`, más filas `type='section'`.
- Decisión confirmada: el motor corre en el CLIENTE (interpretación de flujo, no validación
de seguridad — esa va server-side en 4G).
- Decisión confirmada: las secciones (`type='section'`) son PANTALLAS propias en el grafo.
- La lógica de v2 (`lib/conditional.ts`, `survey-form.tsx`) es INCOMPATIBLE y se reemplaza:
- v2 evalúa AND (`rules.every`); v3 necesita OR entre condiciones de ocultamiento.
- v2 solo conoce `eq`/`eq_any` y acción `show`; v3 necesita `contains`, `eq_sole`, `not_in`, acción `hide`.
- v2 infiere secciones por regex de prefijo; v3 las tiene como datos.
- El comportamiento esperado está validado en el mockup `wizard_formulario_mapeo_v3.html`
(función `buildVisible` y navegación) — usar como referencia de comportamiento, NO de arquitectura.
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- Motor puro en `lib/` (proponé el nombre: ej. `lib/form-engine.ts`), sin React/DOM.
- Vocabulario de operadores: `eq`, `eq_any`, `not_in` (escalares), `contains`, `eq_sole` (array).
- Acciones: `show`, `hide`.
- Combinación: múltiples reglas en el array de una pregunta se evalúan en OR.
(Una pregunta se oculta si CUALQUIER regla `hide` aplica; se muestra si CUALQUIER regla `show` aplica.
Definí la semántica precisa de show+hide combinados y documentala.)
- Secciones = pantallas propias en el grafo.
- Instancias: 4.1 (`instance_generator=true`) genera N instancias; las preguntas con
`instance_of='4.1'` se repiten una vez por instancia activa, en orden por familiar
(familiar1: 4.2→4.3, familiar2: 4.2→4.3, ...) como en el mockup.
- Estado de respuestas con clave compuesta `question_code:instance_id` (instancias) o
`question_code` con `instance_id='default'` (normales).
## 4. Tareas
### 4.1 Tipos del motor (`lib/types.ts` ampliado o nuevo)
Definir/ampliar:
- `ConditionalRule`: operadores y acciones completos del vocabulario v3.
- `FormSchema`: la representación del formulario que el motor consume (secciones, preguntas,
reglas, instanciación) — derivada de las filas de `questions`.
- `WizardState`: respuestas (con instancias), instancia activa, pantalla actual.
- `Screen`: una pantalla resoluble (sección-intro | pregunta | pregunta-de-instancia | fin).
### 4.2 Evaluador de reglas
Reescribir la evaluación (reemplaza `lib/conditional.ts`):
- Implementar los 5 operadores y 2 acciones.
- `contains` / `eq_sole` operan sobre respuestas array (multiple_choice).
- Semántica OR documentada y correcta para el caso `r_hide_ning_sn`.
- Función pura: `(rule, answers) => boolean` y `(question, answers) => 'show'|'hide'`.
### 4.3 Resolución del grafo de pantallas
Función pura que dado el esquema + estado devuelve la lista ordenada de pantallas visibles:
- Expande instancias (4.2/4.3 por cada familiar activo declarado en 4.1).
- Aplica visibilidad (oculta secciones/preguntas según reglas).
- Incluye las pantallas de sección.
- Equivale conceptualmente al `buildVisible()` del mockup, pero como módulo puro.
### 4.4 Navegación
Funciones puras `next(state, schema)` y `back(state, schema)` que devuelven el nuevo
estado (pantalla actual actualizada), resolviendo saltos. Identificar la pantalla actual
por ID estable, no por índice (lección del mockup: el índice se desincroniza cuando
cambian las instancias).
### 4.5 Carga del esquema
Actualizar la query (`app/page.tsx`) para traer las 3 columnas nuevas
(`hint`, `instance_generator`, `instance_of`). Transformar las filas de `questions`
en el `FormSchema` que consume el motor.
### 4.6 Tests del motor (Node, sin navegador)
Tests puros que verifiquen:
- Recorrido completo (todos los vínculos) genera las pantallas esperadas.
- "Solo niño sin discapacidad" (eq_sole) → recorrido liviano correcto (5.3, 8.4, 9.1, 9.4).
- "Ninguna de las anteriores" (contains) → salta a sección 10.
- 3.1='No' oculta 3.2-3.5.
- 3 familiares → 6 pantallas de instancia en orden por familiar.
- Navegación next/back coherente cuando cambian las instancias.
## 5. Entregables (DoD)
- [ ] Motor puro en `lib/` sin dependencias de React/DOM.
- [ ] Los 5 operadores y 2 acciones implementados y testeados.
- [ ] Resolución de grafo con instancias y secciones-pantalla.
- [ ] Navegación por ID estable, no por índice.
- [ ] Query actualizada con las 3 columnas nuevas.
- [ ] Tests Node que cubren los recorridos del punto 4.6, todos en verde.
- [ ] NO se toca el componente todavía (es 4D) — el motor se entrega standalone + tests.
## 6. Qué reportar
```
Verificado:
- Motor en [archivo], puro (sin React/DOM) ✓
- Operadores implementados: eq, eq_any, not_in, contains, eq_sole ✓
- Acciones: show, hide ✓ — semántica de combinación: [descripción]
- Tests: N/N en verde
- recorrido completo ✓
- solo niño (eq_sole) → 5.3/8.4/9.1/9.4 ✓
- ninguna (contains) → salta S10 ✓
- 3.1=No oculta 3.2-3.5 ✓
- 3 familiares → 6 pantallas en orden ✓
- next/back coherente ✓
Asumido:
- [supuestos de diseño del motor]
```
## 7. Qué NO hacer
- ❌ No poner lógica de flujo en un componente React (el motor es puro, lib/).
- ❌ No tocar el componente de formulario todavía (es 4D).
- ❌ No implementar validación de seguridad acá (es 4G, server-side).
- ❌ No usar índices numéricos para la pantalla actual — usar ID estable.
- ❌ No tocar v2.1.0-stable ni el seed.
## 8. Versionado
Commit sugerido:
```
feat(engine): motor de resolución de flujo declarativo [4C]
Módulo puro que interpreta el esquema v3 y resuelve pantallas.
5 operadores (eq, eq_any, not_in, contains, eq_sole), acciones show/hide.
Instancias de vínculo y secciones-pantalla. Navegación por ID estable.
Tests Node cubren los 3 recorridos. Reemplaza lib/conditional.ts de v2.
```

View File

@@ -0,0 +1,129 @@
# ETAPA 4D — Frontend renderer: wizard pregunta-por-pantalla
## 1. Objetivo
Reemplazar el formulario de una sola página (v2) por un wizard que consume el motor
de flujo (4C) y muestra una pregunta por pantalla, con layout de altura estable.
El componente es un RENDERER: dibuja la pantalla que el motor resuelve; no contiene
lógica de flujo (ADR-005).
## 2. Contexto previo
- Motor de flujo listo y testeado: `lib/form-engine.ts` (resolveScreens, next, back,
resolveVisibility) + `lib/form-engine.types.ts`. 16/16 tests.
- Widgets de input AISLADOS y reutilizables en `components/widgets/`:
`radio-group`, `checkbox-group`, `text-short-field`, `text-area-field`
(interfaz genérica value/onChange/error — se reutilizan tal cual).
- `question-renderer.tsx` despacha por `type` — reutilizable con 2 cambios (ver tareas).
- `survey-form.tsx` (una sola página, secciones por regex) se REESCRIBE entero.
- `app/page.tsx` query NO trae `hint`/`instance_generator`/`instance_of` (tarea 4.5 pospuesta de 4C).
- Gaps detectados: falta widget `yes_no`; hay hardcode A1/A2 en question-renderer a quitar.
- Comportamiento de referencia (NO de arquitectura): mockup `wizard_formulario_mapeo_v3.html`.
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
- El componente consume el motor; NO reimplementa visibilidad/navegación/instancias.
- Una pregunta por pantalla. Secciones = pantallas propias (intro con número + descripción).
- Reutilizar los widgets de `components/widgets/` existentes.
- Identificación de pantalla por ID estable (el motor ya lo provee).
- **Layout de altura estable (UX 1+2+3), parte de esta etapa:**
- (1) Viewport de altura estable: el enunciado se ancla en una posición consistente
(tercio superior), NO centrado vertical. La caja no crece/encoge con el contenido.
- (2) Header y footer FIJOS: progreso arriba, navegación (Anterior/Siguiente) abajo,
ambos fuera del área que cambia de tamaño. No "saltan" entre pantallas.
- (3) Área de respuesta con altura máxima y SCROLL INTERNO cuando hay muchas opciones,
con affordance visual (degradado sutil en borde inferior) que indica "hay más abajo".
- Usar los tokens de marca existentes (el verde RE, variables CSS/Tailwind del proyecto).
NO inventar colores nuevos.
## 4. Tareas
### 4.1 Ampliar la query y construir el FormSchema (app/page.tsx)
- Agregar `hint, instance_generator, instance_of` al `.select(...)`.
- Transformar las filas de `questions` (incluidas las `type='section'`) en el `FormSchema`
que consume el motor.
- Pasar el schema al nuevo componente wizard.
### 4.2 Nuevo componente wizard (reemplaza survey-form.tsx)
- Mantiene el `WizardState` del motor (respuestas con instancias, pantalla actual).
- En cada render: pide al motor la pantalla actual y la dibuja.
- Navegación: botones Anterior/Siguiente llaman `next`/`back` del motor.
- Maneja los tipos de pantalla: sección-intro, pregunta, pregunta-de-instancia, fin.
- Para pantallas de instancia: muestra el badge de contexto ("Familiar X de N").
### 4.3 Layout de altura estable (UX 1+2+3)
Implementar el layout descrito en las decisiones. Estructura sugerida:
```
[header fijo: logo + progreso] ← no scrollea
[viewport estable]
[sección-header (si aplica)]
[enunciado anclado: código + pregunta + hint] ← posición consistente
[área de respuesta: max-height + scroll interno + fade inferior]
[footer fijo: Anterior | indicador | Siguiente] ← no scrollea
```
- El enunciado aparece en el mismo lugar pantalla tras pantalla.
- El área de respuesta scrollea internamente; header/footer nunca se mueven.
### 4.4 question-renderer: ajustes
- Quitar el hardcode A1/A2 (no aplica a v3; 2.1/2.2 son single_choice genéricos).
- Agregar el caso `yes_no` (lo usan 1.1/1.2). Crear `widgets/yes-no-field.tsx` si no existe,
o resolver yes_no con el RadioGroup existente si es lo más simple — proponé cuál.
- Migrar las shapes de `lib/types.ts` (v2) a `lib/form-engine.types.ts` (v3) donde el
renderer reciba datos del motor.
### 4.5 Progreso
- La barra/indicador de progreso se calcula desde el motor (pantalla actual / total visible),
no desde "preguntas respondidas". Reutilizar `progress-bar.tsx` si encaja.
### 4.6 Consentimiento
- Reutilizar `consent-screen.tsx` como pantalla 1 (Sección 1), o integrarlo como la primera
pantalla del wizard — proponé cuál es más limpio dado que ahora todo es wizard.
## 5. Entregables (DoD)
- [ ] Wizard funcional una-pregunta-por-pantalla consumiendo el motor.
- [ ] Layout de altura estable: header/footer fijos, enunciado anclado, scroll interno + fade.
- [ ] Query ampliada con las 3 columnas; FormSchema construido y pasado al componente.
- [ ] yes_no renderiza; hardcode A1/A2 removido.
- [ ] Progreso calculado desde el motor.
- [ ] Widgets de v2 reutilizados (no reescritos).
- [ ] **Validación visual del director** contra el mockup + criterios de UX estable.
- [ ] Build sin errores; sin lógica de flujo en el componente (solo consume el motor).
## 6. Qué reportar
```
Verificado:
- Wizard consume el motor (resolveScreens/next/back) ✓ — sin lógica de flujo propia
- Layout estable: header/footer fijos ✓, enunciado anclado ✓, scroll interno + fade ✓
- Query ampliada (hint/instance_generator/instance_of) ✓
- yes_no resuelto vía [RadioGroup | widget nuevo] ✓
- Hardcode A1/A2 removido ✓
- Widgets reutilizados: [lista]
- Build: [resultado]
- Capturas/descripción visual de 3 pantallas: pregunta corta, pregunta con muchas opciones
(scroll interno), sección-intro
Asumido / Decisiones de diseño:
- [consentimiento como pantalla / yes_no / etc.]
Hallazgos no esperados:
- [lo que aparezca]
```
Reporte con datos concretos, no "se ve bien" (Patrón C.4). Describí el comportamiento del
layout estable: ¿el enunciado queda en el mismo lugar entre una pregunta de 3 opciones y
una de 15? ¿El footer se mantiene fijo?
## 7. Qué NO hacer
- ❌ No poner lógica de flujo/visibilidad/instancias en el componente (vive en el motor).
- ❌ No reescribir los widgets de input (reutilizarlos).
- ❌ No implementar el submit todavía (es 4G) ni la validación server-side.
- ❌ No implementar el autocomplete de barrios (es 4F) — barrio queda como text_short por ahora.
- ❌ No inventar colores fuera de los tokens de marca.
- ❌ No tocar v2.1.0-stable, el motor (4C), ni el seed.
## 8. Versionado
Commit sugerido:
```
feat(wizard): renderer pregunta-por-pantalla con layout estable [4D]
Reemplaza el formulario de una página por wizard que consume el motor (4C).
Layout de altura estable: header/footer fijos, enunciado anclado, scroll
interno con affordance. Reutiliza widgets de input. Agrega yes_no, quita
hardcode A1/A2. Query ampliada con columnas declarativas de v3.
```

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,95 @@
# PASO 0 — Preservación de v2.1.0 antes de arrancar Sprint 4
> Ejecutar ANTES de tocar nada de v3. Estos pasos preservan el estado actual
> estable como punto de retorno seguro.
---
## Objetivo
1. Crear tag `v2.1.0` sobre el commit actual de `main`.
2. Crear rama `v2.1.0-stable` que preserva el código de v2.
3. Generar un dump de la base de datos de producción y versionarlo en esa rama.
4. Crear `CHANGELOG.md` en `main`.
5. Dejar `main` listo para desarrollo de v3.
---
## Secuencia de comandos
### 1. Asegurarse de estar en main actualizado
```bash
git checkout main
git pull origin main
git status # debe estar limpio
```
### 2. Crear el tag de la versión estable actual
```bash
git tag -a v2.1.0 -m "Versión 2.1.0 — formulario lineal, dashboard multitenante, instrumento v2.1"
git push origin v2.1.0
```
### 3. Crear la rama de preservación
```bash
git checkout -b v2.1.0-stable
git push -u origin v2.1.0-stable
```
### 4. Generar el dump de la base de datos (en el VPS)
```bash
# En el VPS inq-mart-2:
docker exec supabase-db pg_dump -U postgres --clean --if-exists postgres \
| gzip > ~/dump_v2.1.0_$(date +%Y%m%d).sql.gz
# Verificar tamaño
ls -lh ~/dump_v2.1.0_*.sql.gz
```
### 5. Traer el dump a la máquina local y agregarlo a la rama
```bash
# En tu Mac, desde el repo, estando en la rama v2.1.0-stable:
mkdir -p db-snapshots
scp administrator@172.20.5.11:~/dump_v2.1.0_*.sql.gz db-snapshots/
git add db-snapshots/
git commit -m "chore(v2.1.0): snapshot de base de datos de producción
Dump completo de la DB de producción al momento del corte v2.1.0.
Incluye: 3 organizaciones, 3 surveys, 132 questions, 15 responses, ~500 answers.
Preservado antes de iniciar el desarrollo de v3 (wizard)."
git push origin v2.1.0-stable
```
### 6. Volver a main y crear el CHANGELOG
```bash
git checkout main
# Crear CHANGELOG.md (contenido en el archivo adjunto)
git add CHANGELOG.md
git commit -m "docs: agregar CHANGELOG con versionado semántico
Establece la convención SemVer para el proyecto.
v2.1.0 documentada como versión estable actual.
v3.0.0 (wizard) marcada como en desarrollo."
git push origin main
```
---
## Verificación final
```bash
git tag -l # debe mostrar v2.1.0
git branch -a # debe mostrar main y v2.1.0-stable
git log --oneline -3 # en main, último commit es el CHANGELOG
```
A partir de acá, todo el trabajo de v3 va en `main`. La rama `v2.1.0-stable`
queda congelada como referencia y punto de retorno.
---
## Nota sobre el dump
El dump en `db-snapshots/` permite recrear la DB de v2.1.0 si fuera necesario
volver atrás. Para restaurarlo:
```bash
gunzip -c dump_v2.1.0_YYYYMMDD.sql.gz | docker exec -i supabase-db psql -U postgres -d postgres
```

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,33 @@
-- Migración: agregar flag p_all a _dash_responses
-- UUID especial '00000000-0000-0000-0000-000000000000' = todos los surveys
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 = 'A6'
AND a.value #>> '{}' = ANY(p_genero)
))
AND (p_etario IS NULL OR cardinality(p_etario) = 0
OR r.qi_age_bucket = ANY(p_etario))
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = 'A7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;

View File

@@ -0,0 +1,22 @@
-- Sprint 4 (v3.0.0) — instance_id para respuestas multi-instancia
-- Habilita el mini-wizard de vínculos familiares (una respuesta por familiar)
-- Nombre original de constraint verificado: answers_response_id_question_id_key
-- (inline UNIQUE en CREATE TABLE, migración 20260531000001_create_tables.sql, sin renombrado posterior)
-- 1. Agregar la columna con default para filas existentes
ALTER TABLE public.answers
ADD COLUMN IF NOT EXISTS instance_id text NOT NULL DEFAULT 'default';
-- 2. Eliminar la constraint única vieja
ALTER TABLE public.answers
DROP CONSTRAINT IF EXISTS answers_response_id_question_id_key;
-- 3. Crear la nueva constraint que incluye instance_id
ALTER TABLE public.answers
ADD CONSTRAINT answers_response_question_instance_key
UNIQUE (response_id, question_id, instance_id);
-- 4. Índice para consultas por instancia (análisis de vínculos)
CREATE INDEX IF NOT EXISTS idx_answers_instance
ON public.answers (question_id, instance_id)
WHERE instance_id != 'default';

View File

@@ -0,0 +1,47 @@
-- Sprint 4 (v3.0.0) — Esquema declarativo del formulario wizard
-- Extiende questions con tres campos para soporte de instanciación y secciones.
-- Ver ADR-005: la lógica del flujo vive en datos, el frontend es un renderer.
-- 1. instance_generator: marca la pregunta que genera instancias del mini-wizard.
-- true en pregunta 4.1 (vínculos familiares); false en todas las demás.
ALTER TABLE public.questions
ADD COLUMN IF NOT EXISTS instance_generator boolean NOT NULL DEFAULT false;
-- 2. instance_of: código de la pregunta generadora de la que depende esta pregunta.
-- '4.1' en preguntas 4.2 y 4.3 (se repiten una vez por familiar declarado en 4.1).
-- NULL en todas las preguntas que no forman parte del mini-wizard.
ALTER TABLE public.questions
ADD COLUMN IF NOT EXISTS instance_of text;
-- 3. hint: texto auxiliar que el renderer muestra bajo el enunciado.
-- Para type='section': descripción del "porqué" de la sección (ADR-005 §4.2, Opción C).
-- Para preguntas regulares: hint de ayuda ("Podés marcar más de una opción", etc.).
-- NULL cuando no aplica.
ALTER TABLE public.questions
ADD COLUMN IF NOT EXISTS hint text;
-- ── Vocabulario extendido de conditional_rules (referencia para el motor de flujo) ──
--
-- Formato de cada regla:
-- { "if_question": CODE | CODE[], "op": OPERATOR, "value": VALUE, "action": ACTION }
--
-- Operadores:
-- "eq" — igualdad simple: la respuesta a if_question == value
-- "eq_any" — OR lógico: AL MENOS UNO de los if_question[] == value
-- "not_in" — la respuesta a if_question NO está en value (array de strings)
-- "contains" — la respuesta (multiple_choice) CONTIENE value como uno de los
-- elementos seleccionados; útil para detectar "Ninguna de las anteriores"
-- "eq_sole" — la respuesta (multiple_choice) tiene VALUE como ÚNICO elemento
-- seleccionado; usado para el recorrido "solo niño sin discapacidad"
--
-- Acciones:
-- "show" — mostrar esta pregunta/sección cuando la condición se cumple
-- "hide" — ocultar esta pregunta/sección cuando la condición se cumple
--
-- Semántica de auto-target: la regla afecta a la pregunta/sección que la porta.
-- Sin campo "target": una pregunta declara su propia condición de visibilidad.
--
-- Ejemplo — salto "Ninguna de las anteriores" en 4.1 oculta secciones S5-S7:
-- { "if_question": "4.1", "op": "not_in",
-- "value": ["Discapacidad propia", "Familiar con discapacidad", "Adulto mayor"],
-- "action": "hide" }

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;
$$;

File diff suppressed because it is too large Load Diff

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" }],