44 Commits

Author SHA1 Message Date
markosbenitez
cf09346f29 feat(auth): recuperación de contraseña self-service en /login
Reusa /auth/confirm sin tocarlo — ya escucha PASSWORD_RECOVERY (lo
armamos para invitaciones, Supabase emite el mismo evento para
recovery). Antes, cualquiera que olvidara su contraseña dependía de
que el director la reseteara a mano por SQL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 13:09:42 -03:00
markosbenitez
9461ee1e2e feat(admin): menú de navegación compartido — dashboard, respuestas, usuarios
AdminNavBar reemplaza los links sueltos y duplicados entre páginas
(cada una solo enlazaba a una de las otras dos). Ahora las tres
pantallas detrás del login (/dashboard, /admin/responses,
/admin/usuarios) comparten la misma barra con las 3 secciones +
cerrar sesión, sin importar por dónde se entre primero.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:43:28 -03:00
markosbenitez
3bf92f015c docs: guía de prueba del formulario + lectura del dashboard, seed Fundación
Guía dirigida a dos audiencias: equipo técnico (plan de pruebas del
wizard contra la encuesta sandbox Demo) y Maria Mersan (cómo leer los
7 widgets de Mirada Empresa, k-anonimato, datos sintéticos vs reales).
Incluye también el seed de datos sintéticos de Fundación Solidaridad
ya aplicado en producción (versionado para trazabilidad).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 12:19:05 -03:00
markosbenitez
67042b0941 fix(auth): parsear error_code del fragmento de GoTrue en /auth/confirm
GoTrue redirige con #error=...&error_code=... cuando el token de
invitación/recovery ya fue usado o venció, en vez de emitir
PASSWORD_RECOVERY. Antes esto se descubría recién a los 8s (timeout
genérico); ahora se lee el fragmento al montar y se muestra un mensaje
específico (otp_expired, access_denied, etc.) al instante.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 11:18:37 -03:00
markosbenitez
7efab390da fix(admin): layout 3 columnas en invite form, banner de confirmación visible
Email/Organización/botón en una fila en desktop, apilado en mobile.
El mensaje de éxito/error ahora es un banner (no texto inline) visible
al menos 4s o hasta que el usuario vuelva a tocar el formulario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:56:27 -03:00
markosbenitez
4611fb2363 feat(admin): onboarding por invitación — /admin/usuarios + /auth/confirm
Platform admin invita usuarios de empresa desde /admin/usuarios.
inviteUserByEmail + insert inmediato en user_roles (org_admin).
Rollback explícito si falla el insert. /auth/confirm maneja el link
de invitación con PASSWORD_RECOVERY event. Middleware no requirió
cambios — /auth/confirm ya queda fuera del matcher de rutas protegidas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:54:30 -03:00
markosbenitez
d211382e4d fix(usabilidad): required questions, prompt min-height, autocomplete max-width
Activa required=true en todas las preguntas excepto barrio (2.3) y secciones.
El motor ya valida solo preguntas visibles — las condicionales ocultas no se
validan. Elimina min-height fijo del prompt block para mejor UX mobile.
Agrega max-width de seguridad al dropdown autocomplete en viewports angostos.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:26:10 -03:00
markosbenitez
e29752644a fix(wizard): tests e2e sprint6 — cache geo, usabilidad, draft
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 09:03:49 -03:00
markosbenitez
704d2407e8 fix(wizard): cache geo singleton, feedback carga, debounce draft
geo_paraguay_full.json se cargaba dos veces sin cache compartido.
Módulo lib/geo-cache.ts singleton: un fetch para toda la sesión.
Feedback visual mientras carga (disabled + placeholder) y si falla
(mensaje + texto libre). debounce 500ms en saveDraft para no escribir
en localStorage en cada keystroke.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 22:04:12 -03:00
markosbenitez
5472043a33 fix(ops): setup_first_admin.sql usa email_confirmed_at (GoTrue v2.189.0)
Esquema real verificado en producción (information_schema.columns):
auth.users tiene tanto confirmed_at como email_confirmed_at — ambas son
columnas reales, así que el INSERT con confirmed_at nunca falló por
columna inexistente (por eso el usuario test-e2e se creó sin error).

Pero el login seguía rechazando con "Email o contraseña incorrectos"
incluso después de confirmar que el usuario existía — GoTrue valida
email_confirmed_at para permitir login por password, no confirmed_at.
Dejar confirmed_at solo creaba un usuario que nunca queda "confirmado"
para efectos de auth.

Agregado comentario documentando la versión de GoTrue verificada y cómo
re-verificar el schema si esto vuelve a cambiar.
2026-06-28 02:04:36 -03:00
markosbenitez
0fdee073a6 en el medio del kilombo de la db 2026-06-28 01:36:45 -03:00
markosbenitez
6a09ce9ee5 fix(seed): consolidar en un único bloque DO $$ (DELETE+UPDATE adentro)
La versión anterior (af751d7) ya resolvía los question_id por code en
runtime, pero DELETE y el primer UPDATE quedaban como statements sueltos
ANTES del bloque DO $$ — si psql corre sin -v ON_ERROR_STOP=1, un fallo
dentro del DO no impide que esos statements sueltos ya se hayan
ejecutado, y deja el "único bloque" pedido incompleto.

Ahora DELETE, los dos UPDATE y los 50 PERFORM viven todos dentro de un
solo DO $$ ... END $$. Si algo falla, aborta el bloque completo (un
solo error, no cincuenta) — nada de hardcodeado salvo el uuid del
survey Demo (necesario para inicializar la variable s_id).

Mismos 50 datos, verificado byte a byte contra la versión anterior
(diff vacío salvo nombres de variable).
2026-06-28 00:43:05 -03:00
markosbenitez
af751d771d fix(seed): resolver question_id por code en runtime, no hardcodeado
Los UUIDs de questions cambian cada vez que la DB se reinicializa
(supabase/seed.sql usa gen_random_uuid()) — el seed con UUIDs literales
se desactualizaba cada vez, ya pasó dos veces. Reescrito como un bloque
DO $$ que resuelve los 13 question_id usados (4.1, 2.7, 6.1, 6.2, 6.4,
7.2, 7.3, 7.5, 7.6, 9.1, 9.2, 9.3, 9.4) por code + survey_id al momento
de ejecutarse, con RAISE EXCEPTION explícito si alguno no se encuentra
(en vez de fallar más abajo con invalid_answer_reference).

Mismas 50 respuestas, misma distribución (seed=42), mismo DELETE
idempotente acotado a ip_hash LIKE 'seed-prueba-%'. Generado a partir
del archivo anterior con un script de conversión (no transcripto a
mano) para no introducir errores de transcripción en 50 filas.
2026-06-28 00:30:57 -03:00
markosbenitez
cd87ddb18e fix(ops): setup_first_admin.sql usa confirmed_at, no email_confirmed_at
GoTrue en esta versión de producción no tiene la columna
email_confirmed_at en auth.users — falla con "column does not exist".
La columna correcta es confirmed_at.
2026-06-28 00:17:22 -03:00
markosbenitez
6d9cf918f9 feat(ops): scripts de recuperación tras reinicialización de DB
scripts/apply_all_migrations.sh: aplica todas las migraciones de
supabase/migrations/ en orden de timestamp, una por una, con
ON_ERROR_STOP=1 (sin esto psql sigue tras un error SQL y devuelve exit
0 igual — set -e nunca lo vería).

scripts/setup_all.sh: orquesta la recuperación completa — migraciones,
catálogo de encuestas (supabase/seed.sql, paso agregado: sin él
surveys/questions quedan vacías y seed_prueba_50.sql no tiene nada que
referenciar), usuario platform_admin vía ADMIN_EMAIL/ADMIN_PASSWORD, y
datos de prueba (docs/dashboard/seed_prueba_50.sql).

No ejecutado — solo los archivos, a la espera de uso manual en el VPS.
2026-06-27 23:51:51 -03:00
markosbenitez
a0db888f2e fix(seed): regenerar seed_prueba_50.sql con UUIDs reales de questions
dashboard-propuesta/seed_prueba_50.sql quedó con question_id de una
instancia anterior de la DB — rpc_submit_response rechazaba todo con
invalid_answer_reference. Mapeo code→question_id reconstruido por
vocabulario de opciones contra supabase/seed.sql (sin ambigüedad,
incluye desambiguar 6.1/6.2/6.4 por sus opciones únicas), UUIDs reales
tomados de la DB de producción. Mismas 50 filas, misma distribución
(seed=42), mismo formato de llamada — solo cambian los 13 question_id.

Idempotencia: DELETE acotado a survey_id + ip_hash LIKE 'seed-prueba-%'
en vez de TRUNCATE de las tablas completas (answers/responses pueden
tener datos reales de otras encuestas). Survey Demo queda en 'live' al
inicio y al final, explícito.

NO aplicado en VPS — solo el archivo, a la espera de aprobación.
2026-06-27 17:22:12 -03:00
markosbenitez
a0253b4f90 feat(admin): vista respuestas mejorada + vista detallada [admin]
/admin/responses: filtro survey dinámico (select, no hardcodeado),
paginación 50/página, link a vista detallada, conteos consistentes con
el filtro de preguntas permitidas (evita página fantasma al final).
/admin/responses/[id]: respuestas agrupadas por sección (derivado de
type='section', igual que el wizard — no existen section_number/
section_title, ver TECH-DEBT-011), instancias bajo "Familiar N".
RLS y exclusión de sensibles intactos en ambas vistas.

Fixes de revisión antes de cerrar: toggle expandir/colapsar movido a
un <button> real (evita anidar el link "Ver" dentro de un role=button),
formatDate/renderValue unificados en admin-format.ts, queries
independientes en paralelo (Promise.all).
2026-06-27 16:48:32 -03:00
markosbenitez
a660928085 fix(ui): título card talento en riesgo a carbón #494F4F 2026-06-27 15:46:48 -03:00
markosbenitez
30cd83ffc1 ETAPA_CALIDAD: Nota de discrepancia: las 5 clases de P1 usan var(--color-primary), no var(--color-re-teal) como decía el prompt — ambas resuelven al mismo hex #43BBC8. Hice el reemplazo por nombre de clase (igual intención: texto teal → carbón), no por el literal del grep. 2026-06-27 15:27:54 -03:00
markosbenitez
ca3af1a750 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.
2026-06-27 15:25:16 -03:00
markosbenitez
b3f039fbd5 fix(ui): toggle password en login + fondo blanco card talento
Fix A: botón inline con ícono SVG (ojo/ojo tachado) en el campo
password de /login, alterna type password/text, aria-label según
estado. Sin librería nueva.
Fix B: .db-widget-star pierde el gradiente, hereda background blanco
de .db-widget igual que los demás cards. Borde y título teal intactos.
2026-06-27 15:18:22 -03:00
markosbenitez
430bf7a5da 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.
Event trigger DDL incluido (confirmado viable sin extensiones, ver
diagnóstico en ETAPA_AUDIT_LOG_PROMPT.md §4.1). Gap SEC-003 (TRUNCATE por
superusuario) documentado y aceptado, no resuelto.

NO aplicada en VPS — pendiente de aprobación.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 14:51:07 -03:00
markosbenitez
10bb7299a2 docs: actualizar estado — cierre release v3.0.0 + ETAPA_AUTH + incidente VPS
Sprint 4 completo (widgets Mirada Empresa, tema claro, 4F, 4I/release ya
estaban hechos antes de esta sesión). Documenta el incidente de colisión de
container_name en el VPS y su recuperación, y el cierre de TECH-DEBT-008
(auth de staff) en esta sesión. Próximos pasos quedan abiertos a decisión
del director (dashboard País/Humana, sprint de compliance, o mantenimiento
de dependencias).
2026-06-27 13:38:10 -03:00
markosbenitez
dda4b5320a fix(auth): agregar email_change/email_change_token_new en setup_first_admin
GoTrue fallaba con "Database error querying schema" al hacer login porque
estas dos columnas de auth.users no tienen DEFAULT en este esquema y quedaban
NULL — el driver Go espera string, no NULL. Verificado en VPS: login
funciona después de este fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 13:18:32 -03:00
markosbenitez
44d1f4b9d9 fix(auth): corregir setup_first_admin — psql vars no funcionan en DO $$
Reescribir con CTE INSERT...RETURNING para encadenar auth.users + user_roles
en una sola sentencia SQL plana donde :variable sí funciona.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:47:38 -03:00
markosbenitez
a484ba7849 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.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 09:39:52 -03:00
markosbenitez
ddaba6866a fix(rpc): filtrar elementos individuales en carrera_congelada [BUG-001]
Mover el filtro 'Ninguna de las anteriores' del WHERE sobre a.value
al WHERE del subquery expandido, donde opt es ya un elemento individual
resultado de jsonb_array_elements_text. El operador #>> '{}' aplicado
sobre un array jsonb no filtraba elementos sino la representación textual
del array completo.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 22:38:17 -03:00
markosbenitez
fcf93ea3ea chore(release): CHANGELOG v3.0.0 — wizard declarativo + dashboard tres miradas
Etapas 4A→4I completadas. Motor de flujo puro, wizard renderer,
instancias familiares, submit con validación server-side, dashboard
tres miradas (Mirada Empresa funcional), autocomplete barrios/ciudad.

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

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

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

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

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

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

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

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

5
.gitignore vendored
View File

@@ -14,6 +14,11 @@ supabase/.temp/
# Node # Node
node_modules/ node_modules/
# Playwright — reportes/snapshots, pueden contener credenciales de .env.test en el DOM
test-results/
playwright-report/
blob-report/
# Next.js build output # Next.js build output
.next/ .next/

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,11 +77,95 @@
## [TECH-DEBT-008] /admin/responses sin autenticación ## [TECH-DEBT-008] /admin/responses sin autenticación
- **Estado:** ABIERTO. - **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño - **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/ login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero `/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
implementar un gate de autenticación para staff (Basic Auth con env var como migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
mínimo viable). excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
- **Impacto actual:** ninguno en producción — la URL no está publicada. - **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control. 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
View 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 }
}

View File

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

View File

@@ -0,0 +1,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>
)
}

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

View File

@@ -1,20 +1,15 @@
import { createClient } from '@supabase/supabase-js' import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { ResponsesTable, type DisplayRow } from './responses-table' 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' export const dynamic = 'force-dynamic'
const MAX_SUBMISSIONS = 500 const PAGE_SIZE = 50
const PROMPT_MAX_LEN = 60 const PROMPT_MAX_LEN = 60
const SURVEY_OPTIONS = [
{ label: 'Todos', id: null },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
] as const
interface AllowedQuestion { interface AllowedQuestion {
id: string id: string
code: string code: string
@@ -36,56 +31,77 @@ interface ResponseRow {
answers: AnswerRow[] answers: AnswerRow[]
} }
function getSupabaseAdmin() { interface SurveyOption {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co' id: string
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder' title: string
return createClient(url, serviceKey, { auth: { persistSession: false } })
} }
function formatDate(iso: string): string { async function getSupabaseSession() {
const d = new Date(iso) const cookieStore = await cookies()
const pad = (n: number) => String(n).padStart(2, '0') return createServerClient(
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}` 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 { function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text return text.length > max ? `${text.slice(0, max)}...` : text
} }
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato") function pageHref(surveyId: string | undefined, page: number): string {
function renderValue(value: unknown, type: string): string | null { const params = new URLSearchParams()
if (value === null || value === undefined) return null if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
switch (type) { return `/admin/responses?${params.toString()}`
case 'single_choice':
case 'text_short':
return String(value).replace(/^"|"$/g, '')
case 'multiple_choice':
if (Array.isArray(value)) return value.join(', ')
return String(value)
case 'rating':
return String(value)
default:
return String(value)
}
} }
export default async function AdminResponsesPage({ export default async function AdminResponsesPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ survey_id?: string }> searchParams: Promise<{ survey_id?: string; page?: string }>
}) { }) {
const { survey_id: surveyId } = await searchParams const { survey_id: surveyId, page: pageParam } = await searchParams
const supabase = getSupabaseAdmin() 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Á, // 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan. // antes de tocar la tabla answers. Sus valores nunca se consultan.
const { data: questionsData, error: questionsError } = await supabase const [
.from('questions') { data: surveysData, error: surveysError },
.select('id, code, prompt, type, position') { data: questionsData, error: questionsError },
.eq('is_sensitive', false) ] = await Promise.all([
.neq('type', 'text_long') supabase.from('surveys').select('id, title').order('title'),
.order('position', { ascending: true }) 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) { if (questionsError) {
return ( return (
@@ -103,6 +119,7 @@ export default async function AdminResponsesPage({
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY // 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro). // embebido a solo question_id permitidos (consulta, no post-filtro).
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
let responsesQuery = supabase let responsesQuery = supabase
.from('responses') .from('responses')
.select( .select(
@@ -120,10 +137,44 @@ export default async function AdminResponsesPage({
responsesQuery = responsesQuery.eq('survey_id', surveyId) responsesQuery = responsesQuery.eq('survey_id', surveyId)
} }
const { data: responsesData, error: responsesError } = // 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 allowedIds.length > 0
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS) ? await Promise.all([
: { data: [] as ResponseRow[], error: null } 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) { if (responsesError) {
return ( return (
@@ -136,30 +187,7 @@ export default async function AdminResponsesPage({
} }
const responses = (responsesData ?? []) as unknown as ResponseRow[] const responses = (responsesData ?? []) as unknown as ResponseRow[]
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
// 3. Contadores totales (independientes del límite de 500 submissions),
// filtrados por survey_id cuando hay filtro activo.
let submissionsCountQuery = supabase
.from('responses')
.select('*', { count: 'exact', head: true })
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
const { count: totalSubmissions } = await submissionsCountQuery
let answersCountQuery = supabase
.from('answers')
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
.in('question_id', allowedIds)
if (surveyId) {
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
}
const { count: totalAnswers } =
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission // 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: DisplayRow[] = [] const rows: DisplayRow[] = []
@@ -176,7 +204,8 @@ export default async function AdminResponsesPage({
const isFirstInGroup = ai === 0 const isFirstInGroup = ai === 0
rows.push({ rows.push({
key: `${response.id}-${a.question_id}`, key: `${response.id}-${a.question_id}`,
submissionNum: idx + 1, responseId: response.id,
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
isFirstInGroup, isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '', date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code, code: q.code,
@@ -189,40 +218,57 @@ export default async function AdminResponsesPage({
}) })
return ( return (
<div className="admin-page" role="main"> <>
<AdminNavBar active="responses" />
<div className="admin-page" role="main">
<header className="admin-header"> <header className="admin-header">
<div> <div>
<h1 className="admin-title">Control de respuestas</h1> <h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle"> <p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales · {totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
actualizado {formatDate(new Date().toISOString())} {selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
{formatDate(new Date().toISOString())}
</p> </p>
<nav className="admin-survey-nav" aria-label="Selección de empresa"> <p className="admin-subtitle">
{SURVEY_OPTIONS.map((opt) => ( Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
<a </p>
key={opt.label} <div className="admin-survey-nav">
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'} <SurveySelect surveys={surveys} value={surveyId ?? ''} />
className={ </div>
opt.id === (surveyId ?? null) {surveysError && (
? 'admin-survey-link admin-survey-link-active' <p className="admin-error" role="alert">
: 'admin-survey-link' No se pudo cargar la lista de surveys para el filtro se muestra "Todos".
} </p>
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined} )}
> </div>
{opt.label} <div className="admin-header-actions">
</a> <a href="/admin/responses" className="admin-refresh">Actualizar</a>
))}
</nav>
</div> </div>
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</header> </header>
<div className="admin-table-wrapper"> <div className="admin-table-wrapper">
<ResponsesTable rows={rows} /> <ResponsesTable rows={rows} surveyId={surveyId} page={page} />
{rows.length === 0 && ( {rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p> <p className="admin-empty">Sin respuestas registradas todavía.</p>
)} )}
</div> </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>
</>
) )
} }

View File

@@ -1,9 +1,11 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import Link from 'next/link'
export interface DisplayRow { export interface DisplayRow {
key: string key: string
responseId: string
submissionNum: number submissionNum: number
isFirstInGroup: boolean isFirstInGroup: boolean
date: string date: string
@@ -14,7 +16,15 @@ export interface DisplayRow {
sector: string sector: string
} }
export function ResponsesTable({ rows }: { rows: DisplayRow[] }) { export function ResponsesTable({
rows,
surveyId,
page,
}: {
rows: DisplayRow[]
surveyId?: string
page: number
}) {
const [expanded, setExpanded] = useState<Set<number>>(new Set()) const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(submissionNum: number) { function toggle(submissionNum: number) {
@@ -26,6 +36,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
}) })
} }
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 ( return (
<table className="admin-table"> <table className="admin-table">
<thead> <thead>
@@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
<th>Valor</th> <th>Valor</th>
<th className="admin-col-optional">Departamento</th> <th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th> <th className="admin-col-optional">Sector</th>
<th>Ver</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
<tr <tr
key={row.key} key={row.key}
className={trClass} 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} onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
onKeyDown={
row.isFirstInGroup
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
toggle(row.submissionNum)
}
}
: undefined
}
role={row.isFirstInGroup ? 'button' : undefined}
tabIndex={row.isFirstInGroup ? 0 : undefined}
aria-expanded={row.isFirstInGroup ? isExpanded : undefined}
> >
<td> <td>
{row.isFirstInGroup && ( {row.isFirstInGroup ? (
<span className="admin-toggle-icon" aria-hidden="true"> <button
{isExpanded ? '▼' : '▶'} type="button"
</span> 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
)} )}
{row.submissionNum}
</td> </td>
<td>{row.date}</td> <td>{row.date}</td>
<td>{row.code}</td> <td>{row.code}</td>
@@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
</td> </td>
<td className="admin-col-optional">{row.zone}</td> <td className="admin-col-optional">{row.zone}</td>
<td className="admin-col-optional">{row.sector}</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> </tr>
) )
})} })}

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

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

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

View File

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

191
app/auth/confirm/page.tsx Normal file
View 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>
)
}

View File

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

View File

@@ -63,7 +63,11 @@ body {
.skip-link:focus { left: 4px; } .skip-link:focus { left: 4px; }
/* ── Screen reader only ── */ /* ── Screen reader only ── */
.sr-only { /* El legend/label de los widgets dentro del wizard se oculta visualmente —
el enunciado ya se muestra una sola vez en .wizard-prompt-block. Se deja
el texto accesible (nombre del fieldset/label) en vez de quitarlo. */
.sr-only,
.wizard-answer-area .question-legend {
position: absolute; position: absolute;
width: 1px; width: 1px;
height: 1px; height: 1px;
@@ -285,6 +289,37 @@ body {
box-shadow: 0 0 0 3px rgba(37,99,235,0.15); box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
} }
/* ── Barrio autocomplete ── */
.barrio-autocomplete-wrapper { position: relative; }
.barrio-autocomplete-list {
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
position: fixed;
max-width: calc(100vw - 32px);
background: var(--color-surface);
border: 1.5px solid var(--color-border-focus);
border-radius: var(--radius);
box-shadow: 0 4px 12px rgba(0,0,0,0.10);
list-style: none;
margin: 0;
padding: 4px 0;
max-height: 220px;
overflow-y: auto;
z-index: 30;
}
.barrio-autocomplete-item {
padding: 9px 12px;
cursor: pointer;
font-size: 0.95rem;
color: var(--color-text);
transition: background 0.1s;
}
.barrio-autocomplete-item:hover,
.barrio-autocomplete-item--active {
background: var(--color-primary-light);
color: #494F4F;
}
/* ── Textarea ── */ /* ── Textarea ── */
.text-area { .text-area {
display: block; display: block;
@@ -343,7 +378,7 @@ body {
.btn-primary:hover { background: var(--color-primary-hover); } .btn-primary:hover { background: var(--color-primary-hover); }
.btn-secondary { .btn-secondary {
background: transparent; background: transparent;
color: var(--color-primary); color: #494F4F;
border-color: var(--color-primary); border-color: var(--color-primary);
} }
.btn-secondary:hover { background: var(--color-primary-light); } .btn-secondary:hover { background: var(--color-primary-light); }
@@ -394,7 +429,7 @@ body {
} }
.summary-code { .summary-code {
font-weight: 700; font-weight: 700;
color: var(--color-primary); color: #494F4F;
margin-right: 4px; margin-right: 4px;
} }
.summary-answer { .summary-answer {
@@ -453,7 +488,7 @@ body {
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); } .consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; } .consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
.consent-checkbox-text { flex: 1; } .consent-checkbox-text { flex: 1; }
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-primary); background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; } .consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: #494F4F; background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; } .consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; } .consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); } .consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
@@ -532,7 +567,7 @@ body {
.wizard-section-number { .wizard-section-number {
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 700; font-weight: 700;
color: var(--color-primary); color: #494F4F;
margin: 0 0 8px; margin: 0 0 8px;
} }
.wizard-section-title { .wizard-section-title {
@@ -549,9 +584,8 @@ body {
margin: 0; margin: 0;
} }
/* Enunciado: bloque con altura reservada constante, mismo lugar pantalla tras pantalla. */ /* Enunciado: crece orgánicamente con el contenido, sin altura mínima fija. */
.wizard-prompt-block { .wizard-prompt-block {
min-height: 116px;
flex-shrink: 0; flex-shrink: 0;
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -586,15 +620,26 @@ body {
margin: 0; margin: 0;
} }
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo. */ /* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo.
flex: 0 1 auto (NO flex:1) — mide según su contenido, nunca se estira a llenar el alto
disponible. Si creciera con flex:1, una pregunta corta (3 opciones) dejaría la caja con
espacio vacío abajo y el fade ::after (sticky bottom:0) quedaría flotando justo después
del contenido real, lejos del borde inferior visual de la caja — en vez de pegado a él. */
.wizard-answer-area { .wizard-answer-area {
flex: 1; flex: 0 1 auto;
min-height: 0; min-height: 0;
max-height: 360px; max-height: 480px;
overflow-y: auto; overflow-y: auto;
padding-bottom: 8px; padding-bottom: 8px;
} }
.wizard-answer-area::after { .wizard-answer-area.has-overflow { padding-bottom: 44px; }
/* El fade SOLO se pinta cuando .wizard-answer-area mide overflow real
(clase has-overflow agregada por JS — ver wizard-form.tsx). CSS no tiene
forma de detectar scrollHeight > clientHeight por sí solo: un ::after
incondicional (o un mask-image fijo) se pinta igual sobre el final de la
caja aunque el contenido entre completo, mostrando una franja/degradado
fantasma en preguntas cortas. */
.wizard-answer-area.has-overflow::after {
content: ''; content: '';
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -614,6 +659,7 @@ body {
padding: 16px 0; padding: 16px 0;
margin-top: 16px; margin-top: 16px;
display: flex; display: flex;
flex-wrap: wrap;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
@@ -627,7 +673,12 @@ body {
@media (max-width: 480px) { @media (max-width: 480px) {
.wizard-prompt-text { font-size: 1.1875rem; } .wizard-prompt-text { font-size: 1.1875rem; }
.wizard-section-title { font-size: 1.5rem; } .wizard-section-title { font-size: 1.5rem; }
.wizard-answer-area { max-height: 320px; } .wizard-answer-area { max-height: 400px; }
/* column-reverse: el botón primario (Siguiente/Enviar) queda arriba —
el pulgar llega más fácil al centro de la pantalla que a la base. */
.wizard-bottombar { flex-direction: column-reverse; }
.wizard-bottombar .btn { width: 100%; }
.wizard-bottombar-hint { order: 3; }
} }
/* ── Loading skeleton ── */ /* ── Loading skeleton ── */
@@ -694,27 +745,27 @@ body {
.survey-footer-logo { height: 32px; width: auto; display: block; } .survey-footer-logo { height: 32px; width: auto; display: block; }
/* ══════════════════════════════════════════════════════════════ /* ══════════════════════════════════════════════════════════════
DASHBOARD — tema oscuro premium DASHBOARD — tema claro (marca Re)
Fondo: #0F1923 · Widgets: #1A2535 · Teal: #43BBC8 Fondo: #E7E6E5 · Widgets: #FFFFFF · Teal: #43BBC8
══════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════ */
.db-page { .db-page {
display: flex; min-height: 100vh; display: flex; min-height: 100vh;
background: #0F1923; color: #E7E6E5; background: #E7E6E5; color: #494F4F;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
} }
.db-main { flex: 1; padding: 24px; overflow-x: hidden; } .db-main { flex: 1; padding: 24px; overflow-x: hidden; }
.db-page-header { margin-bottom: 20px; } .db-page-header { margin-bottom: 20px; }
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; } .db-page-title { font-size: 1.375rem; font-weight: 700; color: #494F4F; margin: 0 0 4px; }
.db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; } .db-page-subtitle { font-size: 0.8125rem; color: #6B7878; margin: 0; }
.db-active-filter { color: var(--color-re-teal); font-weight: 600; } .db-active-filter { color: var(--color-re-teal); font-weight: 600; }
.db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } .db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.db-survey-link { .db-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px; font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid rgba(67,187,200,0.3); color: #9ca3af; text-decoration: none; border: 1px solid rgba(67,187,200,0.3); color: #6B7878; text-decoration: none;
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.db-survey-link:hover { color: #E7E6E5; border-color: var(--color-re-teal); } .db-survey-link:hover { color: #494F4F; border-color: var(--color-re-teal); }
.db-survey-link-active { background: var(--color-re-teal); color: #0F1923; border-color: var(--color-re-teal); } .db-survey-link-active { background: var(--color-re-teal); color: #FFFFFF; border-color: var(--color-re-teal); }
.db-section { margin-bottom: 16px; } .db-section { margin-bottom: 16px; }
.db-grid { .db-grid {
display: grid; display: grid;
@@ -724,7 +775,7 @@ body {
/* ── Widget shell ── */ /* ── Widget shell ── */
.db-widget { .db-widget {
background: #1A2535; background: #FFFFFF;
border: 1px solid rgba(67,187,200,0.2); border: 1px solid rgba(67,187,200,0.2);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
@@ -735,24 +786,24 @@ body {
display: flex; justify-content: space-between; align-items: center; display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px 8px; padding: 12px 16px 8px;
} }
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #E7E6E5; margin: 0; } .db-widget-title { font-size: 0.875rem; font-weight: 600; color: #494F4F; margin: 0; }
.db-widget-n { font-size: 0.75rem; color: #9ca3af; } .db-widget-n { font-size: 0.75rem; color: #6B7878; }
.db-widget-body { padding: 0 16px 16px; } .db-widget-body { padding: 0 16px 16px; }
/* ── Suppressed / loading states ── */ /* ── Suppressed / loading states ── */
.db-suppressed { .db-suppressed {
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100px; text-align: center; color: #9ca3af; gap: 6px; min-height: 100px; text-align: center; color: #6B7878; gap: 6px;
} }
.db-suppressed-icon { font-size: 1.5rem; } .db-suppressed-icon { font-size: 1.5rem; }
.db-suppressed-hint { font-size: 0.75rem; color: #6b7280; } .db-suppressed-hint { font-size: 0.75rem; color: #9CA8A8; }
.db-suppressed-card { .db-suppressed-card {
display: flex; flex-direction: column; align-items: center; justify-content: center; display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; color: #6b7280; gap: 4px; color: #9CA8A8;
} }
.db-skeleton-block { background: #1e2d42; border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; } .db-skeleton-block { background: rgba(73,79,79,0.08); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
.db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; } .db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; }
.db-loading-page { padding: 24px; background: #0F1923; min-height: 100vh; } .db-loading-page { padding: 24px; background: #E7E6E5; min-height: 100vh; }
/* ── KPI row ── */ /* ── KPI row ── */
.db-kpi-row { .db-kpi-row {
@@ -760,36 +811,36 @@ body {
padding: 0; padding: 0;
} }
.db-kpi-card { .db-kpi-card {
background: #1A2535; border: 1px solid rgba(67,187,200,0.2); background: #FFFFFF; border: 1px solid rgba(67,187,200,0.2);
border-radius: 10px; padding: 16px; text-align: center; border-radius: 10px; padding: 16px; text-align: center;
display: flex; flex-direction: column; gap: 4px; display: flex; flex-direction: column; gap: 4px;
transition: box-shadow 0.3s; transition: box-shadow 0.3s;
} }
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); } .db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
/* Título de KPI: mayúsculas, bold, siempre visible */ /* Título de KPI: mayúsculas, bold, siempre visible */
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #E7E6E5; text-transform: uppercase; letter-spacing: 0.04em; } .db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #494F4F; text-transform: uppercase; letter-spacing: 0.04em; }
/* Subtítulo de KPI: código de pregunta + descripción */ /* Subtítulo de KPI: código de pregunta + descripción */
.db-kpi-subtitle { font-size: 0.6875rem; color: #7A8FA0; font-weight: 400; line-height: 1.4; margin-bottom: 4px; } .db-kpi-subtitle { font-size: 0.6875rem; color: #8A9A9A; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
.db-kpi-value { .db-kpi-value {
font-size: 2rem; font-weight: 700; color: var(--color-re-teal); font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
font-variant-numeric: tabular-nums; margin-top: 4px; font-variant-numeric: tabular-nums; margin-top: 4px;
} }
.db-kpi-value-suppressed { font-size: 1.25rem; color: #6b7280; } .db-kpi-value-suppressed { font-size: 1.25rem; color: #9CA8A8; }
.db-kpi-note { font-size: 0.75rem; color: #9ca3af; } .db-kpi-note { font-size: 0.75rem; color: #6B7878; }
.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; } .db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; }
/* Compat — se puede quitar si ya no se usa */ /* Compat — se puede quitar si ya no se usa */
.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; } .db-kpi-label { font-size: 0.8125rem; color: #494F4F; }
.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; } .db-kpi-sub { font-size: 0.75rem; color: #6B7878; }
/* Subtítulo de widget-shell */ /* Subtítulo de widget-shell */
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; } .db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.db-widget-subtitle { font-size: 0.75rem; color: #7A8FA0; font-weight: 400; margin: 0; line-height: 1.4; } .db-widget-subtitle { font-size: 0.75rem; color: #8A9A9A; font-weight: 400; margin: 0; line-height: 1.4; }
/* ── Mapa tile ── */ /* ── Mapa tile ── */
.db-map-wrapper { position: relative; } .db-map-wrapper { position: relative; }
.db-map-tooltip { .db-map-tooltip {
position: absolute; top: -28px; left: 50%; transform: translateX(-50%); position: absolute; top: -28px; left: 50%; transform: translateX(-50%);
background: #0F1923; color: #E7E6E5; font-size: 0.75rem; background: #E7E6E5; color: #494F4F; font-size: 0.75rem;
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3); padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3);
white-space: nowrap; z-index: 10; pointer-events: none; white-space: nowrap; z-index: 10; pointer-events: none;
} }
@@ -824,13 +875,13 @@ body {
.db-gauge-wrapper { text-align: center; } .db-gauge-wrapper { text-align: center; }
.db-gauge-svg { width: 100%; max-width: 160px; } .db-gauge-svg { width: 100%; max-width: 160px; }
.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; } .db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; }
.db-gauge-hint { font-size: 0.7rem; color: #6b7280; margin: 0; } .db-gauge-hint { font-size: 0.7rem; color: #9CA8A8; margin: 0; }
/* ── Bar chart ── */ /* ── Bar chart ── */
.db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } .db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.db-bar-item { display: flex; align-items: center; gap: 8px; } .db-bar-item { display: flex; align-items: center; gap: 8px; }
.db-bar-label { font-size: 0.75rem; color: #E7E6E5; width: 180px; flex-shrink: 0; line-height: 1.3; } .db-bar-label { font-size: 0.75rem; color: #494F4F; width: 180px; flex-shrink: 0; line-height: 1.3; }
.db-bar-track { flex: 1; background: #2a3547; border-radius: 4px; height: 14px; overflow: hidden; } .db-bar-track { flex: 1; background: rgba(73,79,79,0.10); border-radius: 4px; height: 14px; overflow: hidden; }
.db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; } .db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; } .db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; }
@@ -838,39 +889,39 @@ body {
.db-heatmap-scroll { overflow-x: auto; } .db-heatmap-scroll { overflow-x: auto; }
.db-heatmap-grid { display: grid; gap: 2px; } .db-heatmap-grid { display: grid; gap: 2px; }
.db-heatmap-header { .db-heatmap-header {
font-size: 0.625rem; color: #9ca3af; text-align: center; padding: 2px; font-size: 0.625rem; color: #6B7878; text-align: center; padding: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.db-heatmap-rowlabel { font-size: 0.625rem; color: #9ca3af; padding: 2px 4px; line-height: 1.2; } .db-heatmap-rowlabel { font-size: 0.625rem; color: #6B7878; padding: 2px 4px; line-height: 1.2; }
.db-heatmap-cell { .db-heatmap-cell {
border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center; border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s; font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s;
} }
.db-heatmap-cell:hover { filter: brightness(1.2); } .db-heatmap-cell:hover { filter: brightness(1.2); }
.db-hexbin-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 8px; } .db-hexbin-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 8px; }
/* ── Pirámide ── */ /* ── Pirámide ── */
.db-piramide { display: flex; flex-direction: column; gap: 6px; } .db-piramide { display: flex; flex-direction: column; gap: 6px; }
.db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } .db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.db-piramide-row { display: flex; align-items: center; gap: 6px; } .db-piramide-row { display: flex; align-items: center; gap: 6px; }
.db-piramide-label { font-size: 0.7rem; color: #9ca3af; width: 90px; flex-shrink: 0; text-align: right; } .db-piramide-label { font-size: 0.7rem; color: #6B7878; width: 90px; flex-shrink: 0; text-align: right; }
.db-piramide-bars { flex: 1; display: flex; gap: 2px; } .db-piramide-bars { flex: 1; display: flex; gap: 2px; }
.db-piramide-bar-wrap { display: flex; flex-direction: column; } .db-piramide-bar-wrap { display: flex; flex-direction: column; }
.db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; } .db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; }
.db-piramide-n { font-size: 0.7rem; color: #9ca3af; width: 24px; text-align: right; } .db-piramide-n { font-size: 0.7rem; color: #6B7878; width: 24px; text-align: right; }
/* ── Tabla riesgo ── */ /* ── Tabla riesgo ── */
.db-table-wrapper { overflow-x: auto; } .db-table-wrapper { overflow-x: auto; }
.db-table-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 10px; } .db-table-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 10px; }
.db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; } .db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.db-table th { color: #9ca3af; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; } .db-table th { color: #6B7878; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #E7E6E5; } .db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #494F4F; }
.db-table tr:last-child td { border-bottom: none; } .db-table tr:last-child td { border-bottom: none; }
.db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; } .db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; }
/* ── Sidebar ── */ /* ── Sidebar ── */
.db-sidebar { .db-sidebar {
width: 220px; flex-shrink: 0; background: #14202e; width: 220px; flex-shrink: 0; background: #F0EFEE;
border-right: 1px solid rgba(67,187,200,0.15); border-right: 1px solid rgba(67,187,200,0.15);
padding: 12px; transition: width 0.3s ease; padding: 12px; transition: width 0.3s ease;
display: flex; flex-direction: column; gap: 8px; display: flex; flex-direction: column; gap: 8px;
@@ -883,12 +934,12 @@ body {
} }
.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } .db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.db-filter-group { display: flex; flex-direction: column; gap: 4px; } .db-filter-group { display: flex; flex-direction: column; gap: 4px; }
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; } .db-filter-label { font-size: 0.75rem; font-weight: 600; color: #6B7878; text-transform: uppercase; letter-spacing: 0.05em; }
.db-filter-select { .db-filter-select {
background: #1e2d42; border: 1px solid #2a3547; color: #E7E6E5; background: #FFFFFF; border: 1px solid #2a3547; color: #494F4F;
border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%; border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%;
} }
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #E7E6E5; cursor: pointer; } .db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #494F4F; cursor: pointer; }
.db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; } .db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; }
.db-filter-reset { .db-filter-reset {
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3); background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
@@ -898,6 +949,70 @@ body {
} }
.db-filter-reset:hover { background: rgba(67,187,200,0.2); } .db-filter-reset:hover { background: rgba(67,187,200,0.2); }
/* ── Tres miradas: tabs ── */
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-tab {
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #6B7878;
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
}
.db-tab:hover { color: #494F4F; }
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
/* ── Tres miradas: intro de cada lente ── */
.db-lens-intro {
background: #F0EFEE; border-left: 3px solid var(--color-re-teal);
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
font-size: 0.8125rem; color: #494F4F; line-height: 1.5;
}
/* ── Card destacada (estrella) ── */
.db-widget-star {
border-color: var(--color-re-teal);
}
.db-widget-star .db-widget-title { color: #494F4F; }
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
.db-big-stat-warn { color: #f0b429; }
.db-big-stat-danger { color: #f06b6b; }
.db-big-stat-sub { font-size: 0.75rem; color: #8A9A9A; margin-top: 6px; line-height: 1.4; }
/* ── Punto ciego: contraste de dos barras ── */
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
.db-gap-col { text-align: center; }
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
.db-gap-lab { font-size: 0.6875rem; color: #8A9A9A; margin-top: 6px; }
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #9A7200; margin-top: 10px; }
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
.db-placeholder {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; min-height: 220px; text-align: center; color: #8A9A9A;
background: #F0EFEE; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
padding: 32px 24px;
}
.db-placeholder-icon { font-size: 1.75rem; }
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
/* ── Nav compartido (dashboard / respuestas / usuarios) ── */
.admin-nav-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 10px 24px; background: var(--color-surface);
border-bottom: 1px solid var(--color-border); flex-wrap: wrap;
}
.admin-nav { display: flex; gap: 4px; }
.admin-nav-link {
padding: 6px 14px; border-radius: var(--radius); font-size: 0.875rem;
font-weight: 600; color: var(--color-text-muted); text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.admin-nav-link:hover { background: var(--color-primary-light); color: var(--color-text); }
.admin-nav-link-active { background: var(--color-primary-light); color: var(--color-primary-hover); }
/* ── Admin: control de respuestas (Etapa 3V) ── */ /* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; } .admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header { .admin-header {
@@ -907,19 +1022,46 @@ body {
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; } .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-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; } .admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.admin-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid var(--color-border); color: var(--color-text-muted); text-decoration: none;
transition: all 0.2s ease;
}
.admin-survey-link:hover { color: var(--color-text); border-color: var(--color-primary); }
.admin-survey-link-active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.admin-refresh { .admin-refresh {
display: inline-block; padding: 8px 16px; border-radius: var(--radius); display: inline-block; padding: 8px 16px; border-radius: var(--radius);
background: var(--color-primary); color: #fff; font-size: 0.8125rem; background: var(--color-primary); color: #fff; font-size: 0.8125rem;
font-weight: 600; text-decoration: none; white-space: nowrap; font-weight: 600; text-decoration: none; white-space: nowrap;
} }
.admin-refresh:hover { background: var(--color-primary-hover); } .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 { .admin-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border); overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card); border-radius: var(--radius); box-shadow: var(--shadow-card);
@@ -940,6 +1082,10 @@ body {
display: inline-block; width: 14px; margin-right: 4px; display: inline-block; width: 14px; margin-right: 4px;
color: var(--color-text-muted); font-size: 0.7rem; 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 { .admin-nodata {
display: inline-block; padding: 1px 8px; border-radius: 10px; display: inline-block; padding: 1px 8px; border-radius: 10px;
background: var(--color-re-light); color: var(--color-re-dark); background: var(--color-re-light); color: var(--color-re-dark);
@@ -965,3 +1111,124 @@ body {
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
} }
/* ── Login ── */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: 24px;
}
.login-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 40px 36px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 28px;
}
.login-form { display: flex; flex-direction: column; gap: 16px; }
.login-field { display: flex; flex-direction: column; gap: 6px; }
.login-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.login-error { margin: 0; }
.login-password-wrapper { position: relative; }
.login-password-wrapper .text-short-input { padding-right: 40px; }
.login-password-toggle {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius);
color: var(--color-text-muted);
cursor: pointer;
}
.login-password-toggle:hover { color: var(--color-text); }
.login-submit {
margin-top: 8px;
padding: 10px 20px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
.login-back-button { margin-top: 16px; }
.login-forgot-link {
align-self: center;
margin-top: 4px;
padding: 4px;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8125rem;
cursor: pointer;
text-decoration: underline;
}
.login-forgot-link:hover { color: var(--color-primary-hover); }
/* ── Invite form (/admin/usuarios) ── */
.invite-form { display: flex; flex-direction: column; gap: 12px; }
.invite-form-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.invite-banner {
margin: 0;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
}
.invite-banner-success {
background: var(--color-primary-light);
color: var(--color-primary-hover);
}
.invite-banner-error {
background: var(--color-error-bg);
color: var(--color-error);
}
@media (max-width: 768px) {
.invite-form-row { grid-template-columns: 1fr; }
}
/* ── Admin sign-out ── */
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
.admin-signout {
font-size: 0.8125rem;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 5px 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }

197
app/login/page.tsx Normal file
View 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>
)
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

@@ -0,0 +1,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>
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (13 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (13 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', '56'),
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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
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 (410 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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (13 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', ''),
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', '34'),
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', '34'),
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 (410 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', ''),
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', '34'),
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', '34'),
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', ''),
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', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (13 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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
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 (13 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
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 (13 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', ''),
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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (410 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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
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 (410 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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (410 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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 (13 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', '34'),
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 (13 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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 (13 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', ''),
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', '34'),
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', ''),
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', '12'),
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', '34'),
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 (13 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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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', ''),
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', '56'),
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 (13 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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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', ''),
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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (13 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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (410 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', ''),
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-%';

View 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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (13 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (13 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', '56'),
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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
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 (410 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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (13 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', ''),
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', '34'),
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', '34'),
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 (410 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', ''),
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', '34'),
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', '34'),
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', ''),
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', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (13 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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
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 (13 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
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 (13 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', ''),
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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (410 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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '34'),
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 (410 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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (410 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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 (13 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', '34'),
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 (13 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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 (13 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', ''),
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', '34'),
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', ''),
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', '12'),
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', '34'),
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 (13 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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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', ''),
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', '56'),
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 (13 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', ''),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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', ''),
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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '12'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', ''),
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', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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 (13 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', '56'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '56'),
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 (410 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', '12'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '34'),
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', '34'),
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '56'),
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '12'),
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', ''),
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-%';

View 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]
```

View File

@@ -1,5 +1,5 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado # ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 20 junio 2026 ## Documento de continuidad — 27 junio 2026
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación > Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce > Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
@@ -7,125 +7,141 @@
--- ---
## 1. Versionado y ramas ## 1. Versionado y ramas
- **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
- **Versión en producción:** 2.1.0 (formulario lineal, dashboard multitenante) en `live`. Release v3 completado (etapa 4I).
- **Versión en desarrollo:** 3.0.0 (wizard, en `main`) - **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
- **Rama de preservación:** `v2.1.0-stable` (código + dump DB) + tag `v2.1.0` - SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
- Convención: SemVer + CHANGELOG.md (raíz) + tags en cada versión estable
- ADRs en `docs/adr/`. ADR-005 = esquema declarativo + estrategia dos fases.
--- ---
## 2. Sprint 4 — Wizard v3 — EN CURSO ## 2. Sprint 4 — Wizard v3 — COMPLETO, release hecho
Arquitectura (ADR-005): el formulario es un **esquema declarativo** (datos); el frontend | Etapa | Estado |
es un **renderer** sin reglas de negocio; el motor interpreta el esquema. |---|---|
Estrategia dos fases: Fase 1 (este sprint) formulario-como-esquema; Fase 2 (BACKLOG-002) | 4.0 Preservación v2 | ✅ |
motor genérico universal. | 4A instance_id | ✅ prod |
| 4B esquema declarativo + seed v3 | ✅ prod |
| 4C motor de flujo (lib/form-engine.ts, puro) | ✅ |
| 4D wizard renderer (layout estable) | ✅ |
| 4E instancias (emergente del motor) | ✅ |
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
| 4F autocomplete barrios (UX) | ✅ |
| 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) |
### Etapas El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
| Etapa | Nombre | Estado | server-side (RPC `rpc_submit_response`, único camino de escritura, verificado). Dashboard
|---|---|---| Mirada Empresa con sus 13 widgets, consumiendo las RPCs `rpc_me_*` reales. `/admin/responses`
| 4.0 | Preservación v2 (tag, rama, dump, CHANGELOG) | ✅ | y `/dashboard` ahora requieren sesión autenticada (antes sin auth — cierra TECH-DEBT-008).
| 4A | Persistencia `instance_id` en answers | ✅ aplicada en prod |
| 4B | Esquema declarativo + seed v3 (10 secciones, 150 questions) | ✅ aplicada en prod |
| 4C | Motor de resolución de flujo (puro, lib/, 16 tests) | ✅ commiteada |
| 4D | Frontend renderer (consume el motor, una preg/pantalla) | ⏳ siguiente |
| 4E | Instancias de vínculo (mini-wizard familiares) | pendiente |
| 4F | Autocomplete barrios (requiere geo_paraguay_full.json) | pendiente |
| 4G | Submit con instance_id + **validación server-side (no negociable)** | pendiente |
| 4H | RPCs de vínculo (agregados por instancia) | pendiente |
| 4I | Validación e2e + tag v3.0.0 + ADR + BACKLOG Fase 2 | pendiente |
Orden: 4D → (4E ∥ 4F) → 4G → 4H → 4I
### Estado técnico v3 en la DB (ya aplicado en prod)
- `answers.instance_id text DEFAULT 'default'`, constraint `(response_id, question_id, instance_id)`
- `questions` extendida: `instance_generator`, `instance_of`, `hint`
- 150 questions (50 × 3 surveys), instrumento v3 de 10 secciones
- 4.1 = `instance_generator`; 4.2/4.3 = `instance_of='4.1'`
- Datos de prueba v2 borrados (TRUNCATE); v3 arranca limpio
### Motor de flujo (4C) — lib/form-engine.ts
- Módulo puro, sin React/DOM. Tests en `lib/form-engine.test.mts` (16/16).
- Operadores: `eq`, `eq_any`, `not_in` (escalares); `contains`, `eq_sole` (array multiple_choice)
- Acciones: `show`, `hide` (hide = veto absoluto sobre show del mismo array)
- Reglas múltiples en un array = OR
- ID de instancia: `familiar_${posición fija de opción}` (determinista, estable)
- Secciones = pantallas propias; se ocultan solo si su fila type='section' tiene regla
### Recorridos del formulario (validados en mockup y tests)
- Completo: todos los vínculos → todas las secciones
- Solo niño sin discapacidad (`eq_sole` op 1): liviano → 5.3, 8.4, 9.1, 9.4
- Ninguna (`contains` op 8): salta a Sección 10
- 3.1='No': oculta 3.2-3.5
--- ---
## 3. Decisiones arquitectónicas clave (no reabrir) ## 2.1 Incidente VPS (27 jun) — recuperado
1. Lógica de flujo en datos (esquema), no en frontend (ADR-005) Una segunda instancia de Dokploy desplegada desde el mismo repo (con `container_name` fijos en
2. Frontend = renderer sin reglas de negocio docker-compose.yml) colisionó con la producción y vació el esquema `public` de `supabase-db`.
3. Motor de flujo (4C) corre en cliente, módulo puro testeable **Sin pérdida de datos reales** (solo seed demo). Se restauró: migraciones completas + seed +
4. **Validación de integridad del submit = server-side, NO negociable (4G)** surveys reactivados a `live` + usuario `platform_admin` creado. Verificado end-to-end: login,
La validación de cliente es UX, no seguridad. Datos de salud sensibles. sign-out, redirect sin sesión, formulario público (3 links `?s=<survey_id>`) — todo OK.
5. Secciones como pantallas propias (reversible — es comportamiento del motor) Detalle completo en memoria `project_vps_container_name_incident`. El director ya separó la
6. Se extiende `questions`, no se crea tabla de definición (ADR-005) segunda instancia a un repo Gitea propio sin `container_name` fijos, para que no se repita.
7. Migraciones vía CLI del VPS (`docker exec -i supabase-db psql`)
8. consent_records inmutable (ADR-003); el seed nunca toca datos de respuesta ---
## 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 ## 4. Deuda técnica y seguridad
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~**resuelto** por ETAPA_AUTH (27 jun).
| ID | Descripción | Prioridad | | ID | Descripción | Prioridad |
|---|---|---| |---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales | | TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales | | TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
| SEC-002 | Sprint de compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado | | TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
| SEC-003 | **Agujero TRUNCATE evade inmutabilidad ADR-003** — cerrar antes de datos reales | Antes de datos reales | | SEC-001 | Next.js — vuln "Cache Key Confusion" vigente en 15.3.8; última mayor es 16.2.9 (breaking) | Media |
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline | | 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 |
COMPLIANCE.md documenta estado de seguridad, roadmap de auditabilidad, y la decisión *(SEC-001/TECH-001 verificados 27 jun vía `npm view next version` / `npm audit --json`
fundamentada de NO usar blockchain. 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 (sin cambios) ## 5. Infraestructura
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11) - Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
- App: `economia-cuidado.inqualityhq.com` · API: `db.inqualityhq.com` - App: economia-cuidado.inqualityhq.com · API: db.inqualityhq.com
- Repos Gitea: `git.inquality.com.py/marcos/motor-de-encuestas` - Repos Gitea: git.inquality.com.py/marcos/motor-de-encuestas
- Backups GFS locales 3AM - Para sembrar datos de prueba: `seed_prueba_50.sql` (en docs/dashboard/), 50 respuestas
deterministas vía RPC. Usa question_id (UUIDs), NO question_code. Incluye su limpieza.
--- ---
## 6. Contexto comercial ## 6. Próximos pasos
- 2 clientes inminentes en 10-20 días (motivan el deadline de v3)
- InQuality y Fundación Solidaridad ya en joint venture del producto **Go-live (widgets → 4F → 4I) está cerrado.** v3.0.0 en producción, surveys `live`, auth para
- Datos de prueba descartables (existe dump en v2.1.0-stable) 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. Próximo paso inmediato ## 7. Contexto comercial
**Etapa 4D — Frontend renderer.** El componente consume el motor (4C) y renderiza - 2 clientes inminentes en ~10-20 días (deadline v3). Mirada Empresa es la que le habla al cliente que paga.
una pregunta por pantalla. Incluye la tarea 4.5 pospuesta de 4C (query de app/page.tsx - InQuality y Fundación Solidaridad en joint venture.
con hint/instance_generator/instance_of). Reemplaza lib/conditional.ts y la lógica
de secciones hardcodeada de survey-form.tsx v2.
--- ---
## 8. Prompt para nueva sesión ## 8. Prompt para nueva sesión
``` ```
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de formularios
formularios soberano (Paraguay). Yo soy el product owner. Estamos en Sprint 4 soberano (Paraguay). Yo soy el product owner. Sprint 4: wizard v3 (ADR-005, esquema declarativo).
construyendo el formulario wizard v3 (esquema declarativo, ADR-005).
Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md en el repo. Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md.
Sprint: sprints/sprint-4/ (BRIEF + prompts de etapa + reportes). Formulario v3: COMPLETO y verificado (motor, wizard, submit con validación server-side).
Completadas: 4.0, 4A, 4B, 4C. Siguiente: 4D (frontend renderer). Dashboard: rediseño de "tres miradas". Mirada Empresa = RPCs implementadas y verificadas
contra datos reales; faltan sus widgets. País/Humana diferidas a sprint posterior.
El motor de flujo ya existe y está testeado (lib/form-engine.ts, 16 tests). Pendiente go-live: widgets Mirada Empresa → 4F (barrios) → 4I (release).
La lógica vive en datos; el frontend es renderer sin reglas. Los 3 surveys en draft (v3 no desplegado aún).
Próximo paso: [COMPLETAR] Próximo paso: [COMPLETAR]
``` ```

View File

@@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

16
lib/geo-cache.ts Normal file
View 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
}

View File

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

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

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

View File

@@ -11,3 +11,14 @@ export function createClient() {
return createSupabaseClient(url, anonKey) return createSupabaseClient(url, anonKey)
} }
// service_role — bypasa RLS por completo (regla 9 CLAUDE.md). Solo se importa
// desde Server Components / Server Actions ('use server'), nunca desde código
// que se bundlea al cliente. No exportar este cliente ni su resultado hacia props
// de un Client Component.
export function getSupabaseAdmin() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'placeholder'
return createSupabaseClient(url, serviceKey)
}

43
middleware.ts Normal file
View 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*'],
}

141
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.8", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
@@ -18,6 +19,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/cli": "^4.11.3", "@axe-core/cli": "^4.11.3",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
@@ -1040,6 +1042,22 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@standard-schema/utils": { "node_modules/@standard-schema/utils": {
"version": "0.3.0", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -1047,9 +1065,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/auth-js": { "node_modules/@supabase/auth-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==", "integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -1183,9 +1201,9 @@
] ]
}, },
"node_modules/@supabase/functions-js": { "node_modules/@supabase/functions-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==", "integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -1195,15 +1213,15 @@
} }
}, },
"node_modules/@supabase/phoenix": { "node_modules/@supabase/phoenix": {
"version": "0.4.2", "version": "0.4.4",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz", "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==", "integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@supabase/postgrest-js": { "node_modules/@supabase/postgrest-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==", "integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "2.8.1" "tslib": "2.8.1"
@@ -1213,9 +1231,9 @@
} }
}, },
"node_modules/@supabase/realtime-js": { "node_modules/@supabase/realtime-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==", "integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/phoenix": "^0.4.2", "@supabase/phoenix": "^0.4.2",
@@ -1225,10 +1243,22 @@
"node": ">=20.0.0" "node": ">=20.0.0"
} }
}, },
"node_modules/@supabase/ssr": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.108.0"
}
},
"node_modules/@supabase/storage-js": { "node_modules/@supabase/storage-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==", "integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"iceberg-js": "^0.8.1", "iceberg-js": "^0.8.1",
@@ -1239,16 +1269,16 @@
} }
}, },
"node_modules/@supabase/supabase-js": { "node_modules/@supabase/supabase-js": {
"version": "2.107.0", "version": "2.108.2",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz", "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==", "integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@supabase/auth-js": "2.107.0", "@supabase/auth-js": "2.108.2",
"@supabase/functions-js": "2.107.0", "@supabase/functions-js": "2.108.2",
"@supabase/postgrest-js": "2.107.0", "@supabase/postgrest-js": "2.108.2",
"@supabase/realtime-js": "2.107.0", "@supabase/realtime-js": "2.108.2",
"@supabase/storage-js": "2.107.0" "@supabase/storage-js": "2.108.2"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -1789,6 +1819,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
@@ -2109,6 +2152,20 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -2976,6 +3033,38 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",

View File

@@ -16,6 +16,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@supabase/ssr": "^0.12.0",
"@supabase/supabase-js": "^2.49.8", "@supabase/supabase-js": "^2.49.8",
"next": "15.3.8", "next": "15.3.8",
"react": "^19.0.0", "react": "^19.0.0",
@@ -25,6 +26,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/cli": "^4.11.3", "@axe-core/cli": "^4.11.3",
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",

14
playwright.config.ts Normal file
View 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',
})

File diff suppressed because it is too large Load Diff

20
scripts/apply_all_migrations.sh Executable file
View 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
View 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."

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

View File

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

View File

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

View File

@@ -0,0 +1,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.

View 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.
```

View File

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

View File

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

View File

@@ -0,0 +1,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.
```

View 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.
```

View 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.
```

View 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.**

View 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

View 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.

View File

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

View File

@@ -0,0 +1,46 @@
-- Sprint 4 (v3.0.0) — Fix puntual: _dash_responses filtraba género/modalidad
-- contra códigos v2 ('A6'/'A7') que no existen en el esquema v3. El filtro
-- devolvía 0 filas SIN error — silencioso, indistinguible de "no hay nadie
-- con ese filtro". Es una trampa: alguien podría leer "ningún hombre es
-- cuidador" cuando en realidad el filtro nunca encontró la pregunta.
--
-- Mapeo: A6 (género, v2) → 2.5 (género, v3) · A7 (modalidad, v2) → 2.7 (modalidad, v3).
--
-- Alcance: SOLO este helper compartido. Las 9 RPCs viejas de v2 (migración
-- 20260605140001_012_dashboard_rpc_v2.sql) NO se tocan — sus propias
-- comparaciones de código (1.1, 1.6, 1.13, 4.3, 6.1, 6.2, 6.5, etc.) siguen
-- sin migrar (Etapa 4H, pendiente de confirmación aparte, no se resuelve
-- acá). Este fix no las rompe: ninguna de esas 9 RPCs compara 'A6'/'A7'
-- directamente, solo le pasan arrays de filtro (p_genero/p_modalidad) a esta
-- función — que ahora los evalúa contra preguntas que SÍ existen en v3.
CREATE OR REPLACE FUNCTION public._dash_responses(
p_survey_id uuid,
p_depto text DEFAULT NULL,
p_genero text[] DEFAULT NULL,
p_etario text[] DEFAULT NULL,
p_modalidad text[] DEFAULT NULL
)
RETURNS TABLE(response_id uuid)
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT r.id
FROM public.responses r
WHERE (p_survey_id = '00000000-0000-0000-0000-000000000000'
OR r.survey_id = p_survey_id)
AND (p_depto IS NULL OR r.qi_zone = p_depto)
AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = '2.5'
AND a.value #>> '{}' = ANY(p_genero)
))
AND (p_etario IS NULL OR cardinality(p_etario) = 0
OR r.qi_age_bucket = ANY(p_etario))
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
SELECT 1 FROM public.answers a
JOIN public.questions q ON q.id = a.question_id
WHERE a.response_id = r.id AND q.code = '2.7'
AND a.value #>> '{}' = ANY(p_modalidad)
));
$$;

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
-- BUG-001 — Fix filtro "Ninguna de las anteriores" en rpc_me_carrera_congelada
--
-- PROBLEMA: la migración anterior (20260626000001) dejó en el subquery interno:
-- AND a.value #>> '{}' != 'Ninguna de las anteriores'
-- El operador `#>> '{}'` aplicado sobre un ARRAY jsonb no extrae elementos;
-- convierte todo el array a su representación textual (ej. '["opcion","Ninguna..."]'),
-- por lo que la comparación nunca descarta el elemento deseado.
--
-- SOLUCIÓN: mover el filtro al WHERE del subquery expandido, donde `opt` ya existe
-- como elemento individual resultado de jsonb_array_elements_text.
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
p_survey_id uuid,
p_filters jsonb DEFAULT '{}'::jsonb
)
RETURNS jsonb
LANGUAGE plpgsql STABLE SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_f record;
v_n bigint;
v_cong bigint;
v_rows jsonb;
BEGIN
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
SELECT
count(*),
count(*) FILTER (
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
ELSE false
END
)
INTO v_n, v_cong
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id);
IF v_n < 5 THEN
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
END IF;
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row,
cnt
FROM (
SELECT opt, count(DISTINCT response_id) AS cnt
FROM (
SELECT jsonb_array_elements_text(a.value) AS opt,
dr.response_id
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
JOIN public.answers a ON a.response_id = dr.response_id
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
WHERE public._dash_es_cuidador(dr.response_id)
AND jsonb_typeof(a.value) = 'array'
) expanded
WHERE opt != 'Ninguna de las anteriores'
GROUP BY opt
) sub
WHERE cnt >= 5
) t;
RETURN jsonb_build_object(
'status', 'ok',
'n', v_n,
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
);
END;
$$;

View File

@@ -0,0 +1,129 @@
-- ============================================================
-- Migration 004 / ETAPA_AUTH 4.2
-- Tabla user_roles + hook de custom claims para JWT
--
-- Después de aplicar esta migración, el director debe:
-- 1. Agregar en Dokploy (servicio supabase-auth):
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_ENABLED=true
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/custom_access_token_hook
-- 2. Reiniciar el servicio supabase-auth (NO la app Next.js).
-- ============================================================
-- ── 1. Tabla user_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)
);
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
-- El usuario ve su propio rol; platform_admin ve todos
CREATE POLICY "user_roles_select_own"
ON public.user_roles
FOR SELECT
TO authenticated
USING (
user_id = auth.uid()
OR public.current_app_role() = 'platform_admin'
);
-- ── 2. Extender policies existentes para incluir platform_admin ──
-- organizations
DROP POLICY IF EXISTS "org_select_own" ON public.organizations;
CREATE POLICY "org_select_own"
ON public.organizations
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND id = public.current_org_id()
)
);
-- surveys (no tocar surveys_select_live_anon — sigue igual)
DROP POLICY IF EXISTS "surveys_select_auth" ON public.surveys;
CREATE POLICY "surveys_select_auth"
ON public.surveys
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND org_id = public.current_org_id()
)
);
-- questions (no tocar questions_select_live — sigue igual)
DROP POLICY IF EXISTS "questions_select_auth" ON public.questions;
CREATE POLICY "questions_select_auth"
ON public.questions
FOR SELECT
TO authenticated
USING (
public.current_app_role() IN ('analyst', 'platform_admin')
OR (
public.current_app_role() = 'org_admin'
AND EXISTS (
SELECT 1 FROM public.surveys s
WHERE s.id = survey_id
AND s.org_id = public.current_org_id()
)
)
);
-- ── 3. Nuevas policies SELECT para platform_admin (responses/answers no tenían) ──
CREATE POLICY "responses_select_platform_admin"
ON public.responses
FOR SELECT
TO authenticated
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
CREATE POLICY "answers_select_platform_admin"
ON public.answers
FOR SELECT
TO authenticated
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
-- ── 4. Hook de custom claims ─────────────────────────────────
-- Inyecta app_role y org_id en el JWT en cada emisión de token.
-- SECURITY DEFINER + search_path garantiza que bypasea RLS para leer user_roles.
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
RETURNS jsonb
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
claims jsonb;
v_role text;
v_org uuid;
BEGIN
claims := event -> 'claims';
SELECT app_role, org_id
INTO v_role, v_org
FROM public.user_roles
WHERE user_id = (event ->> 'user_id')::uuid;
IF v_role IS NOT NULL THEN
claims := jsonb_set(claims, '{app_role}', to_jsonb(v_role));
IF v_org IS NOT NULL THEN
claims := jsonb_set(claims, '{org_id}', to_jsonb(v_org::text));
END IF;
END IF;
RETURN jsonb_set(event, '{claims}', claims);
END;
$$;
-- GoTrue necesita EXECUTE para llamar la función y SELECT para que el SECURITY DEFINER
-- pueda leer user_roles internamente (aunque SECURITY DEFINER corre como postgres,
-- el grant explicito documenta la intención y protege contra cambios de ownership).
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb) TO supabase_auth_admin;
GRANT SELECT ON public.user_roles TO supabase_auth_admin;

View File

@@ -0,0 +1,190 @@
-- ============================================================
-- Migration: audit_log append-only — SEC-002 (Sprint 5, compliance)
--
-- Loggea cambios de configuración crítica:
-- - user_roles: altas/bajas/cambios de app_role u org_id (quién es admin)
-- - surveys.status: activación/desactivación de encuestas
-- - DDL en schema public: toda migración aplicada (CREATE/ALTER/DROP),
-- vía event trigger ddl_command_end. Confirmado viable en este Postgres
-- self-hosted sin extensiones adicionales: el rol `postgres` tiene
-- rolsuper=false pero pudo crear el event trigger sin error
-- (diagnóstico ETAPA_AUDIT_LOG_PROMPT.md §4.1, 2026-06-27).
--
-- NO loggea acceso a datos sensibles (is_sensitive=true): hoy ningún
-- camino de código (ni /admin/responses ni las RPCs rpc_me_*) lee esas
-- columnas (COMPLIANCE.md §3.2). Deferido a cuando ese código exista —
-- ese mismo cambio debe agregar su propio log de acceso, no esta migración.
--
-- GAP CONOCIDO (SEC-003, aceptado, NO resuelto aquí): un operador con
-- acceso directo a `psql -U postgres` en el VPS puede TRUNCATE/DROP esta
-- tabla, o saltear los triggers con `SET session_replication_role =
-- replica` / `ALTER TABLE ... DISABLE TRIGGER ALL`. El append-only de
-- abajo cierra la escritura vía API (anon/authenticated/PostgREST) y
-- bloquea UPDATE/DELETE para cualquier caller normal (incluido
-- service_role, que bypasa RLS pero no bypasa triggers) — no protege
-- contra un operador con shell en la base.
-- ============================================================
CREATE TABLE public.audit_log (
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
);
COMMENT ON TABLE public.audit_log IS
'Append-only. INSERT solo vía funciones SECURITY DEFINER de los triggers de esta migración. UPDATE/DELETE bloqueados por trigger audit_log_block_mutation. Gap conocido: no protege contra TRUNCATE/DROP por superusuario directo en el VPS (SEC-003).';
COMMENT ON COLUMN public.audit_log.actor_role IS
'current_app_role() del que disparó el cambio (user_roles/surveys), o session_user para eventos DDL.';
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
-- Solo platform_admin puede leer. No hay policies de INSERT/UPDATE/DELETE
-- para ningún rol vía API — el único camino de escritura son las
-- funciones SECURITY DEFINER (corren como propietario de la tabla,
-- `postgres`, que por default de Postgres bypasa RLS sobre objetos
-- propios sin necesitar FORCE ROW LEVEL SECURITY).
CREATE POLICY "audit_log_select_platform_admin"
ON public.audit_log FOR SELECT
TO authenticated
USING (public.current_app_role() = 'platform_admin');
REVOKE INSERT, UPDATE, DELETE ON public.audit_log FROM PUBLIC, anon, authenticated;
GRANT SELECT ON public.audit_log TO authenticated;
-- ============================================================
-- Barrera 2: append-only enforcement a nivel de trigger.
-- Se aplica a CUALQUIER caller (incluido service_role) salvo que alguien
-- deshabilite triggers con privilegios de superusuario real en el VPS
-- (gap documentado arriba, SEC-003).
-- ============================================================
CREATE OR REPLACE FUNCTION public.audit_log_block_mutation()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = public
AS $$
BEGIN
RAISE EXCEPTION 'audit_log es append-only: % no permitido', TG_OP;
END;
$$;
REVOKE ALL ON FUNCTION public.audit_log_block_mutation() FROM PUBLIC;
CREATE TRIGGER audit_log_no_update
BEFORE UPDATE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
CREATE TRIGGER audit_log_no_delete
BEFORE DELETE ON public.audit_log
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
-- ============================================================
-- Trigger 1: cambios en user_roles (altas/bajas/cambios de acceso)
-- Función disparada automáticamente por el trigger — no requiere GRANT
-- EXECUTE a ningún rol (el mecanismo de triggers no pasa por una llamada
-- SQL directa del caller).
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_user_roles_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
public.current_app_role(),
auth.uid(),
'user_roles_' || lower(TG_OP),
'user_roles',
COALESCE(NEW.user_id, OLD.user_id)::text,
jsonb_build_object(
'old', CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) ELSE NULL END,
'new', CASE WHEN TG_OP IN ('UPDATE','INSERT') THEN to_jsonb(NEW) ELSE NULL END
)
);
RETURN COALESCE(NEW, OLD);
END;
$$;
REVOKE ALL ON FUNCTION public.log_user_roles_change() FROM PUBLIC;
CREATE TRIGGER user_roles_audit
AFTER INSERT OR UPDATE OR DELETE ON public.user_roles
FOR EACH ROW EXECUTE FUNCTION public.log_user_roles_change();
-- ============================================================
-- Trigger 2: cambios de status en surveys (activación/desactivación)
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_survey_status_change()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
IF NEW.status IS DISTINCT FROM OLD.status THEN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
public.current_app_role(),
auth.uid(),
'survey_status_change',
'surveys',
NEW.id::text,
jsonb_build_object('old_status', OLD.status, 'new_status', NEW.status, 'title', NEW.title)
);
END IF;
RETURN NEW;
END;
$$;
REVOKE ALL ON FUNCTION public.log_survey_status_change() FROM PUBLIC;
CREATE TRIGGER surveys_status_audit
AFTER UPDATE ON public.surveys
FOR EACH ROW EXECUTE FUNCTION public.log_survey_status_change();
-- ============================================================
-- Trigger 3 (Prioridad 2, confirmado viable — ver §4.1 del prompt de etapa):
-- DDL en schema public → loggea toda migración aplicada.
-- ============================================================
CREATE OR REPLACE FUNCTION public.log_ddl_command()
RETURNS event_trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
obj record;
BEGIN
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
IF obj.schema_name = 'public' OR obj.schema_name IS NULL THEN
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
VALUES (
session_user,
NULL,
'ddl_' || obj.command_tag,
COALESCE(obj.object_identity, obj.schema_name, 'unknown'),
obj.objid::text,
jsonb_build_object('command_tag', obj.command_tag, 'object_type', obj.object_type)
);
END IF;
END LOOP;
END;
$$;
REVOKE ALL ON FUNCTION public.log_ddl_command() FROM PUBLIC;
CREATE EVENT TRIGGER audit_ddl_public
ON ddl_command_end
EXECUTE FUNCTION public.log_ddl_command();
-- ============================================================
-- Fin de migración. NO aplicar en el VPS sin aprobación explícita del
-- director — ver ETAPA_AUDIT_LOG_PROMPT.md §4.3.
-- ============================================================

View File

@@ -0,0 +1,14 @@
-- Activar required en todas las preguntas del formulario de cuidadores
-- excepto barrio (2.3) y secciones (type = 'section')
UPDATE public.questions
SET required = true
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
AND code != '2.3'
AND type != 'section';
-- Verificar: debe mostrar todas las preguntas con required=true excepto 2.3
SELECT code, type, required
FROM public.questions
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
AND type != 'section'
ORDER BY position;

View File

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

View File

@@ -0,0 +1,70 @@
import { test, expect, type Page, type BrowserContext } from '@playwright/test'
// Credenciales SIEMPRE desde .env.test (playwright.config.ts las carga) — nunca acá.
const EMAIL = process.env.E2E_EMAIL!
const PASSWORD = process.env.E2E_PASSWORD!
// Survey Demo — la única con datos sembrados (51 respuestas, ver
// docs/estado/ESTADO_PROYECTO_26JUN2026.md §4). Necesaria como ?s= en el
// formulario público: con 3 surveys en `live`, `/` sin parámetro es ambiguo
// (app/page.tsx usa .maybeSingle() sobre status='live').
const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001'
test.describe.serial('Sprint 5 — verificación post-deploy', () => {
let context: BrowserContext
let page: Page
test.beforeAll(async ({ browser }) => {
context = await browser.newContext()
page = await context.newPage()
})
test.afterAll(async () => {
await context.close()
})
test('1. /admin/responses sin sesión redirige a /login', async () => {
await page.goto('/admin/responses')
await expect(page).toHaveURL(/\/login/)
await expect(page.getByRole('textbox', { name: 'Email' })).toBeVisible()
await expect(page.getByRole('textbox', { name: 'Contraseña' })).toBeVisible()
await expect(page.getByRole('button', { name: /contraseña/i })).toBeVisible()
})
test('2. login funcional redirige a /admin/responses con filtro y datos', async () => {
await page.getByRole('textbox', { name: 'Email' }).fill(EMAIL)
await page.getByRole('textbox', { name: 'Contraseña' }).fill(PASSWORD)
await page.getByRole('button', { name: 'Ingresar' }).click()
await expect(page).toHaveURL(/\/admin\/responses/)
await expect(page.locator('select.admin-survey-select')).toBeVisible()
await expect(page.locator('table.admin-table tbody tr').first()).toBeVisible()
})
test('3. vista detallada de una respuesta', async () => {
await page.locator('a.admin-action-link', { hasText: 'Ver' }).first().click()
await expect(page).toHaveURL(/\/admin\/responses\/[0-9a-f-]{36}/)
await expect(page.locator('.admin-detail-section').first()).toBeVisible()
await expect(page.getByText('Respuesta no encontrada')).toHaveCount(0)
})
test('4. dashboard muestra datos reales (no todo suprimido por k<5)', async () => {
await page.goto('/dashboard')
await expect(page.locator('.db-widget').first()).toBeVisible()
await expect(page.locator('body')).toContainText('%')
const totalWidgets = await page.locator('.db-widget').count()
const suppressed = await page.locator('.db-suppressed').count()
expect(totalWidgets).toBeGreaterThan(0)
expect(suppressed).toBeLessThan(totalWidgets)
})
test('5. formulario público accesible sin auth', async ({ browser }) => {
const anonContext = await browser.newContext()
const anonPage = await anonContext.newPage()
await anonPage.goto(`/?s=${DEMO_SURVEY_ID}`)
await expect(anonPage).not.toHaveURL(/\/login/)
await expect(
anonPage.locator('.consent-container, .wizard-shell').first()
).toBeVisible()
await anonContext.close()
})
})

View File

@@ -0,0 +1,211 @@
import { test, expect, type BrowserContext, type Page } from '@playwright/test'
const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001'
const SURVEY_URL = `/?s=${DEMO_SURVEY_ID}`
// Devuelve el código de la pregunta visible (ej: '2.3'), o null si estamos en una sección.
async function currentCode(page: Page): Promise<string | null> {
const el = page.locator('.wizard-prompt-code')
if (await el.count() === 0) return null
return (await el.textContent())?.trim() ?? null
}
// Navega hacia adelante hasta que la pregunta con `targetCode` sea visible.
// Maneja preguntas required (Q1.1) y screens de sección intermedias.
async function navigateUntil(page: Page, targetCode: string, maxSteps = 30): Promise<void> {
for (let i = 0; i < maxSteps; i++) {
if (await currentCode(page) === targetCode) return
// Si hay error de validación (required sin respuesta), responder primero
if (await page.locator('.field-error').count() > 0) {
await page.locator('.option-label').first().click()
await page.waitForTimeout(150)
continue
}
await page.getByRole('button', { name: 'Siguiente' }).click()
// Esperar a que React aplique el state update y re-renderice
await page.waitForTimeout(150)
}
const code = await currentCode(page)
if (code !== targetCode) throw new Error(`navigateUntil: llegué a ${code}, esperaba ${targetCode}`)
}
// Acepta el consent y espera a que el wizard esté visible.
async function acceptConsent(page: Page): Promise<void> {
await page.check('#consent-operativo')
await page.getByRole('button', { name: 'Comenzar la encuesta' }).click()
await page.locator('.wizard-shell').waitFor()
}
test.describe.serial('Sprint 6 — usabilidad y cache geo', () => {
let context: BrowserContext
let page: Page
const geoRequests: string[] = []
test.beforeAll(async ({ browser }) => {
context = await browser.newContext()
page = await context.newPage()
// Interceptar requests geo antes de navegar (para Test 1)
page.on('request', (req) => {
if (req.url().includes('geo_paraguay_full.json')) geoRequests.push(req.url())
})
})
test.afterAll(async () => {
await context.close()
})
// ─────────────────────────────────────────────────────────────
// Test 1 — Cache geo: un solo fetch para barrio y ciudad
// ─────────────────────────────────────────────────────────────
test('1. cache geo: un solo fetch para barrio y ciudad', async () => {
await page.goto(SURVEY_URL)
await acceptConsent(page)
// Navegar hasta Q2.2 (CiudadSelectField monta → geo request se dispara aquí)
await navigateUntil(page, '2.2')
expect(await currentCode(page)).toBe('2.2')
// Avanzar a Q2.3 (BarrioAutocompleteField monta → debe usar cache, no nueva request)
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.3')
// Solo debe haber habido 1 request al JSON de geo (Q2.2 lo cargó, Q2.3 usó el cache)
expect(geoRequests.length).toBe(1)
})
// ─────────────────────────────────────────────────────────────
// Test 2 — Estado loading del campo geo
// ─────────────────────────────────────────────────────────────
test('2. estado loading del campo geo', async () => {
// Estamos en Q2.3 (del test anterior). Volver a Q2.2 para observar el estado del campo.
await page.getByRole('button', { name: 'Anterior' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.2')
// CiudadSelectField: input con clase .text-short-input
const input = page.locator('input.text-short-input').first()
await expect(input).toBeVisible()
const isDisabled = await input.isDisabled()
if (isDisabled) {
// Estado loading capturado — verificar placeholder correcto
const placeholder = await input.getAttribute('placeholder')
expect(placeholder).toBe('Cargando opciones...')
}
// En cualquier caso, el campo debe estar (o volverse) habilitado
await expect(input).toBeEnabled({ timeout: 5000 })
// Volver a Q2.3 para el siguiente test
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.3')
})
// ─────────────────────────────────────────────────────────────
// Test 3 — Texto libre funciona en campo barrio
// ─────────────────────────────────────────────────────────────
test('3. texto libre funciona en campo barrio', async () => {
// Estamos en Q2.3 (BarrioAutocompleteField)
expect(await currentCode(page)).toBe('2.3')
const barrioCampo = page.locator('input.text-short-input')
await expect(barrioCampo).toBeVisible()
await expect(barrioCampo).toBeEnabled()
// Escribir un barrio que no existe en el JSON (texto libre)
await barrioCampo.fill('Barrio Inexistente XYZ')
await expect(barrioCampo).toHaveValue('Barrio Inexistente XYZ')
// Avanzar — campo es opcional, texto libre debe aceptarse sin error
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(200)
await expect(page.locator('.field-error')).toHaveCount(0)
await expect(page.locator('.wizard-shell')).toBeVisible()
})
// ─────────────────────────────────────────────────────────────
// Test 4 — Draft persiste tras recarga
// ─────────────────────────────────────────────────────────────
test('4. draft persiste tras recarga', async () => {
// Volver a Q2.3 para sobreescribir el draft con un valor reconocible
await page.getByRole('button', { name: 'Anterior' }).click()
await page.waitForTimeout(200)
expect(await currentCode(page)).toBe('2.3')
const barrioCampo = page.locator('input.text-short-input')
await barrioCampo.fill('Barrio Test Draft')
await expect(barrioCampo).toHaveValue('Barrio Test Draft')
// Avanzar — handleNext guarda el draft inmediatamente (no debounce)
await page.getByRole('button', { name: 'Siguiente' }).click()
await page.waitForTimeout(300)
// Verificar que el draft fue guardado antes de recargar
const draftRaw = await page.evaluate(
(id) => localStorage.getItem(`survey_draft_${id}`),
DEMO_SURVEY_ID,
)
expect(draftRaw, 'draft debe existir en localStorage antes del reload').not.toBeNull()
expect(draftRaw, 'draft debe contener el valor del barrio').toContain('Barrio Test Draft')
// Recargar
await page.reload()
// Verificar que el draft sobrevive el reload
const draftAfterReload = await page.evaluate(
(id) => localStorage.getItem(`survey_draft_${id}`),
DEMO_SURVEY_ID,
)
expect(draftAfterReload, 'draft debe persistir después del reload').not.toBeNull()
await acceptConsent(page)
// El indicador de restauración es el valor de Q2.3 — navegamos hasta ahí
// (navigateUntil funciona desde cualquier pantalla de inicio, incluyendo S1)
await navigateUntil(page, '2.3')
await expect(page.locator('input.text-short-input')).toHaveValue('Barrio Test Draft')
})
// ─────────────────────────────────────────────────────────────
// Test 5 — Submit con error de red muestra estado error del wizard
// ─────────────────────────────────────────────────────────────
test('5. submit con error de red muestra estado error del wizard', async ({ browser }) => {
const ctx5 = await browser.newContext()
const page5 = await ctx5.newPage()
// Pre-rellenar Q1.1 (única pregunta required) para no bloquearnos al avanzar
await page5.goto(SURVEY_URL)
await page5.evaluate((id) => {
localStorage.setItem(`survey_draft_${id}`, JSON.stringify({ '1.1:default': 'true' }))
}, DEMO_SURVEY_ID)
await acceptConsent(page5)
// Navegar hasta el end screen: todas las preguntas restantes son opcionales
let iters = 0
while (
(await page5.locator('button', { hasText: 'Enviar encuesta' }).count()) === 0 &&
iters < 70
) {
await page5.getByRole('button', { name: 'Siguiente' }).click()
await page5.waitForTimeout(100)
iters++
}
await expect(page5.getByRole('button', { name: 'Enviar encuesta' })).toBeVisible()
// Poner el contexto offline para simular error de red en el submit
await page5.context().setOffline(true)
await page5.getByRole('button', { name: 'Enviar encuesta' }).click()
// El wizard debe mostrar su propio estado de error (no un React error boundary)
await expect(page5.locator('.error-container')).toBeVisible({ timeout: 15000 })
await expect(page5.getByText(/Application error/i)).toHaveCount(0)
await expect(page5.getByRole('button', { name: /Volver e intentar/i })).toBeVisible()
await page5.context().setOffline(false)
await ctx5.close()
})
})

View File

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