Compare commits
65 Commits
92a7923a26
...
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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,6 +14,11 @@ supabase/.temp/
|
||||
# Node
|
||||
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/
|
||||
|
||||
|
||||
@@ -77,11 +77,15 @@ Principios estructurales (de ADR-001):
|
||||
- **Umbral de k-anonimato** obligatorio en toda visualización compartida (nunca mostrar celda con
|
||||
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-002** — Stack tecnológico y residencia de datos.
|
||||
- **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
|
||||
|
||||
|
||||
46
BACKLOG.md
46
BACKLOG.md
@@ -7,6 +7,41 @@
|
||||
|
||||
## 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
|
||||
- **Estado:** Pendiente
|
||||
- **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)
|
||||
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
|
||||
- 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
|
||||
> (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
|
||||
> minimizar re-identificación.
|
||||
|
||||
@@ -106,9 +111,20 @@ id uuid pk
|
||||
response_id uuid fk -> responses(id)
|
||||
question_id uuid fk -> questions(id)
|
||||
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
|
||||
> 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
|
||||
```sql
|
||||
|
||||
303
SEED_SPEC.md
303
SEED_SPEC.md
@@ -1,152 +1,209 @@
|
||||
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v2.1
|
||||
# SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v3.0.0
|
||||
|
||||
> Versión 2.1 — incorpora feedback del equipo médico, Etapa 3I2 (Jun 2026). Reemplaza la v2.
|
||||
> 44 preguntas (se elimina 2.4, se agrega 6.6) · 8 secciones · Códigos únicos corregidos.
|
||||
> Versión 3.0.0 — Sprint 4, Etapa 4B (Jun 2026). Reemplaza la v2.1. Reescritura completa del
|
||||
> 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.
|
||||
|
||||
## 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 2 opciones (Sí · No); solo **"Sí"** dispara
|
||||
el bloque correspondiente.
|
||||
- `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).
|
||||
- En preguntas `multiple_choice` el formulario muestra el subtítulo "Podés marcar más de una opción".
|
||||
- "Prefiero no decir" eliminada de todas las preguntas (Cambio 1, Etapa 3I2).
|
||||
|
||||
## Estructura condicional (4 bloques)
|
||||
- **cond-disc-propia** — 1.1='Sí' → muestra 1.2, 1.3, 1.4, 1.5
|
||||
- **cond-familiar** — 1.6='Sí' → muestra 1.7, 1.8, 1.9, 1.10, 1.11, 1.12
|
||||
- **cond-adulto-mayor** — 1.13='Sí' → muestra 1.14, 1.15, 1.16
|
||||
- **salto-triple** — si 1.1='No' Y 1.6='No' Y 1.13='No' (es decir, ninguno de los tres dio
|
||||
'Sí'), se ocultan completas las secciones 2, 3, 4, 5 y las preguntas 6.1, 6.2, 6.3. El
|
||||
formulario salta directo a 6.4. Implementado como `conditional_rules`
|
||||
`{"if_question":["1.1","1.6","1.13"],"op":"eq_any","value":"Sí","action":"show"}` en cada
|
||||
pregunta del bloque (equivalente: se muestra si AL MENOS uno de los tres es 'Sí').
|
||||
- Sección A, 6.4-6.6 y Sección 7 (cierre): **top-level, siempre visibles.**
|
||||
- Tipos: radio→`single_choice`, checkbox→`multiple_choice`, texto libre corto→`text_short`,
|
||||
texto libre largo→`text_long`, sí/no→`yes_no`.
|
||||
- Secciones persisten como filas `questions.type='section'`: `prompt`=nombre de sección,
|
||||
`hint`=descripción/"porqué" (Opción C, ver ADR-005 §4.2).
|
||||
- `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)
|
||||
| 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 | qi_age_bucket |
|
||||
| A6 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | |
|
||||
| A7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto (home office) · Híbrido | |
|
||||
## Sección 1 — Consentimiento (`S1`, pos. 10)
|
||||
|
||||
## Sección 1 — Prevalencia: cuidado y discapacidad *(sensible)*
|
||||
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|
||||
| Code | Tipo | Pregunta | Requerida | Notas |
|
||||
|---|---|---|---|---|
|
||||
| 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 | ✅ |
|
||||
| 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 | ✅ |
|
||||
| 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 · Tecnología asistiva · No utilizo | ✅ |
|
||||
| 1.4 | single_choice | 1.1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
|
||||
| 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 | ✅ |
|
||||
| 1.6 | single_choice | trigger | ¿Tenés algún familiar cercano (hijo/a, pareja, hermano/a, padre/madre) que viva con discapacidad?* | Sí · No | ✅ |
|
||||
| 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 | ✅ |
|
||||
| 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 | ✅ |
|
||||
| 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 · Tecnología asistiva · 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 | ✅ |
|
||||
| 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 | |
|
||||
| 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 | ✅ |
|
||||
| 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 | |
|
||||
| 2.1 | single_choice | Departamento | 18 departamentos de Paraguay + Capital | no | qi (cuasi-identificador) |
|
||||
| 2.2 | single_choice | Ciudad | 219 ciudades (filtradas por 2.1 en el renderer) | no | qi |
|
||||
| 2.3 | text_short | ¿En qué barrio vivís? | — (texto libre) | no | `hint`='Opcional.' — no entra al store analítico crudo |
|
||||
| 2.4 | single_choice | ¿Cuál es tu rango de edad? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ | no | qi |
|
||||
| 2.5 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | no | qi |
|
||||
| 2.6 | single_choice | ¿En qué nivel organizacional estás? | Directivo · Gerencial · Jefatura · Operativo · Auxiliar · Otro | no | qi |
|
||||
| 2.7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido | no | qi |
|
||||
|
||||
## Sección 2 — Intensidad y rol del cuidado (top-level)
|
||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
||||
## Sección 3 — Prevalencia de discapacidad (`S3`, pos. 30) *(sensible)*
|
||||
|
||||
Cond.: 3.2–3.5 visibles solo si 3.1='Sí' (`r_show_si_s3_1`).
|
||||
|
||||
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|
||||
|---|---|---|---|---|
|
||||
| 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 |
|
||||
| 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).
|
||||
|
||||
| Code | Tipo | Pregunta | Opciones |
|
||||
|---|---|---|---|
|
||||
| 2.1 | multiple_choice ⚠️ TECH-DEBT | ¿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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
> ⚠️ TECH-DEBT (2.1): pasó de `single_choice` a `multiple_choice` — selección múltiple sin orden
|
||||
> de prioridad. Deuda: implementar ranking (Principal cuidador soy yo > secundario instituto >
|
||||
> principal pagado > secundario familiar). Ver `TECH_DEBT.md`.
|
||||
>
|
||||
> **2.4 eliminada** (Cambio 6, Etapa 3I2): "¿Contás con apoyo de otras personas para el cuidado?"
|
||||
> — feedback del equipo médico: pregunta similar/redundante con 2.1. Ver `OBSERVATIONS.md`.
|
||||
## Sección 6 — Intensidad del cuidado (`S6`, pos. 60)
|
||||
|
||||
## Sección 3 — Impacto económico del cuidado (top-level)
|
||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
||||
> 3.4: is_sensitive=true + analyst-only (no exponer a org_admin).
|
||||
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.**
|
||||
|
||||
| Code | Tipo | Pregunta | Opciones | Notas |
|
||||
|---|---|---|---|---|
|
||||
| 3.1 | single_choice | ¿Cuánto estimás que gastás por mes en el cuidado? | 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 | |
|
||||
| 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 | |
|
||||
| 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 · No puedo sostenerlo con mis ingresos | |
|
||||
| 3.4 | single_choice | ¿En qué franja salarial mensual te ubicás? *(CONFIDENCIAL — analyst-only)* | Sueldo mínimo · Gs. 3.000.000 a 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 | is_sensitive=true; never in org_admin views |
|
||||
| 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 |
|
||||
| 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 | |
|
||||
| 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 | |
|
||||
| 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 | |
|
||||
| 9.4 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo? | Sí · No | |
|
||||
|
||||
> **Cambio 7** (3.1): se elimina "Sin gastos significativos" además de "Prefiero no decir".
|
||||
> **Cambio 8** (3.3): "Es difícil sostenerlo con mis ingresos" → "No puedo sostenerlo con mis
|
||||
> ingresos". Breaking change para datos existentes — ver `OBSERVATIONS.md`.
|
||||
> **Cambio 9** (3.4): "Sueldo mínimo – Gs. 5.000.000" se divide en "Sueldo mínimo" y
|
||||
> "Gs. 3.000.000 a 5.000.000". Breaking change para datos existentes — ver `OBSERVATIONS.md`.
|
||||
## Sección 10 — Comentarios adicionales (`S10`, pos. 100)
|
||||
|
||||
## Sección 4 — Tiempo y descanso (top-level)
|
||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
||||
Siempre visible — es el destino del salto "Ninguna de las anteriores" en 4.1.
|
||||
|
||||
| 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)
|
||||
> Cond.: **salto-triple** — toda la sección se oculta si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
||||
|
||||
| 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)
|
||||
> Cond. (6.1-6.3): **salto-triple** — se ocultan si 1.1='No' Y 1.6='No' Y 1.13='No'.
|
||||
> 6.4-6.6: siempre visibles (destino del salto).
|
||||
|
||||
| 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, no se puede hablar del tema | RE-INDEX. Cambio 10: última opción reescrita (era "No, nunca se ha hablado del tema"). Breaking change — ver `OBSERVATIONS.md`. |
|
||||
| 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) |
|
||||
| 6.6 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo a personas con discapacidad y/o cuidadores? | Sí · No · Tal vez | Nueva (Cambio 4b, Etapa 3I2) |
|
||||
|
||||
## Sección 7 — Para cerrar (top-level)
|
||||
| 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_*`)
|
||||
- `qi_age_bucket` ← A5 (ya en bucket)
|
||||
- `qi_sector` ← A4 (área)
|
||||
- `qi_zone` ← A1 (departamento — poblado en `submit-survey.ts` desde Etapa 2E)
|
||||
- `qi_city` ← A2: **Sprint 3** — se agrega como columna qi en migration futura.
|
||||
Por ahora A2 se captura en `answers` como single_choice.
|
||||
## Totales
|
||||
|
||||
10 secciones (`type='section'`) + 40 preguntas = **50 filas** en `questions` por encuesta.
|
||||
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.
|
||||
|
||||
## 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)
|
||||
- **Ranking widget (6.2):** actual es multiple_choice max_select=3. Cuando se implemente el
|
||||
widget de ranking (drag o 3 dropdowns), 6.2 pasa a tipo `ranking` con JSONB `{rank1, rank2, rank3}`.
|
||||
- **qi_city:** migration futura para agregar columna en responses y extender whitelist de agg_segment.
|
||||
Se hace antes de Sprint 3 (dashboard con filtro por ciudad).
|
||||
- **Salary analytics:** metodología para calcular "carga económica del cuidado como % del ingreso"
|
||||
es trabajo de metodología de investigación (fuera del scope del código).
|
||||
- **RE-INDEX scoring:** algoritmo de composición de los índices (descanso, bienestar, rendimiento,
|
||||
conciencia) — decisión antes de construir los reportes del dashboard.
|
||||
- **Formulario de empresa (Relevamiento Empresa):** instrumento separado con PII de contacto.
|
||||
Ejecutar offline para las primeras empresas. Diseño del modelo de datos en el sprint del builder.
|
||||
|
||||
- **Ranking widget (9.2):** actual es `multiple_choice` con `max_select=3`. Si se requiere
|
||||
orden de prioridad, pasa a tipo `ranking` (mismo criterio que 6.2 en v2.1).
|
||||
- **Motor genérico de formularios (Fase 2, BACKLOG-002):** este contrato es el primer caso de
|
||||
prueba; el intérprete declarativo se generaliza después de validar el wizard de cuidadores.
|
||||
- **Migración de datos v2.1 → v3:** NO se ejecuta. v2.1.0-stable permanece intacta; v3 es un
|
||||
instrumento nuevo con seed limpio.
|
||||
- **`qi_city`, RE-INDEX scoring, salary analytics:** decisiones heredadas de v2.1, aún abiertas,
|
||||
no se resuelven en esta etapa.
|
||||
|
||||
100
TECH_DEBT.md
100
TECH_DEBT.md
@@ -77,11 +77,95 @@
|
||||
|
||||
## [TECH-DEBT-008] /admin/responses sin autenticación
|
||||
|
||||
- **Estado:** ABIERTO.
|
||||
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
|
||||
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
|
||||
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
|
||||
implementar un gate de autenticación para staff (Basic Auth con env var como
|
||||
mínimo viable).
|
||||
- **Impacto actual:** ninguno en producción — la URL no está publicada.
|
||||
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control.
|
||||
- **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'
|
||||
import { headers } from 'next/headers'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { createHash } from 'crypto'
|
||||
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 =
|
||||
| { success: true }
|
||||
@@ -9,103 +12,83 @@ export type SubmitResult =
|
||||
|
||||
export async function submitSurvey(payload: {
|
||||
surveyId: string
|
||||
answers: Record<string, unknown>
|
||||
questionMap: Record<string, string> // código → question_id UUID
|
||||
answers: SubmitAnswerEntry[]
|
||||
consentOperativo: boolean
|
||||
consentMacroN1: boolean
|
||||
}): 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 rawIp =
|
||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const salt = process.env.IP_HASH_SALT ?? ''
|
||||
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
||||
|
||||
// 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon)
|
||||
// NUNCA usar service_role — el anon key respeta RLS
|
||||
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
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
|
||||
.from('surveys')
|
||||
.select('org_id, consent_version_id')
|
||||
.select('id, org_id, status')
|
||||
.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.
|
||||
//
|
||||
// DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que
|
||||
// requiere SELECT policy para anon en consent_records/responses. Esa policy no existe
|
||||
// (correcto — anon no debe leer consent_records de otros respondentes).
|
||||
// Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT.
|
||||
const consentId = randomUUID()
|
||||
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)
|
||||
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
|
||||
// nunca contra lo que el cliente afirma que vio.
|
||||
const { data: questionRows, error: qErr } = await supabase
|
||||
.from('questions')
|
||||
.select(
|
||||
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||
)
|
||||
.eq('survey_id', payload.surveyId)
|
||||
|
||||
if (answerRows.length > 0) {
|
||||
const { error: aErr } = await supabase.from('answers').insert(answerRows)
|
||||
if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` }
|
||||
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
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'
|
||||
|
||||
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
|
||||
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const MAX_SUBMISSIONS = 500
|
||||
const PAGE_SIZE = 50
|
||||
const PROMPT_MAX_LEN = 60
|
||||
|
||||
interface AllowedQuestion {
|
||||
@@ -28,64 +31,77 @@ interface ResponseRow {
|
||||
answers: AnswerRow[]
|
||||
}
|
||||
|
||||
interface FlatRow {
|
||||
key: string
|
||||
submissionNum: number
|
||||
isFirstInGroup: boolean
|
||||
submitted_at: string
|
||||
qi_zone: string | null
|
||||
qi_sector: string | null
|
||||
code: string
|
||||
prompt: string
|
||||
type: string
|
||||
value: unknown
|
||||
interface SurveyOption {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
function getSupabaseAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
|
||||
return createClient(url, serviceKey, { auth: { persistSession: false } })
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
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
|
||||
}
|
||||
|
||||
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
|
||||
function renderValue(value: unknown, type: string): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
|
||||
switch (type) {
|
||||
case 'single_choice':
|
||||
case 'text_short':
|
||||
return String(value).replace(/^"|"$/g, '')
|
||||
case 'multiple_choice':
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
case 'rating':
|
||||
return String(value)
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
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() {
|
||||
const supabase = getSupabaseAdmin()
|
||||
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: questionsData, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position')
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.order('position', { ascending: true })
|
||||
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 (
|
||||
@@ -103,23 +119,62 @@ export default async function AdminResponsesPage() {
|
||||
|
||||
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
|
||||
// embebido a solo question_id permitidos (consulta, no post-filtro).
|
||||
const { data: responsesData, error: responsesError } =
|
||||
// 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 supabase
|
||||
.from('responses')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
submitted_at,
|
||||
qi_zone,
|
||||
qi_sector,
|
||||
answers!inner ( value, question_id )
|
||||
`
|
||||
)
|
||||
.in('answers.question_id', allowedIds)
|
||||
.order('submitted_at', { ascending: false })
|
||||
.limit(MAX_SUBMISSIONS)
|
||||
: { data: [] as ResponseRow[], error: null }
|
||||
? 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 (
|
||||
@@ -132,22 +187,10 @@ export default async function AdminResponsesPage() {
|
||||
}
|
||||
|
||||
const responses = (responsesData ?? []) as unknown as ResponseRow[]
|
||||
|
||||
// 3. Contadores totales (independientes del límite de 500 submissions)
|
||||
const { count: totalSubmissions } = await supabase
|
||||
.from('responses')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
const { count: totalAnswers } =
|
||||
allowedIds.length > 0
|
||||
? await supabase
|
||||
.from('answers')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in('question_id', allowedIds)
|
||||
: { count: 0 }
|
||||
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: FlatRow[] = []
|
||||
const rows: DisplayRow[] = []
|
||||
responses.forEach((response, idx) => {
|
||||
const answers = response.answers
|
||||
.filter((a) => questionMap.has(a.question_id))
|
||||
@@ -158,79 +201,74 @@ export default async function AdminResponsesPage() {
|
||||
|
||||
answers.forEach((a, ai) => {
|
||||
const q = questionMap.get(a.question_id)!
|
||||
const isFirstInGroup = ai === 0
|
||||
rows.push({
|
||||
key: `${response.id}-${a.question_id}`,
|
||||
submissionNum: idx + 1,
|
||||
isFirstInGroup: ai === 0,
|
||||
submitted_at: response.submitted_at,
|
||||
qi_zone: response.qi_zone,
|
||||
qi_sector: response.qi_sector,
|
||||
responseId: response.id,
|
||||
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
|
||||
isFirstInGroup,
|
||||
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
||||
code: q.code,
|
||||
prompt: q.prompt,
|
||||
type: q.type,
|
||||
value: a.value,
|
||||
prompt: truncate(q.prompt),
|
||||
rendered: renderValue(a.value, q.type),
|
||||
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
|
||||
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<>
|
||||
<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 ·
|
||||
actualizado {formatDate(new Date().toISOString())}
|
||||
{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>
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
</header>
|
||||
|
||||
<div className="admin-table-wrapper">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Fecha</th>
|
||||
<th>Código</th>
|
||||
<th>Pregunta</th>
|
||||
<th>Tipo</th>
|
||||
<th>Valor</th>
|
||||
<th className="admin-col-optional">Departamento</th>
|
||||
<th className="admin-col-optional">Sector</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => {
|
||||
const groupClass =
|
||||
row.submissionNum % 2 === 0 ? 'admin-group-even' : 'admin-group-odd'
|
||||
const trClass = row.isFirstInGroup ? `${groupClass} admin-group-start` : groupClass
|
||||
const rendered = renderValue(row.value, row.type)
|
||||
return (
|
||||
<tr key={row.key} className={trClass}>
|
||||
<td>{row.submissionNum}</td>
|
||||
<td>{row.isFirstInGroup ? formatDate(row.submitted_at) : ''}</td>
|
||||
<td>{row.code}</td>
|
||||
<td className="admin-prompt">{truncate(row.prompt)}</td>
|
||||
<td><span className="admin-type-badge">{row.type}</span></td>
|
||||
<td>
|
||||
{rendered === null ? <span className="admin-nodata">sin dato</span> : rendered}
|
||||
</td>
|
||||
<td className="admin-col-optional">
|
||||
{row.isFirstInGroup ? (row.qi_zone ?? '—') : ''}
|
||||
</td>
|
||||
<td className="admin-col-optional">
|
||||
{row.isFirstInGroup ? (row.qi_sector ?? '—') : ''}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
|
||||
{rows.length === 0 && (
|
||||
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
||||
)}
|
||||
</div>
|
||||
</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.
|
||||
// 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> = {
|
||||
kpis: 'rpc_dashboard_kpis',
|
||||
prevalencia: 'rpc_prevalencia_por_depto',
|
||||
heatmap: 'rpc_heatmap_intensidad',
|
||||
distribucion: 'rpc_perfil_poblacion',
|
||||
apoyos: 'rpc_apoyos_demandados',
|
||||
score: 'rpc_score_organizacional',
|
||||
piramide: 'rpc_piramide_demografica',
|
||||
intensidad: 'rpc_intensidad_vs_bienestar',
|
||||
saturacion: 'rpc_saturacion_cuidador',
|
||||
'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
|
||||
'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
|
||||
'me-ausentismo': 'rpc_me_ausentismo',
|
||||
'me-presentismo': 'rpc_me_presentismo',
|
||||
'me-punto-ciego': 'rpc_me_punto_ciego',
|
||||
'me-apoyos': 'rpc_me_apoyos_demandados',
|
||||
'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
|
||||
'me-carrera-congelada': 'rpc_me_carrera_congelada',
|
||||
'me-intensidad-carga': 'rpc_me_intensidad_carga',
|
||||
'me-segmentacion': 'rpc_me_segmentacion',
|
||||
'me-disposicion-red': 'rpc_me_disposicion_red',
|
||||
}
|
||||
|
||||
function getSupabaseServer() {
|
||||
@@ -45,10 +52,18 @@ export async function GET(
|
||||
const filters = queryToFilters(sp)
|
||||
const supabase = getSupabaseServer()
|
||||
|
||||
const { data, error } = await supabase.rpc(rpcName, {
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
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) {
|
||||
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'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { KpiCards } from '@/components/dashboard/kpi-cards'
|
||||
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 { AdminNavBar } from '@/components/admin-nav-bar'
|
||||
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'
|
||||
|
||||
// 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() {
|
||||
const searchParams = useSearchParams()
|
||||
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
|
||||
const [filters, setFilters] = useState<DashboardFilters>({})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
function handleDeptoClick(depto: string) {
|
||||
setFilters(f => ({ ...f, depto: depto || undefined }))
|
||||
}
|
||||
const [lens, setLens] = useState<Lens>('empresa')
|
||||
|
||||
return (
|
||||
<div className="db-page">
|
||||
<>
|
||||
<AdminNavBar active="dashboard" />
|
||||
<div className="db-page">
|
||||
<FilterSidebar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(c => !c)}
|
||||
onToggle={() => setSidebarCollapsed((c) => !c)}
|
||||
/>
|
||||
|
||||
<div className="db-main">
|
||||
@@ -40,37 +64,90 @@ export default function DashboardPage() {
|
||||
Datos agregados · k-anonimato k≥5 · Encuesta Mapeo de Cuidadores 2026
|
||||
{filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>}
|
||||
</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>
|
||||
|
||||
{/* Fila 1: KPIs (span 12) */}
|
||||
<section className="db-section" aria-label="Indicadores clave">
|
||||
<KpiCards surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
<nav className="db-tabs" aria-label="Mirada del dashboard">
|
||||
{LENSES.map((l) => (
|
||||
<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>
|
||||
|
||||
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */}
|
||||
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos">
|
||||
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} />
|
||||
<DonutCuidados surveyId={surveyId} filters={filters} />
|
||||
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{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 3: Heatmap (6) + Pirámide (6) */}
|
||||
<section className="db-grid db-section" aria-label="Intensidad y demografía">
|
||||
<HeatmapWidget surveyId={surveyId} filters={filters} />
|
||||
<PiramideDemografica surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{/* 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>
|
||||
|
||||
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
|
||||
<section className="db-grid db-section" aria-label="Apoyos e impacto">
|
||||
<ApoyosBar surveyId={surveyId} filters={filters} />
|
||||
<IntensidadProductividad surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
|
||||
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
|
||||
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
|
||||
<ApoyosBarME surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 5: Saturación del cuidador (12) */}
|
||||
<section className="db-grid db-section" aria-label="Saturación del cuidador">
|
||||
<TablaSaturacion surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{/* Fila 3: segmentación (12, SEC) */}
|
||||
<section className="db-grid db-section" aria-label="Segmentación de la carga">
|
||||
<SegmentacionBar surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
|
||||
<section className="db-grid db-section" aria-label="Disposición a participar">
|
||||
<DisposicionRedCard surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
<p className="db-table-note" style={{ textAlign: 'right' }}>
|
||||
k≥5 aplicado · segmentos con n<5 suprimidos
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{lens === 'pais' && (
|
||||
<PlaceholderLens
|
||||
title="Mirada País"
|
||||
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
|
||||
/>
|
||||
)}
|
||||
|
||||
{lens === 'humana' && (
|
||||
<PlaceholderLens
|
||||
title="Mirada Humana"
|
||||
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
521
app/globals.css
521
app/globals.css
@@ -63,7 +63,11 @@ body {
|
||||
.skip-link:focus { left: 4px; }
|
||||
|
||||
/* ── 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;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
@@ -285,6 +289,37 @@ body {
|
||||
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 ── */
|
||||
.text-area {
|
||||
display: block;
|
||||
@@ -343,7 +378,7 @@ body {
|
||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
color: #494F4F;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-primary-light); }
|
||||
@@ -394,7 +429,7 @@ body {
|
||||
}
|
||||
.summary-code {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
color: #494F4F;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.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-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
||||
.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-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); }
|
||||
@@ -482,6 +517,170 @@ body {
|
||||
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-wrapper {
|
||||
max-width: 720px;
|
||||
@@ -546,19 +745,27 @@ body {
|
||||
.survey-footer-logo { height: 32px; width: auto; display: block; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
DASHBOARD — tema oscuro premium
|
||||
Fondo: #0F1923 · Widgets: #1A2535 · Teal: #43BBC8
|
||||
DASHBOARD — tema claro (marca Re)
|
||||
Fondo: #E7E6E5 · Widgets: #FFFFFF · Teal: #43BBC8
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
.db-page {
|
||||
display: flex; min-height: 100vh;
|
||||
background: #0F1923; color: #E7E6E5;
|
||||
background: #E7E6E5; color: #494F4F;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.db-main { flex: 1; padding: 24px; overflow-x: hidden; }
|
||||
.db-page-header { margin-bottom: 20px; }
|
||||
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; }
|
||||
.db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; }
|
||||
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #494F4F; margin: 0 0 4px; }
|
||||
.db-page-subtitle { font-size: 0.8125rem; color: #6B7878; margin: 0; }
|
||||
.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-grid {
|
||||
display: grid;
|
||||
@@ -568,7 +775,7 @@ body {
|
||||
|
||||
/* ── Widget shell ── */
|
||||
.db-widget {
|
||||
background: #1A2535;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid rgba(67,187,200,0.2);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
@@ -579,24 +786,24 @@ body {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #E7E6E5; margin: 0; }
|
||||
.db-widget-n { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #494F4F; margin: 0; }
|
||||
.db-widget-n { font-size: 0.75rem; color: #6B7878; }
|
||||
.db-widget-body { padding: 0 16px 16px; }
|
||||
|
||||
/* ── Suppressed / loading states ── */
|
||||
.db-suppressed {
|
||||
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-hint { font-size: 0.75rem; color: #6b7280; }
|
||||
.db-suppressed-hint { font-size: 0.75rem; color: #9CA8A8; }
|
||||
.db-suppressed-card {
|
||||
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-loading-page { padding: 24px; background: #0F1923; min-height: 100vh; }
|
||||
.db-loading-page { padding: 24px; background: #E7E6E5; min-height: 100vh; }
|
||||
|
||||
/* ── KPI row ── */
|
||||
.db-kpi-row {
|
||||
@@ -604,36 +811,36 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
.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;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
|
||||
/* Título de KPI: mayúsculas, bold, siempre visible */
|
||||
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #E7E6E5; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.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 */
|
||||
.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 {
|
||||
font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
|
||||
font-variant-numeric: tabular-nums; margin-top: 4px;
|
||||
}
|
||||
.db-kpi-value-suppressed { font-size: 1.25rem; color: #6b7280; }
|
||||
.db-kpi-note { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-kpi-value-suppressed { font-size: 1.25rem; color: #9CA8A8; }
|
||||
.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; }
|
||||
/* Compat — se puede quitar si ya no se usa */
|
||||
.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; }
|
||||
.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-kpi-label { font-size: 0.8125rem; color: #494F4F; }
|
||||
.db-kpi-sub { font-size: 0.75rem; color: #6B7878; }
|
||||
|
||||
/* Subtítulo de widget-shell */
|
||||
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.db-widget-subtitle { font-size: 0.75rem; color: #7A8FA0; font-weight: 400; margin: 0; line-height: 1.4; }
|
||||
.db-widget-subtitle { font-size: 0.75rem; color: #8A9A9A; font-weight: 400; margin: 0; line-height: 1.4; }
|
||||
|
||||
/* ── Mapa tile ── */
|
||||
.db-map-wrapper { position: relative; }
|
||||
.db-map-tooltip {
|
||||
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);
|
||||
white-space: nowrap; z-index: 10; pointer-events: none;
|
||||
}
|
||||
@@ -668,13 +875,13 @@ body {
|
||||
.db-gauge-wrapper { text-align: center; }
|
||||
.db-gauge-svg { width: 100%; max-width: 160px; }
|
||||
.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 ── */
|
||||
.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-label { font-size: 0.75rem; color: #E7E6E5; 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-label { font-size: 0.75rem; color: #494F4F; width: 180px; flex-shrink: 0; line-height: 1.3; }
|
||||
.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-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-grid { display: grid; gap: 2px; }
|
||||
.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;
|
||||
}
|
||||
.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 {
|
||||
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;
|
||||
}
|
||||
.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 ── */
|
||||
.db-piramide { display: flex; flex-direction: column; gap: 6px; }
|
||||
.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-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-bar-wrap { display: flex; flex-direction: column; }
|
||||
.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 ── */
|
||||
.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 th { color: #9ca3af; 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 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: #494F4F; }
|
||||
.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; }
|
||||
|
||||
/* ── 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);
|
||||
padding: 12px; transition: width 0.3s ease;
|
||||
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-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 {
|
||||
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%;
|
||||
}
|
||||
.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-reset {
|
||||
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
|
||||
@@ -742,6 +949,70 @@ body {
|
||||
}
|
||||
.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 {
|
||||
@@ -750,12 +1021,47 @@ body {
|
||||
}
|
||||
.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);
|
||||
@@ -771,10 +1077,14 @@ body {
|
||||
.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-type-badge {
|
||||
display: inline-block; padding: 1px 8px; border-radius: 10px;
|
||||
background: var(--color-primary-light); color: var(--color-primary-hover);
|
||||
font-size: 0.7rem; font-weight: 600; white-space: nowrap;
|
||||
.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;
|
||||
@@ -801,3 +1111,124 @@ body {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::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 type { BrandConfig, Question, Survey } from '@/lib/types'
|
||||
import { SurveyForm } from '@/components/survey-form'
|
||||
import type { BrandConfig, Survey } from '@/lib/types'
|
||||
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.
|
||||
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
|
||||
.from('questions')
|
||||
.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)
|
||||
.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 = {
|
||||
id: surveyRow.id,
|
||||
@@ -79,8 +90,13 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
k_threshold: surveyRow.k_threshold,
|
||||
consent_version_id: surveyRow.consent_version_id,
|
||||
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>
|
||||
)
|
||||
}
|
||||
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
|
||||
children: React.ReactNode
|
||||
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 (
|
||||
<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-title-group">
|
||||
<h3 className="db-widget-title">{title}</h3>
|
||||
|
||||
@@ -1,106 +1,112 @@
|
||||
'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 { CheckboxGroup } from './widgets/checkbox-group'
|
||||
import { RatingScale } from './widgets/rating-scale'
|
||||
import { TextAreaField } from './widgets/text-area-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 {
|
||||
question: Question
|
||||
answers: FormAnswers
|
||||
onChange: (code: string, value: FormAnswers[string]) => void
|
||||
errors: Record<string, string>
|
||||
value: AnswerValue
|
||||
onChange: (value: AnswerValue) => void
|
||||
error?: string
|
||||
answers?: FormAnswers
|
||||
}
|
||||
|
||||
function isArrayOptions(opts: unknown): opts is QuestionOption[] {
|
||||
return Array.isArray(opts)
|
||||
}
|
||||
const YES_NO_OPTIONS: QuestionOption[] = [
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' },
|
||||
]
|
||||
|
||||
function isRatingOptions(opts: unknown): opts is RatingOptions {
|
||||
return typeof opts === 'object' && opts !== null && 'min' in opts && 'max' in opts
|
||||
}
|
||||
|
||||
export function QuestionRenderer({
|
||||
question,
|
||||
answers,
|
||||
onChange,
|
||||
errors,
|
||||
}: QuestionRendererProps) {
|
||||
export function QuestionRenderer({ question, value, onChange, error, answers }: QuestionRendererProps) {
|
||||
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)
|
||||
// Code badges for A1/A2 are rendered inside GeoSelectWidget.
|
||||
if (code === 'A1') {
|
||||
return (
|
||||
<GeoSelectWidget
|
||||
departmentValue={(answers['A1'] as string) ?? ''}
|
||||
cityValue={(answers['A2'] as string) ?? ''}
|
||||
onDepartmentChange={(v) => onChange('A1', v)}
|
||||
onCityChange={(v) => onChange('A2', v)}
|
||||
departmentError={errors['A1']}
|
||||
cityError={errors['A2']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// 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)) {
|
||||
if (type === 'single_choice' && options) {
|
||||
if (code === '2.2') {
|
||||
return (
|
||||
<CiudadSelectField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
departamento={(answers?.['2.1:default'] as string) ?? null}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
options={options}
|
||||
value={answers[code] as string | undefined}
|
||||
value={(value as string) ?? undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'multiple_choice' && isArrayOptions(options)) {
|
||||
if (type === 'multiple_choice' && options) {
|
||||
return (
|
||||
<CheckboxGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
options={options}
|
||||
value={(answers[code] as string[]) ?? []}
|
||||
value={(value as string[]) ?? []}
|
||||
maxSelect={max_select}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
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 (
|
||||
<RatingScale
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
ratingOptions={options}
|
||||
value={answers[code] as number | undefined}
|
||||
options={YES_NO_OPTIONS}
|
||||
value={(value as string) ?? undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<TextShortField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
@@ -111,9 +117,9 @@ export function QuestionRenderer({
|
||||
<TextAreaField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
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, ruleQuestionCodes } 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) => ruleQuestionCodes(r).includes(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,13 +40,14 @@ export function CheckboxGroup({
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</legend>
|
||||
{maxSelect !== null ? (
|
||||
{/* El hint estático ("Podés marcar más de una opción") ya vive en
|
||||
wizard-prompt-block (question.hint) — acá solo el contador vivo,
|
||||
que es información que no está en ningún otro lado. */}
|
||||
{maxSelect !== null && (
|
||||
<p className="max-select-hint" aria-live="polite">
|
||||
Elegí hasta {maxSelect} opciones
|
||||
{value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="max-select-hint">Podés marcar más de una opción</p>
|
||||
)}
|
||||
<div className="options-list" role="list">
|
||||
{options.map((opt) => {
|
||||
|
||||
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.
|
||||
@@ -4,21 +4,43 @@
|
||||
import type { DashboardFilters } from './dashboard-types'
|
||||
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 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}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Mirada Empresa — una entrada por métrica (E1-E10 + estrella).
|
||||
export const queries = {
|
||||
kpis: (sid: string, f: DashboardFilters) => fetchWidget('kpis', sid, f),
|
||||
prevalencia: (sid: string, f: DashboardFilters) => fetchWidget('prevalencia', sid, f),
|
||||
heatmap: (sid: string, f: DashboardFilters) => fetchWidget('heatmap', sid, f),
|
||||
distribucion: (sid: string, f: DashboardFilters) => fetchWidget('distribucion', sid, f),
|
||||
apoyos: (sid: string, f: DashboardFilters) => fetchWidget('apoyos', sid, f),
|
||||
score: (sid: string, f: DashboardFilters) => fetchWidget('score', sid, f),
|
||||
piramide: (sid: string, f: DashboardFilters) => fetchWidget('piramide', sid, f),
|
||||
intensidad: (sid: string, f: DashboardFilters) => fetchWidget('intensidad', sid, f),
|
||||
saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f),
|
||||
mePrevalencia: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-prevalencia', sid, f),
|
||||
meRiesgoRotacion: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-riesgo-rotacion', sid, f),
|
||||
meAusentismo: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-ausentismo', sid, f),
|
||||
mePresentismo: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-presentismo', 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
|
||||
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 RpcResult<T> = T | SuppressedPayload
|
||||
@@ -61,3 +16,123 @@ export type RpcResult<T> = T | SuppressedPayload
|
||||
export function isSuppressed(r: unknown): r is SuppressedPayload {
|
||||
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 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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*'],
|
||||
}
|
||||
222
package-lock.json
generated
222
package-lock.json
generated
@@ -9,8 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"next": "15.3.3",
|
||||
"next": "15.3.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
@@ -18,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/cli": "^4.11.3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -895,15 +897,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.3.tgz",
|
||||
"integrity": "sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==",
|
||||
"version": "15.3.8",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.8.tgz",
|
||||
"integrity": "sha512-SAfHg0g91MQVMPioeFeDjE+8UPF3j3BvHjs8ZKJAUz1BG7eMPvfCKOAgNWJ6s1MLNeP6O2InKQRTNblxPWuq+Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.3.tgz",
|
||||
"integrity": "sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.5.tgz",
|
||||
"integrity": "sha512-lM/8tilIsqBq+2nq9kbTW19vfwFve0NR7MxfkuSUbRSgXlMQoJYg+31+++XwKVSXk4uT23G2eF/7BRIKdn8t8w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -917,9 +919,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.3.tgz",
|
||||
"integrity": "sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.5.tgz",
|
||||
"integrity": "sha512-WhwegPQJ5IfoUNZUVsI9TRAlKpjGVK0tpJTL6KeiC4cux9774NYE9Wu/iCfIkL/5J8rPAkqZpG7n+EfiAfidXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -933,9 +935,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.3.tgz",
|
||||
"integrity": "sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.5.tgz",
|
||||
"integrity": "sha512-LVD6uMOZ7XePg3KWYdGuzuvVboxujGjbcuP2jsPAN3MnLdLoZUXKRc6ixxfs03RH7qBdEHCZjyLP/jBdCJVRJQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -952,9 +954,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.3.tgz",
|
||||
"integrity": "sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.5.tgz",
|
||||
"integrity": "sha512-k8aVScYZ++BnS2P69ClK7v4nOu702jcF9AIHKu6llhHEtBSmM2zkPGl9yoqbSU/657IIIb0QHpdxEr0iW9z53A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -971,9 +973,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.3.tgz",
|
||||
"integrity": "sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.5.tgz",
|
||||
"integrity": "sha512-2xYU0DI9DGN/bAHzVwADid22ba5d/xrbrQlr2U+/Q5WkFUzeL0TDR963BdrtLS/4bMmKZGptLeg6282H/S2i8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -990,9 +992,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.3.tgz",
|
||||
"integrity": "sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.5.tgz",
|
||||
"integrity": "sha512-TRYIqAGf1KCbuAB0gjhdn5Ytd8fV+wJSM2Nh2is/xEqR8PZHxfQuaiNhoF50XfY90sNpaRMaGhF6E+qjV1b9Tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1009,9 +1011,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.3.tgz",
|
||||
"integrity": "sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.5.tgz",
|
||||
"integrity": "sha512-h04/7iMEUSMY6fDGCvdanKqlO1qYvzNxntZlCzfE8i5P0uqzVQWQquU1TIhlz0VqGQGXLrFDuTJVONpqGqjGKQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1025,9 +1027,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.3.tgz",
|
||||
"integrity": "sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==",
|
||||
"version": "15.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.5.tgz",
|
||||
"integrity": "sha512-5fhH6fccXxnX2KhllnGhkYMndhOiLOLEiVGYjP2nizqeGWkN10sA9taATlXwake2E2XMvYZjjz0Uj7T0y+z1yw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1040,6 +1042,22 @@
|
||||
"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": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -1047,9 +1065,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz",
|
||||
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1183,9 +1201,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz",
|
||||
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1195,15 +1213,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz",
|
||||
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1213,9 +1231,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz",
|
||||
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.2",
|
||||
@@ -1225,10 +1243,22 @@
|
||||
"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": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz",
|
||||
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
@@ -1239,16 +1269,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
|
||||
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.107.0",
|
||||
"@supabase/functions-js": "2.107.0",
|
||||
"@supabase/postgrest-js": "2.107.0",
|
||||
"@supabase/realtime-js": "2.107.0",
|
||||
"@supabase/storage-js": "2.107.0"
|
||||
"@supabase/auth-js": "2.108.2",
|
||||
"@supabase/functions-js": "2.108.2",
|
||||
"@supabase/postgrest-js": "2.108.2",
|
||||
"@supabase/realtime-js": "2.108.2",
|
||||
"@supabase/storage-js": "2.108.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -1789,6 +1819,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -2109,6 +2152,20 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -2808,13 +2865,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.3.3",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.3.3.tgz",
|
||||
"integrity": "sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==",
|
||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||
"version": "15.3.8",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.3.8.tgz",
|
||||
"integrity": "sha512-L+4c5Hlr84fuaNADZbB9+ceRX9/CzwxJ+obXIGHupboB/Q1OLbSUapFs4bO8hnS/E6zV/JDX7sG1QpKVR2bguA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.3.3",
|
||||
"@next/env": "15.3.8",
|
||||
"@swc/counter": "0.1.3",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"busboy": "1.6.0",
|
||||
@@ -2829,14 +2885,14 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.3.3",
|
||||
"@next/swc-darwin-x64": "15.3.3",
|
||||
"@next/swc-linux-arm64-gnu": "15.3.3",
|
||||
"@next/swc-linux-arm64-musl": "15.3.3",
|
||||
"@next/swc-linux-x64-gnu": "15.3.3",
|
||||
"@next/swc-linux-x64-musl": "15.3.3",
|
||||
"@next/swc-win32-arm64-msvc": "15.3.3",
|
||||
"@next/swc-win32-x64-msvc": "15.3.3",
|
||||
"@next/swc-darwin-arm64": "15.3.5",
|
||||
"@next/swc-darwin-x64": "15.3.5",
|
||||
"@next/swc-linux-arm64-gnu": "15.3.5",
|
||||
"@next/swc-linux-arm64-musl": "15.3.5",
|
||||
"@next/swc-linux-x64-gnu": "15.3.5",
|
||||
"@next/swc-linux-x64-musl": "15.3.5",
|
||||
"@next/swc-win32-arm64-msvc": "15.3.5",
|
||||
"@next/swc-win32-x64-msvc": "15.3.5",
|
||||
"sharp": "^0.34.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2977,6 +3033,38 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"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": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"next": "15.3.3",
|
||||
"next": "15.3.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.55.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/cli": "^4.11.3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.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;
|
||||
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.
|
||||
```
|
||||
98
sprints/sprint-4/ETAPA_4H_PROMPT.md
Normal file
98
sprints/sprint-4/ETAPA_4H_PROMPT.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# ETAPA 4H — Migrar las RPCs del dashboard de códigos v2 a v3
|
||||
|
||||
## 1. Objetivo
|
||||
Las 9 RPCs del dashboard referencian códigos de pregunta de v2 (`1.6`, `1.13`, `A6`,
|
||||
`A7`, `3.4`, `6.2`, etc.) que NO existen en el instrumento v3. Corriendo contra el seed
|
||||
v3 devuelven 0 en casi todo. Esta etapa migra los códigos v2→v3 para que el dashboard
|
||||
vuelva a funcionar con el instrumento nuevo. Es REPARACIÓN, no funcionalidad nueva.
|
||||
|
||||
## 2. Contexto previo
|
||||
- Las RPCs viven en `supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql`
|
||||
+ parche `20260611000001_dash_responses_all.sql` (flag p_all).
|
||||
- 9 RPCs públicas + 2 helpers (`_dash_responses`, `_dash_parse_filters`).
|
||||
- k-anonimato k≥5 en dos niveles (total y por-segmento) — NO tocar la lógica de k,
|
||||
solo los códigos de pregunta.
|
||||
- Flag p_all = UUID sentinela `00000000-...` en `_dash_responses` — NO tocar.
|
||||
- El instrumento v3 está en el seed (códigos 2.1-10.1, 10 secciones). El mapeo de
|
||||
códigos v2→v3 hay que derivarlo del seed v3 y del mockup validado.
|
||||
- Esta etapa NO incluye los datos de vínculo por instancia (4.2/4.3) — eso es la etapa
|
||||
siguiente (vínculo). Acá solo se migran los códigos de las preguntas NO instanciadas.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- Solo se migran CÓDIGOS de pregunta v2→v3 y los VALORES esperados si cambiaron
|
||||
(ej. opciones de respuesta que se reescribieron en v3).
|
||||
- NO se cambia la lógica de k-anonimato, ni el flag p_all, ni la estructura de las RPCs.
|
||||
- NO se agregan las RPCs de vínculo (es la etapa siguiente).
|
||||
- Si una métrica de v2 ya no tiene pregunta equivalente en v3, se REPORTA (no se inventa
|
||||
un mapeo) — decisión del director/metodología.
|
||||
- Migración nueva, no se editan las migraciones viejas de RPCs.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Construir la tabla de mapeo v2→v3
|
||||
Antes de tocar SQL, derivar del seed v3 y documentar el mapeo de cada código v2 usado
|
||||
en las RPCs a su equivalente v3. Ejemplos a resolver (verificar contra el seed real):
|
||||
- `1.6`/`1.13` (triggers de cuidado v2) → ¿qué los representa en v3? (probablemente 4.1
|
||||
con sus opciones de vínculo, o las preguntas de cuidado de S5-S7)
|
||||
- `A6`/`A7` (demográficas v2) → 2.x en v3
|
||||
- `3.4` (salario v2) → 8.4 en v3
|
||||
- `6.2` (apoyos v2) → 9.2 en v3
|
||||
- etc. — mapear TODOS los códigos que aparezcan en las 9 RPCs.
|
||||
Reportar esta tabla ANTES de escribir el SQL, porque algunos mapeos requieren confirmación
|
||||
(las métricas de "cuidador activo" cambian: v2 usaba 3 triggers, v3 usa 4.1 multi-opción).
|
||||
|
||||
### 4.2 Migración nueva con las RPCs actualizadas
|
||||
Crear `supabase/migrations/2026XXXXXXXXXX_dashboard_rpc_v3.sql` que recree las 9 RPCs
|
||||
con los códigos v3. Mantener idéntica: estructura, lógica de k≥5, manejo de p_all,
|
||||
shape del JSON de retorno (para no romper los widgets).
|
||||
|
||||
### 4.3 Verificar valores de respuesta esperados
|
||||
Algunas RPCs comparan contra valores literales (`a.value #>> '{}' = 'Sí'`). Si las opciones
|
||||
de respuesta cambiaron en v3 (wording), actualizar esos literales. Verificar contra el seed.
|
||||
|
||||
### 4.4 Reportar métricas sin equivalente
|
||||
Si alguna métrica v2 no tiene pregunta v3 equivalente, listarla para decisión — no inventar.
|
||||
|
||||
## 5. Entregables (DoD)
|
||||
- [ ] Tabla de mapeo v2→v3 documentada y reportada ANTES del SQL.
|
||||
- [ ] Migración que recrea las 9 RPCs con códigos v3.
|
||||
- [ ] Lógica de k≥5, p_all y shape de retorno SIN cambios.
|
||||
- [ ] Literales de valores de respuesta actualizados donde el wording cambió.
|
||||
- [ ] Métricas sin equivalente v3 reportadas (no inventadas).
|
||||
- [ ] NO incluye RPCs de vínculo (etapa siguiente).
|
||||
- [ ] NO aplicada a producción (solo archivo) hasta validación del director.
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Tabla de mapeo v2→v3: [código v2 → código v3, por cada uno]
|
||||
- 9 RPCs migradas ✓
|
||||
- k≥5 sin cambios ✓ / p_all sin cambios ✓ / shape de retorno sin cambios ✓
|
||||
- Literales de valores actualizados: [cuáles]
|
||||
|
||||
Métricas sin equivalente v3 (requieren decisión):
|
||||
- [lista o "ninguna"]
|
||||
|
||||
Asumido:
|
||||
- [mapeos que asumió y conviene confirmar]
|
||||
|
||||
NO aplicado a producción.
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No cambiar la lógica de k-anonimato, p_all, ni el shape del JSON de retorno.
|
||||
- ❌ No agregar las RPCs de vínculo por instancia (es la etapa siguiente).
|
||||
- ❌ No inventar mapeos para métricas sin equivalente — reportar.
|
||||
- ❌ No editar las migraciones viejas de RPCs.
|
||||
- ❌ No aplicar a producción sin validación.
|
||||
- ❌ No tocar v2.1.0-stable ni el motor.
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
fix(dashboard): migrar RPCs de códigos v2 a v3 [4H]
|
||||
|
||||
Las 9 RPCs del dashboard referenciaban códigos v2 inexistentes en v3
|
||||
(devolvían 0 con el seed nuevo). Migrados a códigos v3. k-anonimato,
|
||||
flag p_all y shape de retorno sin cambios. No incluye datos de vínculo
|
||||
por instancia (etapa siguiente).
|
||||
```
|
||||
113
sprints/sprint-4/ETAPA_4I_PROMPT.md
Normal file
113
sprints/sprint-4/ETAPA_4I_PROMPT.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# ETAPA 4I — Validación e2e + Release v3.0.0
|
||||
|
||||
## 1. Objetivo
|
||||
Validar el wizard v3 end-to-end y hacer el release a producción. Los 3 surveys
|
||||
pasan de `draft` a `live`. Esta etapa es irreversible en varios pasos — seguir
|
||||
el orden estricto de fases y detenerse si algo falla.
|
||||
|
||||
## 2. Estado de entrada
|
||||
- Último commit: `3e7109a0` (4F ciudad-select)
|
||||
- Commits no desplegados desde último deploy:
|
||||
`8dea04c` fix cnt RPCs · `dbcc60a` tema claro · `3ec5da0` autocomplete barrios ·
|
||||
`3e7109a0` ciudad-select
|
||||
- VPS: seed de 50 respuestas activo (Demo en `live`) — NO limpiar, el director
|
||||
quiere mostrar el dashboard al equipo con esos datos
|
||||
- Los 3 surveys están en `draft`
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Deploy vía Dokploy (rebuild + clean cache)
|
||||
- Migraciones SIEMPRE vía CLI del VPS — ninguna migración pendiente en esta etapa
|
||||
- Los 3 surveys se reactivan a `live` solo DESPUÉS de verificar producción
|
||||
- Tag `v3.0.0` solo DESPUÉS de reactivar surveys
|
||||
- No hay migración de datos v2 → v3 (arranque limpio)
|
||||
|
||||
## 4. Fases — orden estricto, detenerse si algo falla
|
||||
|
||||
### FASE 1 — Validación e2e local (antes de tocar producción)
|
||||
Correr el wizard completo localmente y verificar:
|
||||
- [ ] Consentimiento → flujo completo → submit exitoso
|
||||
- [ ] Una respuesta con al menos 1 familiar (instancia) persiste con instance_id
|
||||
- [ ] El motor no muestra preguntas que el flujo oculta
|
||||
- [ ] El autocomplete de barrio funciona (dropdown visible, no tapado)
|
||||
- [ ] El filtro ciudad-departamento funciona (2.1 → 2.2 filtra, cambiar 2.1 limpia 2.2)
|
||||
- [ ] Submit devuelve confirmación, no error
|
||||
|
||||
Si CUALQUIER item falla: DETENER. Reportar el fallo y esperar instrucción.
|
||||
|
||||
### FASE 2 — Deploy en Dokploy
|
||||
- Trigger rebuild en Dokploy con "Clean cache"
|
||||
- Verificar que el BUILD_ID nuevo aparece en los logs
|
||||
- Verificar que la app responde en `economia-cuidado.inqualityhq.com`
|
||||
- Verificar que el dashboard sigue mostrando los 50 datos sembrados
|
||||
- Verificar que el wizard abre y llega a la pregunta 2.3 sin errores de consola
|
||||
|
||||
Si CUALQUIER item falla: DETENER. Reportar el fallo y esperar instrucción.
|
||||
|
||||
### FASE 3 — Reactivar surveys + Tag
|
||||
Solo ejecutar si Fase 1 y Fase 2 están completas y verificadas.
|
||||
|
||||
Reactivar los 3 surveys en el VPS:
|
||||
```sql
|
||||
UPDATE surveys SET status = 'live'
|
||||
WHERE id IN (
|
||||
SELECT id FROM surveys WHERE status = 'draft'
|
||||
);
|
||||
```
|
||||
|
||||
Verificar que quedaron en `live`:
|
||||
```sql
|
||||
SELECT id, name, status FROM surveys ORDER BY name;
|
||||
```
|
||||
|
||||
Tag de release en el repo local y push:
|
||||
```bash
|
||||
git tag -a v3.0.0 -m "Release v3.0.0 — wizard declarativo + dashboard tres miradas"
|
||||
git push origin v3.0.0
|
||||
```
|
||||
|
||||
Actualizar CHANGELOG.md con las etapas 4C→4I completadas.
|
||||
|
||||
## 5. DoD completo
|
||||
- [ ] `git log --name-only -1` antes de empezar — confirmar commit de entrada
|
||||
- [ ] Fase 1 completa sin fallos
|
||||
- [ ] Fase 2 completa — app en producción respondiendo
|
||||
- [ ] Surveys en `live` — verificado con SELECT
|
||||
- [ ] Tag `v3.0.0` pusheado a Gitea
|
||||
- [ ] CHANGELOG.md actualizado
|
||||
- [ ] `git log --name-only -1` del commit de CHANGELOG
|
||||
|
||||
## 6. Qué reportar
|
||||
Por fase, en orden:
|
||||
FASE 1 — e2e local:
|
||||
|
||||
Submit con familiar: [response_id y instance_id del registro insertado]
|
||||
Items del checklist: ✓/✗ cada uno
|
||||
|
||||
FASE 2 — Deploy:
|
||||
|
||||
BUILD_ID nuevo: [valor]
|
||||
App responde: ✓/✗
|
||||
Dashboard con datos: ✓/✗
|
||||
Wizard sin errores de consola: ✓/✗
|
||||
|
||||
FASE 3 — Release:
|
||||
|
||||
Surveys en live: [output del SELECT]
|
||||
Tag v3.0.0: [output de git tag]
|
||||
CHANGELOG: [secciones agregadas]
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No reactivar surveys antes de verificar producción (Fase 2 primero)
|
||||
- ❌ No taggear antes de reactivar surveys (Fase 3 es la última)
|
||||
- ❌ No limpiar el seed de 50 respuestas — el director los necesita para mostrar al equipo
|
||||
- ❌ No aplicar migraciones — no hay ninguna pendiente en esta etapa
|
||||
- ❌ No tocar `v2.1.0-stable`
|
||||
- ❌ No continuar si algo falla — detenerse y reportar
|
||||
|
||||
## 8. Versionado
|
||||
chore(release): CHANGELOG v3.0.0 — wizard declarativo + dashboard tres miradas
|
||||
Etapas 4C→4I completadas. Motor de flujo puro, wizard renderer,
|
||||
|
||||
instancias familiares, submit con validación server-side, dashboard
|
||||
|
||||
tres miradas (Mirada Empresa funcional), autocomplete barrios/ciudad.
|
||||
148
sprints/sprint-4/ETAPA_AUTH_PROMPT.md
Normal file
148
sprints/sprint-4/ETAPA_AUTH_PROMPT.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# ETAPA AUTH — Supabase Auth + protección /admin
|
||||
|
||||
## 1. Objetivo
|
||||
Implementar autenticación email/password con Supabase Auth y proteger
|
||||
la ruta /admin/*. El dashboard y las vistas de admin quedan detrás de login.
|
||||
Sin esta etapa no se avanza a vista admin mejorada ni vista detallada.
|
||||
|
||||
## 2. Contexto
|
||||
- `current_app_role()` y `current_org_id()` ya leen del JWT — el esquema está listo.
|
||||
- No hay middleware.ts ni sistema de auth hoy — /admin/responses está abierto.
|
||||
- Organizations existentes: Empresa Demo (…0001), InQuality (…0002),
|
||||
Fundación Solidaridad (…0003).
|
||||
- No hay usuarios en auth.users — hay que crear el primero como parte de esta etapa.
|
||||
- La app usa anon key para el formulario público — eso NO cambia.
|
||||
- /admin/* usa service_role hoy — eso SÍ cambia: pasa a usar sesión del usuario.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Supabase Auth nativo, email/password. Sin OAuth por ahora.
|
||||
- Dos roles: `platform_admin` (acceso total) y `org_admin` (acceso a su org).
|
||||
- Roles van como custom claims en el JWT: `app_role` y `org_id`.
|
||||
- Middleware Next.js protege /admin/* — redirige a /login si no hay sesión válida.
|
||||
- Página de login en /login — simple, solo email/password, sin registro público.
|
||||
- El formulario público (/encuesta/*) NO se toca — sigue con anon key.
|
||||
- Las RPCs del dashboard usan current_app_role() y current_org_id() del JWT —
|
||||
cuando el usuario esté autenticado, RLS funciona automáticamente.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Diagnóstico previo — mecanismo de custom claims
|
||||
ANTES de implementar, reportar qué mecanismo de JWT customization está disponible
|
||||
en esta versión de Supabase self-hosted. Puede ser:
|
||||
- Hook SQL en `auth.hooks`
|
||||
- Override de función `auth.jwt()`
|
||||
- Edge Function hook
|
||||
No implementar nada hasta confirmar el mecanismo y reportarlo.
|
||||
|
||||
### 4.2 Migración — tabla user_roles + custom claims hook
|
||||
Migración `20260627000001_auth_user_roles.sql`:
|
||||
|
||||
```sql
|
||||
-- Tabla de roles
|
||||
CREATE TABLE public.user_roles (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
app_role text NOT NULL CHECK (app_role IN ('platform_admin','org_admin')),
|
||||
org_id uuid REFERENCES public.organizations(id)
|
||||
);
|
||||
|
||||
-- RLS: solo el propio usuario puede ver su rol (y platform_admin ve todos)
|
||||
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
Implementar el hook de custom claims según el mecanismo disponible confirmado en 4.1.
|
||||
El JWT resultante debe incluir:
|
||||
```json
|
||||
{ "app_role": "platform_admin", "org_id": "10000000-0000-0000-0000-000000000002" }
|
||||
```
|
||||
|
||||
### 4.3 Script de setup — primer usuario admin
|
||||
Script operativo (NO migración) en `scripts/setup_first_admin.sql`:
|
||||
- Crea el usuario en auth.users vía INSERT directo o Supabase admin API
|
||||
- Inserta en user_roles con app_role='platform_admin' y org_id de InQuality
|
||||
- NO hardcodear credenciales — usar variables `:email` y `:password` con instrucciones
|
||||
de cómo ejecutarlo:
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -d postgres \
|
||||
-v email='admin@ejemplo.com' -v password='CAMBIAR' \
|
||||
< scripts/setup_first_admin.sql
|
||||
```
|
||||
- Documentar en el script cómo cambiar la contraseña después del primer login
|
||||
|
||||
### 4.4 Middleware Next.js
|
||||
`middleware.ts` en la raíz del proyecto:
|
||||
- Protege `/admin/*` y `/dashboard/*`
|
||||
- Si no hay sesión válida → redirect a `/login`
|
||||
- Si hay sesión → deja pasar
|
||||
- `/`, `/encuesta/*`, `/login` — sin restricción
|
||||
|
||||
### 4.5 Página de login
|
||||
`app/login/page.tsx` — Client Component:
|
||||
- Formulario email/password
|
||||
- Llama `supabase.auth.signInWithPassword()`
|
||||
- En éxito: redirect a `/admin/responses`
|
||||
- En error: mensaje claro ("Email o contraseña incorrectos"), nunca trace crudo
|
||||
- Sin registro público
|
||||
- Usar clases Tailwind existentes — sin nuevo sistema de diseño
|
||||
|
||||
### 4.6 Actualizar /admin/responses
|
||||
- Reemplazar `getSupabaseAdmin()` (service_role) por cliente de sesión del usuario
|
||||
- RLS de Postgres filtra según app_role y org_id del JWT automáticamente
|
||||
- Agregar botón "Cerrar sesión" (`supabase.auth.signOut()` → redirect a `/login`)
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — listar todos los archivos tocados
|
||||
- [ ] Diagnóstico de mecanismo de custom claims reportado (4.1) antes de implementar
|
||||
- [ ] Migración `20260627000001` aplicada en VPS — verificar con:
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -d postgres -c "\d user_roles"
|
||||
```
|
||||
- [ ] Script `scripts/setup_first_admin.sql` existe y está documentado
|
||||
- [ ] Hook JWT verificado — el token incluye `app_role` (cómo verificarlo: reportar el método)
|
||||
- [ ] `/login` funciona — email/password → sesión activa → redirect a /admin/responses
|
||||
- [ ] `/admin/responses` sin sesión → redirect a `/login`
|
||||
- [ ] `/admin/responses` con sesión → carga datos correctamente
|
||||
- [ ] Formulario público sin cambios (verificar que /encuesta/* sigue funcionando)
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] Commit + push a Gitea
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Diagnóstico previo (4.1):
|
||||
- Mecanismo de custom claims disponible: [cuál]
|
||||
- Alternativas descartadas: [por qué]
|
||||
|
||||
Verificado:
|
||||
- Migración user_roles aplicada: ✓ — output de \d user_roles
|
||||
- Hook JWT: app_role presente en token ✓/✗ — método de verificación usado
|
||||
- /login funciona: ✓/✗
|
||||
- /admin sin sesión → /login: ✓/✗
|
||||
- /admin con sesión → datos: ✓/✗
|
||||
- Formulario público sin cambios: ✓/✗
|
||||
- Build: ✓/✗
|
||||
- git log --name-only -1: [lista completa de archivos]
|
||||
|
||||
Asumido / decisiones:
|
||||
- [mecanismo de hook y razonamiento]
|
||||
|
||||
Hallazgos:
|
||||
- [lo que aparezca]
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar el formulario público ni la anon key
|
||||
- ❌ No hardcodear credenciales en ningún archivo commiteado
|
||||
- ❌ No implementar registro público de usuarios
|
||||
- ❌ No usar service_role en rutas que ya tienen sesión de usuario
|
||||
- ❌ No avanzar a vista admin mejorada — eso es la etapa siguiente
|
||||
- ❌ No hacer deploy — solo commit y push a Gitea
|
||||
- ❌ No implementar el hook sin reportar primero el diagnóstico de 4.1
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(auth): Supabase Auth email/password + middleware + login [auth]
|
||||
|
||||
Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims
|
||||
(app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión.
|
||||
Página /login con email/password. /admin/responses migrado de service_role
|
||||
a sesión de usuario autenticado.
|
||||
```
|
||||
62
sprints/sprint-4/ETAPA_DASHBOARD_CLARO.md
Normal file
62
sprints/sprint-4/ETAPA_DASHBOARD_CLARO.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ETAPA — Dashboard tema claro (marca Re)
|
||||
|
||||
## 1. Objetivo
|
||||
Convertir el dashboard de tema oscuro a tema claro usando la paleta oficial de marca Re.
|
||||
Un solo archivo modificado: `app/globals.css`.
|
||||
|
||||
## 2. Decisiones ya tomadas — no consultar
|
||||
- Fondo página: #E7E6E5 (gris claro Re)
|
||||
- Cards/widgets: #FFFFFF
|
||||
- Texto principal: #494F4F (carbón Re)
|
||||
- Acento teal, colores de alerta inline y barras de pct: SIN CAMBIO
|
||||
- Texto de tabs activos sobre teal (.db-survey-link-active): #FFFFFF
|
||||
|
||||
## 3. Tarea
|
||||
En `app/globals.css`, sección DASHBOARD (línea 710 en adelante),
|
||||
aplicar los siguientes reemplazos SOLO en esa sección:
|
||||
|
||||
FONDOS:
|
||||
- background: #0F1923 → background: #E7E6E5 (.db-page, línea 716)
|
||||
- background: #1A2535 → background: #FFFFFF (.db-widget, línea 741)
|
||||
- background: #14202e → background: #F0EFEE (.db-sidebar, línea 887)
|
||||
- background: #101a30 → background: #F0EFEE (.db-lens-intro, .db-placeholder)
|
||||
- background: #2a3547 → background: rgba(73,79,79,0.10) (.db-bar-track)
|
||||
- background: #1e2d42 → background: #FFFFFF (.db-filter-select)
|
||||
- background: #1e2d42 → background: rgba(73,79,79,0.08) (.db-skeleton-block)
|
||||
|
||||
TEXTO:
|
||||
- color: #E7E6E5 → color: #494F4F (en TODAS las clases .db-* de esa sección)
|
||||
- color: #9ca3af → color: #6B7878
|
||||
- color: #7A8FA0 → color: #8A9A9A
|
||||
- color: #7d8da5 → color: #8A9A9A
|
||||
- color: #9fb0cc → color: #8A9A9A
|
||||
- color: #6b7280 → color: #9CA8A8
|
||||
- color: #0F1923 → color: #FFFFFF (solo .db-survey-link-active)
|
||||
- color: #fff → color: #494F4F (solo .db-page-title, línea 721)
|
||||
|
||||
NO TOCAR:
|
||||
- color: var(--color-re-teal) — sin cambio en ninguna clase
|
||||
- color: #ef4444, #f0b429, #f06b6b, #f06b6b — sin cambio
|
||||
- Todo lo que esté fuera de la sección DASHBOARD (línea 710 en adelante)
|
||||
- Cualquier archivo .tsx
|
||||
|
||||
## 4. DoD (checklist antes de reportar)
|
||||
- [ ] Solo `app/globals.css` modificado
|
||||
- [ ] `git diff --name-only HEAD` lista únicamente `app/globals.css`
|
||||
- [ ] Ningún valor de acento (teal, rojos, ámbar) fue tocado
|
||||
- [ ] Ninguna clase fuera de la sección DASHBOARD fue modificada
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
|
||||
## 5. Qué reportar
|
||||
- Cantidad de líneas modificadas
|
||||
- Lista de clases tocadas con valor anterior → valor nuevo
|
||||
- Output de `git diff --name-only HEAD`
|
||||
- Output de `npm run build` (últimas 5 líneas)
|
||||
- Hallazgo visual: ¿`.db-gap-brecha` (#f0b429 ámbar) tiene contraste visible
|
||||
sobre el nuevo fondo #F0EFEE? Verificar si el valor en pantalla sería legible.
|
||||
|
||||
## 6. Qué NO hacer
|
||||
- No tocar variables :root
|
||||
- No tocar archivos .tsx
|
||||
- No modificar colores de acento ni alertas
|
||||
- No hacer deploy — solo commit y push a Gitea
|
||||
100
sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md
Normal file
100
sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# ETAPA — Widgets de Mirada Empresa (frontend del dashboard v3)
|
||||
|
||||
## 1. Objetivo
|
||||
Construir el frontend del dashboard de Mirada Empresa: la estructura de tres miradas
|
||||
(Empresa funcional, País/Humana como "próximamente") y los widgets que consumen las RPCs
|
||||
`rpc_me_*` ya implementadas y verificadas contra datos reales. El cliente ve las métricas.
|
||||
|
||||
## 2. Contexto previo
|
||||
- Las RPCs de Mirada Empresa están hechas y VERIFICADAS contra 50 respuestas reales
|
||||
(migración `20260625000002_dashboard_mirada_empresa.sql`): E1-E10 + estrella "Talento en riesgo".
|
||||
- Contrato de cada RPC (shapes de retorno): `docs/dashboard/SPEC_MIRADA_EMPRESA.md`.
|
||||
- Aspecto objetivo: `docs/dashboard/dashboard_tres_miradas_mockup.html`.
|
||||
- Patrón de widget existente a REUTILIZAR (no reinventar):
|
||||
- `components/dashboard/widget-shell.tsx` — estados loading/ok/suppressed/error.
|
||||
- hooks `useEffect` + `queries.X(surveyId, filters)` (lib/dashboard-queries.ts).
|
||||
- `isSuppressed()` type guard (lib/dashboard-types.ts).
|
||||
- Gráficos en SVG/CSS a mano (db-bar-*, db-donut-*, db-kpi-*) — SIN librería de charts.
|
||||
- Tema oscuro db-*, acento único teal `#43BBC8` (--color-re-teal).
|
||||
- Tres archivos de plomería a extender (mapeos mecánicos): `lib/dashboard-types.ts` (tipos
|
||||
de retorno), `lib/dashboard-queries.ts` (entradas queries), `app/api/dashboard/[widget]/route.ts`
|
||||
(RPC_MAP con los `rpc_me_*`).
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- **Opción A:** reemplazar el dashboard actual por la estructura de tres miradas. Los widgets
|
||||
v2 rotos (que consumen RPCs v2 inexistentes en v3) se RETIRAN. País y Humana = placeholder
|
||||
"Disponible próximamente".
|
||||
- Reutilizar WidgetShell, el patrón de hooks, isSuppressed, y los gráficos SVG/CSS a mano.
|
||||
- NO traer librería de charts nueva — replicar el patrón existente.
|
||||
- Mantener el tema db-* y el teal como acento.
|
||||
- NO tocar las RPCs (están verificadas). Solo el frontend + la plomería de tipos/queries/route.
|
||||
- Mirada Empresa NO muestra datos sensibles (ya garantizado en las RPCs; el frontend solo refleja lo que viene).
|
||||
|
||||
## 4. Tareas
|
||||
### 4.1 Plomería (mecánica)
|
||||
- `lib/dashboard-types.ts`: tipos de retorno de cada `rpc_me_*` según los shapes de la SPEC.
|
||||
- `lib/dashboard-queries.ts`: una entrada por métrica (E1-E10 + estrella).
|
||||
- `app/api/dashboard/[widget]/route.ts`: agregar los `rpc_me_*` al RPC_MAP.
|
||||
|
||||
### 4.2 Estructura de tres miradas
|
||||
- Tabs/secciones Empresa | País | Humana (como el mockup).
|
||||
- Empresa: funcional. País/Humana: placeholder "Disponible próximamente" (no widgets vacíos).
|
||||
- El selector de survey/p_all y los filtros del sidebar siguen funcionando como hoy.
|
||||
|
||||
### 4.3 Widgets de Mirada Empresa
|
||||
Implementar los widgets del NÚCLEO primero (los que el cliente necesita):
|
||||
- **Talento en riesgo** (estrella) — KPI destacado, métrica compuesta.
|
||||
- **E1 Prevalencia de cuidadores** — KPI.
|
||||
- **E6 Punto ciego organizacional** — el widget de contraste (carga real vs apoyo percibido),
|
||||
dos barras enfrentadas como en el mockup.
|
||||
- **E2/E3/E4 Señales de impacto** — barras (riesgo, ausentismo, presentismo).
|
||||
- **E7 Apoyos demandados** — barras horizontales (igual patrón que apoyos-bar viejo).
|
||||
Después, si el alcance lo permite, los [SEC]: E5, E8, E9 (segmentación), E10.
|
||||
|
||||
### 4.4 Manejo de estados
|
||||
- Cada widget usa WidgetShell. Estado `suppressed` (k<5) muestra el mensaje estándar 🔒.
|
||||
- Las métricas que segmentan (E7, E9) suprimen filas individuales con n<5 (las RPCs ya lo hacen;
|
||||
el widget solo no muestra lo que no viene).
|
||||
|
||||
## 5. Entregables (DoD)
|
||||
- [ ] Estructura de tres miradas; Empresa funcional, País/Humana placeholder.
|
||||
- [ ] Widgets del núcleo consumiendo las RPCs reales vía la plomería.
|
||||
- [ ] Widgets v2 rotos retirados.
|
||||
- [ ] WidgetShell y patrón existente reutilizados (no librería nueva, no reinvención).
|
||||
- [ ] Estados loading/ok/suppressed manejados.
|
||||
- [ ] Build sin errores.
|
||||
- [ ] **Validación visual del director** contra el mockup + datos reales sembrados.
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Estructura 3 miradas ✓ — Empresa funcional, País/Humana placeholder
|
||||
- Widgets implementados: [lista con qué RPC consume cada uno]
|
||||
- Plomería: tipos + queries + RPC_MAP extendidos ✓
|
||||
- Widgets v2 retirados: [lista]
|
||||
- WidgetShell reutilizado ✓ / sin librería de charts nueva ✓
|
||||
- Build: [resultado]
|
||||
- Descripción visual: cómo se ve cada widget núcleo con datos
|
||||
Asumido / decisiones de diseño:
|
||||
- [lo que haya]
|
||||
```
|
||||
Reporte con datos concretos (C.4). Para validación visual necesito ver el dashboard con datos:
|
||||
indicá cómo levantarlo (el seed_prueba_50.sql siembra los datos).
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar las RPCs (verificadas).
|
||||
- ❌ No traer librería de charts — SVG/CSS a mano como el resto.
|
||||
- ❌ No mostrar datos sensibles (3.x, 4.2, 4.3).
|
||||
- ❌ No implementar widgets de País/Humana (placeholder solo).
|
||||
- ❌ No aplicar a producción sin validación visual.
|
||||
- ❌ No tocar v2.1.0-stable, el motor, el seed del formulario.
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(dashboard): widgets de Mirada Empresa + estructura tres miradas [dashboard]
|
||||
|
||||
Reemplaza el dashboard v2 (widgets rotos contra v3) por la estructura de tres
|
||||
miradas. Mirada Empresa funcional: talento en riesgo, prevalencia, punto ciego,
|
||||
señales de impacto, apoyos demandados. País/Humana como placeholder. Reutiliza
|
||||
WidgetShell y el patrón SVG/CSS existente. Consume las rpc_me_* verificadas.
|
||||
```
|
||||
95
sprints/sprint-4/PASO_0_PRESERVACION.md
Normal file
95
sprints/sprint-4/PASO_0_PRESERVACION.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# PASO 0 — Preservación de v2.1.0 antes de arrancar Sprint 4
|
||||
|
||||
> Ejecutar ANTES de tocar nada de v3. Estos pasos preservan el estado actual
|
||||
> estable como punto de retorno seguro.
|
||||
|
||||
---
|
||||
|
||||
## Objetivo
|
||||
1. Crear tag `v2.1.0` sobre el commit actual de `main`.
|
||||
2. Crear rama `v2.1.0-stable` que preserva el código de v2.
|
||||
3. Generar un dump de la base de datos de producción y versionarlo en esa rama.
|
||||
4. Crear `CHANGELOG.md` en `main`.
|
||||
5. Dejar `main` listo para desarrollo de v3.
|
||||
|
||||
---
|
||||
|
||||
## Secuencia de comandos
|
||||
|
||||
### 1. Asegurarse de estar en main actualizado
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git status # debe estar limpio
|
||||
```
|
||||
|
||||
### 2. Crear el tag de la versión estable actual
|
||||
```bash
|
||||
git tag -a v2.1.0 -m "Versión 2.1.0 — formulario lineal, dashboard multitenante, instrumento v2.1"
|
||||
git push origin v2.1.0
|
||||
```
|
||||
|
||||
### 3. Crear la rama de preservación
|
||||
```bash
|
||||
git checkout -b v2.1.0-stable
|
||||
git push -u origin v2.1.0-stable
|
||||
```
|
||||
|
||||
### 4. Generar el dump de la base de datos (en el VPS)
|
||||
```bash
|
||||
# En el VPS inq-mart-2:
|
||||
docker exec supabase-db pg_dump -U postgres --clean --if-exists postgres \
|
||||
| gzip > ~/dump_v2.1.0_$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Verificar tamaño
|
||||
ls -lh ~/dump_v2.1.0_*.sql.gz
|
||||
```
|
||||
|
||||
### 5. Traer el dump a la máquina local y agregarlo a la rama
|
||||
```bash
|
||||
# En tu Mac, desde el repo, estando en la rama v2.1.0-stable:
|
||||
mkdir -p db-snapshots
|
||||
scp administrator@172.20.5.11:~/dump_v2.1.0_*.sql.gz db-snapshots/
|
||||
|
||||
git add db-snapshots/
|
||||
git commit -m "chore(v2.1.0): snapshot de base de datos de producción
|
||||
|
||||
Dump completo de la DB de producción al momento del corte v2.1.0.
|
||||
Incluye: 3 organizaciones, 3 surveys, 132 questions, 15 responses, ~500 answers.
|
||||
Preservado antes de iniciar el desarrollo de v3 (wizard)."
|
||||
git push origin v2.1.0-stable
|
||||
```
|
||||
|
||||
### 6. Volver a main y crear el CHANGELOG
|
||||
```bash
|
||||
git checkout main
|
||||
# Crear CHANGELOG.md (contenido en el archivo adjunto)
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: agregar CHANGELOG con versionado semántico
|
||||
|
||||
Establece la convención SemVer para el proyecto.
|
||||
v2.1.0 documentada como versión estable actual.
|
||||
v3.0.0 (wizard) marcada como en desarrollo."
|
||||
git push origin main
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verificación final
|
||||
```bash
|
||||
git tag -l # debe mostrar v2.1.0
|
||||
git branch -a # debe mostrar main y v2.1.0-stable
|
||||
git log --oneline -3 # en main, último commit es el CHANGELOG
|
||||
```
|
||||
|
||||
A partir de acá, todo el trabajo de v3 va en `main`. La rama `v2.1.0-stable`
|
||||
queda congelada como referencia y punto de retorno.
|
||||
|
||||
---
|
||||
|
||||
## Nota sobre el dump
|
||||
El dump en `db-snapshots/` permite recrear la DB de v2.1.0 si fuera necesario
|
||||
volver atrás. Para restaurarlo:
|
||||
```bash
|
||||
gunzip -c dump_v2.1.0_YYYYMMDD.sql.gz | docker exec -i supabase-db psql -U postgres -d postgres
|
||||
```
|
||||
BIN
sprints/sprint-4/referencias/Formulario_Mapeo_Corregido.xlsx
Normal file
BIN
sprints/sprint-4/referencias/Formulario_Mapeo_Corregido.xlsx
Normal file
Binary file not shown.
481
sprints/sprint-4/referencias/wizard_formulario_mapeo_v3.html
Normal file
481
sprints/sprint-4/referencias/wizard_formulario_mapeo_v3.html
Normal file
File diff suppressed because one or more lines are too long
154
sprints/sprint-5/ETAPA_ADMIN_PROMPT.md
Normal file
154
sprints/sprint-5/ETAPA_ADMIN_PROMPT.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# ETAPA ADMIN — Vista de respuestas mejorada + vista detallada
|
||||
|
||||
## 1. Objetivo
|
||||
Mejorar `/admin/responses` para que sea una interfaz de administración real,
|
||||
y crear `/admin/responses/[id]` como vista detallada de una respuesta individual.
|
||||
Todo detrás del middleware de auth existente.
|
||||
|
||||
## 2. Contexto
|
||||
- Estado actual: `app/admin/responses/page.tsx` (Server Component, 249 líneas) +
|
||||
`app/admin/responses/responses-table.tsx` (Client Component, 98 líneas).
|
||||
- Auth: ya implementada — getSupabaseSession() con anon key + cookies, respeta RLS.
|
||||
- Exclusión de sensibles: `is_sensitive=false` y `type != 'text_long'` en query de
|
||||
questions — NO tocar, mantener exactamente igual.
|
||||
- SURVEY_OPTIONS hardcodeadas con UUIDs literales — reemplazar por query dinámica.
|
||||
- LIMIT 500 sin paginación real — reemplazar por paginación con page size de 50.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Paginación: page size 50, parámetro `?page=N` en la URL (Server Component,
|
||||
recarga completa — no client-side infinite scroll).
|
||||
- Filtro de survey: `<select>` que carga surveys desde la DB (solo las que el
|
||||
usuario puede ver según RLS), no hardcodeado.
|
||||
- Vista detallada: ruta `/admin/responses/[id]` — Server Component, muestra todas
|
||||
las respuestas no sensibles de esa submission, agrupadas por sección.
|
||||
- Diseño: usar clases Tailwind o clases `.admin-*` existentes — sin nuevo sistema.
|
||||
- Sin librería de tablas externa.
|
||||
- El expand/collapse por submission en la tabla se conserva.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Vista lista mejorada — `app/admin/responses/page.tsx`
|
||||
|
||||
**Filtro de survey dinámico:**
|
||||
Reemplazar SURVEY_OPTIONS hardcodeadas por query a `surveys` table:
|
||||
```ts
|
||||
const { data: surveys } = await supabase
|
||||
.from('surveys')
|
||||
.select('id, title')
|
||||
.order('title')
|
||||
```
|
||||
Renderizar como `<select>` con onChange que navega a `?survey_id=<id>`.
|
||||
Opción "Todos los surveys" con value=''.
|
||||
|
||||
**Paginación:**
|
||||
- Leer `searchParams.page` (default 1).
|
||||
- Query de responses: `.range((page-1)*50, page*50-1)` en vez de `.limit(500)`.
|
||||
- Mostrar: "Página N de M · X respuestas totales".
|
||||
- Links Anterior / Siguiente que modifican `?page=N` en la URL.
|
||||
- Si hay menos de 50 resultados, no mostrar paginación.
|
||||
|
||||
**Columna de acciones:**
|
||||
Agregar columna "Ver" en la fila cabecera de cada submission (isFirstInGroup=true)
|
||||
con link a `/admin/responses/[response_id]`.
|
||||
|
||||
**Header informativo:**
|
||||
Mostrar claramente: total de submissions, total de answers, survey seleccionado.
|
||||
|
||||
### 4.2 Vista detallada — `app/admin/responses/[id]/page.tsx`
|
||||
|
||||
Server Component. Ruta: `/admin/responses/[id]` donde `id` = `response_id` (uuid).
|
||||
|
||||
**Query principal:**
|
||||
```ts
|
||||
// 1. Metadata de la respuesta
|
||||
const { data: response } = await supabase
|
||||
.from('responses')
|
||||
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
// 2. Preguntas permitidas (mismo filtro que la lista)
|
||||
const { data: questions } = await supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position, section_number, section_title')
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.eq('survey_id', response.survey_id)
|
||||
.order('position')
|
||||
|
||||
// 3. Answers de esta respuesta
|
||||
const { data: answers } = await supabase
|
||||
.from('answers')
|
||||
.select('question_id, value, instance_id')
|
||||
.eq('response_id', id)
|
||||
.in('question_id', questions.map(q => q.id))
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- Header: fecha, zona, sector, edad — datos de qi_* de la respuesta.
|
||||
- Link "← Volver a la lista" que preserva el `?survey_id` y `?page` anteriores
|
||||
(pasar como searchParam o usar `referrer`).
|
||||
- Respuestas agrupadas por sección (section_number + section_title).
|
||||
- Dentro de cada sección: tabla código | pregunta | valor.
|
||||
- Instancias (instance_id != 'default'): mostrar agrupadas bajo "Familiar N"
|
||||
con badge visual.
|
||||
- Valores nulos: badge "sin respuesta" (igual que la lista).
|
||||
- Si `id` no existe o RLS bloquea: mostrar "Respuesta no encontrada" —
|
||||
no crashear.
|
||||
|
||||
### 4.3 Tabla de respuestas — `app/admin/responses/responses-table.tsx`
|
||||
Agregar la columna "Ver" en las filas isFirstInGroup con `<Link>` a
|
||||
`/admin/responses/[submissionId]`. Pasar `surveyId` y `page` como props
|
||||
para construir el link de "← Volver" en la vista detallada.
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — listar todos los archivos tocados
|
||||
- [ ] Filtro de survey carga desde DB, no hardcodeado
|
||||
- [ ] Paginación funciona: page=1 muestra primeros 50, page=2 los siguientes
|
||||
- [ ] Link "Ver" en cada submission lleva a `/admin/responses/[id]`
|
||||
- [ ] Vista detallada muestra respuestas agrupadas por sección
|
||||
- [ ] Instancias agrupadas bajo "Familiar N"
|
||||
- [ ] RLS respetado: misma exclusión de sensibles que la lista
|
||||
- [ ] "Respuesta no encontrada" si el id no existe
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] `git log --name-only -1`
|
||||
- [ ] Commit + push a Gitea
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Filtro survey: ✓ — query a tabla surveys, no hardcodeado
|
||||
- Paginación: ✓ — page size 50, range correcto
|
||||
- Link "Ver" → /admin/responses/[id]: ✓
|
||||
- Vista detallada: ✓ — agrupada por sección, instancias bajo "Familiar N"
|
||||
- RLS/exclusión sensibles: ✓ — mismo filtro que antes
|
||||
- "No encontrada" si id inválido: ✓
|
||||
- Build: ✓
|
||||
- git log --name-only -1: [lista completa]
|
||||
- Commit: [hash]
|
||||
|
||||
Decisiones de diseño tomadas:
|
||||
- [lo que haya]
|
||||
|
||||
Hallazgos:
|
||||
- [lo que aparezca, especialmente sobre questions.section_number/section_title
|
||||
— si esas columnas no existen, reportarlo antes de inventar una solución]
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar el filtro `is_sensitive=false` ni `type != 'text_long'`
|
||||
- ❌ No usar service_role — solo getSupabaseSession() con RLS
|
||||
- ❌ No agregar librerías de tablas o UI externas
|
||||
- ❌ No implementar búsqueda por texto (fuera de alcance)
|
||||
- ❌ No hacer deploy — solo commit y push a Gitea
|
||||
- ❌ Si `section_number` o `section_title` no existen en questions,
|
||||
reportarlo y NO inventar columnas — esperar instrucción
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(admin): vista respuestas mejorada + vista detallada [admin]
|
||||
|
||||
/admin/responses: filtro survey dinámico, paginación 50/página,
|
||||
link a vista detallada. /admin/responses/[id]: respuestas por sección,
|
||||
instancias bajo "Familiar N". RLS y exclusión de sensibles intactos.
|
||||
```
|
||||
124
sprints/sprint-5/ETAPA_AUDIT_LOG_PROMPT.md
Normal file
124
sprints/sprint-5/ETAPA_AUDIT_LOG_PROMPT.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# ETAPA AUDIT LOG — Registro de auditoría append-only (Sprint 5, SEC-002)
|
||||
|
||||
## 1. Objetivo
|
||||
Implementar un registro de auditoría append-only en Postgres que loggee eventos
|
||||
de configuración crítica: cambios en `user_roles` (altas/bajas/modificaciones de
|
||||
acceso) y cambios de `status` en `surveys` (activación/desactivación de encuestas).
|
||||
Opcionalmente, si la implementación es simple y sin fricción, agregar también un
|
||||
trigger DDL para loggear migraciones aplicadas.
|
||||
|
||||
## 2. Contexto
|
||||
- Migración de referencia para patrones: `20260627000001_auth_user_roles.sql`
|
||||
(patrón SECURITY DEFINER + GRANT puntual a reutilizar).
|
||||
- Helpers RLS existentes a reusar: `public.current_app_role()` y
|
||||
`public.current_org_id()` — no reinventar.
|
||||
- Gap conocido y aceptado (SEC-003): esto NO protege contra TRUNCATE/DROP ejecutado
|
||||
por superusuario directo en el VPS. Documentar en comentario de la migración, no resolver.
|
||||
- Acceso a datos sensibles: NO loggear en esta etapa — hoy no existe código que
|
||||
lea columnas `is_sensitive=true`. Deferido a cuando ese código exista.
|
||||
- Naming de migración: después de `20260627000001` — usar timestamp del día de
|
||||
ejecución, formato `YYYYMMDDHHMMSS`.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Tabla nueva: `public.audit_log`, append-only real.
|
||||
- Ningún rol (`anon`, `authenticated`, `service_role` vía API) tiene INSERT/UPDATE/DELETE
|
||||
directo en `audit_log`. Solo escriben funciones SECURITY DEFINER de los triggers.
|
||||
- Segunda barrera: trigger `BEFORE UPDATE OR DELETE ON audit_log` que aborta con
|
||||
excepción — defensa en profundidad.
|
||||
- Lectura de audit_log: RLS, solo `platform_admin` via `current_app_role()`.
|
||||
- Patrón de función trigger: SECURITY DEFINER, propiedad de `postgres`,
|
||||
GRANT EXECUTE solo al rol que lo necesita (no a PUBLIC).
|
||||
- Columnas de audit_log:
|
||||
```sql
|
||||
id bigserial PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_role text,
|
||||
actor_user_id uuid,
|
||||
action text NOT NULL,
|
||||
target_table text NOT NULL,
|
||||
target_id text,
|
||||
detail jsonb
|
||||
```
|
||||
- Triggers obligatorios (Prioridad 1):
|
||||
- AFTER INSERT/UPDATE/DELETE en `user_roles`
|
||||
- AFTER UPDATE en `surveys` cuando cambia `status`
|
||||
- Trigger DDL (Prioridad 2 — opcional): solo implementar si es simple y sin fricción.
|
||||
Si requiere extensión nueva o configuración de infraestructura, NO incluir y reportarlo.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Diagnóstico previo — DDL trigger
|
||||
Antes de implementar, verificar si el DDL trigger (`ddl_command_end`) está disponible
|
||||
sin extensiones adicionales en esta versión de Postgres self-hosted:
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -d postgres -c "
|
||||
SELECT event_object_schema, trigger_name
|
||||
FROM information_schema.triggers
|
||||
WHERE trigger_name LIKE '%ddl%' LIMIT 5;"
|
||||
```
|
||||
Reportar si es viable sin fricción. Si requiere configuración extra, documentarlo
|
||||
y omitirlo de esta etapa.
|
||||
|
||||
### 4.2 Migración principal
|
||||
Archivo: `supabase/migrations/YYYYMMDDHHMMSS_audit_log.sql`
|
||||
|
||||
Contenido en orden:
|
||||
1. Crear tabla `audit_log` con columnas definidas arriba.
|
||||
2. RLS en `audit_log`: solo `platform_admin` puede SELECT.
|
||||
3. Trigger `BEFORE UPDATE OR DELETE ON audit_log` → función que lanza excepción
|
||||
(append-only enforcement).
|
||||
4. Función `log_user_roles_change()` SECURITY DEFINER → INSERT en audit_log.
|
||||
5. Trigger AFTER INSERT/UPDATE/DELETE en `user_roles` → llama a la función.
|
||||
6. Función `log_survey_status_change()` SECURITY DEFINER → INSERT en audit_log
|
||||
solo cuando `NEW.status IS DISTINCT FROM OLD.status`.
|
||||
7. Trigger AFTER UPDATE en `surveys` → llama a la función.
|
||||
8. Si DDL trigger es viable (4.1): función + trigger DDL.
|
||||
9. Comentario al final documentando el gap SEC-003.
|
||||
|
||||
### 4.3 Verificación en VPS
|
||||
NO aplicar la migración — solo generar el archivo y reportar el SQL completo para
|
||||
revisión del director. El director aplica manualmente después de aprobar.
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — solo el archivo de migración
|
||||
- [ ] SQL completo de la migración en el reporte (no resumido — el director
|
||||
necesita leerlo antes de aplicar)
|
||||
- [ ] Diagnóstico DDL trigger reportado (4.1)
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] Commit + push a Gitea
|
||||
- [ ] NO aplicado en VPS — esperar aprobación del director
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Diagnóstico DDL trigger (4.1):
|
||||
- ¿Viable sin extensiones? ✓/✗
|
||||
- Si no: razón y alternativa
|
||||
|
||||
SQL completo de la migración:
|
||||
[pegar el SQL íntegro — no resumido]
|
||||
|
||||
Verificado:
|
||||
- Build: ✓/✗
|
||||
- git log --name-only -1: [lista]
|
||||
- Commit: [hash]
|
||||
|
||||
NO aplicado en VPS — pendiente aprobación del director.
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No aplicar la migración en el VPS sin aprobación explícita del director
|
||||
- ❌ No implementar logging de accesos a datos sensibles (deferido)
|
||||
- ❌ No agregar extensiones nuevas a Postgres
|
||||
- ❌ No tocar el formulario público, las RPCs de dashboard, ni el middleware de auth
|
||||
- ❌ No usar GRANT EXECUTE a PUBLIC — solo al rol específico que necesita cada función
|
||||
- ❌ No omitir el comentario del gap SEC-003 en la migración
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(compliance): audit_log append-only — user_roles y surveys.status [SEC-002]
|
||||
|
||||
Tabla audit_log con RLS (solo platform_admin), trigger BEFORE UPDATE/DELETE
|
||||
que aborta (append-only enforcement), triggers AFTER en user_roles y
|
||||
surveys.status. Patrón SECURITY DEFINER reutilizado de auth_user_roles.
|
||||
Gap SEC-003 (TRUNCATE por superusuario) documentado y aceptado.
|
||||
```
|
||||
123
sprints/sprint-5/ETAPA_CALIDAD_PROMPT.md
Normal file
123
sprints/sprint-5/ETAPA_CALIDAD_PROMPT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ETAPA CALIDAD — Contraste, mobile, conectividad, touch (Sprint 5)
|
||||
|
||||
## 1. Objetivo
|
||||
Resolver los 4 problemas de calidad identificados en la auditoría del 27 jun 2026,
|
||||
en orden de impacto. Un solo commit al terminar todo.
|
||||
|
||||
## 2. Contexto
|
||||
Diagnóstico base: auditoría de solidez/usabilidad (27 jun 2026, sin modificaciones).
|
||||
Todos los problemas tienen archivo y línea exacta identificados.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- **Contraste teal:** Opción B — `#43BBC8` queda SOLO para decoración (fondos,
|
||||
bordes, barras, elementos gráficos). Cuando se usa como COLOR DE TEXTO, reemplazar
|
||||
por `#494F4F` (carbón de marca). No crear variante oscura del teal.
|
||||
- **Mobile bottombar:** flex-wrap en el contenedor, no width fijo en los botones.
|
||||
- **Submit sin try/catch:** agregar manejo de error de red que muestre el estado
|
||||
'error' propio del wizard, no la pantalla cruda de React.
|
||||
- **Autocomplete touch:** agregar `onTouchEnd` como fallback junto al `onMouseDown`
|
||||
existente — no reemplazarlo.
|
||||
- Sin librerías nuevas en ningún fix.
|
||||
|
||||
## 4. Tareas en orden
|
||||
|
||||
### P1 — Contraste teal como texto (CRÍTICO — sistemático)
|
||||
En `app/globals.css`, reemplazar `color: var(--color-re-teal)` por
|
||||
`color: #494F4F` en estas clases específicas:
|
||||
- `.btn-secondary` (botón "Anterior") — línea ~380
|
||||
- `.wizard-section-number` ("Sección X de Y") — línea ~569
|
||||
- `.summary-code` — línea ~431
|
||||
- `.barrio-autocomplete-item--active` (texto del ítem activo) — línea ~319
|
||||
- `.consent-required-badge` — línea ~490
|
||||
|
||||
NO tocar usos de `var(--color-re-teal)` como `background-color`, `border-color`,
|
||||
`fill`, o en las clases `.db-*` del dashboard — esos son usos decorativos correctos.
|
||||
|
||||
Verificar después del cambio que no quedó ningún `color: var(--color-re-teal)` en
|
||||
clases del wizard (grep en globals.css sección wizard):
|
||||
```bash
|
||||
grep -n "color: var(--color-re-teal)" app/globals.css
|
||||
```
|
||||
Los únicos resultados aceptables son en sección `.db-*` del dashboard.
|
||||
|
||||
### P2 — Submit sin try/catch (red se cae al enviar)
|
||||
En `components/wizard-form.tsx`, función `handleSubmit` (líneas ~212-226):
|
||||
Envolver el `await submitSurvey(...)` en try/catch. Si el catch recibe un error
|
||||
de red (fetch failed, network error), setear el estado del wizard a 'error' con
|
||||
mensaje accionable: "No pudimos enviar tu respuesta. Verificá tu conexión e
|
||||
intentá de nuevo." El botón de reintento ya existe en el estado 'error'
|
||||
(wizard-form.tsx:136-152) — solo hay que llegar a ese estado correctamente.
|
||||
No crear `app/error.tsx` — el estado de error propio del wizard es suficiente.
|
||||
|
||||
### P3 — Botones bottombar en mobile ≤480px
|
||||
En `app/globals.css`, sección `.wizard-bottombar` (~línea 653):
|
||||
Agregar `flex-wrap: wrap` al contenedor `.wizard-bottombar`.
|
||||
En el breakpoint `@media (max-width: 480px)`:
|
||||
- `.wizard-bottombar` → `flex-direction: column-reverse` (Siguiente arriba, Anterior abajo)
|
||||
- `.btn` dentro del bottombar → `width: 100%`
|
||||
- El span hint entre los botones → `order: 3` para que quede al final
|
||||
`column-reverse` porque el botón primario (Siguiente/Enviar) debe estar arriba
|
||||
en mobile — el pulgar llega más fácil al centro que a la base.
|
||||
|
||||
### P4 — Autocomplete touch frágil
|
||||
En `components/widgets/barrio-autocomplete-field.tsx` y
|
||||
`components/widgets/ciudad-select-field.tsx`:
|
||||
En el handler de selección de ítem del dropdown, agregar `onTouchEnd` junto
|
||||
al `onMouseDown` existente (no reemplazarlo). El `onTouchEnd` debe llamar
|
||||
`e.preventDefault()` antes de ejecutar la selección para evitar el blur
|
||||
sintético que cierra la lista antes de que se procese la selección.
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — listar todos los archivos tocados
|
||||
- [ ] grep de verificación P1:
|
||||
```bash
|
||||
grep -n "color: var(--color-re-teal)" app/globals.css
|
||||
```
|
||||
Solo resultados en sección `.db-*`
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] P2 verificado: en `handleSubmit`, el catch existe y setea estado 'error'
|
||||
con mensaje accionable (pegar el bloque try/catch completo en el reporte)
|
||||
- [ ] P3 verificado: describir el CSS resultante del bottombar en mobile
|
||||
- [ ] P4 verificado: describir el handler resultante de selección en autocomplete
|
||||
- [ ] Commit: `fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch`
|
||||
- [ ] Push a Gitea
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
P1 — Contraste:
|
||||
- Clases modificadas: [lista con línea antes → después]
|
||||
- grep verificación: [output]
|
||||
|
||||
P2 — Submit try/catch:
|
||||
- Bloque handleSubmit completo después del fix: [código]
|
||||
|
||||
P3 — Bottombar mobile:
|
||||
- CSS resultante de .wizard-bottombar y el breakpoint: [código]
|
||||
|
||||
P4 — Autocomplete touch:
|
||||
- Handler de selección resultante (onMouseDown + onTouchEnd): [código]
|
||||
|
||||
Verificado:
|
||||
- Build: ✓/✗
|
||||
- git log --name-only -1: [lista]
|
||||
- Commit: [hash]
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar `var(--color-re-teal)` en usos decorativos (background, border, fill)
|
||||
- ❌ No tocar clases `.db-*` del dashboard
|
||||
- ❌ No crear `app/error.tsx`
|
||||
- ❌ No reemplazar `onMouseDown` — agregar `onTouchEnd` junto a él
|
||||
- ❌ No agregar librerías nuevas
|
||||
- ❌ No hacer deploy — solo commit y push a Gitea
|
||||
- ❌ No mezclar con los fixes rápidos de la etapa anterior
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch
|
||||
|
||||
P1: color de texto teal → #494F4F en 5 clases del wizard (WCAG AA).
|
||||
P2: try/catch en handleSubmit — error de red muestra estado error del wizard.
|
||||
P3: flex-wrap + column-reverse en bottombar ≤480px.
|
||||
P4: onTouchEnd fallback en autocomplete barrio y ciudad.
|
||||
```
|
||||
89
sprints/sprint-5/ETAPA_E2E_PROMPT.md
Normal file
89
sprints/sprint-5/ETAPA_E2E_PROMPT.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# ETAPA E2E — Tests Playwright: verificación deploy Sprint 5
|
||||
|
||||
## Antes de tocar código, leé:
|
||||
1. `CLAUDE.md`
|
||||
2. `docs/estado/ESTADO_PROYECTO_26JUN2026.md` — estado general del proyecto
|
||||
|
||||
Confirmame el plan antes de implementar.
|
||||
|
||||
## Credenciales de test (usuario dedicado — nunca las de Marcos)
|
||||
- URL: `https://economia-cuidado.inqualityhq.com`
|
||||
- Email: `test-e2e@inquality.com.py`
|
||||
- Password: `TestE2E2026!#Cuidado`
|
||||
- Rol: `platform_admin`, org: `InQuality`
|
||||
|
||||
Estas credenciales van en `.env.test` en la raíz (agregar a `.gitignore` si no está).
|
||||
**Nunca hardcodeadas en los specs.**
|
||||
|
||||
## Verificar Playwright antes de instalar nada
|
||||
|
||||
```bash
|
||||
npx playwright --version 2>/dev/null && echo "instalado" || echo "no instalado"
|
||||
ls ~/.cache/ms-playwright 2>/dev/null && echo "browsers presentes" || echo "browsers ausentes"
|
||||
```
|
||||
|
||||
Reportar resultado antes de continuar. Si ya está instalado con browsers, agregar solo
|
||||
`@playwright/test` como devDependency sin correr `npx playwright install`.
|
||||
Si no está: `npx playwright install --with-deps chromium` (solo Chromium).
|
||||
|
||||
## Los 5 tests a implementar
|
||||
|
||||
**Archivo:** `tests/e2e/sprint5-verificacion.spec.ts`
|
||||
|
||||
### Test 1 — Login y redirect
|
||||
- Abrir `/admin/responses` sin sesión
|
||||
- Verificar redirect a `/login`
|
||||
- El DOM de `/login` contiene el campo `email`, el campo `password`, y el botón de toggle
|
||||
de contraseña (buscar `aria-label` que contenga "contraseña")
|
||||
|
||||
### Test 2 — Login funcional
|
||||
- Hacer login con las credenciales de test en `/login`
|
||||
- Verificar redirect a `/admin/responses`
|
||||
- El DOM contiene `<select>` para filtro de survey (no links hardcodeados)
|
||||
- El DOM contiene al menos una fila de respuesta (hay 50 sembradas)
|
||||
|
||||
### Test 3 — Vista detallada de respuesta
|
||||
- Desde `/admin/responses`, hacer click en el primer link "Ver" disponible
|
||||
- Verificar que la URL cambia a `/admin/responses/[uuid]`
|
||||
- El DOM contiene al menos una sección (buscar texto que indique agrupación por sección)
|
||||
- No hay error 404 ni texto "Respuesta no encontrada"
|
||||
|
||||
### Test 4 — Dashboard con datos
|
||||
- Navegar a `/dashboard` (o la ruta del dashboard — verificar cuál es)
|
||||
- Verificar que al menos un widget muestra un porcentaje (buscar `%` en el DOM)
|
||||
- No aparece "Datos insuficientes" en todos los widgets (puede aparecer en algunos
|
||||
segmentos con k<5, pero no en todos)
|
||||
|
||||
### Test 5 — Formulario público accesible sin auth
|
||||
- Navegar a `/` o `/encuesta` (verificar ruta correcta del formulario)
|
||||
- Verificar que NO redirige a `/login` (el formulario es público)
|
||||
- El DOM contiene el wizard o la pantalla de consentimiento
|
||||
|
||||
## Decisiones ya tomadas
|
||||
- Playwright en local apuntando a producción, no en CI (backlog)
|
||||
- `playwright.config.ts` en raíz, `testDir: 'tests/e2e'`
|
||||
- `.env.test` para credenciales, en `.gitignore`
|
||||
- Si un test falla, reportar el error exacto y consultarme — no intentar workarounds
|
||||
|
||||
## Archivos autorizados
|
||||
`playwright.config.ts`, `tests/e2e/sprint5-verificacion.spec.ts`, `.env.test`
|
||||
(no se commitea), `package.json` (devDependency), `package-lock.json`.
|
||||
Cualquier otro en `git diff --name-only HEAD` → revertir y reportar.
|
||||
|
||||
## DoD antes de reportar
|
||||
|
||||
```bash
|
||||
npx playwright test tests/e2e/sprint5-verificacion.spec.ts --reporter=list
|
||||
git log --name-only -1
|
||||
git diff --name-only HEAD # solo archivos autorizados, sin .env.test
|
||||
grep -r "TestE2E2026" . # debe dar vacío — contraseña no commiteada
|
||||
```
|
||||
|
||||
## Qué reportar
|
||||
- Resultado de checks de Playwright previos (versión, browsers)
|
||||
- Resultado de cada test: pass/fail con mensaje exacto si falla
|
||||
- Ruta real del dashboard y del formulario público (las que encontró en el código)
|
||||
- `git log --name-only -1` y `git diff --name-only HEAD`
|
||||
- Hallazgos no esperados
|
||||
|
||||
**No hacer deploy ni modificar nada en producción. No continuar sin mi OK si algo falla.**
|
||||
47
sprints/sprint-5/ETAPA_FIXES_RAPIDOS_PROMPT.md
Normal file
47
sprints/sprint-5/ETAPA_FIXES_RAPIDOS_PROMPT.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# ETAPA — Fixes rápidos: toggle password + card Talento en riesgo
|
||||
|
||||
## 1. Objetivo
|
||||
Dos fixes visuales independientes y quirúrgicos. Scope estricto.
|
||||
|
||||
## 2. DECISIONES YA TOMADAS — no consultar
|
||||
- Toggle de contraseña: solo en el campo password del login, sin librería nueva.
|
||||
- Card Talento en riesgo: eliminar el gradiente, quedar igual que los demás cards
|
||||
(background blanco, sin distinción especial de fondo).
|
||||
|
||||
## 3. Tareas
|
||||
|
||||
### Fix A — Toggle "ver contraseña" en /login
|
||||
En `app/login/page.tsx`:
|
||||
- Agregar estado `showPassword` (useState).
|
||||
- El input password alterna entre `type="password"` y `type="text"`.
|
||||
- Botón inline dentro del campo (position absolute o flex) con ícono SVG
|
||||
simple de ojo (inline, sin librería). Accesible: `aria-label="Mostrar contraseña"` /
|
||||
`aria-label="Ocultar contraseña"` según estado. `type="button"` para no submitear.
|
||||
- Estilo consistente con el formulario existente.
|
||||
|
||||
### Fix B — Card Talento en riesgo sin gradiente
|
||||
En `app/globals.css`, clase `.db-widget-star`:
|
||||
- Eliminar la propiedad `background` (el gradiente actual).
|
||||
- El card hereda `background: #FFFFFF` de `.db-widget` — mismo que los demás.
|
||||
- NO tocar `border-color: var(--color-re-teal)` — mantener el borde distintivo.
|
||||
- NO tocar `.db-widget-star .db-widget-title` — mantener el título en teal.
|
||||
|
||||
## 4. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — solo `app/login/page.tsx` y `app/globals.css`
|
||||
- [ ] Toggle password funciona: click muestra/oculta, aria-label cambia
|
||||
- [ ] Card Talento en riesgo: fondo blanco igual que los demás cards
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] Commit: `fix(ui): toggle password en login + fondo blanco card talento`
|
||||
- [ ] Push a Gitea
|
||||
|
||||
## 5. Qué reportar
|
||||
- `git log --name-only -1`
|
||||
- Línea exacta eliminada en `.db-widget-star`
|
||||
- Build: ✓/✗
|
||||
|
||||
## 6. Qué NO hacer
|
||||
- ❌ No tocar otros campos del formulario de login
|
||||
- ❌ No tocar otros widgets del dashboard
|
||||
- ❌ No agregar librerías de iconos
|
||||
- ❌ No modificar el borde teal ni el título teal del card estrella
|
||||
- ❌ No hacer deploy — solo commit y push
|
||||
106
sprints/sprint-5/INSUMOS_AUDIT_LOG.md
Normal file
106
sprints/sprint-5/INSUMOS_AUDIT_LOG.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# INSUMOS — Etapa Audit Log append-only (Sprint compliance, SEC-002)
|
||||
|
||||
> Material de contexto para que Claude (Projects, arquitecto) redacte el ETAPA_PROMPT.md
|
||||
> de esta etapa. No es el prompt en sí — es lo que el arquitecto necesita para escribirlo
|
||||
> bien, sin tener que releer todo el repo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Por qué esta etapa, y por qué este alcance
|
||||
|
||||
`BACKLOG.md` (SEC-002) y `COMPLIANCE.md` §5.1 piden, dentro del sprint de compliance,
|
||||
un "registro de auditoría append-only (log inmutable de operaciones sensibles)". El
|
||||
director priorizó esta pieza primero, antes de hashing de integridad, trazabilidad de
|
||||
cambios o cifrado en reposo (esas quedan para etapas futuras del mismo sprint).
|
||||
|
||||
COMPLIANCE.md §5.1 pide loggear 3 cosas: accesos a datos sensibles, cambios de
|
||||
configuración, y ejecución de migraciones. Evalué las 3 y propongo **acotar el alcance
|
||||
de esta etapa a 2 de las 3**:
|
||||
|
||||
1. **Migraciones (DDL)** — factible con un event trigger nativo de Postgres
|
||||
(`ddl_command_end`), sin tocar el flujo de deploy.
|
||||
2. **Cambios de configuración** — triggers `AFTER INSERT/UPDATE/DELETE` en
|
||||
`user_roles` (altas/bajas/cambios de `app_role`/`org_id`) y en `surveys` cuando
|
||||
cambia `status` (activación/desactivación de encuestas).
|
||||
3. **Acceso a datos sensibles — propongo DEFERIR explícitamente.** Verificado en
|
||||
COMPLIANCE.md §3.2: hoy ningún camino de código (ni `/admin/responses` ni las RPCs
|
||||
`rpc_me_*`) lee columnas `is_sensitive=true`. Instrumentar "acceso" sin que exista
|
||||
un acceso real sería simular cobertura. Cuando se construya el primer camino que sí
|
||||
lea sensibles, ESE PR debe agregar su propio log de acceso junto con el código que
|
||||
lo necesita — no antes.
|
||||
|
||||
El arquitecto puede confirmar o ajustar este recorte; lo presento como punto de partida,
|
||||
no como cerrado (esto todavía no se aprobó con el director, se está armando el prompt).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisiones de diseño que ya tienen forma (para que el prompt las fije)
|
||||
|
||||
- **Tabla nueva:** `public.audit_log` — append-only real:
|
||||
- Ningún rol (`anon`, `authenticated`, ni `service_role` vía API) tiene
|
||||
`INSERT`/`UPDATE`/`DELETE` directo. Solo escriben funciones `SECURITY DEFINER`
|
||||
de los triggers, propiedad de `postgres` (mismo patrón que
|
||||
`custom_access_token_hook`, ver `supabase/migrations/20260627000001_auth_user_roles.sql`).
|
||||
- Trigger `BEFORE UPDATE OR DELETE ON audit_log` que aborta con excepción — segunda
|
||||
barrera, defensa en profundidad (no a prueba de un operador con `psql -U postgres`
|
||||
en el VPS — ver gap conocido abajo).
|
||||
- Lectura: RLS, solo `platform_admin` (reusar `current_app_role()`, ver
|
||||
`supabase/migrations/20260531000003_rls_policies.sql:17`).
|
||||
- **Columnas sugeridas (a validar por el arquitecto):** `id bigserial pk`,
|
||||
`occurred_at timestamptz default now()`, `actor_role text`, `actor_user_id uuid`,
|
||||
`action text`, `target_table text`, `target_id text`, `detail jsonb`.
|
||||
- **Gap conocido a documentar, no a resolver en esta etapa:** esto no protege contra
|
||||
`TRUNCATE`/`DROP` ejecutado por un superusuario directo en el VPS — mismo gap ya
|
||||
registrado en `SEC-003` (BACKLOG.md). Append-only a nivel de aplicación/RLS, no a
|
||||
prueba de acceso root a la base.
|
||||
|
||||
---
|
||||
|
||||
## 3. Convenciones del repo que el prompt debe respetar
|
||||
|
||||
- **Migraciones:** `supabase/migrations/`, naming `YYYYMMDDHHMMSS_descripcion.sql`.
|
||||
Última migración aplicada: `20260627000001_auth_user_roles.sql`. La próxima debe
|
||||
fecharse después de esa.
|
||||
- **Patrón SECURITY DEFINER + GRANT EXECUTE puntual:** ver
|
||||
`custom_access_token_hook` en `20260627000001_auth_user_roles.sql` — función
|
||||
`SECURITY DEFINER STABLE`, `GRANT EXECUTE ... TO supabase_auth_admin` (rol
|
||||
específico, no `PUBLIC`). Mismo patrón a replicar para las funciones de trigger
|
||||
de `audit_log`.
|
||||
- **Helpers RLS existentes a reusar, no reinventar:** `public.current_app_role()`
|
||||
y `public.current_org_id()` (`20260531000003_rls_policies.sql:17-30`).
|
||||
- **Verificación obligatoria antes de aplicar en VPS:** mostrar el SQL final completo
|
||||
y esperar OK explícito del director antes de correrlo en producción (ya es práctica
|
||||
establecida en este proyecto — ver incidente VPS del 27 jun, recuperación documentada
|
||||
en `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` §2.1).
|
||||
- **Formato esperado del archivo de etapa:** seguir la estructura de
|
||||
`sprints/sprint-4/ETAPA_AUTH_PROMPT.md` o `ETAPA_4G_PROMPT.md` — Objetivo,
|
||||
Decisiones ya tomadas (no consultar), Tareas, DoD, Qué reportar (datos concretos,
|
||||
no "funcionó"), Qué NO hacer, Versionado (mensaje de commit sugerido con tag
|
||||
`[compliance]` o similar).
|
||||
|
||||
---
|
||||
|
||||
## 4. Qué NO debería pedir el prompt (fuera de alcance de esta etapa)
|
||||
|
||||
- Hashing de integridad SHA-256 (siguiente pieza del mismo sprint, etapa separada).
|
||||
- Cifrado en reposo (es trabajo de VPS/infraestructura, no de migración SQL).
|
||||
- Trazabilidad histórica completa de cambios de datos (triggers de historial / WAL) —
|
||||
etapa separada también.
|
||||
- Logging de acceso a datos sensibles (ver §1, punto 3 — deferido a cuando exista
|
||||
código que lea sensibles).
|
||||
- pgaudit o cualquier extensión nueva — no está en el Supabase self-hosted actual,
|
||||
agregar una extensión es Nivel 3 aparte (afecta infraestructura/dependencias).
|
||||
|
||||
---
|
||||
|
||||
## 5. Referencias
|
||||
|
||||
- `COMPLIANCE.md` §5 (roadmap auditabilidad), §5.2 (por qué no blockchain), §3.2
|
||||
(qué es sensible y cómo se protege hoy).
|
||||
- `BACKLOG.md` → SEC-002, SEC-003 (gap de TRUNCATE), SEC-004 (bypass REST, contexto
|
||||
de otro riesgo residual ya aceptado, mismo espíritu de "documentar en vez de
|
||||
sobre-construir").
|
||||
- `TECH_DEBT.md` → TECH-DEBT-008 (resuelto 27 jun, ejemplo de cómo se cierra un ítem).
|
||||
- `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` — estado general post-release v3.0.0
|
||||
+ incidente VPS, para que el arquitecto tenga el panorama completo antes de escribir
|
||||
el prompt.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user