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

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

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

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

SEED_SPEC.md reescrito como contrato v3.0.0.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 17:21:11 -03:00
markosbenitez
a4287a122e docs(compliance): crear COMPLIANCE.md + registrar roadmap de auditabilidad y seguridad 2026-06-22 15:26:22 -03:00
markosbenitez
6db211b9aa docs(adr): registrar ADR-005 esquema declarativo + deuda Fase 2 en backlog 2026-06-22 15:19:37 -03:00
markosbenitez
d50c31f8f4 feat(data): agregar instance_id a answers para multi-instancia [4A]
Habilita respuestas múltiples por pregunta (mini-wizard de vínculos).
Nueva constraint UNIQUE(response_id, question_id, instance_id).
Aplicada en producción. Parte del Sprint 4 hacia v3.0.0.
2026-06-22 14:35:13 -03:00
markosbenitez
d6be2b5c6b docs: agregar CHANGELOG con versionado semántico
Establece la convención SemVer para el proyecto.
v2.1.0 documentada como versión estable actual.
v3.0.0 (wizard) marcada como en desarrollo.
2026-06-22 09:10:00 -03:00
111 changed files with 22481 additions and 3398 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

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

View File

@@ -7,6 +7,41 @@
## Seguridad ## Seguridad
### [SEC-004] RPC de submit es bypasseable vía REST directo (coherencia de flujo)
- **Estado:** Riesgo residual documentado — no resuelto en Etapa 4G
- **Contexto:** `rpc_submit_response` (migración `20260624000001_submit_rpc.sql`) es el único
camino de escritura para `consent_records`/`responses`/`answers` (las policies `WITH CHECK(true)`
que permitían INSERT directo a `anon` se eliminaron). La RPC valida lo ESTRUCTURAL en SQL
(el `question_id` pertenece a la encuesta, la encuesta está viva, el consentimiento llegó).
La validación de COHERENCIA DE FLUJO (visibilidad según `conditional_rules`, instancias
legítimas de `4.1`) vive en TypeScript (`lib/submit-validation.ts`, reusa `lib/form-engine.ts`)
porque reimplementarla en SQL violaría la regla de "no duplicar la lógica de flujo" (BRIEF 4G).
- **Riesgo:** quien llame a `rpc_submit_response` directo por la REST API de Supabase (sin pasar
por el Server Action de Next.js) puede mandar un payload con instancias/visibilidad incoherentes
que igual pase las validaciones estructurales de la RPC.
- **Por qué no es un incidente de privacidad:** el dato resultante sigue siendo anónimo
(`respondent_id=NULL`), `is_sensitive` se sigue respetando, y queda sujeto al umbral k-anonimato
en `agg_segment`/RPCs de dashboard igual que cualquier otra respuesta. Es un problema de calidad
de datos de investigación (una respuesta lógicamente imposible podría colarse), no de exposición
de datos.
- **Cierre posible (no implementado):** firmar el payload en el Server Action (HMAC con un secreto
server-only) y verificar la firma dentro de la RPC antes de insertar — así un caller directo sin
el secreto no puede producir un payload válido. Evaluar costo/beneficio antes de priorizar.
- **Dependencia:** no bloquea Sprint 4; evaluar junto con SEC-002 (sprint de compliance).
### [SEC-002] Sprint de compliance & seguridad
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
de hardening. Antes de escalar a más empresas o datos reales, se requiere un sprint dedicado.
- **Ítems principales (ver COMPLIANCE.md secciones 5 y 6):**
- Registro de auditoría append-only (log inmutable de operaciones sensibles)
- Hashing SHA-256 de integridad de respuestas (detección de tampering sin blockchain)
- Cifrado en reposo a nivel de volumen/disco de la base de datos
- Roadmap de hardening por capas: aplicación, autenticación, datos, infraestructura,
observabilidad y proceso (revisión periódica de CVEs, pen testing)
- **Dependencia:** no bloquea Sprint 4; sí bloquea escalar a producción masiva.
- **Ver:** `COMPLIANCE.md` secciones 5 y 6.
### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3 ### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3
- **Estado:** Pendiente - **Estado:** Pendiente
- **Prioridad:** Alta - **Prioridad:** Alta
@@ -58,6 +93,17 @@
--- ---
### [BACKLOG-002] Fase 2 — Motor genérico de formularios
- **Estado:** Diferido post-deadline
- **Contexto:** En Sprint 4 se construye un intérprete del esquema declarativo específico
para el formulario de cuidadores (ADR-005, Fase 1). Una vez entregado ese caso, el
intérprete se extrae a un motor universal que interprete cualquier esquema definido en
`questions.conditional_rules` + campos de instanciación, sin hardcodear ningún formulario.
El formulario de cuidadores se vuelve el primer caso de prueba del motor sin reescribir
el contrato de datos.
- **Dependencia:** Fase 1 completada (Sprint 4 — wizard funcional).
- **Ver:** ADR-005 (`docs/adr/ADR-005-esquema-declarativo-formularios.md`).
- Dashboard / reportes (Sprint 3) - Dashboard / reportes (Sprint 3)
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B) - Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
- Builder visual de encuestas (post-validación del caso fijo) - Builder visual de encuestas (post-validación del caso fijo)

98
CHANGELOG.md Normal file
View File

@@ -0,0 +1,98 @@
# Changelog — Datos País sobre el Cuidado
Todas las versiones notables de este proyecto se documentan en este archivo.
El formato sigue [Keep a Changelog](https://keepachangelog.com/es/1.1.0/)
y el proyecto adhiere a [Versionado Semántico](https://semver.org/lang/es/).
---
## [3.0.0] — 2026-06-27
Reescritura del formulario a wizard declarativo pregunta-por-pantalla.
Motor de flujo puro, instancias familiares, submit transaccional server-side
y dashboard Tres Miradas con Mirada Empresa funcional.
### Agregado
- **Motor de flujo declarativo** (`lib/form-engine.ts`): `resolveVisibility`,
`resolveScreens`, `expandInstances` — lógica de show/hide/instancias en TypeScript
puro, sin estado UI. Operadores `contains`, `eq_sole`, `contains_any`.
- **Wizard renderer** (`components/wizard-form.tsx`): pregunta-por-pantalla,
progreso lineal, layout estable sin saltos de scroll.
- **Instancias familiares**: columna `instance_id` en `answers`; preguntas
4.2/4.3 se expanden por cada vínculo seleccionado en 4.1 con IDs `familiar_N`.
- **Submit v3 transaccional**: Server Action + RPC `rpc_submit_response`
(SECURITY DEFINER) — persiste consent_record + response + answers en una
transacción atómica; revoca INSERT directo de `anon`.
- **Validación server-side** (`lib/submit-validation.ts`): 7 checks antes del
RPC (consent, códigos legítimos, forma de valores, visibilidad coherente,
instancias válidas, opciones en rango, requeridas presentes).
- **Dashboard Tres Miradas**: Mirada Empresa (`/dashboard`) con 13 widgets,
RPCs STABLE `rpc_me_*` con filtros de segmento y k-anonimato; Mirada País y
Mirada Perfil (base implementada).
- **Autocomplete de barrios** (`BarrioAutocompleteField`): filtra `geo_paraguay_full.json`
por departamento + ciudad, dropdown `position: fixed` con `getBoundingClientRect`.
- **Ciudad select** (`CiudadSelectField`): filtra ciudades por departamento,
limpia el valor al cambiar 2.1.
- **Instrumento v3**: 10 secciones, 40 preguntas, 3 encuestas (encuesta Demo
`30000000-…`, InQuality, Fundación).
- Tema claro marca Re — fondos, cards y paleta de texto revisada.
### Cambiado
- Esquema de DB: tabla `questions` con `instance_generator`, `instance_of`,
`conditional_rules` (jsonb), `code` único por encuesta.
- RLS submit: DROP de policies `*_insert` directas; único canal de escritura
es `rpc_submit_response`.
- Dashboard helper `getDashboardWidgetData` unificado con códigos v3 de pregunta.
### Seguridad
- RPC SECURITY DEFINER para submit: cierra el `WITH CHECK(true)` que dejaba
el store analítico abierto a escritura directa desde el cliente (BRIEF dec. 15).
- Validación de integridad del payload en el Server Action antes del RPC:
el servidor es la única fuente de verdad sobre lo que se persiste.
### Migraciones aplicadas (Sprint 4)
- `20260620000001``instance_id` en `answers` [4A]
- `20260620000002` — esquema declarativo v3 [4B]
- `20260624000001` — RPC submit transaccional [4G]
- `20260625000001` — RPCs dashboard Mirada Empresa [4H]
- `20260625000002` — RPCs prevalencia y segmentos [4H]
- `20260626000001` — fix `cnt` en RPCs `me_carrera` / `me_apoyos` [4H]
---
## [2.1.0] — 2026-06-16
Versión estable preservada en la rama `v2.1.0-stable`. Última versión del
formulario lineal antes del rediseño a wizard.
### Agregado
- Dashboard multitenante con selector de empresa (Todos / InQuality / Fundación / Demo)
- Flag `p_all` (UUID cero) para vista agregada de todos los surveys
- Vista de control `/admin/responses` con tabla plana, filtro por empresa,
colapsar/expandir por submission
- Dos encuestas piloto: InQuality y Fundación Solidaridad (organizaciones separadas)
- Método formalizado: `method/PATTERNS.md` y `method/ANTI_PATTERNS.md`
(patrones C.8, AP.10, AP.11)
### Cambiado
- Instrumento actualizado a v2.1 (feedback equipo médico): salto triple,
nueva pregunta 6.6, eliminación de "Prefiero no decir", 2.4 eliminada,
2.1 a multiple_choice
- Next.js actualizado a 15.3.8 (pin exacto)
### Seguridad
- Resuelto CVE-2025-66478 (+ advisory posterior CVE-2025-55183/55184/67779)
### Infraestructura
- Migración completa a Supabase self-hosted en Dokploy
- Backups GFS locales con retención de 3 días + domingos 14 días
---
## [2.0.0] — 2026-06 (anterior)
Primera versión en producción con Supabase self-hosted.
- Formulario lineal de 44 preguntas
- Dashboard con 11 widgets y k-anonimato (k≥5)
- Instrumento v2 (44 preguntas, 8 secciones)

148
COMPLIANCE.md Normal file
View File

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

View File

@@ -97,6 +97,11 @@ qi_zone text
``` ```
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar > Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`. > (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
> **Mapeo v3 (Etapa 4G, confirmado):** `qi_zone` ← `2.1` (departamento), `qi_age_bucket` ← `2.4`
> (rango de edad). `qi_sector` ← `2.6` (nivel organizacional) — v3 no tiene una pregunta
> equivalente a "área/gerencia" como v2 (`A4`); `2.6` es el cuasi-identificador organizacional
> más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2.
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para > Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
> minimizar re-identificación. > minimizar re-identificación.
@@ -106,9 +111,20 @@ id uuid pk
response_id uuid fk -> responses(id) response_id uuid fk -> responses(id)
question_id uuid fk -> questions(id) question_id uuid fk -> questions(id)
value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc. value jsonb not null -- texto: "x" | matriz: {"rows":{...}} | ranking: [3,1,2] | etc.
instance_id text not null default 'default'
-- 'default' → preguntas normales (la gran mayoría)
-- 'familiar_1', 'familiar_2', ... → instancias del mini-wizard de vínculos (4A, Sprint 4)
UNIQUE (response_id, question_id, instance_id) -- constraint: answers_response_question_instance_key
``` ```
> El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee > El texto libre (`text_long`) **no** se expone al store analítico crudo: la capa de reportes lee
> categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4). > categorías derivadas, no `value` crudo de preguntas abiertas (regla no negociable #4).
>
> **Patrón `instance_id` (v3.0.0):** la pregunta 4.1 ("¿Tenés familiar con discapacidad/adulto
> mayor a cargo?") dispara el mini-wizard. Por cada familiar declarado se genera un conjunto de
> respuestas para las preguntas 4.2, 4.3, etc., cada una con su propio `instance_id`. Las RPCs
> de dashboard que agregan datos de vínculo operan sobre filas donde `instance_id != 'default'`.
> Las RPCs existentes de v2 no se modifican: siguen usando `instance_id = 'default'` implícitamente
> (las preguntas de vínculo no estaban en el instrumento v2).
### `consent_versions` — textos de consentimiento versionados ### `consent_versions` — textos de consentimiento versionados
```sql ```sql

View File

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

View File

@@ -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 [
{ data: surveysData, error: surveysError },
{ data: questionsData, error: questionsError },
] = await Promise.all([
supabase.from('surveys').select('id, title').order('title'),
supabase
.from('questions') .from('questions')
.select('id, code, prompt, type, position') .select('id, code, prompt, type, position')
.eq('is_sensitive', false) .eq('is_sensitive', false)
.neq('type', 'text_long') .neq('type', 'text_long')
.order('position', { ascending: true }) .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 (
<>
<AdminNavBar active="responses" />
<div className="admin-page" role="main"> <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={
opt.id === (surveyId ?? null)
? 'admin-survey-link admin-survey-link-active'
: 'admin-survey-link'
}
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
</div> </div>
{surveysError && (
<p className="admin-error" role="alert">
No se pudo cargar la lista de surveys para el filtro se muestra "Todos".
</p>
)}
</div>
<div className="admin-header-actions">
<a href="/admin/responses" className="admin-refresh">Actualizar</a> <a href="/admin/responses" className="admin-refresh">Actualizar</a>
</div>
</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>
{(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> </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 ? (
<button
type="button"
className="admin-row-toggle-btn"
onClick={(e) => {
e.stopPropagation()
toggle(row.submissionNum)
}}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Colapsar respuesta' : 'Expandir respuesta'}
>
<span className="admin-toggle-icon" aria-hidden="true"> <span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'} {isExpanded ? '▼' : '▶'}
</span> </span>
)}
{row.submissionNum} {row.submissionNum}
</button>
) : (
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 { DonutCuidados } from '@/components/dashboard/donut-cuidados'
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional'
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget'
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica'
import { ApoyosBar } from '@/components/dashboard/apoyos-bar'
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad'
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar' import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
import type { DashboardFilters } from '@/lib/dashboard-types' import type { DashboardFilters } from '@/lib/dashboard-types'
// El survey_id se pasa como query param: /dashboard?survey_id=UUID // El survey_id se pasa como query param: /dashboard?survey_id=UUID
@@ -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 (
<>
<AdminNavBar active="dashboard" />
<div className="db-page"> <div className="db-page">
<FilterSidebar <FilterSidebar
filters={filters} filters={filters}
onChange={setFilters} onChange={setFilters}
collapsed={sidebarCollapsed} collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(c => !c)} onToggle={() => setSidebarCollapsed((c) => !c)}
/> />
<div className="db-main"> <div className="db-main">
@@ -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
key={l.id}
type="button"
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
onClick={() => setLens(l.id)}
aria-current={lens === l.id ? 'page' : undefined}
>
<span>{l.label}</span>
<span className="db-tab-lens">{l.sub}</span>
</button>
))}
</nav>
{lens === 'empresa' && (
<>
<div className="db-lens-intro">
<strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el
punto ciego y qué talento está en riesgo.
</div>
{/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
<section className="db-grid db-section" aria-label="Indicadores núcleo">
<TalentoRiesgoCard surveyId={surveyId} filters={filters} />
<PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
<PuntoCiegoCard surveyId={surveyId} filters={filters} />
</section> </section>
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */} {/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos"> <section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} /> <SenalesImpactoBar surveyId={surveyId} filters={filters} />
<DonutCuidados surveyId={surveyId} filters={filters} /> <ApoyosBarME surveyId={surveyId} filters={filters} />
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
</section> </section>
{/* Fila 3: Heatmap (6) + Pirámide (6) */} {/* Fila 3: segmentación (12, SEC) */}
<section className="db-grid db-section" aria-label="Intensidad y demografía"> <section className="db-grid db-section" aria-label="Segmentación de la carga">
<HeatmapWidget surveyId={surveyId} filters={filters} /> <SegmentacionBar surveyId={surveyId} filters={filters} />
<PiramideDemografica surveyId={surveyId} filters={filters} />
</section> </section>
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */} {/* Fila 4: disposición a red de apoyo (4, SEC) */}
<section className="db-grid db-section" aria-label="Apoyos e impacto"> <section className="db-grid db-section" aria-label="Disposición a participar">
<ApoyosBar surveyId={surveyId} filters={filters} /> <DisposicionRedCard surveyId={surveyId} filters={filters} />
<IntensidadProductividad surveyId={surveyId} filters={filters} />
</section> </section>
{/* Fila 5: Saturación del cuidador (12) */} <p className="db-table-note" style={{ textAlign: 'right' }}>
<section className="db-grid db-section" aria-label="Saturación del cuidador"> k5 aplicado · segmentos con n&lt;5 suprimidos
<TablaSaturacion surveyId={surveyId} filters={filters} /> </p>
</section> </>
)}
{lens === 'pais' && (
<PlaceholderLens
title="Mirada País"
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
/>
)}
{lens === 'humana' && (
<PlaceholderLens
title="Mirada Humana"
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
/>
)}
</div> </div>
</div> </div>
</>
) )
} }

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); }
@@ -482,6 +517,170 @@ body {
margin-top: 16px; margin-top: 16px;
} }
/* ══════════════════════════════════════════════════════════════
WIZARD (4D) — layout de altura estable (UX 1+2+3)
1) viewport estable: enunciado anclado en el tercio superior, no centrado
2) header (progreso) y footer (Anterior/Siguiente) fijos, fuera del área
que cambia de tamaño — nunca "saltan" entre pantallas
3) área de respuesta con altura máxima + scroll interno + fade inferior
══════════════════════════════════════════════════════════════ */
.wizard-shell {
max-width: 720px;
margin: 0 auto;
padding: 0 16px;
display: flex;
flex-direction: column;
min-height: calc(100dvh - 160px); /* deja lugar al header/footer de marca (layout.tsx) */
}
.wizard-topbar {
position: sticky;
top: 0;
z-index: 10;
background: var(--color-surface);
border-bottom: 1px solid var(--color-border);
padding: 12px 0;
}
.wizard-topbar-brand {
margin: 0 0 8px;
padding: 0;
background: transparent;
border-left: none;
}
/* Stage: altura fija, no auto-crece con el contenido de la pregunta actual. */
.wizard-stage {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
padding-top: 48px; /* ancla el enunciado en el tercio superior, no centrado vertical */
}
.wizard-section-screen {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
text-align: left;
}
.wizard-section-number {
font-size: 0.9375rem;
font-weight: 700;
color: #494F4F;
margin: 0 0 8px;
}
.wizard-section-title {
font-size: 1.75rem;
font-weight: 700;
margin: 0 0 12px;
color: var(--color-text);
}
.wizard-section-desc {
font-size: 1.0625rem;
color: var(--color-text-muted);
line-height: 1.6;
max-width: 480px;
margin: 0;
}
/* Enunciado: crece orgánicamente con el contenido, sin altura mínima fija. */
.wizard-prompt-block {
flex-shrink: 0;
margin-bottom: 20px;
}
.wizard-instance-badge {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
color: var(--color-primary-hover);
background: var(--color-primary-light);
padding: 3px 10px;
border-radius: 999px;
margin-bottom: 10px;
}
.wizard-prompt-code {
display: block;
font-size: 0.8125rem;
font-weight: 700;
color: #7A8FA0;
letter-spacing: 0.02em;
margin-bottom: 6px;
}
.wizard-prompt-text {
font-size: 1.375rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.35;
margin: 0 0 8px;
}
.wizard-prompt-hint {
font-size: 0.9375rem;
color: var(--color-text-muted);
margin: 0;
}
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo.
flex: 0 1 auto (NO flex:1) — mide según su contenido, nunca se estira a llenar el alto
disponible. Si creciera con flex:1, una pregunta corta (3 opciones) dejaría la caja con
espacio vacío abajo y el fade ::after (sticky bottom:0) quedaría flotando justo después
del contenido real, lejos del borde inferior visual de la caja — en vez de pegado a él. */
.wizard-answer-area {
flex: 0 1 auto;
min-height: 0;
max-height: 480px;
overflow-y: auto;
padding-bottom: 8px;
}
.wizard-answer-area.has-overflow { padding-bottom: 44px; }
/* El fade SOLO se pinta cuando .wizard-answer-area mide overflow real
(clase has-overflow agregada por JS — ver wizard-form.tsx). CSS no tiene
forma de detectar scrollHeight > clientHeight por sí solo: un ::after
incondicional (o un mask-image fijo) se pinta igual sobre el final de la
caja aunque el contenido entre completo, mostrando una franja/degradado
fantasma en preguntas cortas. */
.wizard-answer-area.has-overflow::after {
content: '';
position: sticky;
bottom: 0;
display: block;
height: 28px;
margin-top: -28px;
background: linear-gradient(to bottom, transparent, var(--color-surface) 75%);
pointer-events: none;
}
.wizard-bottombar {
position: sticky;
bottom: 0;
z-index: 10;
background: var(--color-surface);
border-top: 1px solid var(--color-border);
padding: 16px 0;
margin-top: 16px;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.wizard-bottombar-hint {
font-size: 0.8125rem;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
@media (max-width: 480px) {
.wizard-prompt-text { font-size: 1.1875rem; }
.wizard-section-title { font-size: 1.5rem; }
.wizard-answer-area { max-height: 400px; }
/* column-reverse: el botón primario (Siguiente/Enviar) queda arriba —
el pulgar llega más fácil al centro de la pantalla que a la base. */
.wizard-bottombar { flex-direction: column-reverse; }
.wizard-bottombar .btn { width: 100%; }
.wizard-bottombar-hint { order: 3; }
}
/* ── Loading skeleton ── */ /* ── Loading skeleton ── */
.loading-wrapper { .loading-wrapper {
max-width: 720px; max-width: 720px;
@@ -546,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;
@@ -576,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;
@@ -587,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 {
@@ -612,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;
} }
@@ -676,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; }
@@ -690,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;
@@ -735,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);
@@ -750,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 {
@@ -759,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);
@@ -792,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);
@@ -817,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

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

View File

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

View File

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

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

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

@@ -0,0 +1,338 @@
'use client'
import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'
import type { BrandConfig, Survey } from '@/lib/types'
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
import {
answerKey,
applyAnswer,
expandInstances,
initialState,
isRequiredAndUnanswered,
next,
back,
resolveScreens,
} from '@/lib/form-engine'
import { loadDraft, saveDraft, clearDraft } from '@/lib/storage'
import { submitSurvey } from '@/app/actions/submit-survey'
import { ConsentScreen } from './consent-screen'
import { ProgressBar } from './progress-bar'
import { QuestionRenderer } from './question-renderer'
type Stage = 'consent' | 'form' | 'success' | 'error'
interface WizardFormProps {
survey: Survey
schema: FormSchema
}
function isEmpty(v: AnswerValue): boolean {
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
}
function valueFor(screen: Screen, answers: FormAnswers): AnswerValue {
if (!screen.question) return null
const v = answers[answerKey(screen.question.code, screen.instanceId ?? undefined)]
return v === undefined ? null : v
}
/** Resume en la primera pantalla sin responder (o la sección que la precede). */
function resumeScreenId(schema: FormSchema, answers: FormAnswers): string {
const screens = resolveScreens(schema, answers)
const firstUnanswered = screens.findIndex(
(s) => s.kind === 'question' && isEmpty(valueFor(s, answers))
)
if (firstUnanswered <= 0) return screens[0]?.id ?? 'end'
const prev = screens[firstUnanswered - 1]
return prev.kind === 'section' ? prev.id : screens[firstUnanswered].id
}
export function WizardForm({ survey, schema }: WizardFormProps) {
const [stage, setStage] = useState<Stage>('consent')
const [consentOperativo, setConsentOperativo] = useState(false)
const [consentMacroN1, setConsentMacroN1] = useState(false)
const [state, setState] = useState(() => initialState(schema))
const [errors, setErrors] = useState<Record<string, string>>({})
const [hydrated, setHydrated] = useState(false)
const [a11yAnnounce, setA11yAnnounce] = useState('')
const answerAreaRef = useRef<HTMLDivElement>(null)
const [hasOverflow, setHasOverflow] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
useEffect(() => {
const draft = loadDraft(survey.id)
if (Object.keys(draft).length > 0) {
setState({ answers: draft, currentScreenId: resumeScreenId(schema, draft) })
}
setHydrated(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [survey.id])
useEffect(() => {
if (!hydrated) return
const timer = setTimeout(() => saveDraft(survey.id, state.answers), 500)
return () => clearTimeout(timer)
}, [state.answers, hydrated, survey.id])
const screens = resolveScreens(schema, state.answers)
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
const screen = screens[idx] ?? screens[0]
useEffect(() => {
if (!screen) return
if (screen.kind === 'section') setA11yAnnounce(`Sección: ${screen.question?.prompt}`)
else if (screen.kind === 'question') setA11yAnnounce(`Pregunta ${screen.question?.code}: ${screen.question?.prompt}`)
else setA11yAnnounce('Fin del formulario')
}, [screen?.id])
// El fade de wizard-answer-area solo debe aparecer si hay overflow real —
// CSS no puede expresar esa condición (no hay selector para "este elemento
// scrollea"), así que se mide el DOM. Se remide al cambiar de pantalla
// (cambia todo el contenido) y al resize (puede cambiar cuántas opciones entran).
useLayoutEffect(() => {
const el = answerAreaRef.current
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
}, [screen?.id])
useEffect(() => {
function recalc() {
const el = answerAreaRef.current
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
}
window.addEventListener('resize', recalc)
return () => window.removeEventListener('resize', recalc)
}, [])
if (stage === 'consent') {
return (
<ConsentScreen
brandConfig={survey.brand_config}
onAccept={(op, macro) => {
setConsentOperativo(op)
setConsentMacroN1(macro)
setStage('form')
}}
/>
)
}
if (stage === 'success') {
return (
<div className="survey-wrapper">
<div className="thank-you-container" role="main">
<h1 className="thank-you-title">¡Gracias por participar!</h1>
<p className="thank-you-body">
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
forma de vincular tus respuestas con tu identidad.
</p>
<p className="thank-you-body">
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
diseñar mejores apoyos para las personas cuidadoras.
</p>
</div>
</div>
)
}
if (stage === 'error') {
return (
<div className="survey-wrapper">
<div className="error-container" role="main">
<h1 className="error-title">No se pudo enviar la encuesta</h1>
<p>Hubo un error al guardar tus respuestas. Podés intentarlo de nuevo.</p>
{submitError && <pre className="no-survey-hint">{submitError}</pre>}
<button
type="button"
className="btn btn-primary"
onClick={() => { setSubmitError(null); setStage('form') }}
>
Volver e intentar de nuevo
</button>
</div>
</div>
)
}
if (!screen) return null
const currentValue = valueFor(screen, state.answers)
const questionScreens = screens.filter((s) => s.kind !== 'end')
const ordinal = Math.min(idx + 1, questionScreens.length)
const sectionScreens = screens.filter((s) => s.kind === 'section')
const sectionOrdinal = screen.kind === 'section' ? sectionScreens.findIndex((s) => s.id === screen.id) + 1 : null
// Contexto de instancia ("Familiar X de N") — derivado, no decide visibilidad ni navegación.
let instanceCtx: { label: string; ordinal: number; total: number } | null = null
if (screen.instanceId && screen.question?.instance_of) {
const generator = schema.questions.find((q) => q.code === screen.question!.instance_of)
if (generator) {
const instances = expandInstances(generator, state.answers)
const ord = instances.findIndex((i) => i.instanceId === screen.instanceId)
if (ord >= 0) {
instanceCtx = { label: instances[ord].optionValue, ordinal: ord + 1, total: instances.length }
}
}
}
function handleChange(value: AnswerValue) {
if (!screen.question) return
const code = screen.question.code
const instanceId = screen.instanceId ?? undefined
setState((prev) => applyAnswer(prev, schema, code, value, instanceId))
setErrors((prev) => {
if (!prev[code]) return prev
const { [code]: _, ...rest } = prev
return rest
})
}
function handleNext() {
if (isRequiredAndUnanswered(screen, state.answers)) {
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return
}
saveDraft(survey.id, state.answers)
setState((s) => next(s, schema))
}
function handleBack() {
saveDraft(survey.id, state.answers)
setState((s) => back(s, schema))
}
function handleSubmit() {
if (isPending) return
// state.answers usa claves "code:instanceId" — se descompone una sola vez
// acá, al borde cliente/servidor; el validador (lib/submit-validation.ts)
// recibe la forma explícita {code, instanceId, value}[].
const answers = Object.entries(state.answers).map(([key, value]) => {
const sep = key.indexOf(':')
return { code: key.slice(0, sep), instanceId: key.slice(sep + 1), value }
})
startTransition(async () => {
try {
const result = await submitSurvey({
surveyId: survey.id,
answers,
consentOperativo,
consentMacroN1,
})
if (result.success) {
clearDraft(survey.id)
setStage('success')
} else {
setSubmitError(result.error)
setStage('error')
}
} catch {
// Falla de red al invocar el Server Action (fetch failed) — no es un
// error que submitSurvey pueda devolver como result, hay que capturarlo acá.
setSubmitError('No pudimos enviar tu respuesta. Verificá tu conexión e intentá de nuevo.')
setStage('error')
}
})
}
const bc: BrandConfig | null = survey.brand_config
return (
<div className="wizard-shell">
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
{a11yAnnounce}
</div>
<div className="wizard-topbar">
{bc?.org_name && (
<p className="brand-text wizard-topbar-brand">
Encuesta de <strong>{bc.org_name}</strong>
</p>
)}
<ProgressBar answered={ordinal} total={questionScreens.length} />
</div>
<main className="wizard-stage" id="wizard-stage">
{screen.kind === 'section' && screen.question && (
<div className="wizard-section-screen" role="region" aria-labelledby="wizard-section-title">
<p className="wizard-section-number">
Sección {sectionOrdinal} de {sectionScreens.length}
</p>
<h2 id="wizard-section-title" className="wizard-section-title">
{screen.question.prompt}
</h2>
{screen.question.hint && <p className="wizard-section-desc">{screen.question.hint}</p>}
</div>
)}
{screen.kind === 'question' && screen.question && (
<>
<div className="wizard-prompt-block">
{instanceCtx && (
<span className="wizard-instance-badge">
{instanceCtx.label} · familiar {instanceCtx.ordinal} de {instanceCtx.total}
</span>
)}
<span className="wizard-prompt-code">{screen.question.code}</span>
<h2 className="wizard-prompt-text">{screen.question.prompt}</h2>
{screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>}
</div>
<div
ref={answerAreaRef}
className={`wizard-answer-area${hasOverflow ? ' has-overflow' : ''}`}
>
<QuestionRenderer
question={screen.question}
value={currentValue}
onChange={handleChange}
error={errors[screen.question.code]}
answers={state.answers}
/>
</div>
</>
)}
{screen.kind === 'end' && (
<div className="wizard-section-screen">
<h2 className="wizard-section-title">Llegaste al final</h2>
<p className="wizard-section-desc">
Revisá tus respuestas con &quot;Anterior&quot; si querés corregir algo, o enviá la
encuesta cuando estés listo/a.
</p>
</div>
)}
</main>
<div className="wizard-bottombar">
<button
type="button"
className="btn btn-secondary"
onClick={handleBack}
disabled={idx === 0 || isPending}
aria-disabled={idx === 0 || isPending}
>
Anterior
</button>
<span className="wizard-bottombar-hint" aria-hidden="true">
{ordinal}/{questionScreens.length}
</span>
{screen.kind !== 'end' ? (
<button type="button" className="btn btn-primary" onClick={handleNext}>
Siguiente
</button>
) : (
<button
type="button"
className="btn btn-primary"
onClick={handleSubmit}
disabled={isPending}
aria-disabled={isPending}
>
{isPending ? 'Enviando…' : 'Enviar encuesta'}
</button>
)}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,102 @@
# ADR-005 — Esquema declarativo de formularios y estrategia de dos fases
> Para agregar a `ARCHITECTURE.md` (o `docs/adr/`). Estado: **Aceptada**.
> Fecha: 2026-06-20 · Sprint 4 · Versión objetivo 3.0.0
---
## Contexto
El producto es un **motor de formularios/flujos de encuesta**. La aplicación
actual (encuesta "Mapeo de Cuidadores") es su primer caso de uso real, pero la
arquitectura debe servir al producto, no solo a este formulario.
El rediseño a wizard v3 introduce lógica de flujo significativamente más compleja
que v2: recorridos ramificados (cuidador completo / solo niño / sin vínculo),
mini-wizard de instancias de vínculo familiar (preguntas que se repiten N veces,
una por familiar), y saltos condicionales de sección.
Existe una restricción de negocio: dos clientes inminentes en 10-20 días requieren
la encuesta funcionando para validación.
El esquema actual de `questions` ya posee una base declarativa:
- `conditional_rules` JSONB con operadores `eq` / `eq_any` y acción `show`
- Auto-target implícito (la regla afecta a la pregunta que la porta)
- Tipos de pregunta extensibles vía CHECK constraint
## Problema
¿Dónde reside la lógica del flujo del formulario? Tres anti-patrones a evitar:
1. **Lógica en el presentador** (frontend con reglas hardcodeadas) — impide
reutilización, genera bugs de sincronización de estado, no escala al producto.
2. **Lógica dispersa en columnas sueltas** de la base — deuda técnica; no soporta
evolución a flujos más complejos sin migrar el esquema entero.
3. **Sobre-ingeniería prematura** — construir un motor genérico universal para
casos hipotéticos antes de conocerlos, retrasando el deadline real.
## Decisión
**La lógica del flujo vive en una capa de definición declarativa (datos),
interpretada por un motor en el backend, y renderizada por un frontend sin reglas
de negocio.**
Se adopta una **estrategia de dos fases**:
### Fase 1 (Sprint 4 — para el deadline)
- El formulario de cuidadores se define como **esquema declarativo persistido**,
extendiendo el `conditional_rules` JSONB existente y agregando campos
declarativos de instanciación a `questions`.
- Se construye un motor de resolución de flujo **para este formulario** (interpreta
el esquema; calcula qué pantalla sigue desde esquema + estado).
- El frontend wizard es un **renderer** del esquema, sin reglas de negocio.
- NO se construye el intérprete genérico universal todavía.
### Fase 2 (sprints posteriores — post-deadline, registrada en BACKLOG)
- Se extrae el intérprete a un **motor genérico de formularios**.
- El formulario de cuidadores, ya definido como esquema, se vuelve el primer caso
de prueba del motor — sin reescribir el contrato.
### Diseño técnico del esquema (Fase 1)
Se extiende `questions` en lugar de crear una tabla de definición separada:
- `conditional_rules` extendido con nuevos operadores (`not_in`, acción `hide`)
para expresar saltos como "Ninguno → salta a sección final".
- Nuevos campos declarativos para instanciación:
- `instance_generator` — marca la pregunta que genera instancias (ej. 4.1)
- `instance_of` — código del generador del que depende una pregunta (ej. 4.2 → "4.1")
- Representación de secciones con nombre y descripción (el "porqué").
## Alternativas consideradas
1. **Lógica en el frontend (rechazada):** rápida para este formulario, pero viola
el principio de producto, no escala, y reproduce los bugs de sincronización ya
vistos en el mockup.
2. **Columnas sueltas tipo `is_instanced` (rechazada):** resuelve hoy, genera deuda;
no soporta flujos más complejos sin migración de esquema.
3. **Motor genérico universal ahora (rechazada):** arquitectónicamente deseable como
fin, pero sobre-ingeniería antes del deadline y con un solo caso de uso conocido
(diseñar en el vacío). Se difiere a Fase 2 cuando haya más casos que informen el
diseño abstracto.
4. **Tabla de definición de flujo separada (diferida):** posible mejor forma para el
motor genérico, pero para un solo formulario es sobre-ingeniería. Se reevalúa en
Fase 2.
## Consecuencias
**Positivas:**
- Entrega la encuesta en plazo sin generar retrabajo estructural.
- La lógica en datos habilita guardado parcial, reanudación, validación consistente
y progreso preciso — mejor UX, fuente única de verdad del estado.
- La Fase 2 es generalización del intérprete, no reescritura del contrato.
- El esquema declarativo es versionable y auditable.
**Negativas / deuda intencional:**
- Fase 1 es más trabajo que parchear el formulario actual.
- El motor de Fase 1 es específico a este formulario (no genérico) — deuda
registrada en BACKLOG, asumida conscientemente, no oculta.
- Extender `conditional_rules` puede requerir reevaluación cuando lleguen flujos
con lógica que el vocabulario actual de operadores no exprese.
## Cumplimiento (cómo se verifica)
- Revisión de código: el frontend wizard NO contiene reglas de flujo del negocio.
- El flujo es reproducible desde el esquema persistido.
- La deuda de Fase 2 figura en `BACKLOG.md`.

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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,155 +1,147 @@
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado # ESTADO DEL PROYECTO — Datos País sobre el Cuidado
## Documento de continuidad — 11 junio 2026 ## Documento de continuidad — 27 junio 2026
> Director: Marcos · Metodología: Maria Mersan · Contacto Fundación: Valeria Arce > Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
--- ---
## 1. URLs operativas ## 1. Versionado y ramas
- **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
en `live`. Release v3 completado (etapa 4I).
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
| Recurso | URL | ---
## 2. Sprint 4 — Wizard v3 — COMPLETO, release hecho
| Etapa | Estado |
|---|---| |---|---|
| Formulario Demo | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001 | | 4.0 Preservación v2 | ✅ |
| Formulario InQuality | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000002 | | 4A instance_id | ✅ prod |
| Formulario Fundación Solidaridad | https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000003 | | 4B esquema declarativo + seed v3 | ✅ prod |
| Dashboard — Todos | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=00000000-0000-0000-0000-000000000000 | | 4C motor de flujo (lib/form-engine.ts, puro) | ✅ |
| Dashboard — InQuality | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000002 | | 4D wizard renderer (layout estable) | ✅ |
| Dashboard — Fundación Solidaridad | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000003 | | 4E instancias (emergente del motor) | ✅ |
| Dashboard — Demo | https://economia-cuidado.inqualityhq.com/dashboard?survey_id=30000000-0000-0000-0000-000000000001 | | 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
| Control de respuestas | https://economia-cuidado.inqualityhq.com/admin/responses | | 4F autocomplete barrios (UX) | ✅ |
| Widgets Mirada Empresa (frontend) | ✅ |
| Tema claro dashboard (marca Re) | ✅ |
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ✅ |
| ETAPA_AUTH — login/middleware/sign-out para `/admin` y `/dashboard` | ✅ (27 jun) |
El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
server-side (RPC `rpc_submit_response`, único camino de escritura, verificado). Dashboard
Mirada Empresa con sus 13 widgets, consumiendo las RPCs `rpc_me_*` reales. `/admin/responses`
y `/dashboard` ahora requieren sesión autenticada (antes sin auth — cierra TECH-DEBT-008).
--- ---
## 2. Infraestructura — OPERATIVA ✅ ## 2.1 Incidente VPS (27 jun) — recuperado
- **App:** Next.js 15.3.8 (CVE-2025-66478 resuelto ✅) Una segunda instancia de Dokploy desplegada desde el mismo repo (con `container_name` fijos en
- **DB:** Supabase self-hosted en Dokploy v0.29.4 docker-compose.yml) colisionó con la producción y vació el esquema `public` de `supabase-db`.
- **VPS:** inq-mart-2, IP VPN: 172.20.5.11 **Sin pérdida de datos reales** (solo seed demo). Se restauró: migraciones completas + seed +
- **Repos:** surveys reactivados a `live` + usuario `platform_admin` creado. Verificado end-to-end: login,
- App: `git.inquality.com.py/marcos/motor-de-encuestas` (rama main) sign-out, redirect sin sesión, formulario público (3 links `?s=<survey_id>`) — todo OK.
- Supabase stack: `git.inquality.com.py/marcos/supabase-motor-fomularios` (rama master) Detalle completo en memoria `project_vps_container_name_incident`. El director ya separó la
- **API Supabase:** https://db.inqualityhq.com (Kong + SSL) segunda instancia a un repo Gitea propio sin `container_name` fijos, para que no se repita.
- **Backups:** `/var/backups/supabase-prod/`, cron 3AM, GFS (3 días diarios + domingos 14 días)
- **Studio:** solo vía SSH tunnel → `ssh -L 3001:localhost:3001 administrator@172.20.5.11`
--- ---
## 3. Instrumento — v2.1 ACTIVO ## 3. Dashboard v3 — "tres miradas"
- **44 preguntas**, 8 secciones **Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar,
- **Salto triple:** 1.1=No AND 1.6=No AND 1.13=No → salta a 6.4 (validado con datos reales ✅) dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
- **Nueva pregunta 6.6:** voluntariado en redes de apoyo (Sí/No/Tal vez)
- **2.4 eliminada**, **2.1 → multiple_choice** ### Estado
- "Prefiero no decir" eliminado de todas las preguntas - **Mirada Empresa:** ✅ RPCs + widgets de frontend completos y en producción. E1-E10 + estrella
- Hint "Podés marcar más de una opción" en todas las multiple_choice "Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
- "Tecnología asistiva" en 1.3 y 1.10 - **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. DB — estado actual ## 4. Deuda técnica y seguridad
| Tabla | Filas | ~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~**resuelto** por ETAPA_AUTH (27 jun).
|---|---|
| organizations | 3 (Empresa Demo, InQuality, Fundación Solidaridad) |
| surveys | 3 (una por organización) |
| questions | 132 (44 × 3 surveys) |
| responses | 15 (8 InQuality + 7 Demo — todas de prueba) |
| answers | ~500 (estimado) |
--- | ID | Descripción | Prioridad |
## 5. Dashboard — multitenante operativo ✅
- Selector de empresa en el header: Todos / InQuality / Fundación Solidaridad / Demo
- "Todos" usa UUID especial `00000000-0000-0000-0000-000000000000` → pool agregado
- Implementado via flag en `_dash_responses` (migración `20260611000001`)
- Validado visualmente con n=15: KPIs, heatmap departamental, perfil población ✅
---
## 6. Vista de control /admin/responses
- Tabla plana: una fila por answer, agrupada por submission
- Colapsar/expandir por submission (Client Component island)
- Excluye: `text_long`, `is_sensitive=true` (TECH-DEBT-008 — sin auth)
- Sin columna "Tipo"
---
## 7. Método — Sprint 2 cerrado ✅
- `method/PATTERNS.md` y `method/ANTI_PATTERNS.md` creados en el repo
- C.8 promovido a estable con casos reales del proyecto
- AP.10 y AP.11 documentados con casos reales
---
## 8. Deuda técnica abierta
| ID | Descripción | Bloqueante |
|---|---|---| |---|---|---|
| TECH-DEBT-006 | Backup remoto S3 | SÍ — antes del primer dato real | | TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
| TECH-DEBT-007 | auth.jwt() deprecado en GoTrue v2.189+ | No (piloto) | | TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
| TECH-DEBT-008 | /admin/responses sin auth | No (URL no publicada) | | TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
| TECH-DEBT-009 | Rotar secrets Supabase post-CVE-2025-66478 | SÍ — antes del primer dato real | | SEC-001 | Next.js — vuln "Cache Key Confusion" vigente en 15.3.8; última mayor es 16.2.9 (breaking) | Media |
| TECH-001 | `npm audit`: 4 vulns (1 moderate, 3 high) — `form-data`, `next`, `undici` | Pendiente revisión |
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
| SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales |
| SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
| BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
*(SEC-001/TECH-001 verificados 27 jun vía `npm view next version` / `npm audit --json`
los números en BACKLOG.md/TECH_DEBT.md estaban desactualizados, ej. citaban Next 15.3.3
y "2 vulnerabilidades" cuando ya corre 15.3.8 con 4.)*
--- ---
## 9. Pendientes próxima sesión ## 5. Infraestructura
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
1. TECH-DEBT-006 — backup remoto S3 - App: economia-cuidado.inqualityhq.com · API: db.inqualityhq.com
2. TECH-DEBT-009 — rotar secrets Supabase - Repos Gitea: git.inquality.com.py/marcos/motor-de-encuestas
3. RE Index — confirmar mapeo G1/G2/G4/G5 → códigos seed con Marcos - Para sembrar datos de prueba: `seed_prueba_50.sql` (en docs/dashboard/), 50 respuestas
4. Informe descargable — prometido en deck comercial deterministas vía RPC. Usa question_id (UUIDs), NO question_code. Incluye su limpieza.
5. Onboarding empresas reales — cuando validación esté lista
--- ---
## 10. Decisiones tomadas — no reabrir ## 6. Próximos pasos
1. Supabase en Dokploy siempre via modo Git **Go-live (widgets → 4F → 4I) está cerrado.** v3.0.0 en producción, surveys `live`, auth para
2. POSTGRES_PASSWORD siempre con `openssl rand -hex` staff funcionando. No queda ninguna etapa de Sprint 4 pendiente — el roadmap pre-escrito se
3. kong.yml: una sola entrada por bloque keyauth_credentials agotó. El próximo paso requiere decisión del director entre:
4. SMTP_PORT=587 siempre explícito aunque SMTP esté desactivado
5. GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=false siempre explícito 1. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
6. LUA_AUTH_EXPR con prefijo "Bearer " en kong-entrypoint.sh (bienestar, horas no remuneradas, escolarización), decisión qi_modalidad (TECH-DEBT-010).
7. NEXT_PUBLIC_SUPABASE_URL via Build Argument en Dokploy 2. **Sprint de compliance/seguridad (SEC-002):** audit log append-only, hashing de integridad,
8. App conecta a https://db.inqualityhq.com cifrado en reposo — bloquea escalar a más empresas/datos reales.
9. /admin/responses no muestra is_sensitive=true sin auth (TECH-DEBT-008) 3. **Mantenimiento de dependencias:** SEC-001 (Next.js) + TECH-001 (`npm audit`, 4 vulns).
10. brand_config.org_name alimenta el nombre de empresa en el formulario 4. Otro ítem de BACKLOG.md/TECH_DEBT.md no listado arriba.
11. Organizaciones separadas para cada encuesta
12. conditional_rules referencian códigos de pregunta, no UUIDs Ninguno de estos se decide unilateralmente (Nivel 3 — tocan planificación/seguridad).
13. UUID cero `00000000-...` es el flag p_all del dashboard
14. Next.js pinneado a versión exacta `15.3.8` (sin caret)
--- ---
## 11. Contexto comercial ## 7. Contexto comercial
- 2 clientes inminentes en ~10-20 días (deadline v3). Mirada Empresa es la que le habla al cliente que paga.
- 2 empresas comprometidas para piloto (InQuality y Fundación Solidaridad en espacio de pruebas) - InQuality y Fundación Solidaridad en joint venture.
- Cronograma: JUN inicio · JUL-AGO relevamiento · SEP análisis · OCT informe + evento
- Meta: 20 empresas pioneras
- Precios: Gs. 4.5M (≤50) · 7.5M (≤200) · 12M (≤400)
- GAPs deck vs producto: RE Index no implementado, informe descargable no existe
- Contacto Fundación: Valeria Arce — ddff@fundacionsolidaridad.org.py · +595 991 854333
--- ---
## 12. 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 formularios
soberano (Paraguay). Yo soy el product owner. Sprint 4: wizard v3 (ADR-005, esquema declarativo).
Pegá al inicio de la nueva conversación: Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md.
Formulario v3: COMPLETO y verificado (motor, wizard, submit con validación server-side).
Dashboard: rediseño de "tres miradas". Mirada Empresa = RPCs implementadas y verificadas
contra datos reales; faltan sus widgets. País/Humana diferidas a sprint posterior.
--- Pendiente go-live: widgets Mirada Empresa → 4F (barrios) → 4I (release).
Los 3 surveys en draft (v3 no desplegado aún).
Sos el copiloto técnico del proyecto "Datos País sobre el Cuidado" — plataforma soberana Próximo paso: [COMPLETAR]
de encuestas sobre economía del cuidado en Paraguay. Tenemos múltiples sesiones de trabajo. ```
Stack: Next.js 15.3.8 + Supabase self-hosted + Dokploy v0.29.4.
URL: https://economia-cuidado.inqualityhq.com
Repos en Gitea: git.inquality.com.py/marcos/
El estado completo está en `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` en el repo.
Los prompts de etapas pasadas están en `sprints/`.
Las reglas no negociables están en `CLAUDE.md`.
Próximos pasos: [COMPLETAR CON LO QUE VAS A HACER]

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

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

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

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

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

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

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

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;

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More