Compare commits
69 Commits
2f39ce555c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf09346f29 | ||
|
|
9461ee1e2e | ||
|
|
3bf92f015c | ||
|
|
67042b0941 | ||
|
|
7efab390da | ||
|
|
4611fb2363 | ||
|
|
d211382e4d | ||
|
|
e29752644a | ||
|
|
704d2407e8 | ||
|
|
5472043a33 | ||
|
|
0fdee073a6 | ||
|
|
6a09ce9ee5 | ||
|
|
af751d771d | ||
|
|
cd87ddb18e | ||
|
|
6d9cf918f9 | ||
|
|
a0db888f2e | ||
|
|
a0253b4f90 | ||
|
|
a660928085 | ||
|
|
30cd83ffc1 | ||
|
|
ca3af1a750 | ||
|
|
b3f039fbd5 | ||
|
|
430bf7a5da | ||
|
|
10bb7299a2 | ||
|
|
dda4b5320a | ||
|
|
44d1f4b9d9 | ||
|
|
a484ba7849 | ||
|
|
ddaba6866a | ||
|
|
fcf93ea3ea | ||
|
|
3e7109a0f1 | ||
|
|
6e9ad89f7b | ||
|
|
3ec5da0e54 | ||
|
|
dbcc60ad95 | ||
|
|
474b501fff | ||
|
|
c847ef3ef5 | ||
|
|
8dea04cae3 | ||
|
|
56443a739f | ||
|
|
df9b168577 | ||
|
|
83782ab6f8 | ||
|
|
b0cf273bc8 | ||
|
|
eaf71567a7 | ||
|
|
2e6fbd7801 | ||
|
|
61676e1423 | ||
|
|
ce679ec1c3 | ||
|
|
7c8b034756 | ||
|
|
c30688810a | ||
|
|
f2666c7d3c | ||
|
|
424071b97c | ||
|
|
eba93db452 | ||
|
|
ed524af16b | ||
|
|
5987502664 | ||
|
|
aa893368cd | ||
|
|
7e4991f960 | ||
|
|
a4287a122e | ||
|
|
6db211b9aa | ||
|
|
d50c31f8f4 | ||
|
|
d6be2b5c6b | ||
|
|
5c17a9846a | ||
|
|
b9a3118c84 | ||
|
|
cfd2eb0624 | ||
|
|
edf82bf7fd | ||
|
|
60c9c81224 | ||
|
|
1bab6874a9 | ||
|
|
933d00ad58 | ||
|
|
059dfa071e | ||
|
|
2d13982d92 | ||
|
|
92a7923a26 | ||
|
|
af0ec65be5 | ||
|
|
157e108322 | ||
|
|
bf963e8894 |
@@ -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.
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,6 +14,11 @@ supabase/.temp/
|
|||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Playwright — reportes/snapshots, pueden contener credenciales de .env.test en el DOM
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
|
||||||
# Next.js build output
|
# Next.js build output
|
||||||
.next/
|
.next/
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
46
BACKLOG.md
46
BACKLOG.md
@@ -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
98
CHANGELOG.md
Normal 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
148
COMPLIANCE.md
Normal 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). |
|
||||||
@@ -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
384
INSTALL_SUPABASE.md
Normal 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
89
OBSERVATIONS.md
Normal 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.
|
||||||
276
SEED_SPEC.md
276
SEED_SPEC.md
@@ -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 2–7) 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.1–8.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 S5–S9 oculto).
|
||||||
|
- **4.1 = cualquier combinación de opciones 2–7** (con o sin la opción 1): recorrido completo,
|
||||||
|
4.2/4.3 generan una instancia por cada vínculo 2–7 declarado.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sección A — Datos Generales (top-level)
|
## Sección 1 — Consentimiento (`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.2–3.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 2–7 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? | 1–2 · 3–4 · 5–6 · 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? | 1–2 · 3–4 · 5–6 · 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? | 1–2 · 3–4 · 5–6 · 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.1–8.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.000–1.500.000 · Gs. 1.500.000–3.000.000 · Gs. 3.000.000–6.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.001–8.000.000 · Gs. 8.000.001–12.000.000 · Gs. 12.000.001–15.000.000 · Gs. 15.000.001–18.000.000 · Gs. 18.000.001–25.000.000 · Gs. 25.000.001–50.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 4 — Tiempo y descanso (top-level)
|
## Sección 10 — Comentarios 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 (1–3 veces al año) · Varias veces (4–10 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.
|
|
||||||
|
|||||||
111
TECH_DEBT.md
111
TECH_DEBT.md
@@ -58,3 +58,114 @@
|
|||||||
- **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:** RESUELTO (27 jun 2026, ETAPA_AUTH).
|
||||||
|
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
|
||||||
|
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
|
||||||
|
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
|
||||||
|
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
|
||||||
|
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
|
||||||
|
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
|
||||||
|
en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
|
||||||
|
una etapa nueva, no parte de este cierre.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
|
||||||
|
|
||||||
|
- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
|
||||||
|
ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
|
||||||
|
de etapa asumía columnas `questions.section_number`/`section_title` que NO
|
||||||
|
existen. Verificado contra el esquema real (`supabase/migrations/
|
||||||
|
20260531000001_create_tables.sql` — única definición de `questions`,
|
||||||
|
`20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
|
||||||
|
migración agrega esas columnas).
|
||||||
|
- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
|
||||||
|
pregunta pertenece a la sección X". La sección es una fila más en la misma
|
||||||
|
tabla, con `type='section'`, intercalada por `position` entre las preguntas
|
||||||
|
reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
|
||||||
|
detallada de admin) infiere el agrupamiento recorriendo las filas en orden
|
||||||
|
de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
|
||||||
|
`type='section'`. El título de la sección es el `prompt` de esa fila — no
|
||||||
|
hay un campo `title` separado.
|
||||||
|
- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
|
||||||
|
pensado para ser un "motor de formularios de encuestas" genérico — no toda
|
||||||
|
encuesta futura va a tener secciones, y las que las tengan no necesariamente
|
||||||
|
van a querer modelarlas como filas-marcador en la misma tabla que las
|
||||||
|
preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
|
||||||
|
(wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
|
||||||
|
el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
|
||||||
|
`type='section'` más cercana hacia atrás en `position` es la sección de esta
|
||||||
|
pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
|
||||||
|
promoverla a una función compartida — y si el motor crece a encuestas con
|
||||||
|
estructuras más ricas (subsecciones, secciones condicionales, secciones que
|
||||||
|
no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
|
||||||
|
cada consumidor.
|
||||||
|
- **Alternativa más genérica y escalable (no implementada, propuesta para
|
||||||
|
evaluar cuando se priorice):** sacar la sección de `questions.type` y
|
||||||
|
modelarla como entidad propia — opción simple: `questions.section_id uuid
|
||||||
|
NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
|
||||||
|
position)`; opción más liviana sin tabla nueva: `questions.section_title
|
||||||
|
text NULL` + `questions.section_position int NULL` poblados directamente en
|
||||||
|
cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
|
||||||
|
Cualquiera de las dos deja "pertenece a la sección X" como un hecho
|
||||||
|
consultable con una columna, no como un cálculo derivado de recorrer
|
||||||
|
`position` — y ninguna asume que toda encuesta tiene secciones (el campo es
|
||||||
|
nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
|
||||||
|
desde las filas `type='section'` existentes, (3) actualizar
|
||||||
|
`lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
|
||||||
|
de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
|
||||||
|
- **Acción tomada mientras tanto:** la vista detallada de admin
|
||||||
|
(`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
|
||||||
|
`lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
|
||||||
|
cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
|
||||||
|
actual.
|
||||||
|
- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
|
||||||
|
la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
|
||||||
|
producto efectivamente evoluciona a soportar surveys con estructuras de
|
||||||
|
sección más complejas que el caso actual, evaluar la migración a columna/
|
||||||
|
tabla explícita antes de seguir apilando consumidores sobre el supuesto
|
||||||
|
implícito.
|
||||||
|
|||||||
106
app/actions/invite-user.ts
Normal file
106
app/actions/invite-user.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use server'
|
||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export type InviteUserResult = { success: true } | { success: false; error: string }
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
|
// Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo
|
||||||
|
// para identificar a quien llama y leer SU PROPIA fila de user_roles
|
||||||
|
// (policy user_roles_select_own: user_id = auth.uid()). Nunca para las
|
||||||
|
// escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin).
|
||||||
|
async function getSessionClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Action no siempre puede mutar cookies — se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invita a un usuario de empresa y lo asocia a su organización con rol org_admin.
|
||||||
|
// Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles
|
||||||
|
// en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData.
|
||||||
|
// El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un
|
||||||
|
// valor que el formulario pueda controlar para escalar privilegios.
|
||||||
|
export async function inviteUser(formData: FormData): Promise<InviteUserResult> {
|
||||||
|
const sessionClient = await getSessionClient()
|
||||||
|
|
||||||
|
// auth.getUser() valida el token contra el servidor de Auth — no decodifica
|
||||||
|
// la cookie localmente (mismo patrón que middleware.ts).
|
||||||
|
const { data: { user: caller } } = await sessionClient.auth.getUser()
|
||||||
|
if (!caller) return { success: false, error: 'no_autenticado' }
|
||||||
|
|
||||||
|
// Verificación de autorización — lee la fila real de user_roles del llamante.
|
||||||
|
const { data: callerRole, error: roleErr } = await sessionClient
|
||||||
|
.from('user_roles')
|
||||||
|
.select('app_role')
|
||||||
|
.eq('user_id', caller.id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (roleErr || callerRole?.app_role !== 'platform_admin') {
|
||||||
|
return { success: false, error: 'forbidden' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = String(formData.get('email') ?? '').trim().toLowerCase()
|
||||||
|
const orgId = String(formData.get('orgId') ?? '').trim()
|
||||||
|
|
||||||
|
if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' }
|
||||||
|
if (!orgId) return { success: false, error: 'organizacion_requerida' }
|
||||||
|
|
||||||
|
const admin = getSupabaseAdmin()
|
||||||
|
|
||||||
|
// La organización debe existir — admin client porque ya está autorizado el
|
||||||
|
// llamante (paso anterior) y queremos evitar depender del claim app_role del
|
||||||
|
// JWT (puede estar stale si el rol cambió desde la última emisión de token).
|
||||||
|
const { data: org } = await admin
|
||||||
|
.from('organizations')
|
||||||
|
.select('id')
|
||||||
|
.eq('id', orgId)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (!org) return { success: false, error: 'organizacion_no_encontrada' }
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? ''
|
||||||
|
const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail(
|
||||||
|
email,
|
||||||
|
{ redirectTo: `${siteUrl}/auth/confirm` }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (inviteErr || !inviteData?.user) {
|
||||||
|
return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUserId = inviteData.user.id
|
||||||
|
|
||||||
|
// Insert inmediato — antes de que el usuario confirme nada. user_roles no
|
||||||
|
// tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin.
|
||||||
|
const { error: roleInsertErr } = await admin
|
||||||
|
.from('user_roles')
|
||||||
|
.insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId })
|
||||||
|
|
||||||
|
if (roleInsertErr) {
|
||||||
|
// Rollback — no dejar un usuario en auth.users sin rol asignado.
|
||||||
|
await admin.auth.admin.deleteUser(newUserId)
|
||||||
|
return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
274
app/admin/responses/[id]/page.tsx
Normal file
274
app/admin/responses/[id]/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { formatDate, renderValue } from '../admin-format'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface QuestionRow {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
type: string
|
||||||
|
position: number
|
||||||
|
instance_of: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnswerRow {
|
||||||
|
question_id: string
|
||||||
|
value: unknown
|
||||||
|
instance_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResponseMeta {
|
||||||
|
id: string
|
||||||
|
submitted_at: string
|
||||||
|
qi_zone: string | null
|
||||||
|
qi_sector: string | null
|
||||||
|
qi_age_bucket: string | null
|
||||||
|
survey_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailRow {
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
rendered: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InstanceGroup {
|
||||||
|
instanceId: string
|
||||||
|
rows: DetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SectionGroup {
|
||||||
|
title: string | null
|
||||||
|
instances: InstanceGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupabaseSession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — no puede mutar cookies, se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function backHref(surveyId: string | undefined, page: string | undefined): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
if (page) params.set('page', page)
|
||||||
|
const qs = params.toString()
|
||||||
|
return `/admin/responses${qs ? `?${qs}` : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ResponseDetailPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||||
|
}) {
|
||||||
|
const { id } = await params
|
||||||
|
const { survey_id: backSurveyId, page: backPage } = await searchParams
|
||||||
|
const back = backHref(backSurveyId, backPage)
|
||||||
|
const supabase = await getSupabaseSession()
|
||||||
|
|
||||||
|
const { data: response, error: responseError } = await supabase
|
||||||
|
.from('responses')
|
||||||
|
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
|
||||||
|
.eq('id', id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (responseError || !response) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
<h1 className="admin-title">Respuesta no encontrada</h1>
|
||||||
|
<p className="admin-empty">
|
||||||
|
No existe esta respuesta o no tenés permiso para verla.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = response as ResponseMeta
|
||||||
|
|
||||||
|
// Mismo filtro de sensibles que la lista (regla no negociable #4 CLAUDE.md) — NO diverge.
|
||||||
|
const { data: questionsData, error: questionsError } = await supabase
|
||||||
|
.from('questions')
|
||||||
|
.select('id, code, prompt, type, position, instance_of')
|
||||||
|
.eq('survey_id', meta.survey_id)
|
||||||
|
.eq('is_sensitive', false)
|
||||||
|
.neq('type', 'text_long')
|
||||||
|
.order('position', { ascending: true })
|
||||||
|
|
||||||
|
if (questionsError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
<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 questions = (questionsData ?? []) as QuestionRow[]
|
||||||
|
const answerQuestionIds = questions.filter((q) => q.type !== 'section').map((q) => q.id)
|
||||||
|
|
||||||
|
const { data: answersData, error: answersError } =
|
||||||
|
answerQuestionIds.length > 0
|
||||||
|
? await supabase
|
||||||
|
.from('answers')
|
||||||
|
.select('question_id, value, instance_id')
|
||||||
|
.eq('response_id', id)
|
||||||
|
.in('question_id', answerQuestionIds)
|
||||||
|
: { data: [] as AnswerRow[], error: null }
|
||||||
|
|
||||||
|
if (answersError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
<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">{answersError.message}</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const answers = (answersData ?? []) as AnswerRow[]
|
||||||
|
const answersByQuestion = new Map<string, AnswerRow[]>()
|
||||||
|
answers.forEach((a) => {
|
||||||
|
const list = answersByQuestion.get(a.question_id) ?? []
|
||||||
|
list.push(a)
|
||||||
|
answersByQuestion.set(a.question_id, list)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Agrupar por sección. No existe section_number/section_title en `questions`
|
||||||
|
// (verificado — ver TECH_DEBT.md). Las secciones hoy son filas type='section'
|
||||||
|
// intercaladas por position, mismo modelo que usa el wizard en
|
||||||
|
// lib/form-engine.ts:146-148 — se replica esa lógica acá, no se inventa una
|
||||||
|
// columna nueva.
|
||||||
|
const sections: SectionGroup[] = []
|
||||||
|
let current: SectionGroup = { title: null, instances: [] }
|
||||||
|
let hasContent = false
|
||||||
|
|
||||||
|
function instanceGroupFor(section: SectionGroup, instanceId: string): InstanceGroup {
|
||||||
|
let group = section.instances.find((g) => g.instanceId === instanceId)
|
||||||
|
if (!group) {
|
||||||
|
group = { instanceId, rows: [] }
|
||||||
|
section.instances.push(group)
|
||||||
|
}
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
questions.forEach((q) => {
|
||||||
|
if (q.type === 'section') {
|
||||||
|
if (hasContent || current.title !== null) sections.push(current)
|
||||||
|
current = { title: q.prompt, instances: [] }
|
||||||
|
hasContent = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const qAnswers = answersByQuestion.get(q.id)
|
||||||
|
if (qAnswers && qAnswers.length > 0) {
|
||||||
|
qAnswers.forEach((a) => {
|
||||||
|
const group = instanceGroupFor(current, a.instance_id)
|
||||||
|
group.rows.push({ code: q.code, prompt: q.prompt, rendered: renderValue(a.value, q.type) })
|
||||||
|
})
|
||||||
|
hasContent = true
|
||||||
|
} else if (!q.instance_of) {
|
||||||
|
// Pregunta normal sin respuesta — placeholder "sin respuesta" visible.
|
||||||
|
// Si es instance_of y no hay respuestas, no se generaron instancias
|
||||||
|
// (igual que el wizard no muestra pantallas de instancia sin generador
|
||||||
|
// disparado) — no se fabrica un grupo "Familiar" vacío.
|
||||||
|
const group = instanceGroupFor(current, 'default')
|
||||||
|
group.rows.push({ code: q.code, prompt: q.prompt, rendered: null })
|
||||||
|
hasContent = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (hasContent || current.title !== null) sections.push(current)
|
||||||
|
|
||||||
|
sections.forEach((s) => {
|
||||||
|
s.instances.sort((a, b) => {
|
||||||
|
if (a.instanceId === 'default') return -1
|
||||||
|
if (b.instanceId === 'default') return 1
|
||||||
|
return a.instanceId.localeCompare(b.instanceId, undefined, { numeric: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||||
|
|
||||||
|
<header className="admin-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Respuesta {meta.id.slice(0, 8)}</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
{formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '}
|
||||||
|
{meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{sections.map((section, si) => (
|
||||||
|
<section key={si} className="admin-detail-section">
|
||||||
|
{section.title && <h2 className="admin-detail-section-title">{section.title}</h2>}
|
||||||
|
{section.instances.map((group) => (
|
||||||
|
<div key={group.instanceId} className="admin-detail-instance">
|
||||||
|
{group.instanceId !== 'default' && (
|
||||||
|
<span className="admin-instance-badge">
|
||||||
|
Familiar {group.instanceId.replace('familiar_', '')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Código</th>
|
||||||
|
<th>Pregunta</th>
|
||||||
|
<th>Valor</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.rows.map((row, ri) => (
|
||||||
|
<tr key={ri}>
|
||||||
|
<td>{row.code}</td>
|
||||||
|
<td className="admin-prompt">{row.prompt}</td>
|
||||||
|
<td>
|
||||||
|
{row.rendered === null ? (
|
||||||
|
<span className="admin-nodata">sin respuesta</span>
|
||||||
|
) : (
|
||||||
|
row.rendered
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sections.length === 0 && (
|
||||||
|
<p className="admin-empty">Esta respuesta no tiene preguntas visibles para mostrar.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
app/admin/responses/admin-format.ts
Normal file
27
app/admin/responses/admin-format.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Compartido entre la lista (page.tsx) y la vista detallada ([id]/page.tsx) —
|
||||||
|
// deben renderizar fecha y valor exactamente igual; antes vivían duplicados
|
||||||
|
// en los dos archivos y ya habían divergido.
|
||||||
|
|
||||||
|
export 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())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// null/undefined → null (renderizado como badge "sin dato"/"sin respuesta" por el caller)
|
||||||
|
export 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
app/admin/responses/page.tsx
Normal file
274
app/admin/responses/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { ResponsesTable, type DisplayRow } from './responses-table'
|
||||||
|
import { SurveySelect } from './survey-select'
|
||||||
|
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
|
import { formatDate, renderValue } from './admin-format'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
const PROMPT_MAX_LEN = 60
|
||||||
|
|
||||||
|
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[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SurveyOption {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupabaseSession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — no puede mutar cookies, se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text: string, max = PROMPT_MAX_LEN): string {
|
||||||
|
return text.length > max ? `${text.slice(0, max)}...` : text
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageHref(surveyId: string | undefined, page: number): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
params.set('page', String(page))
|
||||||
|
return `/admin/responses?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminResponsesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||||
|
}) {
|
||||||
|
const { survey_id: surveyId, page: pageParam } = await searchParams
|
||||||
|
const page = Math.max(1, Number(pageParam) || 1)
|
||||||
|
const supabase = await getSupabaseSession()
|
||||||
|
|
||||||
|
// 0 y 1 no dependen entre sí — en paralelo.
|
||||||
|
// 0. Surveys para el filtro — dinámico, no hardcodeado. Solo trae lo que
|
||||||
|
// RLS deja ver al usuario autenticado. Si falla, el filtro se degrada a
|
||||||
|
// "sin opciones" en vez de tirar abajo la página entera — la lista de
|
||||||
|
// respuestas no depende de esta query.
|
||||||
|
// 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: surveysData, error: surveysError },
|
||||||
|
{ data: questionsData, error: questionsError },
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('surveys').select('id, title').order('title'),
|
||||||
|
supabase
|
||||||
|
.from('questions')
|
||||||
|
.select('id, code, prompt, type, position')
|
||||||
|
.eq('is_sensitive', false)
|
||||||
|
.neq('type', 'text_long')
|
||||||
|
.order('position', { ascending: true }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const surveys = (surveysData ?? []) as SurveyOption[]
|
||||||
|
const selectedSurvey = surveys.find((s) => s.id === surveyId)
|
||||||
|
|
||||||
|
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).
|
||||||
|
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Contadores totales (independientes de la página actual). submissionsCount
|
||||||
|
// usa el MISMO answers!inner + .in() que responsesQuery — si contara todas
|
||||||
|
// las responses sin ese filtro, una response sin ningún answer permitido
|
||||||
|
// inflaría el total y totalPages quedaría desalineado con lo que .range()
|
||||||
|
// realmente puede devolver (página "fantasma" al final).
|
||||||
|
let submissionsCountQuery = supabase
|
||||||
|
.from('responses')
|
||||||
|
.select('*, answers!inner(question_id)', { count: 'exact', head: true })
|
||||||
|
.in('answers.question_id', allowedIds)
|
||||||
|
|
||||||
|
if (surveyId) {
|
||||||
|
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Las tres son independientes entre sí — en paralelo.
|
||||||
|
const [
|
||||||
|
{ data: responsesData, error: responsesError },
|
||||||
|
{ count: totalSubmissions },
|
||||||
|
{ count: totalAnswers },
|
||||||
|
] =
|
||||||
|
allowedIds.length > 0
|
||||||
|
? await Promise.all([
|
||||||
|
responsesQuery
|
||||||
|
.order('submitted_at', { ascending: false })
|
||||||
|
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1),
|
||||||
|
submissionsCountQuery,
|
||||||
|
answersCountQuery,
|
||||||
|
])
|
||||||
|
: [{ data: [] as ResponseRow[], error: null }, { count: 0 }, { count: 0 }]
|
||||||
|
|
||||||
|
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[]
|
||||||
|
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
|
||||||
|
|
||||||
|
// 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}`,
|
||||||
|
responseId: response.id,
|
||||||
|
submissionNum: (page - 1) * PAGE_SIZE + 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 (
|
||||||
|
<>
|
||||||
|
<AdminNavBar active="responses" />
|
||||||
|
<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 ·{' '}
|
||||||
|
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
|
||||||
|
{formatDate(new Date().toISOString())}
|
||||||
|
</p>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
|
||||||
|
</p>
|
||||||
|
<div className="admin-survey-nav">
|
||||||
|
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
|
||||||
|
</div>
|
||||||
|
{surveysError && (
|
||||||
|
<p className="admin-error" role="alert">
|
||||||
|
No se pudo cargar la lista de surveys para el filtro — se muestra "Todos".
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="admin-header-actions">
|
||||||
|
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="admin-table-wrapper">
|
||||||
|
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(totalSubmissions ?? 0) > PAGE_SIZE && (
|
||||||
|
<nav className="admin-pagination" aria-label="Paginación de respuestas">
|
||||||
|
{page > 1 ? (
|
||||||
|
<a href={pageHref(surveyId, page - 1)} className="admin-pagination-link">← Anterior</a>
|
||||||
|
) : (
|
||||||
|
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">← Anterior</span>
|
||||||
|
)}
|
||||||
|
<span className="admin-pagination-current">Página {page} de {totalPages}</span>
|
||||||
|
{page < totalPages ? (
|
||||||
|
<a href={pageHref(surveyId, page + 1)} className="admin-pagination-link">Siguiente →</a>
|
||||||
|
) : (
|
||||||
|
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">Siguiente →</span>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
app/admin/responses/responses-table.tsx
Normal file
127
app/admin/responses/responses-table.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
|
||||||
|
export interface DisplayRow {
|
||||||
|
key: string
|
||||||
|
responseId: string
|
||||||
|
submissionNum: number
|
||||||
|
isFirstInGroup: boolean
|
||||||
|
date: string
|
||||||
|
code: string
|
||||||
|
prompt: string
|
||||||
|
rendered: string | null
|
||||||
|
zone: string
|
||||||
|
sector: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResponsesTable({
|
||||||
|
rows,
|
||||||
|
surveyId,
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
rows: DisplayRow[]
|
||||||
|
surveyId?: string
|
||||||
|
page: number
|
||||||
|
}) {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailHref(responseId: string): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (surveyId) params.set('survey_id', surveyId)
|
||||||
|
params.set('page', String(page))
|
||||||
|
return `/admin/responses/${responseId}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<th>Ver</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}
|
||||||
|
// Conveniencia para mouse — sin semántica ARIA en la fila (evita
|
||||||
|
// anidar un control interactivo real, el Link "Ver", dentro de un
|
||||||
|
// elemento expuesto como role="button" a lectores de pantalla).
|
||||||
|
// El toggle accesible por teclado vive en el <button> de abajo.
|
||||||
|
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
{row.isFirstInGroup ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="admin-row-toggle-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
toggle(row.submissionNum)
|
||||||
|
}}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
aria-label={isExpanded ? 'Colapsar respuesta' : 'Expandir respuesta'}
|
||||||
|
>
|
||||||
|
<span className="admin-toggle-icon" aria-hidden="true">
|
||||||
|
{isExpanded ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
{row.submissionNum}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
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>
|
||||||
|
<td onClick={(e) => e.stopPropagation()}>
|
||||||
|
{row.isFirstInGroup && (
|
||||||
|
<Link href={detailHref(row.responseId)} className="admin-action-link">
|
||||||
|
Ver
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
app/admin/responses/survey-select.tsx
Normal file
31
app/admin/responses/survey-select.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
interface SurveyOption {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SurveySelect({ surveys, value }: { surveys: SurveyOption[]; value: string }) {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className="admin-survey-select"
|
||||||
|
aria-label="Selección de encuesta"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value
|
||||||
|
router.push(id ? `/admin/responses?survey_id=${id}` : '/admin/responses')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Todos los surveys</option>
|
||||||
|
{surveys.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
app/admin/sign-out-button.tsx
Normal file
24
app/admin/sign-out-button.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
await supabase.auth.signOut()
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={handleSignOut} className="admin-signout">
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
89
app/admin/usuarios/invite-form.tsx
Normal file
89
app/admin/usuarios/invite-form.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
'use client'
|
||||||
|
import { useActionState, useEffect, useState } from 'react'
|
||||||
|
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
|
||||||
|
const BANNER_VISIBLE_MS = 4000
|
||||||
|
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
|
||||||
|
forbidden: 'No tenés permisos para invitar usuarios.',
|
||||||
|
email_invalido: 'El email no es válido.',
|
||||||
|
organizacion_requerida: 'Elegí una organización.',
|
||||||
|
organizacion_no_encontrada: 'La organización seleccionada no existe.',
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeError(code: string): string {
|
||||||
|
if (code.startsWith('invite_failed'))
|
||||||
|
return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}`
|
||||||
|
if (code.startsWith('role_insert_failed'))
|
||||||
|
return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}`
|
||||||
|
return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
|
||||||
|
const [state, formAction, isPending] = useActionState(
|
||||||
|
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
|
||||||
|
INITIAL_STATE
|
||||||
|
)
|
||||||
|
const [bannerVisible, setBannerVisible] = useState(false)
|
||||||
|
|
||||||
|
// Banner visible al menos BANNER_VISIBLE_MS tras cada resultado nuevo, o
|
||||||
|
// hasta que el usuario vuelva a tocar el formulario (lo que pase primero).
|
||||||
|
useEffect(() => {
|
||||||
|
const hasMessage = state.success || (!state.success && state.error !== '')
|
||||||
|
setBannerVisible(hasMessage)
|
||||||
|
if (!hasMessage) return
|
||||||
|
const timer = setTimeout(() => setBannerVisible(false), BANNER_VISIBLE_MS)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className="invite-form"
|
||||||
|
onChange={() => setBannerVisible(false)}
|
||||||
|
>
|
||||||
|
<div className="invite-form-row">
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="email" className="login-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="orgId" className="login-label">Organización</label>
|
||||||
|
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
|
||||||
|
<option value="">Seleccioná una organización</option>
|
||||||
|
{organizations.map((org) => (
|
||||||
|
<option key={org.id} value={org.id}>{org.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={isPending} className="login-submit">
|
||||||
|
{isPending ? 'Enviando…' : 'Enviar invitación'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{bannerVisible && !state.success && state.error && (
|
||||||
|
<p role="alert" className="invite-banner invite-banner-error">
|
||||||
|
{describeError(state.error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{bannerVisible && state.success && (
|
||||||
|
<p role="status" className="invite-banner invite-banner-success">
|
||||||
|
Invitación enviada correctamente.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
app/admin/usuarios/page.tsx
Normal file
195
app/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||||
|
import { formatDate } from '../responses/admin-format'
|
||||||
|
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
|
import { InviteForm } from './invite-form'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface OrgOption {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleRow {
|
||||||
|
user_id: string
|
||||||
|
app_role: string
|
||||||
|
org_id: string | null
|
||||||
|
organizations: { name: string } | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserDisplayRow {
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
orgName: string
|
||||||
|
role: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSupabaseSession() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — no puede mutar cookies, se ignora
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// auth.users no está expuesto vía PostgREST (no es schema público) — no hay
|
||||||
|
// forma de hacer un .from('auth.users') ni un join SQL directo desde
|
||||||
|
// supabase-js. listUsers() es la API admin soportada para resolver el email;
|
||||||
|
// pagina hasta agotar resultados (tope defensivo de 2000 usuarios).
|
||||||
|
async function fetchAllUsersMap(admin: ReturnType<typeof getSupabaseAdmin>) {
|
||||||
|
const map = new Map<string, { email: string; createdAt: string }>()
|
||||||
|
const perPage = 200
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
|
||||||
|
if (error || !data) break
|
||||||
|
data.users.forEach((u) => {
|
||||||
|
map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at })
|
||||||
|
})
|
||||||
|
if (data.users.length < perPage) break
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminUsuariosPage() {
|
||||||
|
const supabase = await getSupabaseSession()
|
||||||
|
|
||||||
|
// 1. Identificar a quien llama — defensivo, el middleware ya debería haber
|
||||||
|
// redirigido a /login si no hay sesión.
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Acceso restringido</h1>
|
||||||
|
<p className="admin-error">No se encontró una sesión activa.</p>
|
||||||
|
<a href="/login" className="admin-back-link">← Iniciar sesión</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta
|
||||||
|
// página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así
|
||||||
|
// que el gate de autorización tiene que ir acá, no delegarse a las policies.
|
||||||
|
const { data: callerRole } = await supabase
|
||||||
|
.from('user_roles')
|
||||||
|
.select('app_role')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
|
if (callerRole?.app_role !== 'platform_admin') {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Acceso restringido</h1>
|
||||||
|
<p className="admin-error">
|
||||||
|
Esta sección es exclusiva para administradores de plataforma.
|
||||||
|
</p>
|
||||||
|
<a href="/admin/responses" className="admin-back-link">← Volver</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Datos — admin client para todo a partir de acá. La autorización ya
|
||||||
|
// quedó probada en el paso 2 con una lectura directa a la DB; usar admin
|
||||||
|
// para el resto evita depender de policies/claims del JWT que podrían
|
||||||
|
// haber quedado stale.
|
||||||
|
const admin = getSupabaseAdmin()
|
||||||
|
|
||||||
|
const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] =
|
||||||
|
await Promise.all([
|
||||||
|
admin.from('organizations').select('id, name').order('name'),
|
||||||
|
admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (orgsError || rolesError) {
|
||||||
|
return (
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<h1 className="admin-title">Usuarios</h1>
|
||||||
|
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||||
|
<pre className="admin-error-detail">
|
||||||
|
{(orgsError ?? rolesError)?.message}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizations = (orgsData ?? []) as OrgOption[]
|
||||||
|
const roles = (rolesData ?? []) as unknown as RoleRow[]
|
||||||
|
const usersMap = await fetchAllUsersMap(admin)
|
||||||
|
|
||||||
|
const userRows: UserDisplayRow[] = roles.map((r) => {
|
||||||
|
const u = usersMap.get(r.user_id)
|
||||||
|
return {
|
||||||
|
userId: r.user_id,
|
||||||
|
email: u?.email ?? '—',
|
||||||
|
orgName: r.organizations?.name ?? '—',
|
||||||
|
role: r.app_role,
|
||||||
|
createdAt: u?.createdAt ? formatDate(u.createdAt) : '—',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AdminNavBar active="usuarios" />
|
||||||
|
<div className="admin-page" role="main">
|
||||||
|
<header className="admin-header">
|
||||||
|
<div>
|
||||||
|
<h1 className="admin-title">Usuarios</h1>
|
||||||
|
<p className="admin-subtitle">{userRows.length} usuarios registrados</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="admin-detail-section">
|
||||||
|
<h2 className="admin-detail-section-title">Invitar usuario</h2>
|
||||||
|
<InviteForm organizations={organizations} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-detail-section">
|
||||||
|
<h2 className="admin-detail-section-title">Usuarios existentes</h2>
|
||||||
|
<div className="admin-table-wrapper">
|
||||||
|
<table className="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Organización</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Fecha de creación</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{userRows.map((row) => (
|
||||||
|
<tr key={row.userId}>
|
||||||
|
<td>{row.email}</td>
|
||||||
|
<td>{row.orgName}</td>
|
||||||
|
<td>{row.role}</td>
|
||||||
|
<td>{row.createdAt}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{userRows.length === 0 && (
|
||||||
|
<p className="admin-empty">Todavía no hay usuarios invitados.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
191
app/auth/confirm/page.tsx
Normal file
191
app/auth/confirm/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
type Stage = 'validating' | 'ready' | 'submitting' | 'error'
|
||||||
|
|
||||||
|
const DEFAULT_LINK_ERROR = 'Pedile al administrador que te envíe una nueva invitación.'
|
||||||
|
|
||||||
|
// GoTrue redirige con #error=...&error_code=...&error_description=... cuando
|
||||||
|
// el token ya fue usado, venció, o es inválido — en vez de emitir PASSWORD_RECOVERY.
|
||||||
|
// Mapeo a mensajes accionables; default cubre códigos no contemplados acá.
|
||||||
|
function describeLinkError(errorCode: string | null): string {
|
||||||
|
switch (errorCode) {
|
||||||
|
case 'otp_expired':
|
||||||
|
return 'Este link ya fue usado o venció. ' + DEFAULT_LINK_ERROR
|
||||||
|
case 'access_denied':
|
||||||
|
return 'El acceso fue denegado para este link. ' + DEFAULT_LINK_ERROR
|
||||||
|
default:
|
||||||
|
return 'El link de invitación no es válido. ' + DEFAULT_LINK_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmPage() {
|
||||||
|
const [stage, setStage] = useState<Stage>('validating')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [linkErrorMessage, setLinkErrorMessage] = useState(DEFAULT_LINK_ERROR)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// GoTrue ya nos dice en el fragmento si el token es inválido/vencido/usado
|
||||||
|
// (#error=...&error_code=...) — leerlo evita esperar el timeout genérico.
|
||||||
|
const hash = window.location.hash.startsWith('#')
|
||||||
|
? window.location.hash.slice(1)
|
||||||
|
: window.location.hash
|
||||||
|
const hashParams = new URLSearchParams(hash)
|
||||||
|
const errorCode = hashParams.get('error_code')
|
||||||
|
|
||||||
|
if (errorCode) {
|
||||||
|
setLinkErrorMessage(describeLinkError(errorCode))
|
||||||
|
setStage('error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite
|
||||||
|
// cuando el token de la URL (#access_token) es válido.
|
||||||
|
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
|
||||||
|
if (event === 'PASSWORD_RECOVERY') setStage('ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Carrera posible: si el token ya se procesó antes de montar el listener,
|
||||||
|
// ya hay sesión activa aunque el evento no se vuelva a emitir.
|
||||||
|
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||||
|
setStage((s) => (s === 'validating' && session ? 'ready' : s))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente
|
||||||
|
// sin que GoTrue haya mandado un error explícito en el fragmento.
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setStage((s) => (s === 'validating' ? 'error' : s))
|
||||||
|
}, 8000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listener.subscription.unsubscribe()
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [supabase])
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const password = String(fd.get('password') ?? '')
|
||||||
|
const confirmPassword = String(fd.get('confirmPassword') ?? '')
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('La contraseña debe tener al menos 8 caracteres.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError('Las contraseñas no coinciden.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setStage('submitting')
|
||||||
|
|
||||||
|
const { error: updateErr } = await supabase.auth.updateUser({ password })
|
||||||
|
|
||||||
|
if (updateErr) {
|
||||||
|
setError('No pudimos guardar tu contraseña. Probá de nuevo.')
|
||||||
|
setStage('ready')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/dashboard')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'validating') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Validando tu invitación...</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stage === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Link inválido o vencido</h1>
|
||||||
|
<p role="alert" className="field-error login-error">
|
||||||
|
{linkErrorMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Creá tu contraseña</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||||
|
<div className="login-password-wrapper">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={stage === 'submitting'}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-password-toggle"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="confirmPassword" className="login-label">Confirmar contraseña</label>
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
disabled={stage === 'submitting'}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error login-error">{error}</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={stage === 'submitting'} className="login-submit">
|
||||||
|
{stage === 'submitting' ? 'Guardando…' : 'Crear contraseña'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,36 +1,60 @@
|
|||||||
'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 { AdminNavBar } from '@/components/admin-nav-bar'
|
||||||
import { ParaguayMap } from '@/components/dashboard/paraguay-map'
|
|
||||||
import { DonutCuidados } from '@/components/dashboard/donut-cuidados'
|
|
||||||
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional'
|
|
||||||
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget'
|
|
||||||
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica'
|
|
||||||
import { ApoyosBar } from '@/components/dashboard/apoyos-bar'
|
|
||||||
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad'
|
|
||||||
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
|
|
||||||
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
||||||
|
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
|
||||||
|
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
|
||||||
|
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
|
||||||
|
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
|
||||||
|
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
|
||||||
|
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
|
||||||
|
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
|
||||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
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 (
|
||||||
|
<>
|
||||||
|
<AdminNavBar active="dashboard" />
|
||||||
<div className="db-page">
|
<div className="db-page">
|
||||||
<FilterSidebar
|
<FilterSidebar
|
||||||
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,37 +64,90 @@ export default function DashboardPage() {
|
|||||||
Datos agregados · k-anonimato k≥5 · Encuesta Mapeo de Cuidadores 2026
|
Datos agregados · k-anonimato k≥5 · 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
|
||||||
|
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>
|
||||||
|
|
||||||
|
{lens === 'empresa' && (
|
||||||
|
<>
|
||||||
|
<div className="db-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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
|
||||||
|
<section className="db-grid db-section" aria-label="Indicadores núcleo">
|
||||||
|
<TalentoRiesgoCard surveyId={surveyId} filters={filters} />
|
||||||
|
<PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
|
||||||
|
<PuntoCiegoCard surveyId={surveyId} filters={filters} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */}
|
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
|
||||||
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos">
|
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
|
||||||
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} />
|
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
|
||||||
<DonutCuidados surveyId={surveyId} filters={filters} />
|
<ApoyosBarME surveyId={surveyId} filters={filters} />
|
||||||
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Fila 3: Heatmap (6) + Pirámide (6) */}
|
{/* Fila 3: segmentación (12, SEC) */}
|
||||||
<section className="db-grid db-section" aria-label="Intensidad y demografía">
|
<section className="db-grid db-section" aria-label="Segmentación de la carga">
|
||||||
<HeatmapWidget surveyId={surveyId} filters={filters} />
|
<SegmentacionBar surveyId={surveyId} filters={filters} />
|
||||||
<PiramideDemografica surveyId={surveyId} filters={filters} />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
|
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
|
||||||
<section className="db-grid db-section" aria-label="Apoyos e impacto">
|
<section className="db-grid db-section" aria-label="Disposición a participar">
|
||||||
<ApoyosBar surveyId={surveyId} filters={filters} />
|
<DisposicionRedCard surveyId={surveyId} filters={filters} />
|
||||||
<IntensidadProductividad surveyId={surveyId} filters={filters} />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Fila 5: Saturación del cuidador (12) */}
|
<p className="db-table-note" style={{ textAlign: 'right' }}>
|
||||||
<section className="db-grid db-section" aria-label="Saturación del cuidador">
|
k≥5 aplicado · segmentos con n<5 suprimidos
|
||||||
<TablaSaturacion surveyId={surveyId} filters={filters} />
|
</p>
|
||||||
</section>
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
561
app/globals.css
561
app/globals.css
@@ -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,37 @@ 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;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
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: #494F4F;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Textarea ── */
|
/* ── Textarea ── */
|
||||||
.text-area {
|
.text-area {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -343,7 +378,7 @@ body {
|
|||||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
.btn-secondary:hover { background: var(--color-primary-light); }
|
.btn-secondary:hover { background: var(--color-primary-light); }
|
||||||
@@ -394,7 +429,7 @@ body {
|
|||||||
}
|
}
|
||||||
.summary-code {
|
.summary-code {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--color-primary);
|
color: #494F4F;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
.summary-answer {
|
.summary-answer {
|
||||||
@@ -453,7 +488,7 @@ body {
|
|||||||
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
|
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
|
||||||
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
||||||
.consent-checkbox-text { flex: 1; }
|
.consent-checkbox-text { flex: 1; }
|
||||||
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-primary); background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
|
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: #494F4F; background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
|
||||||
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
|
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
|
||||||
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||||
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
||||||
@@ -482,6 +517,170 @@ 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: #494F4F;
|
||||||
|
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: crece orgánicamente con el contenido, sin altura mínima fija. */
|
||||||
|
.wizard-prompt-block {
|
||||||
|
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;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
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; }
|
||||||
|
/* column-reverse: el botón primario (Siguiente/Enviar) queda arriba —
|
||||||
|
el pulgar llega más fácil al centro de la pantalla que a la base. */
|
||||||
|
.wizard-bottombar { flex-direction: column-reverse; }
|
||||||
|
.wizard-bottombar .btn { width: 100%; }
|
||||||
|
.wizard-bottombar-hint { order: 3; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Loading skeleton ── */
|
/* ── Loading skeleton ── */
|
||||||
.loading-wrapper {
|
.loading-wrapper {
|
||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
@@ -546,19 +745,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 +775,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 +786,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 +811,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 +875,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 +889,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 +934,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,14 +949,286 @@ 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);
|
||||||
|
}
|
||||||
|
.db-widget-star .db-widget-title { color: #494F4F; }
|
||||||
|
|
||||||
|
/* ── 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; }
|
||||||
|
|
||||||
|
/* ── Nav compartido (dashboard / respuestas / usuarios) ── */
|
||||||
|
.admin-nav-bar {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px; padding: 10px 24px; background: var(--color-surface);
|
||||||
|
border-bottom: 1px solid var(--color-border); flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.admin-nav { display: flex; gap: 4px; }
|
||||||
|
.admin-nav-link {
|
||||||
|
padding: 6px 14px; border-radius: var(--radius); font-size: 0.875rem;
|
||||||
|
font-weight: 600; color: var(--color-text-muted); text-decoration: none;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.admin-nav-link:hover { background: var(--color-primary-light); color: var(--color-text); }
|
||||||
|
.admin-nav-link-active { background: var(--color-primary-light); color: var(--color-primary-hover); }
|
||||||
|
|
||||||
|
/* ── 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-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-survey-select {
|
||||||
|
font-size: 0.8125rem; padding: 6px 10px; border-radius: var(--radius);
|
||||||
|
border: 1px solid var(--color-border); color: var(--color-text);
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
.admin-action-link {
|
||||||
|
font-size: 0.75rem; font-weight: 600; color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.admin-action-link:hover { text-decoration: underline; }
|
||||||
|
.admin-pagination {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||||
|
margin-top: 16px; font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
.admin-pagination-link { color: var(--color-primary); text-decoration: none; font-weight: 600; }
|
||||||
|
.admin-pagination-link:hover { text-decoration: underline; }
|
||||||
|
.admin-pagination-disabled { color: var(--color-text-muted); cursor: default; }
|
||||||
|
.admin-pagination-current { color: var(--color-text-muted); }
|
||||||
|
.admin-back-link {
|
||||||
|
display: inline-block; margin-bottom: 12px; font-size: 0.8125rem;
|
||||||
|
font-weight: 600; color: var(--color-primary); text-decoration: none;
|
||||||
|
}
|
||||||
|
.admin-back-link:hover { text-decoration: underline; }
|
||||||
|
.admin-detail-section { margin-bottom: 24px; }
|
||||||
|
.admin-detail-section-title {
|
||||||
|
font-size: 1.0625rem; font-weight: 700; color: var(--color-text);
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.admin-detail-instance { margin-bottom: 14px; }
|
||||||
|
.admin-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: 6px;
|
||||||
|
}
|
||||||
|
.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-row-toggle-btn {
|
||||||
|
background: none; border: none; padding: 0; margin: 0;
|
||||||
|
font: inherit; color: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.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 ── */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Login ── */
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-bg);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
padding: 40px 36px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
.login-form { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.login-field { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.login-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
.login-error { margin: 0; }
|
||||||
|
.login-password-wrapper { position: relative; }
|
||||||
|
.login-password-wrapper .text-short-input { padding-right: 40px; }
|
||||||
|
.login-password-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 8px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-password-toggle:hover { color: var(--color-text); }
|
||||||
|
.login-submit {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||||
|
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
.login-back-button { margin-top: 16px; }
|
||||||
|
.login-forgot-link {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.login-forgot-link:hover { color: var(--color-primary-hover); }
|
||||||
|
|
||||||
|
/* ── Invite form (/admin/usuarios) ── */
|
||||||
|
.invite-form { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.invite-form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.invite-banner {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.invite-banner-success {
|
||||||
|
background: var(--color-primary-light);
|
||||||
|
color: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
.invite-banner-error {
|
||||||
|
background: var(--color-error-bg);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.invite-form-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admin sign-out ── */
|
||||||
|
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.admin-signout {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 5px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||||
|
|||||||
197
app/login/page.tsx
Normal file
197
app/login/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
|
||||||
|
type Mode = 'login' | 'reset' | 'reset-sent'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [mode, setMode] = useState<Mode>('login')
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [resetEmail, setResetEmail] = useState('')
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const supabase = createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsPending(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const { error: authError } = await supabase.auth.signInWithPassword({
|
||||||
|
email: fd.get('email') as string,
|
||||||
|
password: fd.get('password') as string,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
setError('Email o contraseña incorrectos')
|
||||||
|
setIsPending(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push('/admin/responses')
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusa /auth/confirm: ya escucha PASSWORD_RECOVERY (lo armamos para el
|
||||||
|
// flujo de invitación, pero Supabase emite el mismo evento acá).
|
||||||
|
async function handleReset(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsPending(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
const fd = new FormData(e.currentTarget)
|
||||||
|
const email = String(fd.get('email') ?? '')
|
||||||
|
|
||||||
|
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/confirm`,
|
||||||
|
})
|
||||||
|
|
||||||
|
setIsPending(false)
|
||||||
|
|
||||||
|
if (resetError) {
|
||||||
|
setError('No pudimos enviar el link. Probá de nuevo.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setResetEmail(email)
|
||||||
|
setMode('reset-sent')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'reset-sent') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Revisá tu email</h1>
|
||||||
|
<p className="admin-subtitle">
|
||||||
|
Si <strong>{resetEmail}</strong> tiene una cuenta, te enviamos un link para crear una
|
||||||
|
contraseña nueva.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-submit login-back-button"
|
||||||
|
onClick={() => { setMode('login'); setError(null) }}
|
||||||
|
>
|
||||||
|
Volver al login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'reset') {
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Recuperar contraseña</h1>
|
||||||
|
<form onSubmit={handleReset} className="login-form" noValidate>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="reset-email" className="login-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="reset-email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error login-error">{error}</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={isPending} className="login-submit">
|
||||||
|
{isPending ? 'Enviando…' : 'Enviar link de recuperación'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-forgot-link"
|
||||||
|
onClick={() => { setMode('login'); setError(null) }}
|
||||||
|
>
|
||||||
|
← Volver al login
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="login-page">
|
||||||
|
<div className="login-card">
|
||||||
|
<h1 className="login-title">Acceso interno</h1>
|
||||||
|
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="email" className="login-label">Email</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field">
|
||||||
|
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||||
|
<div className="login-password-wrapper">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-short-input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-password-toggle"
|
||||||
|
onClick={() => setShowPassword((v) => !v)}
|
||||||
|
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||||
|
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M3 3l18 18" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error login-error">{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="login-submit"
|
||||||
|
>
|
||||||
|
{isPending ? 'Ingresando…' : 'Ingresar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="login-forgot-link"
|
||||||
|
onClick={() => { setMode('reset'); setError(null) }}
|
||||||
|
>
|
||||||
|
¿Olvidaste tu contraseña?
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
app/page.tsx
32
app/page.tsx
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
34
components/admin-nav-bar.tsx
Normal file
34
components/admin-nav-bar.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { SignOutButton } from '@/app/admin/sign-out-button'
|
||||||
|
|
||||||
|
interface AdminNavBarProps {
|
||||||
|
active: 'dashboard' | 'responses' | 'usuarios'
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINKS = [
|
||||||
|
{ id: 'dashboard', href: '/dashboard', label: 'Dashboard' },
|
||||||
|
{ id: 'responses', href: '/admin/responses', label: 'Respuestas' },
|
||||||
|
{ id: 'usuarios', href: '/admin/usuarios', label: 'Usuarios' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
// Nav compartido entre /dashboard, /admin/responses y /admin/usuarios — las
|
||||||
|
// tres pantallas detrás del login. Sin esto, cada una era una isla y el único
|
||||||
|
// modo de llegar a otra era escribir la URL a mano.
|
||||||
|
export function AdminNavBar({ active }: AdminNavBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="admin-nav-bar">
|
||||||
|
<nav className="admin-nav" aria-label="Navegación admin">
|
||||||
|
{LINKS.map((link) => (
|
||||||
|
<a
|
||||||
|
key={link.id}
|
||||||
|
href={link.href}
|
||||||
|
className={`admin-nav-link${active === link.id ? ' admin-nav-link-active' : ''}`}
|
||||||
|
aria-current={active === link.id ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<SignOutButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
52
components/dashboard/apoyos-bar-me.tsx
Normal file
52
components/dashboard/apoyos-bar-me.tsx
Normal 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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
53
components/dashboard/disposicion-red-card.tsx
Normal file
53
components/dashboard/disposicion-red-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 0–100" status={status} n={data?.n} colSpan={3}>
|
|
||||||
{data && (
|
|
||||||
<div className="db-gauge-wrapper">
|
|
||||||
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>
|
|
||||||
{/* 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">
|
|
||||||
0–33 baja · 34–66 desarrollo · 67–100 consciente
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</WidgetShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 < 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
'use client'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { isSuppressed, type KpiData, type SuppressedPayload } from '@/lib/dashboard-types'
|
|
||||||
import { queries } from '@/lib/dashboard-queries'
|
|
||||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
|
||||||
|
|
||||||
const KPI_DEFS = [
|
|
||||||
{
|
|
||||||
title: 'CUIDADORES ACTIVOS',
|
|
||||||
subtitle: '1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros',
|
|
||||||
getValue: (d: KpiData) => `${d.pct_cuidadores}%`,
|
|
||||||
getN: (d: KpiData) => `n = ${d.n}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'FRECUENCIA DE AUSENTISMO',
|
|
||||||
subtitle: '5.2 · % con ausencias laborales recurrentes (4 o más veces al año)',
|
|
||||||
getValue: (d: KpiData) => `${d.pct_ausencias}%`,
|
|
||||||
getN: () => 'ausencias frecuentes',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'BIENESTAR',
|
|
||||||
subtitle: '4.3 · autorreporte de bienestar general · escala 1–5',
|
|
||||||
getValue: (d: KpiData) => d.score_bienestar !== null ? d.score_bienestar.toFixed(1) : '—',
|
|
||||||
getN: () => '/ 5',
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
interface KpiCardProps {
|
|
||||||
title: string
|
|
||||||
subtitle: string
|
|
||||||
value: string
|
|
||||||
valueNote: string
|
|
||||||
suppressed: boolean
|
|
||||||
loading: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function KpiCard({ title, subtitle, value, valueNote, suppressed, loading }: KpiCardProps) {
|
|
||||||
return (
|
|
||||||
<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 < 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
42
components/dashboard/prevalencia-cuidadores-card.tsx
Normal file
42
components/dashboard/prevalencia-cuidadores-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
components/dashboard/punto-ciego-card.tsx
Normal file
73
components/dashboard/punto-ciego-card.tsx
Normal 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<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
components/dashboard/segmentacion-bar.tsx
Normal file
74
components/dashboard/segmentacion-bar.tsx
Normal 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 n≥5 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 n≥5 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
components/dashboard/senales-impacto-bar.tsx
Normal file
126
components/dashboard/senales-impacto-bar.tsx
Normal 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<5
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</WidgetShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<5)</span>
|
|
||||||
: seg.apoyo ?? '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</WidgetShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
58
components/dashboard/talento-riesgo-card.tsx
Normal file
58
components/dashboard/talento-riesgo-card.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
<GeoSelectWidget
|
<CiudadSelectField
|
||||||
departmentValue={(answers['A1'] as string) ?? ''}
|
name={code}
|
||||||
cityValue={(answers['A2'] as string) ?? ''}
|
label={legend}
|
||||||
onDepartmentChange={(v) => onChange('A1', v)}
|
value={(value as string) ?? ''}
|
||||||
onCityChange={(v) => onChange('A2', v)}
|
required={required}
|
||||||
departmentError={errors['A1']}
|
departamento={(answers?.['2.1:default'] as string) ?? null}
|
||||||
cityError={errors['A2']}
|
onChange={onChange}
|
||||||
|
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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
170
components/widgets/barrio-autocomplete-field.tsx
Normal file
170
components/widgets/barrio-autocomplete-field.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { getGeoData, type GeoData } from '@/lib/geo-cache'
|
||||||
|
|
||||||
|
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 [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [activeIdx, setActiveIdx] = useState(-1)
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getGeoData()
|
||||||
|
.then((data) => { setGeoData(data); setGeoStatus('ready') })
|
||||||
|
.catch(() => setGeoStatus('error'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
disabled={geoStatus === 'loading'}
|
||||||
|
placeholder={
|
||||||
|
geoStatus === 'loading' ? 'Cargando opciones...' :
|
||||||
|
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
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)}
|
||||||
|
onTouchEnd={(e) => { e.preventDefault(); select(barrio) }}
|
||||||
|
>
|
||||||
|
{barrio}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
174
components/widgets/ciudad-select-field.tsx
Normal file
174
components/widgets/ciudad-select-field.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
'use client'
|
||||||
|
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||||
|
import { getGeoData, type GeoData } from '@/lib/geo-cache'
|
||||||
|
|
||||||
|
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 [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||||
|
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(() => {
|
||||||
|
getGeoData()
|
||||||
|
.then((data) => { setGeoData(data); setGeoStatus('ready') })
|
||||||
|
.catch(() => setGeoStatus('error'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
disabled={geoStatus === 'loading'}
|
||||||
|
placeholder={
|
||||||
|
geoStatus === 'loading' ? 'Cargando opciones...' :
|
||||||
|
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
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)}
|
||||||
|
onTouchEnd={(e) => { e.preventDefault(); select(ciudad) }}
|
||||||
|
>
|
||||||
|
{ciudad}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
338
components/wizard-form.tsx
Normal file
338
components/wizard-form.tsx
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
'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
|
||||||
|
const timer = setTimeout(() => saveDraft(survey.id, state.answers), 500)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [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
|
||||||
|
}
|
||||||
|
saveDraft(survey.id, state.answers)
|
||||||
|
setState((s) => next(s, schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBack() {
|
||||||
|
saveDraft(survey.id, state.answers)
|
||||||
|
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 () => {
|
||||||
|
try {
|
||||||
|
const result = await submitSurvey({
|
||||||
|
surveyId: survey.id,
|
||||||
|
answers,
|
||||||
|
consentOperativo,
|
||||||
|
consentMacroN1,
|
||||||
|
})
|
||||||
|
if (result.success) {
|
||||||
|
clearDraft(survey.id)
|
||||||
|
setStage('success')
|
||||||
|
} else {
|
||||||
|
setSubmitError(result.error)
|
||||||
|
setStage('error')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Falla de red al invocar el Server Action (fetch failed) — no es un
|
||||||
|
// error que submitSurvey pueda devolver como result, hay que capturarlo acá.
|
||||||
|
setSubmitError('No pudimos enviar tu respuesta. Verificá tu conexión e intentá de nuevo.')
|
||||||
|
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 "Anterior" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
57
dashboard-propuesta/seed_prueba_50.sql
Normal file
57
dashboard-propuesta/seed_prueba_50.sql
Normal 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":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"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":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"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 (1–3 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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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 (1–3 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":"5–6"},{"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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"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 (4–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":"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":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"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 (1–3 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":"3–4"},{"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":"3–4"},{"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 (4–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":"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":"3–4"},{"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":"3–4"},{"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":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"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":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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 (1–3 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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"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 (1–3 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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"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 (1–3 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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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 (4–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":"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"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 (4–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":"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":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"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 (4–10 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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"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 (1–3 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":"3–4"},{"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 (1–3 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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"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 (1–3 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":"3–4"},{"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":"1–2"},{"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":"3–4"},{"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 (1–3 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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"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":"5–6"},{"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 (1–3 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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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 (1–3 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":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"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 (4–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":"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":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"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":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"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';
|
||||||
102
docs/adr/ADR-005-esquema-declarativo-formularios.md
Normal file
102
docs/adr/ADR-005-esquema-declarativo-formularios.md
Normal 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`.
|
||||||
156
docs/dashboard/PROPUESTA_DASHBOARD_v3.md
Normal file
156
docs/dashboard/PROPUESTA_DASHBOARD_v3.md
Normal 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.
|
||||||
159
docs/dashboard/SPEC_MIRADA_EMPRESA.md
Normal file
159
docs/dashboard/SPEC_MIRADA_EMPRESA.md
Normal 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 (4–10 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.
|
||||||
275
docs/dashboard/dashboard_tres_miradas_mockup.html
Normal file
275
docs/dashboard/dashboard_tres_miradas_mockup.html
Normal 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<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<5</span></div>
|
||||||
|
<div class="note">Itapúa suprimido por k-anonimato (n<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>
|
||||||
971
docs/dashboard/seed_prueba_50.sql
Normal file
971
docs/dashboard/seed_prueba_50.sql
Normal file
@@ -0,0 +1,971 @@
|
|||||||
|
-- SEED DE PRUEBA — 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%
|
||||||
|
--
|
||||||
|
-- Resuelve los question_id por code DENTRO de un bloque DO $$ — nunca
|
||||||
|
-- hardcodeados. Antes este archivo tenía los UUIDs literales y se
|
||||||
|
-- desactualizaba cada vez que la DB se reinicializaba (gen_random_uuid()
|
||||||
|
-- en supabase/seed.sql genera UUIDs nuevos en cada corrida). Con esto,
|
||||||
|
-- el seed funciona contra cualquier instancia sin importar cuándo se
|
||||||
|
-- recreó, siempre que el catálogo de preguntas (supabase/seed.sql) ya
|
||||||
|
-- esté aplicado.
|
||||||
|
--
|
||||||
|
-- Idempotente: limpia SOLO las filas que este script genera (ip_hash con el
|
||||||
|
-- prefijo 'seed-prueba-'), no un TRUNCATE de las tablas completas — answers/
|
||||||
|
-- responses/consent_records pueden tener datos reales de otras encuestas
|
||||||
|
-- (InQuality, Fundación Solidaridad) que este script no debe tocar.
|
||||||
|
|
||||||
|
-- Único bloque DO $$ — sin statements sueltos antes/después que psql
|
||||||
|
-- pueda seguir ejecutando como filas independientes si algo de acá adentro
|
||||||
|
-- falla. Si un PERFORM revienta, todo el bloque aborta y se ve UN error,
|
||||||
|
-- no cincuenta.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
s_id uuid := '30000000-0000-0000-0000-000000000001';
|
||||||
|
q_2_7 uuid;
|
||||||
|
q_4_1 uuid;
|
||||||
|
q_6_1 uuid;
|
||||||
|
q_6_2 uuid;
|
||||||
|
q_6_4 uuid;
|
||||||
|
q_7_2 uuid;
|
||||||
|
q_7_3 uuid;
|
||||||
|
q_7_5 uuid;
|
||||||
|
q_7_6 uuid;
|
||||||
|
q_9_1 uuid;
|
||||||
|
q_9_2 uuid;
|
||||||
|
q_9_3 uuid;
|
||||||
|
q_9_4 uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Resolver UUIDs de questions por code, en el momento de ejecutarse.
|
||||||
|
SELECT id INTO q_2_7 FROM public.questions
|
||||||
|
WHERE code = '2.7' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_4_1 FROM public.questions
|
||||||
|
WHERE code = '4.1' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_6_1 FROM public.questions
|
||||||
|
WHERE code = '6.1' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_6_2 FROM public.questions
|
||||||
|
WHERE code = '6.2' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_6_4 FROM public.questions
|
||||||
|
WHERE code = '6.4' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_2 FROM public.questions
|
||||||
|
WHERE code = '7.2' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_3 FROM public.questions
|
||||||
|
WHERE code = '7.3' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_5 FROM public.questions
|
||||||
|
WHERE code = '7.5' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_6 FROM public.questions
|
||||||
|
WHERE code = '7.6' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_1 FROM public.questions
|
||||||
|
WHERE code = '9.1' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_2 FROM public.questions
|
||||||
|
WHERE code = '9.2' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_3 FROM public.questions
|
||||||
|
WHERE code = '9.3' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_4 FROM public.questions
|
||||||
|
WHERE code = '9.4' AND survey_id = s_id;
|
||||||
|
|
||||||
|
-- Si alguna no se resolvió, frenar acá con un mensaje claro en vez de
|
||||||
|
-- dejar que rpc_submit_response falle más abajo con invalid_answer_reference.
|
||||||
|
IF q_2_7 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '2.7';
|
||||||
|
END IF;
|
||||||
|
IF q_4_1 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '4.1';
|
||||||
|
END IF;
|
||||||
|
IF q_6_1 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '6.1';
|
||||||
|
END IF;
|
||||||
|
IF q_6_2 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '6.2';
|
||||||
|
END IF;
|
||||||
|
IF q_6_4 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '6.4';
|
||||||
|
END IF;
|
||||||
|
IF q_7_2 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.2';
|
||||||
|
END IF;
|
||||||
|
IF q_7_3 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.3';
|
||||||
|
END IF;
|
||||||
|
IF q_7_5 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.5';
|
||||||
|
END IF;
|
||||||
|
IF q_7_6 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.6';
|
||||||
|
END IF;
|
||||||
|
IF q_9_1 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.1';
|
||||||
|
END IF;
|
||||||
|
IF q_9_2 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.2';
|
||||||
|
END IF;
|
||||||
|
IF q_9_3 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.3';
|
||||||
|
END IF;
|
||||||
|
IF q_9_4 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.4';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM public.responses
|
||||||
|
WHERE survey_id = s_id
|
||||||
|
AND ip_hash LIKE 'seed-prueba-%';
|
||||||
|
-- answers cae por ON DELETE CASCADE (responses.id). consent_records de
|
||||||
|
-- estas filas queda huérfano a propósito — es evidencia de consentimiento
|
||||||
|
-- (ADR-003), no se borra aunque la response que lo usaba se borre.
|
||||||
|
|
||||||
|
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||||
|
|
||||||
|
-- seed-prueba-000
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-000', '55 años o más', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-001
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-001', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-002
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-002', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-003
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-003', '25 a 34 años', 'Gerencial', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-004
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-004', '55 años o más', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-005
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-005', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-006
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-006', '25 a 34 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar', 'Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Redes de apoyo / grupos de pares', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-007
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-007', '35 a 44 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-008
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-008', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-009
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-009', 'Menos de 25 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Información sobre recursos y servicios', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-010
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-011
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-011', '35 a 44 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-012
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-012', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-013
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-013', '25 a 34 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-014
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-015
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-016
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-016', '25 a 34 años', 'Directivo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-017
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-017', '55 años o más', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-018
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-019
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-019', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-020
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-020', '55 años o más', 'Jefatura', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado', 'Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-021
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-021', '45 a 54 años', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-022
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-023
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-023', '25 a 34 años', 'Operativo', 'Itapúa',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-024
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-025
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-026
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-026', '25 a 34 años', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Rechazar un ascenso o nueva responsabilidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-027
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-027', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-028
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-028', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-029
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-029', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-030
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-030', '35 a 44 años', 'Operativo', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-031
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-032
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-033
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-034
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-034', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-035
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-035', '45 a 54 años', 'Jefatura', 'Itapúa',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-036
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-036', '25 a 34 años', 'Jefatura', 'Itapúa',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-037
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-037', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-038
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-038', '35 a 44 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-039
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-040
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-040', '45 a 54 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-041
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-042
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-042', '25 a 34 años', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-043
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-043', 'Menos de 25 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-044
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-044', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-045
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-045', '25 a 34 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-046
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-046', '25 a 34 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-047
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-047', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-048
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-048', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-049
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Survey queda en 'live' (ya lo estaba desde el UPDATE de arriba; este
|
||||||
|
-- segundo UPDATE es explícito para que quede claro al releer el script,
|
||||||
|
-- no depende de que nada de arriba lo haya dejado sin tocar).
|
||||||
|
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Para limpiar manualmente este lote de prueba sin tocar otras encuestas:
|
||||||
|
-- DELETE FROM public.responses WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND ip_hash LIKE 'seed-prueba-%';
|
||||||
975
docs/dashboard/seed_prueba_fundacion_solidaridad.sql
Normal file
975
docs/dashboard/seed_prueba_fundacion_solidaridad.sql
Normal file
@@ -0,0 +1,975 @@
|
|||||||
|
-- SEED DE PRUEBA — Fundación Solidaridad — 50 respuestas + modalidad (2.7).
|
||||||
|
-- Clon de docs/dashboard/seed_prueba_50.sql (mismo patrón, mismas distribuciones
|
||||||
|
-- determinísticas seed=42) apuntado a survey_id de Fundación Solidaridad
|
||||||
|
-- ('30000000-0000-0000-0000-000000000003') en vez de Empresa Demo.
|
||||||
|
-- DATOS SINTÉTICOS — no son respuestas reales de cuidadores de la Fundación.
|
||||||
|
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
|
||||||
|
--
|
||||||
|
-- Resuelve los question_id por code DENTRO de un bloque DO $$ — nunca
|
||||||
|
-- hardcodeados. Antes este archivo tenía los UUIDs literales y se
|
||||||
|
-- desactualizaba cada vez que la DB se reinicializaba (gen_random_uuid()
|
||||||
|
-- en supabase/seed.sql genera UUIDs nuevos en cada corrida). Con esto,
|
||||||
|
-- el seed funciona contra cualquier instancia sin importar cuándo se
|
||||||
|
-- recreó, siempre que el catálogo de preguntas (supabase/seed.sql) ya
|
||||||
|
-- esté aplicado.
|
||||||
|
--
|
||||||
|
-- Idempotente: limpia SOLO las filas que este script genera (ip_hash con el
|
||||||
|
-- prefijo 'seed-prueba-fs-'), no un TRUNCATE de las tablas completas — answers/
|
||||||
|
-- responses/consent_records pueden tener datos reales de otras encuestas
|
||||||
|
-- (Empresa Demo, InQuality) que este script no debe tocar.
|
||||||
|
|
||||||
|
-- Único bloque DO $$ — sin statements sueltos antes/después que psql
|
||||||
|
-- pueda seguir ejecutando como filas independientes si algo de acá adentro
|
||||||
|
-- falla. Si un PERFORM revienta, todo el bloque aborta y se ve UN error,
|
||||||
|
-- no cincuenta.
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
s_id uuid := '30000000-0000-0000-0000-000000000003';
|
||||||
|
q_2_7 uuid;
|
||||||
|
q_4_1 uuid;
|
||||||
|
q_6_1 uuid;
|
||||||
|
q_6_2 uuid;
|
||||||
|
q_6_4 uuid;
|
||||||
|
q_7_2 uuid;
|
||||||
|
q_7_3 uuid;
|
||||||
|
q_7_5 uuid;
|
||||||
|
q_7_6 uuid;
|
||||||
|
q_9_1 uuid;
|
||||||
|
q_9_2 uuid;
|
||||||
|
q_9_3 uuid;
|
||||||
|
q_9_4 uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Resolver UUIDs de questions por code, en el momento de ejecutarse.
|
||||||
|
SELECT id INTO q_2_7 FROM public.questions
|
||||||
|
WHERE code = '2.7' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_4_1 FROM public.questions
|
||||||
|
WHERE code = '4.1' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_6_1 FROM public.questions
|
||||||
|
WHERE code = '6.1' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_6_2 FROM public.questions
|
||||||
|
WHERE code = '6.2' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_6_4 FROM public.questions
|
||||||
|
WHERE code = '6.4' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_2 FROM public.questions
|
||||||
|
WHERE code = '7.2' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_3 FROM public.questions
|
||||||
|
WHERE code = '7.3' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_5 FROM public.questions
|
||||||
|
WHERE code = '7.5' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_7_6 FROM public.questions
|
||||||
|
WHERE code = '7.6' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_1 FROM public.questions
|
||||||
|
WHERE code = '9.1' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_2 FROM public.questions
|
||||||
|
WHERE code = '9.2' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_3 FROM public.questions
|
||||||
|
WHERE code = '9.3' AND survey_id = s_id;
|
||||||
|
SELECT id INTO q_9_4 FROM public.questions
|
||||||
|
WHERE code = '9.4' AND survey_id = s_id;
|
||||||
|
|
||||||
|
-- Si alguna no se resolvió, frenar acá con un mensaje claro en vez de
|
||||||
|
-- dejar que rpc_submit_response falle más abajo con invalid_answer_reference.
|
||||||
|
IF q_2_7 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '2.7';
|
||||||
|
END IF;
|
||||||
|
IF q_4_1 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '4.1';
|
||||||
|
END IF;
|
||||||
|
IF q_6_1 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '6.1';
|
||||||
|
END IF;
|
||||||
|
IF q_6_2 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '6.2';
|
||||||
|
END IF;
|
||||||
|
IF q_6_4 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '6.4';
|
||||||
|
END IF;
|
||||||
|
IF q_7_2 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.2';
|
||||||
|
END IF;
|
||||||
|
IF q_7_3 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.3';
|
||||||
|
END IF;
|
||||||
|
IF q_7_5 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.5';
|
||||||
|
END IF;
|
||||||
|
IF q_7_6 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '7.6';
|
||||||
|
END IF;
|
||||||
|
IF q_9_1 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.1';
|
||||||
|
END IF;
|
||||||
|
IF q_9_2 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.2';
|
||||||
|
END IF;
|
||||||
|
IF q_9_3 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.3';
|
||||||
|
END IF;
|
||||||
|
IF q_9_4 IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'UUID no encontrado para code %', '9.4';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM public.responses
|
||||||
|
WHERE survey_id = s_id
|
||||||
|
AND ip_hash LIKE 'seed-prueba-fs-%';
|
||||||
|
-- answers cae por ON DELETE CASCADE (responses.id). consent_records de
|
||||||
|
-- estas filas queda huérfano a propósito — es evidencia de consentimiento
|
||||||
|
-- (ADR-003), no se borra aunque la response que lo usaba se borre.
|
||||||
|
|
||||||
|
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||||
|
|
||||||
|
-- seed-prueba-fs-000
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-000', '55 años o más', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-001
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-001', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-002
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-002', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-003
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-003', '25 a 34 años', 'Gerencial', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-004
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-004', '55 años o más', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-005
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-005', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-006
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-006', '25 a 34 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar', 'Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Redes de apoyo / grupos de pares', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-007
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-007', '35 a 44 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-008
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-008', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-009
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-009', 'Menos de 25 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Información sobre recursos y servicios', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-010
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-011
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-011', '35 a 44 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-012
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-012', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-013
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-013', '25 a 34 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-014
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-015
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-016
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-016', '25 a 34 años', 'Directivo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-017
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-017', '55 años o más', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-018
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-019
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-019', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-020
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-020', '55 años o más', 'Jefatura', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado', 'Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-021
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-021', '45 a 54 años', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-022
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-023
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-023', '25 a 34 años', 'Operativo', 'Itapúa',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-024
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-025
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-026
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-026', '25 a 34 años', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Rechazar un ascenso o nueva responsabilidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-027
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-027', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-028
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-028', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar menor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-029
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-029', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-030
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-030', '35 a 44 años', 'Operativo', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-031
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-032
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-033
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-034
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-034', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Hijo menor sin discapacidad']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Redes de apoyo / grupos de pares']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-035
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-035', '45 a 54 años', 'Jefatura', 'Itapúa',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||||
|
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||||
|
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||||
|
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||||
|
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||||
|
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||||
|
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||||
|
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||||
|
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||||
|
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||||
|
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Capacitación y autocuidado']::text[])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-036
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-036', '25 a 34 años', 'Jefatura', 'Itapúa',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-037
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-037', '35 a 44 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-038
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-038', '35 a 44 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-039
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-040
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-040', '45 a 54 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-041
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-042
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-042', '25 a 34 años', 'Jefatura', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-043
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-043', 'Menos de 25 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-044
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-044', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-045
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-045', '25 a 34 años', 'Auxiliar', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-046
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-046', '25 a 34 años', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-047
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-047', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-048
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-048', '55 años o más', 'Operativo', 'Central',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
-- seed-prueba-fs-049
|
||||||
|
PERFORM public.rpc_submit_response(
|
||||||
|
s_id, 'seed-prueba-fs-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||||
|
true, false,
|
||||||
|
jsonb_build_array(
|
||||||
|
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||||
|
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Survey queda en 'live' (ya lo estaba desde el UPDATE de arriba; este
|
||||||
|
-- segundo UPDATE es explícito para que quede claro al releer el script,
|
||||||
|
-- no depende de que nada de arriba lo haya dejado sin tocar).
|
||||||
|
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Para limpiar manualmente este lote de prueba sin tocar otras encuestas:
|
||||||
|
-- DELETE FROM public.responses WHERE survey_id = '30000000-0000-0000-0000-000000000003' AND ip_hash LIKE 'seed-prueba-fs-%';
|
||||||
169
docs/estado/ESTADO_PROYECTO_26JUN2026.md
Normal file
169
docs/estado/ESTADO_PROYECTO_26JUN2026.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
||||||
|
## Documento de continuidad — 26 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:** **3.0.0** — wizard v3 + dashboard tres miradas desplegado · tag `v3.0.0` · commit `fcf93ea`
|
||||||
|
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||||
|
- **3 surveys en `live`** — reactivados al desplegar v3 (26 jun 2026)
|
||||||
|
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sprint 4 — Wizard v3 — COMPLETO Y EN PRODUCCIÓN
|
||||||
|
|
||||||
|
| Etapa | Estado |
|
||||||
|
|---|---|
|
||||||
|
| 4.0 Preservación v2 | ✅ |
|
||||||
|
| 4A instance_id | ✅ |
|
||||||
|
| 4B esquema declarativo + seed v3 | ✅ |
|
||||||
|
| 4C motor de flujo (`lib/form-engine.ts`, puro, 20+ tests) | ✅ |
|
||||||
|
| 4D wizard renderer (layout estable, header/footer fijos) | ✅ |
|
||||||
|
| 4E instancias familiares (mini-wizard emergente del motor) | ✅ |
|
||||||
|
| 4F autocomplete barrios + filtro ciudad por departamento | ✅ |
|
||||||
|
| 4G submit + validación server-side (RPC `rpc_submit_response`) | ✅ |
|
||||||
|
| 4I validación e2e + tag v3.0.0 + deploy + surveys live | ✅ |
|
||||||
|
|
||||||
|
### Decisiones arquitectónicas clave (no reabrir)
|
||||||
|
- **ADR-005:** lógica de flujo en datos (esquema), nunca en frontend.
|
||||||
|
- Motor puro en cliente; reusado server-side para validación de submit.
|
||||||
|
- Único camino de escritura: RPC `rpc_submit_response` (SECURITY DEFINER).
|
||||||
|
- `instance_id` por answer — instancias familiares persistidas correctamente.
|
||||||
|
- Validación server-side: 7 checks (preguntas visibles, valores válidos, instancias legítimas,
|
||||||
|
consentimiento, requeridas presentes). Rechaza el submit completo si algo falla.
|
||||||
|
|
||||||
|
### Autocomplete geo (4F)
|
||||||
|
- `public/data/geo_paraguay_full.json` — 8152 barrios, jerarquía depto → ciudad → barrios.
|
||||||
|
- Pregunta 2.3 (barrio): autocomplete con `position: fixed` + `getBoundingClientRect()`.
|
||||||
|
- Pregunta 2.2 (ciudad): filtra por departamento (2.1) usando el mismo JSON. Limpia valor
|
||||||
|
al cambiar departamento. Texto libre siempre permitido como fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dashboard v3 — Mirada Empresa en producción
|
||||||
|
|
||||||
|
**Decisión vigente:** NO migrar RPCs v2. Dashboard nuevo de tres miradas
|
||||||
|
(OIT 5R, CEPAL, PNCUPA Paraguay).
|
||||||
|
|
||||||
|
### Estado
|
||||||
|
- **Mirada Empresa:** ✅ COMPLETA EN PRODUCCIÓN
|
||||||
|
- 11 RPCs (`rpc_me_*`) + helper `_dash_es_cuidador` verificados contra 50 respuestas reales.
|
||||||
|
- 9 widgets funcionando: Talento en riesgo, Prevalencia, Punto ciego, Señales de impacto
|
||||||
|
(presentismo 41.7%, ausentismo 36.1%, riesgo 38.9%, carrera congelada 47.2%),
|
||||||
|
Apoyos demandados (ordenados por demanda), Segmentación (nivel y modalidad),
|
||||||
|
Disposición a red de apoyo.
|
||||||
|
- Tema claro marca Re: fondo `#E7E6E5`, cards `#FFFFFF`, texto `#494F4F`.
|
||||||
|
- Migraciones aplicadas: `20260625000001` (fix `_dash_responses`) +
|
||||||
|
`20260625000002` (11 RPCs) + `20260626000001` (fix bug `cnt` Opción B).
|
||||||
|
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA
|
||||||
|
a sprint de dashboard post-deadline.
|
||||||
|
|
||||||
|
### Bug conocido abierto
|
||||||
|
- `rpc_me_carrera_congelada`: "Ninguna de las anteriores" aparece en `top_opciones`
|
||||||
|
a pesar del filtro en el subquery. El filtro opera sobre el array completo, no sobre
|
||||||
|
los elementos individuales del `jsonb_array_elements_text`. No bloqueante para el
|
||||||
|
release — registrado para próximo sprint.
|
||||||
|
|
||||||
|
### Documentos (en `docs/dashboard/`)
|
||||||
|
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados
|
||||||
|
- `dashboard_tres_miradas_mockup.html` — mockup funcional 3 pestañas
|
||||||
|
- `SPEC_MIRADA_EMPRESA.md` — contrato técnico 10 métricas + estrella
|
||||||
|
|
||||||
|
### Decisiones del dashboard (no reabrir)
|
||||||
|
- CUIDADOR_ACTIVO incluye "hijo menor sin discapacidad" (criterio UNFPA, validado por Maria jun 2026).
|
||||||
|
- Mirada Empresa NO expone datos sensibles (3.x, 4.2, 4.3).
|
||||||
|
- k-anonimato k≥5 sobre `count(DISTINCT response_id)` — también en métricas por instancia.
|
||||||
|
- 2 gaps de datos para sprint dashboard: horas cuidado no remunerado (País),
|
||||||
|
escolarización niñez (Humana).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Datos de prueba en VPS
|
||||||
|
- **51 respuestas** en survey Demo (`30000000-0000-0000-0000-000000000001`) — 50 del seed
|
||||||
|
determinista (seed=42) + 1 del e2e de 4I.
|
||||||
|
- Survey Demo en `live`. Datos NO limpiados — el director los necesita para mostrar al equipo.
|
||||||
|
- Script de limpieza cuando corresponda:
|
||||||
|
```bash
|
||||||
|
docker exec supabase-db psql -U postgres -d postgres -c "
|
||||||
|
TRUNCATE answers, responses, consent_records CASCADE;
|
||||||
|
UPDATE surveys SET status='draft' WHERE id='30000000-0000-0000-0000-000000000001';"
|
||||||
|
```
|
||||||
|
- ⚠️ El seed corrió dos veces en sesión anterior (detectado y corregido). Agregar guard
|
||||||
|
anti-duplicación al `seed_prueba_50.sql` — anotado en BACKLOG.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Deuda técnica y seguridad
|
||||||
|
|
||||||
|
| ID | Descripción | Prioridad |
|
||||||
|
|---|---|---|
|
||||||
|
| BUG-001 | "Ninguna de las anteriores" aparece en `rpc_me_carrera_congelada` | Próximo sprint dashboard |
|
||||||
|
| TECH-DEBT-006 | Backup remoto (S3 o VPS alternativo o Google Drive) | Antes de datos reales no desechables |
|
||||||
|
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales no desechables |
|
||||||
|
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de `qi_modalidad` | Baja |
|
||||||
|
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
|
||||||
|
| SEC-003 | TRUNCATE evade inmutabilidad ADR-003 | Antes de datos reales no desechables |
|
||||||
|
| SEC-004 | RPC bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
|
||||||
|
| BACKLOG-001 | `seed_prueba_50.sql` idempotente (guard anti-duplicación) | Baja |
|
||||||
|
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline |
|
||||||
|
|
||||||
|
**Riesgo aceptado explícitamente (26 jun 2026):** TECH-DEBT-006, TECH-DEBT-009 y SEC-003
|
||||||
|
aplican cuando haya datos reales no desechables. Los datos actuales son de empresas voluntarias
|
||||||
|
y grupos de control — desechables si es necesario.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 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` (Kong + SSL)
|
||||||
|
- Repo: `git.inquality.com.py/marcos/motor-de-encuestas` (rama `main`)
|
||||||
|
- Repo local: `/Users/marcos/InQ/Motor de encuestas`
|
||||||
|
- Migraciones SIEMPRE vía CLI del VPS:
|
||||||
|
`docker exec -i supabase-db psql -U postgres -d postgres < archivo.sql`
|
||||||
|
- Deploy: Dokploy → Rebuild + "Clean cache"
|
||||||
|
- Anon key Kong (diagnóstico):
|
||||||
|
`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMDAwMDAwMDB9.6D79NWu1KfeOzEZXT6Amf-NmG0T9uk8SK9xNd32oLaY`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contexto comercial
|
||||||
|
- 2 clientes inminentes. Mirada Empresa es la que le habla al cliente que paga.
|
||||||
|
- InQuality y Fundación Solidaridad en joint venture.
|
||||||
|
- Datos actuales: empresas voluntarias y grupos de control (desechables).
|
||||||
|
- Datos reales no desechables: avisará el director — en ese momento activar deuda de seguridad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Próximos sprints sugeridos
|
||||||
|
|
||||||
|
1. **Sprint dashboard** — Miradas País y Humana, fix BUG-001 carrera congelada,
|
||||||
|
preguntas faltantes (horas no remuneradas, escolarización), decisión `qi_modalidad`.
|
||||||
|
2. **Sprint compliance** — SEC-002 (audit log, hashing, cifrado), SEC-003, TECH-DEBT-006/009.
|
||||||
|
3. **Sprint motor genérico** — BACKLOG-002 Fase 2, extracción del intérprete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 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 (Marcos).
|
||||||
|
Claude Code (VS Code, contexto separado) hace la implementación; vos planificás,
|
||||||
|
especificás y auditás.
|
||||||
|
|
||||||
|
Estado completo: docs/estado/ESTADO_PROYECTO_26JUN2026.md
|
||||||
|
|
||||||
|
Producción: v3.0.0 desplegada · 3 surveys en live · tag v3.0.0 (commit fcf93ea)
|
||||||
|
Formulario v3: COMPLETO (motor, wizard, instancias, submit server-side, autocomplete geo)
|
||||||
|
Dashboard: Mirada Empresa funcional en producción · País/Humana diferidas
|
||||||
|
Datos en VPS: 51 respuestas en survey Demo (no limpiar — para mostrar al equipo)
|
||||||
|
|
||||||
|
Bug abierto: "Ninguna de las anteriores" en rpc_me_carrera_congelada (no bloqueante)
|
||||||
|
|
||||||
|
Próximo paso: [COMPLETAR]
|
||||||
|
```
|
||||||
147
docs/estado/ESTADO_PROYECTO_JUNIO_2026.md
Normal file
147
docs/estado/ESTADO_PROYECTO_JUNIO_2026.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
||||||
|
## Documento de continuidad — 27 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:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
|
||||||
|
en `live`. Release v3 completado (etapa 4I).
|
||||||
|
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||||
|
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Sprint 4 — Wizard v3 — COMPLETO, release hecho
|
||||||
|
|
||||||
|
| 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) | ✅ |
|
||||||
|
| Widgets Mirada Empresa (frontend) | ✅ |
|
||||||
|
| Tema claro dashboard (marca Re) | ✅ |
|
||||||
|
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ✅ |
|
||||||
|
| ETAPA_AUTH — login/middleware/sign-out para `/admin` y `/dashboard` | ✅ (27 jun) |
|
||||||
|
|
||||||
|
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). Dashboard
|
||||||
|
Mirada Empresa con sus 13 widgets, consumiendo las RPCs `rpc_me_*` reales. `/admin/responses`
|
||||||
|
y `/dashboard` ahora requieren sesión autenticada (antes sin auth — cierra TECH-DEBT-008).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2.1 Incidente VPS (27 jun) — recuperado
|
||||||
|
|
||||||
|
Una segunda instancia de Dokploy desplegada desde el mismo repo (con `container_name` fijos en
|
||||||
|
docker-compose.yml) colisionó con la producción y vació el esquema `public` de `supabase-db`.
|
||||||
|
**Sin pérdida de datos reales** (solo seed demo). Se restauró: migraciones completas + seed +
|
||||||
|
surveys reactivados a `live` + usuario `platform_admin` creado. Verificado end-to-end: login,
|
||||||
|
sign-out, redirect sin sesión, formulario público (3 links `?s=<survey_id>`) — todo OK.
|
||||||
|
Detalle completo en memoria `project_vps_container_name_incident`. El director ya separó la
|
||||||
|
segunda instancia a un repo Gitea propio sin `container_name` fijos, para que no se repita.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dashboard v3 — "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 + widgets de frontend completos y en producción. E1-E10 + estrella
|
||||||
|
"Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
|
||||||
|
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
|
||||||
|
sprint de dashboard post-deadline (sin cambios desde el último estado).
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~ — **resuelto** por ETAPA_AUTH (27 jun).
|
||||||
|
|
||||||
|
| 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-001 | Next.js — vuln "Cache Key Confusion" vigente en 15.3.8; última mayor es 16.2.9 (breaking) | Media |
|
||||||
|
| TECH-001 | `npm audit`: 4 vulns (1 moderate, 3 high) — `form-data`, `next`, `undici` | Pendiente revisión |
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
*(SEC-001/TECH-001 verificados 27 jun vía `npm view next version` / `npm audit --json` —
|
||||||
|
los números en BACKLOG.md/TECH_DEBT.md estaban desactualizados, ej. citaban Next 15.3.3
|
||||||
|
y "2 vulnerabilidades" cuando ya corre 15.3.8 con 4.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
**Go-live (widgets → 4F → 4I) está cerrado.** v3.0.0 en producción, surveys `live`, auth para
|
||||||
|
staff funcionando. No queda ninguna etapa de Sprint 4 pendiente — el roadmap pre-escrito se
|
||||||
|
agotó. El próximo paso requiere decisión del director entre:
|
||||||
|
|
||||||
|
1. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
|
||||||
|
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad (TECH-DEBT-010).
|
||||||
|
2. **Sprint de compliance/seguridad (SEC-002):** audit log append-only, hashing de integridad,
|
||||||
|
cifrado en reposo — bloquea escalar a más empresas/datos reales.
|
||||||
|
3. **Mantenimiento de dependencias:** SEC-001 (Next.js) + TECH-001 (`npm audit`, 4 vulns).
|
||||||
|
4. Otro ítem de BACKLOG.md/TECH_DEBT.md no listado arriba.
|
||||||
|
|
||||||
|
Ninguno de estos se decide unilateralmente (Nivel 3 — tocan planificación/seguridad).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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]
|
||||||
|
```
|
||||||
142
docs/guias/GUIA_PRUEBA_FORMULARIO_Y_DASHBOARD.md
Normal file
142
docs/guias/GUIA_PRUEBA_FORMULARIO_Y_DASHBOARD.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Guía — Probar el formulario y leer el dashboard
|
||||||
|
## Datos País sobre el Cuidado
|
||||||
|
|
||||||
|
> Dos públicos en un documento: **Sección 1** es para el equipo técnico que va a
|
||||||
|
> probar el formulario. **Sección 2** es para Maria Mersan (metodología) — cómo
|
||||||
|
> leer el dashboard de Mirada Empresa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sección 1 — Plan de pruebas del formulario (equipo técnico)
|
||||||
|
|
||||||
|
### El link
|
||||||
|
|
||||||
|
```
|
||||||
|
https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001
|
||||||
|
```
|
||||||
|
|
||||||
|
Esta es la encuesta **"Empresa Demo S.A."** — un sandbox pensado exactamente para
|
||||||
|
esto. **Pueden responderla las veces que quieran.** No es la encuesta real de
|
||||||
|
ningún cliente: nada de lo que carguen ahí se mezcla con datos reales.
|
||||||
|
|
||||||
|
> ⚠️ No usen el link de Fundación Solidaridad ni el de InQuality para pruebas.
|
||||||
|
> El sistema es **anónimo por diseño** (no guarda qué respuesta es de quién), así
|
||||||
|
> que una respuesta de prueba enviada a una encuesta real queda mezclada para
|
||||||
|
> siempre con datos reales, sin forma de distinguirla ni borrarla después.
|
||||||
|
|
||||||
|
### Qué van a ver al entrar
|
||||||
|
|
||||||
|
1. **Pantalla de consentimiento** — checkbox operativo (obligatorio) + checkbox de
|
||||||
|
estudio país (opcional). Sin esto no se entra al formulario.
|
||||||
|
2. **El wizard** — 10 secciones, 40 preguntas. La mayoría son **obligatorias**
|
||||||
|
(recién activado) excepto la pregunta de barrio (2.3), que es texto libre
|
||||||
|
opcional.
|
||||||
|
3. **Pantalla final** — botón "Enviar encuesta".
|
||||||
|
|
||||||
|
### Checklist de cosas a probar
|
||||||
|
|
||||||
|
- [ ] Completar el formulario entero y enviarlo — confirmar que llega la pantalla
|
||||||
|
de agradecimiento.
|
||||||
|
- [ ] Intentar avanzar sin responder una pregunta obligatoria — debe bloquear con
|
||||||
|
mensaje "Este campo es obligatorio."
|
||||||
|
- [ ] Dejar el campo de barrio (2.3) vacío y avanzar — **no** debe bloquear (es
|
||||||
|
opcional).
|
||||||
|
- [ ] Escribir un barrio que no exista en la lista — debe aceptarse como texto
|
||||||
|
libre, sin error.
|
||||||
|
- [ ] Seleccionar Departamento → confirmar que Ciudad se filtra por ese
|
||||||
|
departamento.
|
||||||
|
- [ ] Ir hacia atrás con "Anterior" y adelante de nuevo — los valores que ya
|
||||||
|
cargaron deben seguir ahí.
|
||||||
|
- [ ] Cerrar la pestaña a mitad de formulario, volver a abrir el link — debe
|
||||||
|
ofrecer retomar donde quedaron (guardado automático).
|
||||||
|
- [ ] Probar en el celular (o achicando la ventana del navegador) — los botones y
|
||||||
|
el texto no se deben cortar ni superponer.
|
||||||
|
- [ ] Si pueden, prueben con el wifi cortado a mitad de un envío — debe mostrar
|
||||||
|
un mensaje de error claro, no una pantalla rota.
|
||||||
|
|
||||||
|
Cualquier cosa rara (texto que no carga, botón que no responde, algo que se ve
|
||||||
|
mal en el celular) — repórtenlo con captura de pantalla y en qué pregunta pasó.
|
||||||
|
|
||||||
|
### Dónde ver lo que enviaron
|
||||||
|
|
||||||
|
- **Lista de respuestas:** `/admin/responses` (filtro por encuesta "Empresa Demo
|
||||||
|
S.A."). Necesita login.
|
||||||
|
- **Dashboard:** `/dashboard?survey_id=30000000-0000-0000-0000-000000000001`
|
||||||
|
(ver Sección 2 para cómo leerlo). Con pocas respuestas de prueba, varios
|
||||||
|
números van a aparecer suprimidos — es el umbral de privacidad k≥5 funcionando
|
||||||
|
como debe (ver más abajo), no un bug.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sección 2 — Cómo leer el dashboard (para Maria)
|
||||||
|
|
||||||
|
### Acceso
|
||||||
|
|
||||||
|
`https://economia-cuidado.inqualityhq.com/dashboard` — requiere login.
|
||||||
|
|
||||||
|
**Importante sobre roles, antes de entrar:** hoy el sistema tiene dos roles
|
||||||
|
posibles — `platform_admin` (ve todas las organizaciones, incluido el selector
|
||||||
|
"Todos") y `org_admin` (ve solo la organización a la que está asociado). El
|
||||||
|
flujo de invitación que ya existe (`/admin/usuarios`) **solo puede crear
|
||||||
|
usuarios `org_admin`** — no hay forma, hoy, de autoinvitarse como
|
||||||
|
`platform_admin`. Si necesitás ver **varias** organizaciones desde tu propia
|
||||||
|
cuenta (no solo Fundación Solidaridad), avisale a Marcos para que te dé acceso
|
||||||
|
`platform_admin` directo en la base — el self-service todavía no lo cubre.
|
||||||
|
|
||||||
|
### Qué mirar primero
|
||||||
|
|
||||||
|
Arriba del dashboard hay un selector de organización. Para ver los datos que
|
||||||
|
acabamos de cargar:
|
||||||
|
|
||||||
|
```
|
||||||
|
/dashboard?survey_id=30000000-0000-0000-0000-000000000003
|
||||||
|
```
|
||||||
|
|
||||||
|
(o click en "Fundación Solidaridad" en la barra superior).
|
||||||
|
|
||||||
|
> 🧪 **Estos son datos sintéticos, no respuestas reales de cuidadores de la
|
||||||
|
> Fundación.** Se generaron para que el dashboard tenga algo que mostrar
|
||||||
|
> mientras se valida el diseño — no son representativos de la población real
|
||||||
|
> de la Fundación todavía. Cuando empecemos a recolectar datos reales, te
|
||||||
|
> avisamos explícitamente (así lo acordamos con Marcos).
|
||||||
|
|
||||||
|
Las pestañas **Mirada País** y **Mirada Humana** todavía no están construidas
|
||||||
|
(dicen "Disponible próximamente") — hoy solo **Mirada Empresa** tiene datos.
|
||||||
|
|
||||||
|
### Los 7 indicadores de Mirada Empresa
|
||||||
|
|
||||||
|
Cada tarjeta está anclada a una o más preguntas del instrumento (el número de
|
||||||
|
pregunta aparece como subtítulo en cada tarjeta, para que puedan rastrear de
|
||||||
|
dónde sale el número).
|
||||||
|
|
||||||
|
| Tarjeta (como aparece en pantalla) | Pregunta(s) | Qué responde | Cómo leerla |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **Talento en riesgo** | 7.6 + 7.3 + 7.5 | Métrica compuesta: % de la plantilla cuidadora con señales combinadas de fuga (probabilidad de irse/reducir + ausentismo + carrera frenada). | Es el número "traductor a negocio" — cuanto más alto, más costo de reemplazo en riesgo. Pensado para abrir la conversación con RRHH/dirección, no como diagnóstico aislado. |
|
||||||
|
| **Cuidadores en la plantilla** | 4.1 | % de empleados con algún vínculo de cuidado activo (incluye hijo/a menor sin discapacidad, criterio UNFPA). | Es la prevalencia base — el "cuánta gente de mi plantilla está en esto" antes de mirar cualquier otro indicador. |
|
||||||
|
| **Punto ciego organizacional** | 9.1 vs. 4.1 | Brecha entre la carga real de cuidado (prevalencia) y la percepción de que la empresa lo nota/apoya. | Si la brecha es grande, hay gente cuidando sin que la organización lo sepa — es el hallazgo central que justifica el dashboard (ver marco conceptual en `docs/dashboard/PROPUESTA_DASHBOARD_v3.md`). |
|
||||||
|
| **Señales de impacto laboral** | 7.2, 7.3, 7.6, 7.5 | Desglose de presentismo, ausentismo, probabilidad de salida y carrera frenada — los componentes de "Talento en riesgo" mostrados por separado. | Útil para ver CUÁL señal pesa más en este segmento (¿es ausentismo o es que están rechazando ascensos?). |
|
||||||
|
| **Apoyos más demandados** | 9.2 | Qué beneficios pedirían (hasta 3 opciones por persona). | Es el insumo directo para diseñar beneficios — léelo como un ranking, no como un % que suma 100%. |
|
||||||
|
| **Dónde se concentra la carga** | 4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad) | % de cuidadores por segmento (jerarquía × modalidad de trabajo). | Mirar si la carga se concentra en niveles operativos vs. gerenciales, y presencial vs. remoto — relevante para política de flexibilidad. |
|
||||||
|
| **Disposición a red de apoyo** | 9.3 + 9.4 | % dispuesto a sumarse a un grupo de apoyo interno / hacer voluntariado. | Mide apetito para iniciativas comunitarias internas, no necesidad — alguien puede necesitar apoyo y no querer un grupo. |
|
||||||
|
|
||||||
|
### El umbral de privacidad (k≥5)
|
||||||
|
|
||||||
|
Todo el dashboard aplica **k-anonimato k≥5**: ningún segmento con menos de 5
|
||||||
|
personas se muestra desagregado (aparece suprimido). Esto es una regla de
|
||||||
|
diseño no negociable del proyecto (protege a quien respondió, especialmente
|
||||||
|
tratándose de datos de salud/discapacidad) — si ves un segmento "vacío" o con
|
||||||
|
guion, probablemente es esto, no un error de carga. Con el lote de 50
|
||||||
|
respuestas sintéticas la mayoría de los cortes generales van a tener datos,
|
||||||
|
pero cruces muy específicos (ej. un departamento + un nivel jerárquico puntual)
|
||||||
|
pueden quedar por debajo del umbral.
|
||||||
|
|
||||||
|
### Decisión metodológica pendiente (marcada para vos en la propuesta original)
|
||||||
|
|
||||||
|
El documento `docs/dashboard/PROPUESTA_DASHBOARD_v3.md` (sección "Decisión
|
||||||
|
metodológica pendiente — para Maria") deja abierta una definición: las métricas
|
||||||
|
de vínculo de cuidado (4.1) se pueden leer **por persona** ("% de empleados con
|
||||||
|
al menos un familiar con discapacidad X") o **por instancia** ("del total de
|
||||||
|
familiares cuidados, % con discapacidad X"). Hoy el dashboard usa la lectura
|
||||||
|
por persona. Si para tu análisis necesitás la lectura por instancia, es un
|
||||||
|
cambio de método de cálculo a definir y construir — avisame cuando lo hayas
|
||||||
|
decidido.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.0–5.0)
|
|
||||||
pct_ausencias: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeptoData {
|
|
||||||
depto: string
|
|
||||||
n: number | null
|
|
||||||
pct_cuidadores: number | null
|
|
||||||
suppressed: boolean
|
|
||||||
}
|
|
||||||
export interface PrevalenciaData { status: 'ok'; n_total: number; data: DeptoData[] }
|
|
||||||
|
|
||||||
export interface HeatmapCell { horas: string; ausencias: string; n: number | null; suppressed: boolean }
|
|
||||||
export interface HeatmapData { status: 'ok'; n_total: number; data: HeatmapCell[] }
|
|
||||||
|
|
||||||
export interface DistribucionData {
|
|
||||||
status: 'ok'; n: number
|
|
||||||
disc_propia: number; familiar: number; adulto_mayor: number; ninguno: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApoyoItem { politica: string; n: number; pct: number }
|
|
||||||
export interface ApoyosData { status: 'ok'; n_total: number; data: ApoyoItem[] }
|
|
||||||
|
|
||||||
export interface ScoreData { status: 'ok'; n: number; score: number; zona: 'baja' | 'desarrollo' | 'consciente' }
|
|
||||||
|
|
||||||
export interface PiramideCell { etario: string; genero: string; n: number | null; suppressed: boolean }
|
|
||||||
export interface PiramideData { status: 'ok'; n_total: number; data: PiramideCell[] }
|
|
||||||
|
|
||||||
export interface IntensidadCell { horas: string; bienestar: string; n: number | null; suppressed: boolean }
|
|
||||||
export interface IntensidadData { status: 'ok'; n_total: number; data: IntensidadCell[] }
|
|
||||||
|
|
||||||
export interface SaturacionSegmento {
|
|
||||||
segmento: string
|
|
||||||
n: number | null
|
|
||||||
pct: number | null
|
|
||||||
apoyo: string | null
|
|
||||||
suppressed: boolean
|
|
||||||
}
|
|
||||||
export interface SaturacionData { status: 'ok'; n_total: number; segmentos: SaturacionSegmento[] }
|
|
||||||
|
|
||||||
export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number }
|
export type 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (1–3 veces al año)','Varias veces (4–10 veces al año)','Con frecuencia (más de 10 veces al año)']
|
|
||||||
export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir']
|
export 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
435
lib/form-engine.test.mts
Normal 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 S3–S10 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
244
lib/form-engine.ts
Normal 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
72
lib/form-engine.types.ts
Normal 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
|
||||||
|
}
|
||||||
16
lib/geo-cache.ts
Normal file
16
lib/geo-cache.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// departamento → ciudad → barrios[]
|
||||||
|
export type GeoData = Record<string, Record<string, string[]>>
|
||||||
|
|
||||||
|
let geoData: GeoData | null = null
|
||||||
|
let geoPromise: Promise<GeoData> | null = null
|
||||||
|
|
||||||
|
export async function getGeoData(): Promise<GeoData> {
|
||||||
|
if (geoData) return geoData
|
||||||
|
if (!geoPromise) {
|
||||||
|
geoPromise = fetch('/data/geo_paraguay_full.json')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data: GeoData) => { geoData = data; return data })
|
||||||
|
.catch((err) => { geoPromise = null; throw err })
|
||||||
|
}
|
||||||
|
return geoPromise
|
||||||
|
}
|
||||||
232
lib/submit-validation.test.mts
Normal file
232
lib/submit-validation.test.mts
Normal 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
150
lib/submit-validation.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -11,3 +11,14 @@ export function createClient() {
|
|||||||
|
|
||||||
return createSupabaseClient(url, anonKey)
|
return createSupabaseClient(url, anonKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// service_role — bypasa RLS por completo (regla 9 CLAUDE.md). Solo se importa
|
||||||
|
// desde Server Components / Server Actions ('use server'), nunca desde código
|
||||||
|
// que se bundlea al cliente. No exportar este cliente ni su resultado hacia props
|
||||||
|
// de un Client Component.
|
||||||
|
export function getSupabaseAdmin() {
|
||||||
|
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||||
|
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'placeholder'
|
||||||
|
|
||||||
|
return createSupabaseClient(url, serviceKey)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
246
method/ANTI_PATTERNS.md
Normal 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
513
method/PATTERNS.md
Normal 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
|
||||||
43
middleware.ts
Normal file
43
middleware.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let supabaseResponse = NextResponse.next({ request })
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) =>
|
||||||
|
request.cookies.set(name, value)
|
||||||
|
)
|
||||||
|
supabaseResponse = NextResponse.next({ request })
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// getUser() valida el token contra el servidor — no confiar solo en la cookie local
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const loginUrl = new URL('/login', request.url)
|
||||||
|
return NextResponse.redirect(loginUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabaseResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/admin/:path*', '/dashboard/:path*'],
|
||||||
|
}
|
||||||
178
migrate-claude/ESTADO_PROYECTO_JUNIO_2026.md
Normal file
178
migrate-claude/ESTADO_PROYECTO_JUNIO_2026.md
Normal 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)
|
||||||
70
migrate-claude/PROMPT_NUEVA_SESION.md
Normal file
70
migrate-claude/PROMPT_NUEVA_SESION.md
Normal 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].
|
||||||
48
migrate-claude/TECH_DEBT_ACTUALIZADO.md
Normal file
48
migrate-claude/TECH_DEBT_ACTUALIZADO.md
Normal 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.
|
||||||
222
package-lock.json
generated
222
package-lock.json
generated
@@ -9,8 +9,9 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@supabase/ssr": "^0.12.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",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/cli": "^4.11.3",
|
"@axe-core/cli": "^4.11.3",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
@@ -895,15 +897,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 +919,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 +935,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 +954,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 +973,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 +992,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 +1011,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 +1027,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"
|
||||||
],
|
],
|
||||||
@@ -1040,6 +1042,22 @@
|
|||||||
"node": ">= 10"
|
"node": ">= 10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/utils": {
|
"node_modules/@standard-schema/utils": {
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
@@ -1047,9 +1065,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/auth-js": {
|
"node_modules/@supabase/auth-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==",
|
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1183,9 +1201,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/functions-js": {
|
"node_modules/@supabase/functions-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==",
|
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1195,15 +1213,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/phoenix": {
|
"node_modules/@supabase/phoenix": {
|
||||||
"version": "0.4.2",
|
"version": "0.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/postgrest-js": {
|
"node_modules/@supabase/postgrest-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==",
|
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -1213,9 +1231,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/realtime-js": {
|
"node_modules/@supabase/realtime-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==",
|
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/phoenix": "^0.4.2",
|
"@supabase/phoenix": "^0.4.2",
|
||||||
@@ -1225,10 +1243,22 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.108.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@supabase/storage-js": {
|
"node_modules/@supabase/storage-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==",
|
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"iceberg-js": "^0.8.1",
|
"iceberg-js": "^0.8.1",
|
||||||
@@ -1239,16 +1269,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@supabase/supabase-js": {
|
"node_modules/@supabase/supabase-js": {
|
||||||
"version": "2.107.0",
|
"version": "2.108.2",
|
||||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||||
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
|
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/auth-js": "2.107.0",
|
"@supabase/auth-js": "2.108.2",
|
||||||
"@supabase/functions-js": "2.107.0",
|
"@supabase/functions-js": "2.108.2",
|
||||||
"@supabase/postgrest-js": "2.107.0",
|
"@supabase/postgrest-js": "2.108.2",
|
||||||
"@supabase/realtime-js": "2.107.0",
|
"@supabase/realtime-js": "2.108.2",
|
||||||
"@supabase/storage-js": "2.107.0"
|
"@supabase/storage-js": "2.108.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -1789,6 +1819,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/core-util-is": {
|
"node_modules/core-util-is": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
@@ -2109,6 +2152,20 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
@@ -2808,13 +2865,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 +2885,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": {
|
||||||
@@ -2977,6 +3033,38 @@
|
|||||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -16,8 +16,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@supabase/ssr": "^0.12.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",
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/cli": "^4.11.3",
|
"@axe-core/cli": "^4.11.3",
|
||||||
|
"@playwright/test": "^1.61.1",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@tailwindcss/postcss": "^4.1.8",
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
|
|||||||
14
playwright.config.ts
Normal file
14
playwright.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from '@playwright/test'
|
||||||
|
|
||||||
|
// .env.test nunca se commitea (ver .gitignore) — credenciales de un usuario
|
||||||
|
// dedicado de e2e, nunca las del director.
|
||||||
|
process.loadEnvFile('.env.test')
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: 'tests/e2e',
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.E2E_BASE_URL,
|
||||||
|
},
|
||||||
|
// Local contra producción — no corre en CI (backlog).
|
||||||
|
reporter: 'list',
|
||||||
|
})
|
||||||
8690
public/data/geo_paraguay_full.json
Normal file
8690
public/data/geo_paraguay_full.json
Normal file
File diff suppressed because it is too large
Load Diff
20
scripts/apply_all_migrations.sh
Executable file
20
scripts/apply_all_migrations.sh
Executable file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Aplica todas las migraciones de supabase/migrations/ en orden de timestamp.
|
||||||
|
# Uso: scripts/apply_all_migrations.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MIGRATIONS_DIR="$(dirname "$0")/../supabase/migrations"
|
||||||
|
|
||||||
|
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
|
||||||
|
echo "Applying: $f"
|
||||||
|
# -v ON_ERROR_STOP=1: sin esto, psql sigue ejecutando statements después de un
|
||||||
|
# error SQL y termina con exit code 0 igual — set -e nunca vería el fallo.
|
||||||
|
if docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$f"; then
|
||||||
|
echo "Done: $f"
|
||||||
|
else
|
||||||
|
echo "ERROR applying: $f" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All migrations applied."
|
||||||
45
scripts/setup_all.sh
Executable file
45
scripts/setup_all.sh
Executable file
@@ -0,0 +1,45 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Recuperación completa de la DB tras una reinicialización (deploy que vació
|
||||||
|
# supabase-db). Orden:
|
||||||
|
# 1. Todas las migraciones (scripts/apply_all_migrations.sh)
|
||||||
|
# 2. Catálogo de encuestas (supabase/seed.sql) — sin esto, las migraciones
|
||||||
|
# dejan organizations/surveys/questions vacías y nada de lo que sigue
|
||||||
|
# tiene contra qué referenciar (setup_first_admin.sql sí funcionaría,
|
||||||
|
# pero seed_prueba_50.sql fallaría: rpc_submit_response necesita
|
||||||
|
# questions.id reales, que solo existen después de este paso).
|
||||||
|
# 3. Usuario platform_admin (scripts/setup_first_admin.sql)
|
||||||
|
# 4. Datos de prueba (docs/dashboard/seed_prueba_50.sql)
|
||||||
|
#
|
||||||
|
# Uso:
|
||||||
|
# ADMIN_EMAIL='admin@inquality.com.py' ADMIN_PASSWORD='...' scripts/setup_all.sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(dirname "$0")"
|
||||||
|
ROOT_DIR="$SCRIPT_DIR/.."
|
||||||
|
|
||||||
|
if [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
|
||||||
|
echo "ERROR: ADMIN_EMAIL y ADMIN_PASSWORD son obligatorios (variables de entorno)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "== 1/4: Aplicando todas las migraciones =="
|
||||||
|
"$SCRIPT_DIR/apply_all_migrations.sh"
|
||||||
|
|
||||||
|
echo "== 2/4: Sembrando catálogo de encuestas (supabase/seed.sql) =="
|
||||||
|
# ADVERTENCIA propia del archivo: "SOLO ENTORNOS DE DESARROLLO — nunca ejecutar
|
||||||
|
# en producción". Esa advertencia es para NO re-correrlo contra una DB con
|
||||||
|
# datos reales (DELETE+INSERT de questions con UUIDs nuevos puede chocar con
|
||||||
|
# answers existentes via FK). En este escenario de recuperación la DB está
|
||||||
|
# vacía (reinicializada por el deploy) — no hay answers/responses que puedan
|
||||||
|
# quedar huérfanas. Si la DB NO está realmente vacía, frenar antes de este paso.
|
||||||
|
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$ROOT_DIR/supabase/seed.sql"
|
||||||
|
|
||||||
|
echo "== 3/4: Creando usuario platform_admin =="
|
||||||
|
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
|
||||||
|
-v email="$ADMIN_EMAIL" -v password="$ADMIN_PASSWORD" \
|
||||||
|
< "$ROOT_DIR/scripts/setup_first_admin.sql"
|
||||||
|
|
||||||
|
echo "== 4/4: Sembrando datos de prueba (seed_prueba_50.sql) =="
|
||||||
|
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$ROOT_DIR/docs/dashboard/seed_prueba_50.sql"
|
||||||
|
|
||||||
|
echo "Setup completo."
|
||||||
45
scripts/setup_first_admin.sql
Normal file
45
scripts/setup_first_admin.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Script: crear primer usuario platform_admin
|
||||||
|
--
|
||||||
|
-- Verificado contra GoTrue v2.189.0 (supabase/gotrue:v2.189.0)
|
||||||
|
-- Schema de auth.users puede variar entre versiones de GoTrue.
|
||||||
|
-- Si falla con "column does not exist", verificar el schema real:
|
||||||
|
-- SELECT column_name FROM information_schema.columns
|
||||||
|
-- WHERE table_schema='auth' AND table_name='users'
|
||||||
|
-- ORDER BY ordinal_position;
|
||||||
|
--
|
||||||
|
-- USO:
|
||||||
|
-- docker exec -i supabase-db psql -U postgres -d postgres \
|
||||||
|
-- -v email='admin@ejemplo.com' -v password='CAMBIAR' \
|
||||||
|
-- < scripts/setup_first_admin.sql
|
||||||
|
--
|
||||||
|
-- NOTA: las variables psql solo funcionan en SQL plano, no en DO $$.
|
||||||
|
-- Este script usa un CTE INSERT...RETURNING para encadenar ambos inserts.
|
||||||
|
--
|
||||||
|
-- CAMBIAR CONTRASEÑA LUEGO DEL PRIMER LOGIN:
|
||||||
|
-- docker exec -i supabase-db psql -U postgres -d postgres \
|
||||||
|
-- -c "UPDATE auth.users SET encrypted_password = extensions.crypt('NuevaContraseña', extensions.gen_salt('bf')) WHERE email = 'admin@ejemplo.com';"
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
WITH new_user AS (
|
||||||
|
INSERT INTO auth.users (
|
||||||
|
id, instance_id, aud, role, email, encrypted_password,
|
||||||
|
email_confirmed_at, created_at, updated_at,
|
||||||
|
raw_app_meta_data, raw_user_meta_data,
|
||||||
|
confirmation_token, recovery_token,
|
||||||
|
email_change_token_new, email_change,
|
||||||
|
is_super_admin
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'00000000-0000-0000-0000-000000000000',
|
||||||
|
'authenticated', 'authenticated',
|
||||||
|
:'email',
|
||||||
|
extensions.crypt(:'password', extensions.gen_salt('bf')),
|
||||||
|
now(), now(), now(),
|
||||||
|
'{"provider":"email","providers":["email"]}',
|
||||||
|
'{}', '', '', '', '', false
|
||||||
|
) RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO public.user_roles (user_id, app_role, org_id)
|
||||||
|
SELECT id, 'platform_admin', '10000000-0000-0000-0000-000000000002'
|
||||||
|
FROM new_user;
|
||||||
189
sprints/sprint-3-infra/ETAPA_3C_PROMPT.md
Normal file
189
sprints/sprint-3-infra/ETAPA_3C_PROMPT.md
Normal 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.
|
||||||
174
sprints/sprint-3-infra/ETAPA_3I2_PROMPT.md
Normal file
174
sprints/sprint-3-infra/ETAPA_3I2_PROMPT.md
Normal 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.000–1.500.000 · Gs. 1.500.000–3.000.000
|
||||||
|
· Gs. 3.000.000–6.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.
|
||||||
437
sprints/sprint-3-infra/ETAPA_3I_PROMPT.md
Normal file
437
sprints/sprint-3-infra/ETAPA_3I_PROMPT.md
Normal 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).
|
||||||
175
sprints/sprint-3-infra/ETAPA_3V_B_PROMPT.md
Normal file
175
sprints/sprint-3-infra/ETAPA_3V_B_PROMPT.md
Normal 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.
|
||||||
133
sprints/sprint-3-infra/ETAPA_3V_PROMPT.md
Normal file
133
sprints/sprint-3-infra/ETAPA_3V_PROMPT.md
Normal 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á.
|
||||||
124
sprints/sprint-3-instrumento/ETAPA_3ALL_PROMPT.md
Normal file
124
sprints/sprint-3-instrumento/ETAPA_3ALL_PROMPT.md
Normal 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
143
sprints/sprint-4/BRIEF.md
Normal 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.
|
||||||
104
sprints/sprint-4/ETAPA_4A_PROMPT.md
Normal file
104
sprints/sprint-4/ETAPA_4A_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
111
sprints/sprint-4/ETAPA_4B_PROMPT.md
Normal file
111
sprints/sprint-4/ETAPA_4B_PROMPT.md
Normal 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).
|
||||||
|
```
|
||||||
124
sprints/sprint-4/ETAPA_4C_PROMPT.md
Normal file
124
sprints/sprint-4/ETAPA_4C_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
121
sprints/sprint-4/ETAPA_4G_PROMPT.md
Normal file
121
sprints/sprint-4/ETAPA_4G_PROMPT.md
Normal 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.
|
||||||
|
```
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user