79 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
markosbenitez
5c17a9846a feat(admin): filtro por empresa + contadores filtrados 2026-06-17 10:01:59 -03:00
markosbenitez
b9a3118c84 Filtro en listado de respuestas admin 2026-06-17 09:25:18 -03:00
markosbenitez
cfd2eb0624 docs: estado del proyecto 11 junio 2026 2026-06-11 16:56:17 -03:00
markosbenitez
edf82bf7fd feat(dashboard): selector de empresa + flag p_all en _dash_responses 2026-06-11 16:35:03 -03:00
markosbenitez
60c9c81224 estado del proyecto 2026-06-10 21:42:14 -03:00
markosbenitez
1bab6874a9 feat(admin): colapsar/expandir submissions + eliminar columna Tipo 2026-06-10 21:41:47 -03:00
markosbenitez
933d00ad58 Etapa 2G: C.8 promovido a estable + AP.10/AP.11 documentados con casos reales 2026-06-10 21:36:33 -03:00
markosbenitez
059dfa071e Etapa 3C parte 2: orgs y surveys piloto InQuality y Fundación Solidaridad
Agrega organizations + surveys para los dos pilotos en espacio de pruebas
real, con su propio set de 44 preguntas (mismo instrumento que el survey
demo, IDs namespaced 02xx/03xx). Survey demo (...0001) intacto.
2026-06-10 21:05:06 -03:00
markosbenitez
2d13982d92 Pin next a 15.3.8 sin caret
Mantiene el estilo de pin exacto que tenía next antes de CVE-2025-66478,
evitando que un futuro npm install resuelva versiones 15.x posteriores
sin pasar por revisión.
2026-06-10 20:49:43 -03:00
markosbenitez
92a7923a26 Con las encuestas para 3 empresas 2026-06-10 16:55:41 -03:00
markosbenitez
af0ec65be5 Documentar deuda técnica: /admin/responses sin autenticación
Registra TECH-DEBT-008 como bloqueante para ampliar la vista de
control a preguntas is_sensitive=true (salud/discapacidad).
2026-06-10 16:30:42 -03:00
markosbenitez
157e108322 Los triggers 1.1, 1.6, 1.13 muestran solo Sí · No (sin "Prefiero no decir")
Al responder "No" en las tres, el formulario salta a 6.4 (no muestra secciones 2-5 ni 6.1-6.3)
La pregunta 6.6 aparece antes del cierre
Las preguntas multiple_choice muestran el hint "Podés marcar más de una opción"
2026-06-10 15:49:05 -03:00
markosbenitez
bf963e8894 Con el listado de encuestas cargadas/respondidas 2026-06-10 08:59:50 -03:00
markosbenitez
2f39ce555c solucionado los build args 2026-06-08 20:44:46 -03:00
markosbenitez
52295a78d0 Con los números de preguntas y secciones 2026-06-08 20:25:23 -03:00
markosbenitez
307a54dabc refactor completo + truncate de base de datos + reingenieria dashboard 2026-06-05 17:13:45 -03:00
markosbenitez
a17e810ea8 Con marca y dashboards 2026-06-05 07:18:10 -03:00
markosbenitez
b813622e30 Con la marca incorporada primera versión 2026-06-04 22:49:25 -03:00
markosbenitez
b1ca0e0795 Validaciones mandatorias — Sección 8 — IDs concretos 2026-06-04 21:52:39 -03:00
markosbenitez
d91ea6b2ff Instrumento v2: 44 preguntas + widget text_short 2026-06-04 14:30:28 -03:00
markosbenitez
10c665d25c Fix build time: env var placeholders + sacar .next del repo
- lib/supabase.ts: ?? placeholders para que el módulo cargue en build
  time sin las env vars; force-dynamic garantiza que nunca se llame
  en build time.
- next.config.ts: env fallbacks vacíos para que Next.js no aborte
  al no encontrar NEXT_PUBLIC_SUPABASE_* en build time.
- .gitignore: agrega .next/ (artefactos de build no van a git).
- git rm --cached .next/**: saca los artefactos ya trackeados.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 21:22:55 -03:00
markosbenitez
df2c33f2d9 Fix para deploy en dokploy 2026-06-02 20:10:02 -03:00
markosbenitez
ad35c46a7a Terminado sprint 2A 2026-06-02 15:50:59 -03:00
249 changed files with 30345 additions and 1391 deletions

BIN
.DS_Store vendored

Binary file not shown.

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.next
node_modules
.env
.env.*
!.env.example
*.md
sprints/
supabase/
.claude/

View File

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

8
.gitignore vendored
View File

@@ -14,6 +14,14 @@ 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/
# Supabase — claves y credenciales # Supabase — claves y credenciales
*.key *.key
service_role* service_role*

View File

@@ -1,3 +0,0 @@
{
"pages": {}
}

View File

@@ -1,17 +0,0 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pages": {
"/_app": []
},
"ampFirstPages": []
}

View File

@@ -1 +0,0 @@
{"encryption.key":"rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc=","encryption.expire_at":1781610552095}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
{"type": "commonjs"}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"

View File

@@ -1,19 +0,0 @@
globalThis.__BUILD_MANIFEST = {
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [],
"ampDevFiles": [],
"lowPriorityFiles": [],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pages": {
"/_app": []
},
"ampFirstPages": []
};
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
];

View File

@@ -1,6 +0,0 @@
{
"version": 3,
"middleware": {},
"functions": {},
"sortedMiddleware": []
}

View File

@@ -1 +0,0 @@
self.__REACT_LOADABLE_MANIFEST="{}"

View File

@@ -1 +0,0 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"

View File

@@ -1 +0,0 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}

View File

@@ -1 +0,0 @@
{}

View File

@@ -1 +0,0 @@
self.__RSC_SERVER_MANIFEST="{\n \"node\": {},\n \"edge\": {},\n \"encryptionKey\": \"process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY\"\n}"

View File

@@ -1,5 +0,0 @@
{
"node": {},
"edge": {},
"encryptionKey": "rCrwV8YThn7yWBHzp4+yH4ABfW8goSONtUiZDNhogWc="
}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
self.__BUILD_MANIFEST = (function(a){return {__rewrites:{afterFiles:[],beforeFiles:[],fallback:[]},__routerFilterStatic:a,__routerFilterDynamic:a,sortedPages:["\u002F_app"]}}(void 0));self.__BUILD_MANIFEST_CB && self.__BUILD_MANIFEST_CB()

View File

@@ -1 +0,0 @@
self.__SSG_MANIFEST=new Set;self.__SSG_MANIFEST_CB&&self.__SSG_MANIFEST_CB()

File diff suppressed because one or more lines are too long

View File

@@ -1,141 +0,0 @@
// Type definitions for Next.js cacheLife configs
declare module 'next/cache' {
export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache'
export {
revalidateTag,
revalidatePath,
unstable_expireTag,
unstable_expirePath,
} from 'next/dist/server/web/spec-extension/revalidate'
export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store'
/**
* Cache this `"use cache"` for a timespan defined by the `"default"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 900 seconds (15 minutes)
* expire: never
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 15 minutes, start revalidating new values in the background.
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
*/
export function unstable_cacheLife(profile: "default"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"seconds"` profile.
* ```
* stale: 0 seconds
* revalidate: 1 seconds
* expire: 60 seconds (1 minute)
* ```
*
* This cache may be stale on clients for 0 seconds before checking with the server.
* If the server receives a new request after 1 seconds, start revalidating new values in the background.
* If this entry has no traffic for 1 minute it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "seconds"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"minutes"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 60 seconds (1 minute)
* expire: 3600 seconds (1 hour)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 minute, start revalidating new values in the background.
* If this entry has no traffic for 1 hour it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "minutes"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"hours"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 3600 seconds (1 hour)
* expire: 86400 seconds (1 day)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 hour, start revalidating new values in the background.
* If this entry has no traffic for 1 day it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "hours"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"days"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 86400 seconds (1 day)
* expire: 604800 seconds (1 week)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 day, start revalidating new values in the background.
* If this entry has no traffic for 1 week it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "days"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"weeks"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 604800 seconds (1 week)
* expire: 2592000 seconds (30 days)
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 1 week, start revalidating new values in the background.
* If this entry has no traffic for 30 days it will expire. The next request will recompute it.
*/
export function unstable_cacheLife(profile: "weeks"): void
/**
* Cache this `"use cache"` for a timespan defined by the `"max"` profile.
* ```
* stale: 300 seconds (5 minutes)
* revalidate: 2592000 seconds (30 days)
* expire: never
* ```
*
* This cache may be stale on clients for 5 minutes before checking with the server.
* If the server receives a new request after 30 days, start revalidating new values in the background.
* It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request.
*/
export function unstable_cacheLife(profile: "max"): void
/**
* Cache this `"use cache"` using a custom timespan.
* ```
* stale: ... // seconds
* revalidate: ... // seconds
* expire: ... // seconds
* ```
*
* This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate`
*
* If a value is left out, the lowest of other cacheLife() calls or the default, is used instead.
*/
export function unstable_cacheLife(profile: {
/**
* This cache may be stale on clients for ... seconds before checking with the server.
*/
stale?: number,
/**
* If the server receives a new request after ... seconds, start revalidating new values in the background.
*/
revalidate?: number,
/**
* If this entry has no traffic for ... seconds it will expire. The next request will recompute it.
*/
expire?: number
}): void
export { cacheTag as unstable_cacheTag } from 'next/dist/server/use-cache/cache-tag'
}

View File

@@ -1 +0,0 @@
{"type": "module"}

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

113
BACKLOG.md Normal file
View File

@@ -0,0 +1,113 @@
# BACKLOG.md
> Ítems pendientes que no entran en el sprint actual. El agente NO los ejecuta
> sin aprobación del director (Nivel 3 salvo indicación contraria).
---
## Seguridad
### [SEC-004] RPC de submit es bypasseable vía REST directo (coherencia de flujo)
- **Estado:** Riesgo residual documentado — no resuelto en Etapa 4G
- **Contexto:** `rpc_submit_response` (migración `20260624000001_submit_rpc.sql`) es el único
camino de escritura para `consent_records`/`responses`/`answers` (las policies `WITH CHECK(true)`
que permitían INSERT directo a `anon` se eliminaron). La RPC valida lo ESTRUCTURAL en SQL
(el `question_id` pertenece a la encuesta, la encuesta está viva, el consentimiento llegó).
La validación de COHERENCIA DE FLUJO (visibilidad según `conditional_rules`, instancias
legítimas de `4.1`) vive en TypeScript (`lib/submit-validation.ts`, reusa `lib/form-engine.ts`)
porque reimplementarla en SQL violaría la regla de "no duplicar la lógica de flujo" (BRIEF 4G).
- **Riesgo:** quien llame a `rpc_submit_response` directo por la REST API de Supabase (sin pasar
por el Server Action de Next.js) puede mandar un payload con instancias/visibilidad incoherentes
que igual pase las validaciones estructurales de la RPC.
- **Por qué no es un incidente de privacidad:** el dato resultante sigue siendo anónimo
(`respondent_id=NULL`), `is_sensitive` se sigue respetando, y queda sujeto al umbral k-anonimato
en `agg_segment`/RPCs de dashboard igual que cualquier otra respuesta. Es un problema de calidad
de datos de investigación (una respuesta lógicamente imposible podría colarse), no de exposición
de datos.
- **Cierre posible (no implementado):** firmar el payload en el Server Action (HMAC con un secreto
server-only) y verificar la firma dentro de la RPC antes de insertar — así un caller directo sin
el secreto no puede producir un payload válido. Evaluar costo/beneficio antes de priorizar.
- **Dependencia:** no bloquea Sprint 4; evaluar junto con SEC-002 (sprint de compliance).
### [SEC-002] Sprint de compliance & seguridad
- **Estado:** Diferido — pendiente de priorizar tras Sprint 4
- **Contexto:** COMPLIANCE.md documenta el estado actual de seguridad (v2.1.0) y el roadmap
de hardening. Antes de escalar a más empresas o datos reales, se requiere un sprint dedicado.
- **Ítems principales (ver COMPLIANCE.md secciones 5 y 6):**
- Registro de auditoría append-only (log inmutable de operaciones sensibles)
- Hashing SHA-256 de integridad de respuestas (detección de tampering sin blockchain)
- Cifrado en reposo a nivel de volumen/disco de la base de datos
- Roadmap de hardening por capas: aplicación, autenticación, datos, infraestructura,
observabilidad y proceso (revisión periódica de CVEs, pen testing)
- **Dependencia:** no bloquea Sprint 4; sí bloquea escalar a producción masiva.
- **Ver:** `COMPLIANCE.md` secciones 5 y 6.
### [SEC-001] Actualizar Next.js — CVE pendiente en 15.3.3
- **Estado:** Pendiente
- **Prioridad:** Alta
- **Contexto:** Next.js 15.3.3 tiene un CVE conocido. La versión no se actualizó
en la tarea del 2026-06-02 para no mezclar cambios con el fix de producción de
chromedriver. Actualizar en el próximo ciclo de mantenimiento.
- **Acción:** `npm install next@latest` + verificar que el build y la app sigan
funcionando antes de deployar.
- **Nota:** revisar changelog de Next.js por breaking changes antes de actualizar.
---
## Técnico / Deuda
### [TECH-001] `npm audit` reporta 2 vulnerabilidades en deps de producción
- **Estado:** Pendiente de revisión
- **Contexto:** `npm audit` señala 1 moderate + 1 critical. Evaluar con
`npm audit --json` si afectan el runtime de la app o solo herramientas de build.
- **Acción:** `npm audit fix` (o fix manual) y re-verificar build.
### [TECH-002] Mockup `formulario_mapeo.html` no encontrado en el árbol de trabajo
- **Estado:** Pendiente
- **Contexto:** El BRIEF Sprint 2 lo cita como "contrato visual". El agente diseñó
la UI basándose en SEED_SPEC.md. Requiere validación visual del director contra
el mockup original.
### [TECH-003] Auditoría a11y interactiva pendiente (foco, aria-expanded, widgets)
- **Estado:** Pendiente
- **Contexto:** La auditoría de Etapa 2A se corrió sobre el HTML SSR estático
(jsdom). La verificación de componentes interactivos (foco al abrir bloque
condicional, aria-expanded en triggers, operación solo con teclado) requiere
auditoría en browser real.
- **Acción:** Correr axe-cli con Chrome 149+ (o actualizar Chrome a 149) y
hacer test manual de navegación por teclado.
---
## Features
*(Sprint 3 y posteriores — ver ARCHITECTURE.md y BRIEF por sprint)*
### [BACKLOG-001] Consentimiento inicial editable desde admin
- **Estado:** hoy el texto del consentimiento está hardcoded en `consent-screen.tsx`.
- **Necesidad:** cuando se construya el motor de formularios (builder), el texto del
consentimiento debe ser editable por encuesta desde la administración, para reflejar
cambios en política de privacidad o ajustes específicos por tipo de relevamiento.
- **Bloquea:** implementación del builder visual.
- **Decisión cerrada:** la edición es desde admin, no desde el código.
---
### [BACKLOG-002] Fase 2 — Motor genérico de formularios
- **Estado:** Diferido post-deadline
- **Contexto:** En Sprint 4 se construye un intérprete del esquema declarativo específico
para el formulario de cuidadores (ADR-005, Fase 1). Una vez entregado ese caso, el
intérprete se extrae a un motor universal que interprete cualquier esquema definido en
`questions.conditional_rules` + campos de instanciación, sin hardcodear ningún formulario.
El formulario de cuidadores se vuelve el primer caso de prueba del motor sin reescribir
el contrato de datos.
- **Dependencia:** Fase 1 completada (Sprint 4 — wizard funcional).
- **Ver:** ADR-005 (`docs/adr/ADR-005-esquema-declarativo-formularios.md`).
- Dashboard / reportes (Sprint 3)
- Formulario de consentimiento + persistencia de respuestas (Etapa 2B)
- Builder visual de encuestas (post-validación del caso fijo)
- Integración LLM para análisis de texto libre (diferenciador)
- Modo B / controller_role dual (reservado en esquema)
- Columnas `qi_gender` / `qi_modality` en `responses` — decisión Nivel 3 abierta
(ver hallazgo en SEED_SPEC.md)

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

43
Dockerfile Normal file
View File

@@ -0,0 +1,43 @@
FROM node:20-alpine AS base
# ── Dependencias ──────────────────────────────────────────────────────────────
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ── Build ─────────────────────────────────────────────────────────────────────
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Las variables NEXT_PUBLIC_* se hornean en el bundle en build time.
# Pasarlas como build args desde Dokploy (Environment → Build Args).
ARG NEXT_PUBLIC_SUPABASE_URL
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
RUN npm run build
# ── Runner ────────────────────────────────────────────────────────────────────
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

384
INSTALL_SUPABASE.md Normal file
View File

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

89
OBSERVATIONS.md Normal file
View File

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

View File

@@ -1,105 +1,209 @@
# SEED_SPEC.md — Contrato del instrumento "Mapeo de cuidadores" # SEED_SPEC.md — Contrato del instrumento "Mapeo de Cuidadores" v3.0.0
> Inventario completo y autoritativo de la encuesta, extraído del mockup real > Versión 3.0.0 — Sprint 4, Etapa 4B (Jun 2026). Reemplaza la v2.1. Reescritura completa del
> (`formulario_mapeo.html`). Reemplaza el seed inventado de la Etapa 1 (que tenía 9 de 34 preguntas y > instrumento: 10 secciones, 40 preguntas (2 consentimiento + 38 de relevamiento), esquema
> el supuesto incorrecto C1→G4). Claude Code reescribe `seed.sql` a partir de ESTE contrato. > declarativo de flujo (ADR-005). Seed LIMPIO — no migra datos de v2.1.
>
> Fuente de contenido AUTORITATIVA: `Formulario_Mapeo_Corregido.xlsx` (versión corregida,
> en rojo = cambios aplicados). El mockup `wizard_formulario_mapeo_v3.html` define estructura
> de secciones y mecánica del mini-wizard (4.1→4.2/4.3); donde el Excel difiere en el flujo
> condicional de "solo niño sin discapacidad", gana el Excel (decisión de la directora, ver
> abajo "Flujo condicional — recorrido soloNino").
>
> Claude Code reescribe `seed.sql` a partir de ESTE contrato.
## Convenciones (decisiones cerradas) ## Convenciones
- **Mapeo de tipos al esquema:** radio→`single_choice`, checkbox→`multiple_choice`, escala 1-5→
`rating`, textarea→`text_long`.
- **`conditional_rules` viven en la pregunta DEPENDIENTE** (cada pregunta condicional declara su
propia condición de visibilidad: `{if_question, op:'eq', value, action:'show'}`). El motor, al
decidir si renderiza X, mira las reglas de X. (Resuelve la ambigüedad que el agente había dejado.)
- **B1/C1/D1 son `single_choice` de 3 opciones** (Sí / No / Prefiero no decir), **NO `yes_no`**.
Solo el valor **"Sí"** dispara el bloque condicional; "No" y "Prefiero no decir" lo ocultan.
- **`is_sensitive = true`** en toda pregunta que elicita salud/discapacidad/diagnóstico (marcadas
abajo). La encuesta entera es `data_sensitivity = 'sensible'`.
## Estructura condicional (3 bloques, el resto siempre visible) - Tipos: radio→`single_choice`, checkbox→`multiple_choice`, texto libre corto→`text_short`,
- **cond-B** — visible si `B1 = 'Sí'` → contiene B2, B3, B4, B5 texto libre largo→`text_long`, sí/no→`yes_no`.
- **cond-C** — visible si `C1 = 'Sí'` → contiene C2, C3, C4, C5 - Secciones persisten como filas `questions.type='section'`: `prompt`=nombre de sección,
- **cond-D** — visible si `D1 = 'Sí'` → contiene D2, D3, D4, D5 `hint`=descripción/"porqué" (Opción C, ver ADR-005 §4.2).
- Secciones A (datos generales), E (rol cuidador), F (económico), G (bienestar), H (necesidades): - `conditional_rules` vive en la pregunta/sección DEPENDIENTE (auto-target implícito, sin
**top-level, siempre visibles.** campo `target`). Vocabulario completo en `supabase/migrations/20260620000002_form_schema_v3.sql`:
operadores `eq`, `eq_any`, `not_in`, `contains`, `eq_sole`; acciones `show`, `hide`.
- `is_sensitive=true` en preguntas de salud/discapacidad/diagnóstico y en 8.4 (franja salarial,
CONFIDENCIAL — analyst-only, nunca en vistas de empresa, igual que 3.4 en v2.1).
- En preguntas `multiple_choice` el `hint` suele ser "Podés marcar más de una opción".
- Mini-wizard de instanciación (ADR-005): 4.1 es `instance_generator=true`; 4.2 y 4.3 tienen
`instance_of='4.1'` y se repiten una vez por cada vínculo familiar (opciones 27) marcado en 4.1.
Las respuestas de cada instancia se guardan en `answers.instance_id` (`'familiar_1'`, `'familiar_2'`...).
- Geo: 2.1 (departamento, 18 opciones) y 2.2 (ciudad, 219 opciones filtradas por departamento en
el renderer) son cuasi-identificadores. 2.3 (barrio) es `text_short` de texto libre, opcional —
NO entra al store analítico crudo sin categorizar (regla 4 CLAUDE.md); se usa solo como apoyo
cualitativo, no agregable.
## Flujo condicional — las 3 reglas principales
1. **`r_show_si_s3_1`** — 3.1='Sí' → muestra 3.2, 3.3, 3.4, 3.5 (discapacidad propia).
Si 3.1='No', toda la sub-rama queda oculta.
2. **`r_hide_ning`** — 4.1 contiene "Ninguna de las anteriores / No tengo vinculación de cuidado"
→ oculta S5, S6, S7, S8(header), 8.4, S9(header), 9.1, 9.4. El formulario salta directo a S10.
3. **`r_hide_ning_sn`** (Ninguna O soloNino) — además de la condición anterior, también oculta
cuando 4.1 tiene como ÚNICO elemento seleccionado "Hijo menor sin discapacidad" (`eq_sole`).
Oculta: 5.1, 5.2, 5.4, S6 completa, S7 completa, 8.1, 8.2, 8.3, 9.2, 9.3.
### Flujo condicional — recorrido "solo niño sin discapacidad" (soloNino)
Decisión de la directora (Etapa 4B): cuando 4.1 tiene como única respuesta "Hijo menor sin
discapacidad", el recorrido visible es **5.3 → 8.4 → 9.1 → 9.4 → fin**, tal como lo especifica
el Excel corregido (no el mockup, que marcaba toda la Sección 5 como oculta). Esto es más
granular que un simple "salto de sección": 5.3, 8.4, 9.1 y 9.4 quedan visibles porque no
dependen de que haya responsabilidad económica o de cuidado activo — preguntan por quién
cuida durante el horario laboral (5.3), franja salarial (8.4) y percepción/voluntariado
organizacional (9.1, 9.4), que aplican igual a quien solo tiene un hijo menor sin discapacidad.
- **4.1 = sole "Hijo menor sin discapacidad"**: 4.2 y 4.3 se ocultan (no hay tipo de
discapacidad ni dispositivos de apoyo que relevar), 5.1/5.2/5.4 ocultas, 5.3 VISIBLE,
S6 y S7 completas ocultas, 8.18.3 ocultas, 8.4 VISIBLE, 9.1 VISIBLE, 9.2/9.3 ocultas,
9.4 VISIBLE.
- **4.1 contiene "Ninguna de las anteriores..."**: salta directo a S10 (todo S5S9 oculto).
- **4.1 = cualquier combinación de opciones 27** (con o sin la opción 1): recorrido completo,
4.2/4.3 generan una instancia por cada vínculo 27 declarado.
--- ---
## Sección 0Datos Generales (top-level, cuasi-identificadores) ## Sección 1Consentimiento (`S1`, pos. 10)
| Code | Tipo | Pregunta | Opciones |
|---|---|---|---|
| A1 | single_choice | ¿En qué área o gerencia trabajás? | Operaciones/Producción · Comercial/Ventas · Administración/Finanzas · RRHH/Cultura · Tecnología/Sistemas · Logística/Distribución · Otra área |
| A2 | single_choice | ¿Cuántos años tenés? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ · Prefiero no decir |
| A3 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro · Prefiero no decir |
| A4 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido |
## Sección 1 — ¿Vivís con 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 |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| B1 | single_choice | trigger | ¿Vos mismo/a tenés alguna discapacidad o condición de salud…? | Sí · No · Prefiero no decir | ✅ | | 2.1 | single_choice | Departamento | 18 departamentos de Paraguay + Capital | no | qi (cuasi-identificador) |
| B2 | multiple_choice | B1=Sí | ¿Qué tipo de discapacidad tenés? (más de una) | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial · Condición crónica · Otra | | | 2.2 | single_choice | Ciudad | 219 ciudades (filtradas por 2.1 en el renderer) | no | qi |
| B3 | single_choice | B1=Sí | ¿Contás con certificado oficial de discapacidad? | Sí · En trámite · No | ✅ | | 2.3 | text_short | ¿En qué barrio vivís? | — (texto libre) | no | `hint`='Opcional.' — no entra al store analítico crudo |
| B4 | single_choice | B1=Sí | ¿Tu discapacidad afecta tu desempeño o comodidad en el trabajo? | Sí, significativamente · Sí, aspectos puntuales · No, bien gestionada · Prefiero no decir | ✅ | | 2.4 | single_choice | ¿Cuál es tu rango de edad? | Menos de 25 · 25-34 · 35-44 · 45-54 · 55+ | no | qi |
| B5 | single_choice | B1=Sí | ¿La empresa conoce tu situación? | Sí, con apoyo · Sí, sin apoyo · No, privado | ✅ | | 2.5 | single_choice | ¿Con qué género te identificás? | Femenino · Masculino · No binario/Otro | no | qi |
| 2.6 | single_choice | ¿En qué nivel organizacional estás? | Directivo · Gerencial · Jefatura · Operativo · Auxiliar · Otro | no | qi |
| 2.7 | single_choice | ¿Cuál es tu modalidad de trabajo actual? | Presencial · Remoto · Híbrido | no | qi |
## Sección 2Familiares con Discapacidad *(sensible)* ## Sección 3Prevalencia de discapacidad (`S3`, pos. 30) *(sensible)*
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|---|---|---|---|---|---|
| C1 | single_choice | trigger | ¿Tenés algún familiar cercano que viva con discapacidad? | Sí · No · Prefiero no decir | ✅ |
| C2 | multiple_choice | C1=Sí | ¿Cuál es el vínculo? (más de una) | Hijo/a · Cónyuge/pareja · Padre/madre · Hermano/a · Otro familiar | ✅ |
| C3 | multiple_choice | C1=Sí | ¿Qué tipo de discapacidad tiene? (más de una) | Física/motriz · Visual · Auditiva · Intelectual · TEA · Psicosocial · Condición crónica | ✅ |
| C4 | single_choice | C1=Sí | ¿Esta persona requiere apoyo o cuidado cotidiano? | Alta dependencia · Apoyo parcial · Autónomo/a | ✅ |
| C5 | single_choice | C1=Sí | ¿Quién es el responsable principal del cuidado? | Yo principal · Compartido · Cuidador pagado · Institución | |
## Sección 3 — Adultos Mayores a Cargo *(sensible)* Cond.: 3.23.5 visibles solo si 3.1='Sí' (`r_show_si_s3_1`).
| Code | Tipo | Cond. | Pregunta | Opciones | is_sensitive |
|---|---|---|---|---|---| | Code | Tipo | Pregunta | Opciones | is_sensitive |
| D1 | single_choice | trigger | ¿Tenés algún adulto mayor (60+) a tu cuidado? | Sí · No · Prefiero no decir | | |---|---|---|---|---|
| D2 | single_choice | D1=Sí | ¿Cuántos adultos mayores están bajo tu cuidado? | 1 · 2 · 3+ | | | 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 |
| D3 | single_choice | D1=Sí | ¿Qué nivel de dependencia tiene/n? | Supervisión · Dependencia leve · Dependencia moderada · Dependencia severa | ✅ | | 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 | ✅ |
| D4 | multiple_choice | D1=Sí | ¿Tiene condición diagnosticada? (más de una) | Demencia/Alzheimer · ACV/Parálisis · Enf. crónica · Movilidad reducida · Deterioro cognitivo · Ninguna específica | ✅ | | 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 | ✅ |
| D5 | single_choice | D1=Sí | ¿La persona vive con vos o en otra vivienda? | Vive conmigo · Otra vivienda · Institución | | | 3.4 | single_choice | ¿Contás con certificado oficial de discapacidad? | Sí · No, pero estoy en trámite · No | ✅ |
| 3.5 | multiple_choice | ¿Cómo afecta tu discapacidad y/o condición limitante en tu trabajo? | No afecta mi rendimiento · Falta de adecuaciones afecta más que la discapacidad · Transporte · Accesibilidad para participar | ✅ |
## Sección 4 — Vinculación familiar (`S4`, pos. 40) *(sensible)*
Mini-wizard: 4.1 `instance_generator=true`; 4.2/4.3 `instance_of='4.1'`, una instancia por
vínculo 27 declarado en 4.1. Ocultas (4.2/4.3) si Ninguna o soloNino (`r_hide_ning_sn`).
| Code | Tipo | Pregunta | Opciones | is_sensitive |
|---|---|---|---|---|
| 4.1 | multiple_choice | ¿Cuál de las siguientes vinculaciones familiares tenés? | 1. Hijo menor sin discapacidad · 2. Hijo menor con discapacidad · 3. Hijo adulto con discapacidad · 4. Familiar menor con discapacidad · 5. Familiar mayor con discapacidad · 6. Padre/madre con movilidad reducida o 100% dependiente · 7. Otro/Especificar · 8. Ninguna de las anteriores / No tengo vinculación de cuidado | ✅ generador |
| 4.2 | multiple_choice (instancia) | ¿Qué tipo de discapacidades o limitaciones tienen tus familiares vinculados? | (mismo set que 3.2) | ✅ |
| 4.3 | multiple_choice (instancia) | ¿Qué tipo de dispositivos de apoyo utilizan tus familiares vinculados? | (mismo set que 3.3, "No utiliza" en 3ra persona) | ✅ |
## Sección 5 — Responsabilidad y cuidado (`S5`, pos. 50)
Cond.: oculta completa si Ninguna (`r_hide_ning`). 5.1/5.2/5.4 además ocultas si soloNino;
**5.3 permanece visible en soloNino** (ver "recorrido soloNino" arriba).
## Sección 4 — Tu Rol como Cuidador/a (top-level)
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| E1 | single_choice | ¿Cuántas horas por día dedicás, en promedio, al cuidado directo? | Menos de 1h · 1-3h · 3-6h · Más de 6h · Variable | | 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 |
| E2 | multiple_choice | ¿En qué momentos realizás principalmente el cuidado? (más de una) | Antes del trabajo · Durante horario laboral · Al llegar · Durante la noche · Fines de semana · Continuo | | 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 |
| E3 | single_choice | ¿Contás con apoyo de otras personas para el cuidado? | Apoyo familiar suficiente · Apoyo insuficiente · Único responsable · Apoyo contratado | | 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 |
| E4 | single_choice | ¿Tuviste que faltar al trabajo por el cuidado en el último año? | Nunca · Pocas veces (1-3) · Varias veces (4-10) · Con frecuencia (10+) | | 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 |
## Sección 6 — Intensidad del cuidado (`S6`, pos. 60)
Cond.: oculta completa si Ninguna o soloNino (`r_hide_ning_sn`).
## Sección 5 — Impacto Económico (top-level)
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| F1 | single_choice | ¿Cuánto estimás que gastás mensualmente en el cuidado? | Sin gastos significativos · Menos de 500k · 500k-1.5M · 1.5M-3M · 3M-6M · Más de 6M · Prefiero no decir | | 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 |
| F2 | multiple_choice | ¿Qué servicios o apoyos utilizás actualmente? (más de una) | Terapias especializadas · Cuidador particular · Centro de día · Transporte adaptado · Medicación alto costo · Tecnología asistiva · Apoyo ONG · Sin servicios formales | | 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 |
| F3 | rating | ¿Los gastos de cuidado impactan en tu situación económica personal? | Escala 1 (Sin impacto) → 5 (Muy alto) | | 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`).
## Sección 6 — Bienestar y Descanso (top-level)
| Code | Tipo | Pregunta | Opciones | | Code | Tipo | Pregunta | Opciones |
|---|---|---|---| |---|---|---|---|
| G1 | single_choice | ¿Cómo describirías tu nivel de descanso habitual? | Descanso bien · Descanso irregular · Me cuesta descansar · Muy poco descanso | | 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 |
| G2 | single_choice | En los últimos 3 meses, ¿con qué frecuencia el cuidado afectó tu concentración o rendimiento laboral? | Nunca/casi nunca · Algunas veces al mes · Varias veces por semana · Casi todos los días | | 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 |
| G3 | multiple_choice | ¿Qué aspectos de tu vida se ven más afectados? (más de una) | Tiempo libre · Sueño y descanso · Vida social · Vida en pareja/familia · Salud física · Salud emocional · Desarrollo profesional · Situación económica | | 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) |
| G4 | rating | ¿Cómo evaluás tu bienestar general en este momento? | Escala 1 (Muy mal) → 5 (Muy bien) | | 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 |
| G5 | single_choice | ¿Sentís que tu empresa comprende la realidad de ser cuidador/a? | Sí, acompañado/a · Parcialmente · No, invisible · Nunca lo supo | | 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 7Necesidades (top-level) ## Sección 8Impacto económico (`S8`, pos. 80)
| Code | Tipo | Pregunta | Opciones / nota |
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 |
|---|---|---|---|---|
| 9.1 | single_choice | ¿Sentís que tu empresa conoce o comprende la realidad del cuidado y la discapacidad de su gente? | Sí, acompañado/a · Parcialmente · No, invisible · Nunca hemos tocado el tema | |
| 9.2 | multiple_choice (**max_select=3**) | ¿Qué apoyos de la empresa te ayudarían más? | Flexibilidad horaria/remoto · Apoyo económico/convenios · Beneficios terapias/salud · Contención emocional/psicológica · Capacitación y autocuidado · Redes de apoyo/grupos de pares · Información sobre recursos | |
| 9.3 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores dentro de la empresa? | Sí, definitivamente · Tal vez · No por ahora · Prefiero mantenerlo en privado | |
| 9.4 | single_choice | ¿Estarías dispuesto/a a ser voluntario/a en iniciativas y/o redes de apoyo? | Sí · No | |
## Sección 10 — Comentarios adicionales (`S10`, pos. 100)
Siempre visible — es el destino del salto "Ninguna de las anteriores" en 4.1.
| Code | Tipo | Pregunta | Notas |
|---|---|---|---| |---|---|---|---|
| H1 | multiple_choice (**max_select=3**) | ¿Qué tipo de apoyo de tu empresa te ayudaría más? (hasta 3) | Flexibilidad horaria/remoto · Info recursos y servicios · Apoyo psicológico · Espacio de escucha · Convenios descuentos · Grupos de pares · Capacitación autocuidado · Nada, estoy bien | | 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) |
| H2 | single_choice | ¿Estarías dispuesto/a a participar en un grupo de apoyo para cuidadores? | Sí, definitivamente · Tal vez · No por ahora · Prefiero privacidad |
| H3 | text_long | ¿Querés compartir algo más sobre tu experiencia? (opcional) | Texto libre, opcional. **No entra al store analítico crudo** (regla 4 CLAUDE.md). |
--- ---
## Mapeo a cuasi-identificadores (`responses.qi_*`) ## Totales
- `qi_age_bucket`**A2** (ya viene en bucket — bien).
- `qi_sector`**A1** (área).
- `qi_zone`**no aplica en este instrumento** (no se pregunta zona geográfica; es intra-empresa). Va NULL.
## ⚠️ Hallazgo para el director (decisión de modelo de datos, Nivel 3 — NO ejecutar) 10 secciones (`type='section'`) + 40 preguntas = **50 filas** en `questions` por encuesta.
El dashboard original quería filtrar por **género (A3)** y **modalidad (A4)**, pero `agg_segment` 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.
solo tiene en su whitelist `qi_age_bucket / qi_sector / qi_zone`. Para segmentar/filtrar por género o
modalidad en el dashboard (Sprint 3), hay que decidir: ¿se agregan columnas `qi_gender` / `qi_modality` ## Consent versions
(y a la whitelist de `agg_segment`), o se resuelven por otra vía? Es decisión de esquema → consultar
antes de Sprint 3, no resolver en el seed. - **v1** (`20000000-0000-0000-0000-000000000001`): texto original v2.1, preservado — no se
altera para no invalidar `consent_records` ya firmados contra esa versión (regla 5 CLAUDE.md).
- **v2** (`20000000-0000-0000-0000-000000000002`): texto revisado para el instrumento v3.0.0,
asignado a las 3 encuestas seed. Scopes: `["operativo","macro_n1"]` (Modo A).
## Discrepancias mockup vs. Excel resueltas (AP.10)
Reportadas y resueltas con la directora antes de escribir el seed:
1. **Wording de preguntas y opciones**: el Excel corregido es la fuente autoritativa en todos
los casos donde difiere del mockup (~30 discrepancias menores de texto).
2. **Recorrido soloNino**: el mockup marcaba `skipSoloNino:true` en TODA la Sección 5 (incluida
5.3). El Excel especifica un recorrido más granular: 5.3 → 8.4 → 9.1 → 9.4 → fin. Resuelto a
favor del Excel (ver "Flujo condicional — recorrido soloNino" arriba).
3. **8.3 "Eventos de recaudación"**: opción presente en el Excel, ausente en el mockup. Incluida.
## ⚠️ Decisiones pendientes para el director (NO ejecutar sin aprobación)
- **Ranking widget (9.2):** actual es `multiple_choice` con `max_select=3`. Si se requiere
orden de prioridad, pasa a tipo `ranking` (mismo criterio que 6.2 en v2.1).
- **Motor genérico de formularios (Fase 2, BACKLOG-002):** este contrato es el primer caso de
prueba; el intérprete declarativo se generaliza después de validar el wizard de cuidadores.
- **Migración de datos v2.1 → v3:** NO se ejecuta. v2.1.0-stable permanece intacta; v3 es un
instrumento nuevo con seed limpio.
- **`qi_city`, RE-INDEX scoring, salary analytics:** decisiones heredadas de v2.1, aún abiertas,
no se resuelven en esta etapa.

171
TECH_DEBT.md Normal file
View File

@@ -0,0 +1,171 @@
# TECH_DEBT.md — Deuda técnica registrada
> Ítems identificados durante el desarrollo. Cada ítem documenta qué falta, por qué
> no se hizo ahora, y qué trigger habilitaría su resolución.
---
## [TECH-DEBT-001] Ranking widget para 6.2
- **Estado:** 6.2 es actualmente `multiple_choice` max_select=3. El widget muestra
frecuencia simple (top 8).
- **Deuda:** cuando el tipo pase a `ranking` con JSONB `{rank1, rank2, rank3}`, el
widget debe reescribirse para mostrar ponderación por posición.
- **Trigger:** decisión del director sobre el tipo de 6.2 en próxima versión del
instrumento (anotada en SEED_SPEC.md § Decisiones pendientes).
---
## [TECH-DEBT-002] KPI NPS Cuidador
- **Estado:** el instrumento actual no tiene pregunta NPS. El KPI fue eliminado del
dashboard en Etapa 2E.
- **Para implementar:** agregar en próxima versión del instrumento:
"En una escala de 0 a 10, ¿qué tan probable es que recomiendes esta empresa a otra
persona cuidadora?"
- **Trigger:** decisión de incluir NPS en el instrumento v2.
---
## [TECH-DEBT-003] Brecha de políticas (oferta vs. demanda)
- **Estado:** el instrumento captura solo demanda (6.2 = apoyos que ayudarían).
No captura qué políticas la empresa ya ofrece. W6 muestra solo demanda.
- **Para implementar:** agregar en próxima versión del instrumento:
"¿Qué apoyos para el cuidado ofrece tu empresa hoy?" (multi-select con las mismas
opciones de 6.2 + "Ninguno" + "No lo sé").
- **Trigger:** decisión de agregar la pregunta de oferta al instrumento.
---
## [TECH-DEBT-004] Intención directa de salida laboral
- **Estado:** el instrumento no pregunta directamente por intención de dejar el
empleo. W8 usa indicador compuesto (4.3 + 5.1 + 5.2) renombrado a "Saturación".
- **Para implementar:** agregar en próxima versión:
"En los últimos 6 meses, ¿pensaste en dejar este empleo por razones relacionadas
al cuidado?" (Sí/Frecuentemente · Sí/A veces · No · Prefiero no decir).
- **Trigger:** validación metodológica de que el indicador compuesto no es suficiente
para medir intención de salida.
---
## [TECH-DEBT-005] qi_city / qi_country — migración pendiente
- **Estado:** A1 (Departamento) y A2 (Ciudad) se guardan en `answers`. Las columnas
`qi_city` y `qi_country` en `responses` son NULL. Sprint 3 las necesita para
segmentación avanzada.
- **Para implementar:** migration futura que agregue extracción de A1/A2 en el insert
y extienda la whitelist de `agg_segment`.
- **Trigger:** inicio de Sprint 3 (dashboard avanzado con filtros de ciudad).
---
## [TECH-DEBT-006] Ranking widget para 2.1
- **Estado:** Etapa 3I2 (Cambio 5) cambió 2.1 ("¿Quién es el/la responsable principal
del cuidado?") de `single_choice` a `multiple_choice`. Permite selección múltiple
pero sin orden de prioridad — pierde la noción de "responsable principal" cuando
el respondente marca más de una opción.
- **Deuda:** implementar widget de ranking para 2.1 con el orden:
Principal cuidador soy yo > secundario instituto > principal pagado > secundario
familiar (mismo patrón que TECH-DEBT-001 para 6.2).
- **Trigger:** decisión del director sobre el tipo de 2.1 en próxima versión del
instrumento.
---
## [TECH-DEBT-008] /admin/responses sin autenticación
- **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
una etapa nueva, no parte de este cierre.
---
## [TECH-DEBT-010] E9 lee modalidad por JOIN a `answers` en vez de `qi_modalidad`
- **Estado:** ABIERTO — decisión consciente, no un descuido.
- **Descripción:** `rpc_me_segmentacion` (E9, Mirada Empresa) segmenta por nivel
organizacional y por modalidad de trabajo. El nivel (`2.6`) sí se promovió a
`responses.qi_sector` en el submit (Etapa 4G) — E9 lee de ahí directo. La
modalidad (`2.7`) NO tiene una columna `qi_modalidad` equivalente — promoverla
hubiera significado reabrir `rpc_submit_response`, ya verificada contra
Postgres real en 4G. Para no reabrirla, E9 resuelve modalidad con un JOIN a
`answers`/`questions` por `code='2.7'` en tiempo de consulta.
- **Costo del JOIN vs. la columna:** una pasada extra por `answers`/`questions`
en cada llamada a E9 — aceptable para el volumen actual (decenas/cientos de
respuestas por encuesta), pero más lento que leer una columna denormalizada
si el dataset crece mucho o si la segmentación por modalidad se usa con
frecuencia en varios widgets.
- **Acción si se decide promover:** agregar `qi_modalidad text` a `responses`,
poblarla en `rpc_submit_response` (requiere re-verificar esa RPC contra
Postgres real, igual que se hizo en 4G), y migrar E9 a leerla directo —
mismo patrón que se usó para `qi_sector`.
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
---
## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
de etapa asumía columnas `questions.section_number`/`section_title` que NO
existen. Verificado contra el esquema real (`supabase/migrations/
20260531000001_create_tables.sql` — única definición de `questions`,
`20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
migración agrega esas columnas).
- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
pregunta pertenece a la sección X". La sección es una fila más en la misma
tabla, con `type='section'`, intercalada por `position` entre las preguntas
reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
detallada de admin) infiere el agrupamiento recorriendo las filas en orden
de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
`type='section'`. El título de la sección es el `prompt` de esa fila — no
hay un campo `title` separado.
- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
pensado para ser un "motor de formularios de encuestas" genérico — no toda
encuesta futura va a tener secciones, y las que las tengan no necesariamente
van a querer modelarlas como filas-marcador en la misma tabla que las
preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
(wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
`type='section'` más cercana hacia atrás en `position` es la sección de esta
pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
promoverla a una función compartida — y si el motor crece a encuestas con
estructuras más ricas (subsecciones, secciones condicionales, secciones que
no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
cada consumidor.
- **Alternativa más genérica y escalable (no implementada, propuesta para
evaluar cuando se priorice):** sacar la sección de `questions.type` y
modelarla como entidad propia — opción simple: `questions.section_id uuid
NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
position)`; opción más liviana sin tabla nueva: `questions.section_title
text NULL` + `questions.section_position int NULL` poblados directamente en
cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
Cualquiera de las dos deja "pertenece a la sección X" como un hecho
consultable con una columna, no como un cálculo derivado de recorrer
`position` — y ninguna asume que toda encuesta tiene secciones (el campo es
nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
desde las filas `type='section'` existentes, (3) actualizar
`lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
- **Acción tomada mientras tanto:** la vista detallada de admin
(`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
`lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
actual.
- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
producto efectivamente evoluciona a soportar surveys con estructuras de
sección más complejas que el caso actual, evaluar la migración a columna/
tabla explícita antes de seguir apilando consumidores sobre el supuesto
implícito.

106
app/actions/invite-user.ts Normal file
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

@@ -0,0 +1,94 @@
'use server'
import { headers } from 'next/headers'
import { createHash } from 'crypto'
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
import { buildFormSchema } from '@/lib/form-engine'
import type { Question as EngineQuestion } from '@/lib/form-engine.types'
import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation'
export type SubmitResult =
| { success: true }
| { success: false; error: string }
export async function submitSurvey(payload: {
surveyId: string
answers: SubmitAnswerEntry[]
consentOperativo: boolean
consentMacroN1: boolean
}): Promise<SubmitResult> {
// ip_hash: server-only, nunca en texto plano (dedup sin identidad)
const headersList = await headers()
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const salt = process.env.IP_HASH_SALT ?? ''
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
// 1. Validación #1: survey existe y está activa
const { data: survey, error: sErr } = await supabase
.from('surveys')
.select('id, org_id, status')
.eq('id', payload.surveyId)
.eq('status', 'live')
.maybeSingle()
if (sErr) return { success: false, error: 'survey_lookup_failed' }
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
// nunca contra lo que el cliente afirma que vio.
const { data: questionRows, error: qErr } = await supabase
.from('questions')
.select(
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
'max_select, options, conditional_rules, instance_generator, instance_of'
)
.eq('survey_id', payload.surveyId)
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
const schema = buildFormSchema(questionRows as unknown as EngineQuestion[])
// 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo.
const result = validateSubmission(schema, payload.answers, payload.consentOperativo)
if (!result.ok) {
return { success: false, error: `validation_failed: ${result.errors.join(', ')}` }
}
// 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad).
// qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2.
// Se usa 2.6 (nivel organizacional) como proxy más cercano disponible —
// decisión a confirmar con el director, no hay un mapeo exacto.
const findDefault = (code: string): string | null => {
const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default')
return typeof entry?.value === 'string' ? entry.value : null
}
const qiZone = findDefault('2.1')
const qiAgeBucket = findDefault('2.4')
const qiSector = findDefault('2.6')
// 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración
// 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no
// INSERTs sueltos desde acá.
const { error: rpcErr } = await supabase.rpc('rpc_submit_response', {
p_survey_id: payload.surveyId,
p_ip_hash: ipHash,
p_qi_age_bucket: qiAgeBucket,
p_qi_sector: qiSector,
p_qi_zone: qiZone,
p_consent_operativo: payload.consentOperativo,
p_consent_macro_n1: payload.consentMacroN1,
p_answers: result.rows.map((r) => ({
question_id: r.questionId,
instance_id: r.instanceId,
value: r.value,
})),
})
if (rpcErr) return { success: false, error: 'submit_failed' }
return { success: true }
}

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

@@ -0,0 +1,274 @@
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { ResponsesTable, type DisplayRow } from './responses-table'
import { SurveySelect } from './survey-select'
import { AdminNavBar } from '@/components/admin-nav-bar'
import { formatDate, renderValue } from './admin-format'
export const dynamic = 'force-dynamic'
const PAGE_SIZE = 50
const PROMPT_MAX_LEN = 60
interface AllowedQuestion {
id: string
code: string
prompt: string
type: string
position: number
}
interface AnswerRow {
value: unknown
question_id: string
}
interface ResponseRow {
id: string
submitted_at: string
qi_zone: string | null
qi_sector: string | null
answers: AnswerRow[]
}
interface SurveyOption {
id: string
title: string
}
async function getSupabaseSession() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Server Component — no puede mutar cookies, se ignora
}
},
},
}
)
}
function truncate(text: string, max = PROMPT_MAX_LEN): string {
return text.length > max ? `${text.slice(0, max)}...` : text
}
function pageHref(surveyId: string | undefined, page: number): string {
const params = new URLSearchParams()
if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
return `/admin/responses?${params.toString()}`
}
export default async function AdminResponsesPage({
searchParams,
}: {
searchParams: Promise<{ survey_id?: string; page?: string }>
}) {
const { survey_id: surveyId, page: pageParam } = await searchParams
const page = Math.max(1, Number(pageParam) || 1)
const supabase = await getSupabaseSession()
// 0 y 1 no dependen entre sí — en paralelo.
// 0. Surveys para el filtro — dinámico, no hardcodeado. Solo trae lo que
// RLS deja ver al usuario autenticado. Si falla, el filtro se degrada a
// "sin opciones" en vez de tirar abajo la página entera — la lista de
// respuestas no depende de esta query.
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
// antes de tocar la tabla answers. Sus valores nunca se consultan.
const [
{ data: surveysData, error: surveysError },
{ data: questionsData, error: questionsError },
] = await Promise.all([
supabase.from('surveys').select('id, title').order('title'),
supabase
.from('questions')
.select('id, code, prompt, type, position')
.eq('is_sensitive', false)
.neq('type', 'text_long')
.order('position', { ascending: true }),
])
const surveys = (surveysData ?? []) as SurveyOption[]
const selectedSurvey = surveys.find((s) => s.id === surveyId)
if (questionsError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{questionsError.message}</pre>
</div>
)
}
const allowedQuestions = (questionsData ?? []) as AllowedQuestion[]
const allowedIds = allowedQuestions.map((q) => q.id)
const questionMap = new Map(allowedQuestions.map((q) => [q.id, q]))
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
// embebido a solo question_id permitidos (consulta, no post-filtro).
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
let responsesQuery = supabase
.from('responses')
.select(
`
id,
submitted_at,
qi_zone,
qi_sector,
answers!inner ( value, question_id )
`
)
.in('answers.question_id', allowedIds)
if (surveyId) {
responsesQuery = responsesQuery.eq('survey_id', surveyId)
}
// 3. Contadores totales (independientes de la página actual). submissionsCount
// usa el MISMO answers!inner + .in() que responsesQuery — si contara todas
// las responses sin ese filtro, una response sin ningún answer permitido
// inflaría el total y totalPages quedaría desalineado con lo que .range()
// realmente puede devolver (página "fantasma" al final).
let submissionsCountQuery = supabase
.from('responses')
.select('*, answers!inner(question_id)', { count: 'exact', head: true })
.in('answers.question_id', allowedIds)
if (surveyId) {
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
}
let answersCountQuery = supabase
.from('answers')
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
.in('question_id', allowedIds)
if (surveyId) {
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
}
// Las tres son independientes entre sí — en paralelo.
const [
{ data: responsesData, error: responsesError },
{ count: totalSubmissions },
{ count: totalAnswers },
] =
allowedIds.length > 0
? await Promise.all([
responsesQuery
.order('submitted_at', { ascending: false })
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1),
submissionsCountQuery,
answersCountQuery,
])
: [{ data: [] as ResponseRow[], error: null }, { count: 0 }, { count: 0 }]
if (responsesError) {
return (
<div className="admin-page" role="main">
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
<pre className="admin-error-detail">{responsesError.message}</pre>
</div>
)
}
const responses = (responsesData ?? []) as unknown as ResponseRow[]
const totalPages = Math.max(1, Math.ceil((totalSubmissions ?? 0) / PAGE_SIZE))
// 4. Aplanar: una fila por answer, ordenadas por position dentro de cada submission
const rows: DisplayRow[] = []
responses.forEach((response, idx) => {
const answers = response.answers
.filter((a) => questionMap.has(a.question_id))
.sort(
(a, b) =>
questionMap.get(a.question_id)!.position - questionMap.get(b.question_id)!.position
)
answers.forEach((a, ai) => {
const q = questionMap.get(a.question_id)!
const isFirstInGroup = ai === 0
rows.push({
key: `${response.id}-${a.question_id}`,
responseId: response.id,
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
isFirstInGroup,
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
code: q.code,
prompt: truncate(q.prompt),
rendered: renderValue(a.value, q.type),
zone: isFirstInGroup ? (response.qi_zone ?? '—') : '',
sector: isFirstInGroup ? (response.qi_sector ?? '—') : '',
})
})
})
return (
<>
<AdminNavBar active="responses" />
<div className="admin-page" role="main">
<header className="admin-header">
<div>
<h1 className="admin-title">Control de respuestas</h1>
<p className="admin-subtitle">
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
{formatDate(new Date().toISOString())}
</p>
<p className="admin-subtitle">
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
</p>
<div className="admin-survey-nav">
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
</div>
{surveysError && (
<p className="admin-error" role="alert">
No se pudo cargar la lista de surveys para el filtro se muestra "Todos".
</p>
)}
</div>
<div className="admin-header-actions">
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
</div>
</header>
<div className="admin-table-wrapper">
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
{rows.length === 0 && (
<p className="admin-empty">Sin respuestas registradas todavía.</p>
)}
</div>
{(totalSubmissions ?? 0) > PAGE_SIZE && (
<nav className="admin-pagination" aria-label="Paginación de respuestas">
{page > 1 ? (
<a href={pageHref(surveyId, page - 1)} className="admin-pagination-link"> Anterior</a>
) : (
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true"> Anterior</span>
)}
<span className="admin-pagination-current">Página {page} de {totalPages}</span>
{page < totalPages ? (
<a href={pageHref(surveyId, page + 1)} className="admin-pagination-link">Siguiente </a>
) : (
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">Siguiente </span>
)}
</nav>
)}
</div>
</>
)
}

View File

@@ -0,0 +1,127 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
export interface DisplayRow {
key: string
responseId: string
submissionNum: number
isFirstInGroup: boolean
date: string
code: string
prompt: string
rendered: string | null
zone: string
sector: string
}
export function ResponsesTable({
rows,
surveyId,
page,
}: {
rows: DisplayRow[]
surveyId?: string
page: number
}) {
const [expanded, setExpanded] = useState<Set<number>>(new Set())
function toggle(submissionNum: number) {
setExpanded((prev) => {
const next = new Set(prev)
if (next.has(submissionNum)) next.delete(submissionNum)
else next.add(submissionNum)
return next
})
}
function detailHref(responseId: string): string {
const params = new URLSearchParams()
if (surveyId) params.set('survey_id', surveyId)
params.set('page', String(page))
return `/admin/responses/${responseId}?${params.toString()}`
}
return (
<table className="admin-table">
<thead>
<tr>
<th>#</th>
<th>Fecha</th>
<th>Código</th>
<th>Pregunta</th>
<th>Valor</th>
<th className="admin-col-optional">Departamento</th>
<th className="admin-col-optional">Sector</th>
<th>Ver</th>
</tr>
</thead>
<tbody>
{rows
.filter((row) => row.isFirstInGroup || expanded.has(row.submissionNum))
.map((row) => {
const groupClass =
row.submissionNum % 2 === 0 ? 'admin-group-even' : 'admin-group-odd'
const isExpanded = expanded.has(row.submissionNum)
const trClass = row.isFirstInGroup
? `${groupClass} admin-group-start admin-row-toggle`
: groupClass
return (
<tr
key={row.key}
className={trClass}
// Conveniencia para mouse — sin semántica ARIA en la fila (evita
// anidar un control interactivo real, el Link "Ver", dentro de un
// elemento expuesto como role="button" a lectores de pantalla).
// El toggle accesible por teclado vive en el <button> de abajo.
onClick={row.isFirstInGroup ? () => toggle(row.submissionNum) : undefined}
>
<td>
{row.isFirstInGroup ? (
<button
type="button"
className="admin-row-toggle-btn"
onClick={(e) => {
e.stopPropagation()
toggle(row.submissionNum)
}}
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Colapsar respuesta' : 'Expandir respuesta'}
>
<span className="admin-toggle-icon" aria-hidden="true">
{isExpanded ? '▼' : '▶'}
</span>
{row.submissionNum}
</button>
) : (
row.submissionNum
)}
</td>
<td>{row.date}</td>
<td>{row.code}</td>
<td className="admin-prompt">{row.prompt}</td>
<td>
{row.rendered === null ? (
<span className="admin-nodata">sin dato</span>
) : (
row.rendered
)}
</td>
<td className="admin-col-optional">{row.zone}</td>
<td className="admin-col-optional">{row.sector}</td>
<td onClick={(e) => e.stopPropagation()}>
{row.isFirstInGroup && (
<Link href={detailHref(row.responseId)} className="admin-action-link">
Ver
</Link>
)}
</td>
</tr>
)
})}
</tbody>
</table>
)
}

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

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils'
// Route Handler server-side: llama a las RPC con el cliente Supabase.
// El service_role nunca llega al browser — vive solo en este servidor.
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen 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> = {
'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
'me-ausentismo': 'rpc_me_ausentismo',
'me-presentismo': 'rpc_me_presentismo',
'me-punto-ciego': 'rpc_me_punto_ciego',
'me-apoyos': 'rpc_me_apoyos_demandados',
'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
'me-carrera-congelada': 'rpc_me_carrera_congelada',
'me-intensidad-carga': 'rpc_me_intensidad_carga',
'me-segmentacion': 'rpc_me_segmentacion',
'me-disposicion-red': 'rpc_me_disposicion_red',
}
function getSupabaseServer() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!
// Usar anon key — las RPC son SECURITY DEFINER y aplican sus propias reglas
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
return createClient(url, key)
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ widget: string }> }
) {
const { widget } = await params
const rpcName = RPC_MAP[widget]
if (!rpcName) {
return NextResponse.json({ error: 'Widget desconocido' }, { status: 404 })
}
const sp = request.nextUrl.searchParams
const surveyId = sp.get('survey_id')
if (!surveyId) {
return NextResponse.json({ error: 'survey_id requerido' }, { status: 400 })
}
const filters = queryToFilters(sp)
const supabase = getSupabaseServer()
const rpcParams: Record<string, unknown> = {
p_survey_id: surveyId,
p_filters: filtersToRpc(filters),
}
// Único caso con parámetro extra: el umbral parametrizable de la estrella.
if (widget === 'me-talento-riesgo') {
const umbral = sp.get('umbral')
if (umbral) rpcParams.p_umbral_senales = parseInt(umbral, 10)
}
const { data, error } = await supabase.rpc(rpcName, rpcParams)
if (error) {
console.error(`[dashboard/${widget}] RPC error:`, error.message)
return NextResponse.json({ error: error.message }, { status: 500 })
}
return NextResponse.json(data)
}

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

15
app/dashboard/loading.tsx Normal file
View File

@@ -0,0 +1,15 @@
export default function DashboardLoading() {
return (
<div className="db-loading-page" aria-busy="true" aria-label="Cargando dashboard…">
<div className="db-skeleton-block" style={{ height: 60, marginBottom: 16 }} />
<div className="db-grid">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="db-skeleton-block" style={{ gridColumn: 'span 3', height: 96 }} />
))}
{Array.from({ length: 6 }).map((_, i) => (
<div key={i+4} className="db-skeleton-block" style={{ gridColumn: 'span 6', height: 240 }} />
))}
</div>
</div>
)
}

153
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { AdminNavBar } from '@/components/admin-nav-bar'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
import type { DashboardFilters } from '@/lib/dashboard-types'
// El survey_id se pasa como query param: /dashboard?survey_id=UUID
// '00000000-0000-0000-0000-000000000000' = flag p_all (vista agregada de todos los surveys)
const SURVEY_OPTIONS = [
{ label: 'Todos', id: '00000000-0000-0000-0000-000000000000' },
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
]
type Lens = 'empresa' | 'pais' | 'humana'
const LENSES: { id: Lens; label: string; sub: string }[] = [
{ id: 'empresa', label: 'Mirada Empresa', sub: 'rendimiento · rotación · clima' },
{ id: 'pais', label: 'Mirada País', sub: 'desarrollo · vulnerabilidad · política' },
{ id: 'humana', label: 'Mirada Humana', sub: 'bienestar · derechos · desarrollo' },
]
function PlaceholderLens({ title, hint }: { title: string; hint: string }) {
return (
<div className="db-placeholder" role="status">
<span className="db-placeholder-icon" aria-hidden="true">🔒</span>
<p className="db-placeholder-title">{title} Disponible próximamente</p>
<p className="db-placeholder-hint">{hint}</p>
</div>
)
}
export default function DashboardPage() {
const searchParams = useSearchParams()
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
const [filters, setFilters] = useState<DashboardFilters>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [lens, setLens] = useState<Lens>('empresa')
return (
<>
<AdminNavBar active="dashboard" />
<div className="db-page">
<FilterSidebar
filters={filters}
onChange={setFilters}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed((c) => !c)}
/>
<div className="db-main">
<header className="db-page-header">
<h1 className="db-page-title">Economía del Cuidado Paraguay</h1>
<p className="db-page-subtitle">
Datos agregados · k-anonimato k5 · Encuesta Mapeo de Cuidadores 2026
{filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>}
</p>
<nav className="db-survey-nav" aria-label="Selección de empresa">
{SURVEY_OPTIONS.map((opt) => (
<a
key={opt.id}
href={`/dashboard?survey_id=${opt.id}`}
className={
opt.id === surveyId
? 'db-survey-link db-survey-link-active'
: 'db-survey-link'
}
aria-current={opt.id === surveyId ? 'page' : undefined}
>
{opt.label}
</a>
))}
</nav>
</header>
<nav className="db-tabs" aria-label="Mirada del dashboard">
{LENSES.map((l) => (
<button
key={l.id}
type="button"
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
onClick={() => setLens(l.id)}
aria-current={lens === l.id ? 'page' : undefined}
>
<span>{l.label}</span>
<span className="db-tab-lens">{l.sub}</span>
</button>
))}
</nav>
{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>
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
<ApoyosBarME surveyId={surveyId} filters={filters} />
</section>
{/* Fila 3: segmentación (12, SEC) */}
<section className="db-grid db-section" aria-label="Segmentación de la carga">
<SegmentacionBar surveyId={surveyId} filters={filters} />
</section>
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
<section className="db-grid db-section" aria-label="Disposición a participar">
<DisposicionRedCard surveyId={surveyId} filters={filters} />
</section>
<p className="db-table-note" style={{ textAlign: 'right' }}>
k5 aplicado · segmentos con n&lt;5 suprimidos
</p>
</>
)}
{lens === 'pais' && (
<PlaceholderLens
title="Mirada País"
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
/>
)}
{lens === 'humana' && (
<PlaceholderLens
title="Mirada Humana"
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
/>
)}
</div>
</div>
</>
)
}

View File

@@ -2,18 +2,27 @@
/* ── Tokens ── */ /* ── Tokens ── */
:root { :root {
/* ── Marca RE ── */
--color-re-teal: #43BBC8;
--color-re-dark: #494F4F;
--color-re-orange: #F8AD13;
--color-re-orange-intense: #EE761A;
--color-re-light: #E7E6E5;
--color-re-white: #FFFFFF;
/* ── Sistema (primario = teal RE) ── */
--color-bg: #f9fafb; --color-bg: #f9fafb;
--color-surface: #ffffff; --color-surface: #ffffff;
--color-text: #111827; --color-text: #111827;
--color-text-muted: #6b7280; --color-text-muted: #6b7280;
--color-primary: #1d4ed8; --color-primary: #43BBC8;
--color-primary-hover: #1e40af; --color-primary-hover: #3DAAB6;
--color-primary-light: #dbeafe; --color-primary-light: #e8f8fa;
--color-accent: #2563eb; --color-accent: #43BBC8;
--color-error: #b91c1c; --color-error: #b91c1c;
--color-error-bg: #fef2f2; --color-error-bg: #fef2f2;
--color-border: #d1d5db; --color-border: #d1d5db;
--color-border-focus: #2563eb; --color-border-focus: #43BBC8;
--color-sensitive: #fef3c7; --color-sensitive: #fef3c7;
--color-sensitive-border: #f59e0b; --color-sensitive-border: #f59e0b;
--radius: 8px; --radius: 8px;
@@ -23,6 +32,7 @@
/* ── Reset & base ── */ /* ── Reset & base ── */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html { font-size: 16px; scroll-behavior: smooth; } html { font-size: 16px; scroll-behavior: smooth; }
html, body { height: 100%; }
body { body {
margin: 0; margin: 0;
background: var(--color-bg); background: var(--color-bg);
@@ -30,7 +40,12 @@ body {
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
line-height: 1.6; line-height: 1.6;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
min-height: 100vh;
} }
/* El contenido entre header y footer crece para empujar el footer al fondo */
.layout-content { flex: 1; }
/* ── Skip link ── */ /* ── Skip link ── */
.skip-link { .skip-link {
@@ -48,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;
@@ -132,16 +151,6 @@ body {
border-radius: 0 var(--radius) var(--radius) 0; border-radius: 0 var(--radius) var(--radius) 0;
color: var(--color-primary-hover); color: var(--color-primary-hover);
} }
.section-sensitive-note {
font-size: 0.8125rem;
color: #92400e;
background: var(--color-sensitive);
border: 1px solid var(--color-sensitive-border);
border-radius: var(--radius);
padding: 8px 12px;
margin: 0;
}
/* ── Question wrapper ── */ /* ── Question wrapper ── */
.question-wrapper { .question-wrapper {
background: var(--color-surface); background: var(--color-surface);
@@ -168,6 +177,12 @@ body {
line-height: 1.4; line-height: 1.4;
} }
.required-mark { color: var(--color-error); } .required-mark { color: var(--color-error); }
.q-code {
font-size: 0.78em;
font-weight: 700;
color: #7A8FA0;
letter-spacing: 0.02em;
}
/* ── Options list ── */ /* ── Options list ── */
.options-list { display: flex; flex-direction: column; gap: 8px; } .options-list { display: flex; flex-direction: column; gap: 8px; }
@@ -254,6 +269,57 @@ body {
.rating-step:hover .rating-dot { border-color: var(--color-accent); background: var(--color-primary-light); } .rating-step:hover .rating-dot { border-color: var(--color-accent); background: var(--color-primary-light); }
.rating-step:has(input:checked):hover .rating-dot { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; } .rating-step:has(input:checked):hover .rating-dot { background: var(--color-primary-hover); border-color: var(--color-primary-hover); color: #fff; }
/* ── Text short (single-line input) ── */
.text-short-input {
display: block;
width: 100%;
padding: 10px 12px;
border: 1.5px solid var(--color-border);
border-radius: var(--radius);
font-size: 1rem;
font-family: inherit;
line-height: 1.5;
transition: border-color 0.15s;
color: var(--color-text);
background: var(--color-surface);
}
.text-short-input:focus {
outline: none;
border-color: var(--color-border-focus);
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;
@@ -312,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); }
@@ -363,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 {
@@ -378,6 +444,61 @@ body {
justify-content: flex-start; justify-content: flex-start;
} }
/* ── Geo select widget ── */
.geo-select-wrapper { display: flex; flex-direction: column; gap: 16px; }
.geo-select-group { display: flex; flex-direction: column; gap: 8px; }
.geo-select {
display: block; width: 100%;
padding: 10px 12px;
border: 1.5px solid var(--color-border); border-radius: var(--radius);
font-size: 1rem; font-family: inherit; background: var(--color-surface);
color: var(--color-text); cursor: pointer; appearance: auto;
transition: border-color 0.15s;
}
.geo-select:focus { outline: none; border-color: var(--color-border-focus); box-shadow: 0 0 0 3px rgba(37,99,235,0.15); }
.geo-select-disabled { opacity: 0.5; cursor: not-allowed; background: #f3f4f6; }
.geo-label-disabled { color: var(--color-text-muted); }
.geo-disabled-hint { font-size: 0.8125rem; font-weight: 400; color: var(--color-text-muted); }
/* ── Brand header ── */
.brand-header {
background: var(--color-primary-light); border-left: 4px solid var(--color-primary);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 10px 14px; margin: 16px 0 0;
}
.brand-text { margin: 0; font-size: 0.875rem; color: var(--color-primary-hover); line-height: 1.5; }
/* ── Consent screen ── */
.consent-container { max-width: 720px; margin: 0 auto; padding: 32px 16px 80px; }
.consent-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 24px; }
.consent-section { margin-bottom: 24px; }
.consent-section-title { font-size: 1.0625rem; font-weight: 700; margin: 0 0 10px; }
.consent-list { margin: 8px 0 0; padding-left: 20px; line-height: 1.7; display: flex; flex-direction: column; gap: 8px; }
.consent-anon-box {
background: var(--color-sensitive); border: 1px solid var(--color-sensitive-border);
border-radius: var(--radius); padding: 14px 16px; margin-bottom: 24px;
font-size: 0.9375rem; line-height: 1.6;
}
.consent-checkboxes { margin-bottom: 28px; display: flex; flex-direction: column; gap: 14px; }
.consent-checkbox-label {
display: flex; align-items: flex-start; gap: 12px;
padding: 14px 16px; border: 1.5px solid var(--color-border); border-radius: var(--radius);
cursor: pointer; transition: border-color 0.15s, background 0.15s; line-height: 1.5;
}
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
.consent-checkbox-text { flex: 1; }
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: #494F4F; background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
.btn-disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Thank you / error screens ── */
.thank-you-container, .error-container { max-width: 560px; margin: 80px auto; padding: 0 16px; }
.thank-you-title, .error-title { font-size: 1.5rem; font-weight: 700; margin: 0 0 16px; }
.thank-you-body { color: var(--color-text-muted); line-height: 1.7; margin: 0 0 12px; }
/* ── No survey state ── */ /* ── No survey state ── */
.no-survey { .no-survey {
max-width: 480px; max-width: 480px;
@@ -396,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;
@@ -419,7 +704,531 @@ body {
.btn { width: 100%; } .btn { width: 100%; }
} }
/* ── Survey header (marca RE) ── */
.survey-header-bar {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 20;
}
.survey-header-logo { display: block; height: 48px; width: auto; flex-shrink: 0; }
.survey-header-title {
color: var(--color-re-white);
font-weight: 700;
font-size: 1rem;
letter-spacing: 0.08em;
text-transform: uppercase;
line-height: 1.2;
}
/* ── Survey footer ── */
.survey-footer-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
margin-top: 40px;
}
.survey-footer-group {
display: flex;
align-items: center;
gap: 8px;
}
.survey-footer-label {
color: rgba(255,255,255,0.7);
font-size: 0.75rem;
white-space: nowrap;
}
.survey-footer-logo { height: 32px; width: auto; display: block; }
/* ══════════════════════════════════════════════════════════════
DASHBOARD — tema claro (marca Re)
Fondo: #E7E6E5 · Widgets: #FFFFFF · Teal: #43BBC8
══════════════════════════════════════════════════════════════ */
.db-page {
display: flex; min-height: 100vh;
background: #E7E6E5; color: #494F4F;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
}
.db-main { flex: 1; padding: 24px; overflow-x: hidden; }
.db-page-header { margin-bottom: 20px; }
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #494F4F; margin: 0 0 4px; }
.db-page-subtitle { font-size: 0.8125rem; color: #6B7878; margin: 0; }
.db-active-filter { color: var(--color-re-teal); font-weight: 600; }
.db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.db-survey-link {
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
border: 1px solid rgba(67,187,200,0.3); color: #6B7878; text-decoration: none;
transition: all 0.2s ease;
}
.db-survey-link:hover { color: #494F4F; border-color: var(--color-re-teal); }
.db-survey-link-active { background: var(--color-re-teal); color: #FFFFFF; border-color: var(--color-re-teal); }
.db-section { margin-bottom: 16px; }
.db-grid {
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 12px;
}
/* ── Widget shell ── */
.db-widget {
background: #FFFFFF;
border: 1px solid rgba(67,187,200,0.2);
border-radius: 10px;
overflow: hidden;
transition: box-shadow 0.3s ease;
}
.db-widget:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
.db-widget-header {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px 8px;
}
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #494F4F; margin: 0; }
.db-widget-n { font-size: 0.75rem; color: #6B7878; }
.db-widget-body { padding: 0 16px 16px; }
/* ── Suppressed / loading states ── */
.db-suppressed {
display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 100px; text-align: center; color: #6B7878; gap: 6px;
}
.db-suppressed-icon { font-size: 1.5rem; }
.db-suppressed-hint { font-size: 0.75rem; color: #9CA8A8; }
.db-suppressed-card {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 4px; color: #9CA8A8;
}
.db-skeleton-block { background: rgba(73,79,79,0.08); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
.db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; }
.db-loading-page { padding: 24px; background: #E7E6E5; min-height: 100vh; }
/* ── KPI row ── */
.db-kpi-row {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
padding: 0;
}
.db-kpi-card {
background: #FFFFFF; border: 1px solid rgba(67,187,200,0.2);
border-radius: 10px; padding: 16px; text-align: center;
display: flex; flex-direction: column; gap: 4px;
transition: box-shadow 0.3s;
}
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
/* Título de KPI: mayúsculas, bold, siempre visible */
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #494F4F; text-transform: uppercase; letter-spacing: 0.04em; }
/* Subtítulo de KPI: código de pregunta + descripción */
.db-kpi-subtitle { font-size: 0.6875rem; color: #8A9A9A; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
.db-kpi-value {
font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
font-variant-numeric: tabular-nums; margin-top: 4px;
}
.db-kpi-value-suppressed { font-size: 1.25rem; color: #9CA8A8; }
.db-kpi-note { font-size: 0.75rem; color: #6B7878; }
.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; }
/* Compat — se puede quitar si ya no se usa */
.db-kpi-label { font-size: 0.8125rem; color: #494F4F; }
.db-kpi-sub { font-size: 0.75rem; color: #6B7878; }
/* Subtítulo de widget-shell */
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; }
.db-widget-subtitle { font-size: 0.75rem; color: #8A9A9A; font-weight: 400; margin: 0; line-height: 1.4; }
/* ── Mapa tile ── */
.db-map-wrapper { position: relative; }
.db-map-tooltip {
position: absolute; top: -28px; left: 50%; transform: translateX(-50%);
background: #E7E6E5; color: #494F4F; font-size: 0.75rem;
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3);
white-space: nowrap; z-index: 10; pointer-events: none;
}
.db-tile-grid {
display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px;
grid-template-rows: repeat(6, 1fr); min-height: 220px;
}
.db-tile {
border: none; border-radius: 4px; cursor: pointer; padding: 4px 2px;
display: flex; flex-direction: column; align-items: center; justify-content: center;
transition: filter 0.15s, outline 0.15s;
min-height: 36px;
}
.db-tile:hover { filter: brightness(1.2); }
.db-tile:focus-visible { outline: 2px solid var(--color-re-teal); outline-offset: 1px; }
.db-tile-selected { outline: 2px solid #fff; }
.db-tile-suppressed { background-image: repeating-linear-gradient(
45deg, transparent, transparent 4px, rgba(255,255,255,0.06) 4px, rgba(255,255,255,0.06) 8px
) !important; }
.db-tile-name { font-size: 0.5rem; color: #fff; text-align: center; line-height: 1.2; font-weight: 600; }
.db-tile-pct { font-size: 0.6rem; color: rgba(255,255,255,0.85); font-weight: 700; }
/* ── Donut ── */
.db-donut-wrapper { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.db-donut-svg { width: 130px; height: 130px; flex-shrink: 0; }
.db-donut-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.db-legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; }
.db-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.db-legend-val { margin-left: auto; font-weight: 600; color: var(--color-re-teal); }
/* ── Gauge ── */
.db-gauge-wrapper { text-align: center; }
.db-gauge-svg { width: 100%; max-width: 160px; }
.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; }
.db-gauge-hint { font-size: 0.7rem; color: #9CA8A8; margin: 0; }
/* ── Bar chart ── */
.db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.db-bar-item { display: flex; align-items: center; gap: 8px; }
.db-bar-label { font-size: 0.75rem; color: #494F4F; width: 180px; flex-shrink: 0; line-height: 1.3; }
.db-bar-track { flex: 1; background: rgba(73,79,79,0.10); border-radius: 4px; height: 14px; overflow: hidden; }
.db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; }
/* ── Heatmap / hexbin shared ── */
.db-heatmap-scroll { overflow-x: auto; }
.db-heatmap-grid { display: grid; gap: 2px; }
.db-heatmap-header {
font-size: 0.625rem; color: #6B7878; text-align: center; padding: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.db-heatmap-rowlabel { font-size: 0.625rem; color: #6B7878; padding: 2px 4px; line-height: 1.2; }
.db-heatmap-cell {
border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s;
}
.db-heatmap-cell:hover { filter: brightness(1.2); }
.db-hexbin-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 8px; }
/* ── Pirámide ── */
.db-piramide { display: flex; flex-direction: column; gap: 6px; }
.db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.db-piramide-row { display: flex; align-items: center; gap: 6px; }
.db-piramide-label { font-size: 0.7rem; color: #6B7878; width: 90px; flex-shrink: 0; text-align: right; }
.db-piramide-bars { flex: 1; display: flex; gap: 2px; }
.db-piramide-bar-wrap { display: flex; flex-direction: column; }
.db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; }
.db-piramide-n { font-size: 0.7rem; color: #6B7878; width: 24px; text-align: right; }
/* ── Tabla riesgo ── */
.db-table-wrapper { overflow-x: auto; }
.db-table-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 10px; }
.db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.db-table th { color: #6B7878; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #494F4F; }
.db-table tr:last-child td { border-bottom: none; }
.db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; }
/* ── Sidebar ── */
.db-sidebar {
width: 220px; flex-shrink: 0; background: #F0EFEE;
border-right: 1px solid rgba(67,187,200,0.15);
padding: 12px; transition: width 0.3s ease;
display: flex; flex-direction: column; gap: 8px;
}
.db-sidebar-collapsed { width: 52px; }
.db-sidebar-toggle {
background: none; border: 1px solid rgba(67,187,200,0.3);
color: var(--color-re-teal); border-radius: 6px; padding: 6px;
cursor: pointer; font-size: 0.75rem; white-space: nowrap;
}
.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
.db-filter-group { display: flex; flex-direction: column; gap: 4px; }
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #6B7878; text-transform: uppercase; letter-spacing: 0.05em; }
.db-filter-select {
background: #FFFFFF; border: 1px solid #2a3547; color: #494F4F;
border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%;
}
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #494F4F; cursor: pointer; }
.db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; }
.db-filter-reset {
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
color: var(--color-re-teal); border-radius: 6px; padding: 8px;
cursor: pointer; font-size: 0.8125rem; font-weight: 600;
transition: background 0.15s;
}
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
/* ── Tres miradas: tabs ── */
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-tab {
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #6B7878;
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
}
.db-tab:hover { color: #494F4F; }
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
/* ── Tres miradas: intro de cada lente ── */
.db-lens-intro {
background: #F0EFEE; border-left: 3px solid var(--color-re-teal);
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
font-size: 0.8125rem; color: #494F4F; line-height: 1.5;
}
/* ── Card destacada (estrella) ── */
.db-widget-star {
border-color: var(--color-re-teal);
}
.db-widget-star .db-widget-title { color: #494F4F; }
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
.db-big-stat-warn { color: #f0b429; }
.db-big-stat-danger { color: #f06b6b; }
.db-big-stat-sub { font-size: 0.75rem; color: #8A9A9A; margin-top: 6px; line-height: 1.4; }
/* ── Punto ciego: contraste de dos barras ── */
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
.db-gap-col { text-align: center; }
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
.db-gap-lab { font-size: 0.6875rem; color: #8A9A9A; margin-top: 6px; }
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #9A7200; margin-top: 10px; }
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
.db-placeholder {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; min-height: 220px; text-align: center; color: #8A9A9A;
background: #F0EFEE; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
padding: 32px 24px;
}
.db-placeholder-icon { font-size: 1.75rem; }
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
/* ── Nav compartido (dashboard / respuestas / usuarios) ── */
.admin-nav-bar {
display: flex; align-items: center; justify-content: space-between;
gap: 16px; padding: 10px 24px; background: var(--color-surface);
border-bottom: 1px solid var(--color-border); flex-wrap: wrap;
}
.admin-nav { display: flex; gap: 4px; }
.admin-nav-link {
padding: 6px 14px; border-radius: var(--radius); font-size: 0.875rem;
font-weight: 600; color: var(--color-text-muted); text-decoration: none;
transition: background 0.15s, color 0.15s;
}
.admin-nav-link:hover { background: var(--color-primary-light); color: var(--color-text); }
.admin-nav-link-active { background: var(--color-primary-light); color: var(--color-primary-hover); }
/* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 16px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.admin-refresh {
display: inline-block; padding: 8px 16px; border-radius: var(--radius);
background: var(--color-primary); color: #fff; font-size: 0.8125rem;
font-weight: 600; text-decoration: none; white-space: nowrap;
}
.admin-refresh:hover { background: var(--color-primary-hover); }
.admin-survey-select {
font-size: 0.8125rem; padding: 6px 10px; border-radius: var(--radius);
border: 1px solid var(--color-border); color: var(--color-text);
background: var(--color-surface);
}
.admin-action-link {
font-size: 0.75rem; font-weight: 600; color: var(--color-primary);
text-decoration: none;
}
.admin-action-link:hover { text-decoration: underline; }
.admin-pagination {
display: flex; align-items: center; justify-content: center; gap: 16px;
margin-top: 16px; font-size: 0.8125rem;
}
.admin-pagination-link { color: var(--color-primary); text-decoration: none; font-weight: 600; }
.admin-pagination-link:hover { text-decoration: underline; }
.admin-pagination-disabled { color: var(--color-text-muted); cursor: default; }
.admin-pagination-current { color: var(--color-text-muted); }
.admin-back-link {
display: inline-block; margin-bottom: 12px; font-size: 0.8125rem;
font-weight: 600; color: var(--color-primary); text-decoration: none;
}
.admin-back-link:hover { text-decoration: underline; }
.admin-detail-section { margin-bottom: 24px; }
.admin-detail-section-title {
font-size: 1.0625rem; font-weight: 700; color: var(--color-text);
margin: 0 0 10px;
}
.admin-detail-instance { margin-bottom: 14px; }
.admin-instance-badge {
display: inline-block; font-size: 0.75rem; font-weight: 600;
color: var(--color-primary-hover); background: var(--color-primary-light);
padding: 3px 10px; border-radius: 999px; margin-bottom: 6px;
}
.admin-table-wrapper {
overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border);
border-radius: var(--radius); box-shadow: var(--shadow-card);
}
.admin-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
.admin-table th {
position: sticky; top: 0; background: var(--color-re-dark); color: #fff;
font-weight: 600; text-align: left; padding: 10px 12px; white-space: nowrap;
}
.admin-table td { padding: 8px 12px; border-bottom: 1px solid var(--color-border); color: var(--color-text); vertical-align: top; }
.admin-table tbody tr:last-child td { border-bottom: none; }
.admin-table tbody tr.admin-group-even { background: var(--color-bg); }
.admin-table tbody tr.admin-group-start td { border-top: 2px solid var(--color-re-dark); }
.admin-table tbody tr:hover { background: var(--color-primary-light); }
.admin-prompt { color: var(--color-text); }
.admin-row-toggle { cursor: pointer; }
.admin-toggle-icon {
display: inline-block; width: 14px; margin-right: 4px;
color: var(--color-text-muted); font-size: 0.7rem;
}
.admin-row-toggle-btn {
background: none; border: none; padding: 0; margin: 0;
font: inherit; color: inherit; cursor: pointer;
}
.admin-nodata {
display: inline-block; padding: 1px 8px; border-radius: 10px;
background: var(--color-re-light); color: var(--color-re-dark);
opacity: 0.6; font-size: 0.7rem; font-style: italic;
}
.admin-empty { padding: 24px; text-align: center; color: var(--color-text-muted); }
.admin-error { color: var(--color-error); font-weight: 600; }
.admin-error-detail {
background: var(--color-error-bg); border-radius: var(--radius);
padding: 12px; font-family: 'Courier New', monospace; font-size: 0.75rem;
overflow-x: auto;
}
/* ── Responsive ── */
@media (max-width: 768px) {
.db-kpi-row { grid-template-columns: repeat(2,1fr); }
.db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-page { flex-direction: column; }
.admin-col-optional { display: none; }
}
/* ── Reduced motion ── */ /* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
} }
/* ── Login ── */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: 24px;
}
.login-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 40px 36px;
width: 100%;
max-width: 380px;
}
.login-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin: 0 0 28px;
}
.login-form { display: flex; flex-direction: column; gap: 16px; }
.login-field { display: flex; flex-direction: column; gap: 6px; }
.login-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.login-error { margin: 0; }
.login-password-wrapper { position: relative; }
.login-password-wrapper .text-short-input { padding-right: 40px; }
.login-password-toggle {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: var(--radius);
color: var(--color-text-muted);
cursor: pointer;
}
.login-password-toggle:hover { color: var(--color-text); }
.login-submit {
margin-top: 8px;
padding: 10px 20px;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius);
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
.login-back-button { margin-top: 16px; }
.login-forgot-link {
align-self: center;
margin-top: 4px;
padding: 4px;
background: none;
border: none;
color: var(--color-text-muted);
font-size: 0.8125rem;
cursor: pointer;
text-decoration: underline;
}
.login-forgot-link:hover { color: var(--color-primary-hover); }
/* ── Invite form (/admin/usuarios) ── */
.invite-form { display: flex; flex-direction: column; gap: 12px; }
.invite-form-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
align-items: end;
}
.invite-banner {
margin: 0;
padding: 10px 14px;
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
}
.invite-banner-success {
background: var(--color-primary-light);
color: var(--color-primary-hover);
}
.invite-banner-error {
background: var(--color-error-bg);
color: var(--color-error);
}
@media (max-width: 768px) {
.invite-form-row { grid-template-columns: 1fr; }
}
/* ── Admin sign-out ── */
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
.admin-signout {
font-size: 0.8125rem;
color: var(--color-text-muted);
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 5px 12px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }

View File

@@ -1,5 +1,7 @@
import type { Metadata, Viewport } from 'next' import type { Metadata, Viewport } from 'next'
import './globals.css' import './globals.css'
import { SurveyHeader } from '@/components/survey-header'
import { SurveyFooter } from '@/components/survey-footer'
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Mapeo de Cuidadores', title: 'Mapeo de Cuidadores',
@@ -17,7 +19,11 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="es"> <html lang="es">
<body>{children}</body> <body>
<SurveyHeader />
<div className="layout-content">{children}</div>
<SurveyFooter />
</body>
</html> </html>
) )
} }

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,21 +1,34 @@
import { createClient } from '@/lib/supabase' import { createClient } from '@/lib/supabase'
import type { 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.
// Never statically generated: credentials live in .env.local (runtime only).
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// Server Component — fetches survey with anon key; RLS ensures only status='live' is returned. // In Next.js 15, searchParams is a Promise and must be awaited.
export default async function Page() { interface PageProps {
searchParams: Promise<{ s?: string }>
}
export default async function Page({ searchParams }: PageProps) {
const { s: surveyId } = await searchParams
const supabase = createClient() const supabase = createClient()
// 1. Fetch the live survey // 1. Fetch survey — by ?s=UUID if provided, otherwise first live survey
const { data: surveyRow, error: surveyError } = await supabase const surveyQuery = supabase
.from('surveys') .from('surveys')
.select('id, title, status, is_anonymous, k_threshold') .select('id, org_id, title, status, is_anonymous, k_threshold, consent_version_id, brand_config')
.eq('status', 'live')
.maybeSingle() const { data: surveyRow, error: surveyError } = surveyId
? await surveyQuery.eq('id', surveyId).maybeSingle()
: await surveyQuery.eq('status', 'live').maybeSingle()
if (surveyError) { if (surveyError) {
return ( return (
@@ -32,20 +45,22 @@ export default async function Page() {
<div className="no-survey" role="main"> <div className="no-survey" role="main">
<h1 className="no-survey-title">No hay encuesta activa</h1> <h1 className="no-survey-title">No hay encuesta activa</h1>
<p> <p>
Para ver el formulario, la encuesta debe estar en estado <code>live</code>. Para ver el formulario, la encuesta debe estar en estado <code>live</code>,
o pasá el UUID por parámetro: <code>?s=UUID</code>.
</p> </p>
<pre className="no-survey-hint"> <pre className="no-survey-hint">
{`-- Activar la encuesta en Supabase Cloud:\nUPDATE surveys\nSET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`} {`-- Activar en Supabase Cloud:\nUPDATE surveys SET status = 'live'\nWHERE id = '30000000-0000-0000-0000-000000000001';`}
</pre> </pre>
</div> </div>
) )
} }
// 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 })
@@ -59,22 +74,29 @@ export default async function Page() {
) )
} }
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[]
// 3. Log question count for validation (visible in server logs and browser Network tab) console.log(`[4D] Survey loaded: id=${surveyRow.id} | questions=${engineQuestions.length}`)
console.log(
`[2A] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`
)
const survey: Survey = { const survey: Survey = {
...(surveyRow as Omit<Survey, 'questions'>), id: surveyRow.id,
questions, org_id: surveyRow.org_id,
title: surveyRow.title,
status: surveyRow.status,
is_anonymous: surveyRow.is_anonymous,
k_threshold: surveyRow.k_threshold,
consent_version_id: surveyRow.consent_version_id,
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
questions: [],
} }
return ( // El consentimiento (S1, 1.1, 1.2) lo resuelve ConsentScreen, no el motor — ver nota arriba.
<SurveyForm const schema: FormSchema = buildFormSchema(
survey={survey} engineQuestions.filter((q) => !CONSENT_CODES.has(q.code))
questions={questions}
/>
) )
return <WizardForm survey={survey} schema={schema} />
} }

BIN
assets/.DS_Store vendored Normal file

Binary file not shown.

BIN
assets/fundacion/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,380 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 16.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="535.614px" height="308.44px" viewBox="0 0 535.614 308.44" enable-background="new 0 0 535.614 308.44"
xml:space="preserve">
<path fill-rule="evenodd" clip-rule="evenodd" fill="#129539" d="M181.184,99.452c-0.34-0.962-0.875-1.782-1.596-2.409
c-0.729-0.643-1.628-1.059-2.608-1.183c-2.093-0.275-4.58,0.708-6.829,3.731c-1.76,1.805-4.482,5.509-7.164,9.176
c-2.851,3.915-5.667,7.738-7.12,9.029c-0.727,0.664-1.556,0.967-2.206,0.945c-0.281-0.027-0.548-0.098-0.732-0.238h-0.021
c-0.165-0.14-0.303-0.323-0.4-0.588c-0.167-0.584-0.046-1.432,0.492-2.549c2.257-4.683,6.057-12.303,9.529-19.074
c2.979-5.827,5.676-11.001,6.939-13.166c2.279-3.803,2.374-7.205,1.248-9.457c-0.494-0.939-1.199-1.711-2.065-2.182
c-0.843-0.491-1.855-0.723-2.933-0.604c-2.441,0.232-5.228,2.177-7.482,6.47c-2.673,5.163-5.185,9.78-7.674,14.381
c-2.981,5.466-5.913,10.86-8.935,16.779c-0.521,0.815-1.193,1.426-1.828,1.701c-0.289,0.124-0.524,0.194-0.737,0.151
c-0.162-0.027-0.319-0.124-0.44-0.308c-0.259-0.324-0.421-0.913-0.421-1.831c-0.027-3.052,3.124-12.577,5.627-20.154
c1.037-3.191,1.971-6.021,2.536-7.933c0.891-3.116,2.374-8.484,3.521-12.945c1.032-3.937,1.828-7.177,1.828-7.625
c-0.021-2.23-0.516-3.916-1.291-5.114c-0.748-1.129-1.709-1.81-2.819-2.128c-1.075-0.34-2.252-0.286-3.391,0.043
c-1.512,0.47-2.962,1.432-4.094,2.787c-1.755,2.139-4.102,10.217-6.045,16.968l-0.608,2.058c-0.542,1.879-1.574,5.59-2.724,9.764
c-2.09,7.646-4.623,16.871-5.422,19.031c-0.373,1.053-1.01,1.798-1.685,2.149c-0.275,0.146-0.591,0.216-0.851,0.194
c-0.232-0.027-0.438-0.124-0.632-0.308c-0.395-0.378-0.702-1.097-0.748-2.252c-0.327-5.654,0.256-13.118,0.845-20.506
c0.491-6.194,0.958-12.313,0.915-17.319c0.235-3.32-0.842-5.643-2.468-6.934c-0.775-0.632-1.685-1.01-2.646-1.156
c-0.942-0.135-1.928-0.021-2.86,0.324c-2.068,0.756-3.896,2.685-4.601,5.676c-1.269,5.455-1.925,13.686-2.533,21.311
c-0.588,7.463-1.15,14.332-2.16,17.222c-0.448,1.215-1.199,3.24-2.136,4.854c-0.821,1.383-1.736,2.506-2.536,2.35
c-2.533-0.48-2.859-2.587-3.14-4.396c-0.046-0.389-0.119-0.793-0.189-1.16c-0.235-1.912-0.603-3.904-1.078-5.801
c-0.448-1.922-1.007-3.774-1.593-5.4c-1.064-2.835-3.005-5.551-5.239-7.344c-1.08-0.838-2.249-1.496-3.416-1.831
c-1.226-0.318-2.468-0.318-3.664,0.118c-1.013,0.373-1.974,1.086-2.841,2.166c-2.136,2.674-0.632,6.658,1.288,11.849
c1.95,5.228,4.393,11.751,3.832,19.042c-0.127,1.522-0.24,3.057-0.383,4.53c-0.762,9.202-1.38,17.33-0.148,26.965
c1.045,0.34,2.092,0.681,3.181,1.074c0,0-0.256-0.967,1.248-11.789c2.787-20.073,27.423-29.054,45.787-18.923
c12.418,6.854,17.333,18.696,18.124,31.619c3.98-6.604,6.618-12.285,7.59-15.429c0.443-1.501,2.959-5.396,6-9.808
c4.577-6.62,10.398-14.321,12.461-16.8C181.532,104.62,181.951,101.683,181.184,99.452z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#163F75" d="M153.942,161.811c-0.454,0-0.856,0-1.302,0
c-0.934,8.527-3.151,17-5.765,24.361c-8.868,25.03-8.111,45.039-45.396,37.861c-11.679-2.246-24.604-4.73-36.032-8.883
c14.938,42.295,79.458,45.109,90.397,6.248C159.669,207.789,161.961,181.332,153.942,161.811z"/>
<path fill-rule="evenodd" clip-rule="evenodd" fill="#E00914" d="M91.254,160.644c3.381,2.273-0.47-4.86,2.627-17.275
c4.412-17.567,20.135-29.179,36.528-20.128c21.699,11.968,17.783,40.968,10.744,60.857c-7.925,22.357-7.272,40.238-40.563,33.833
c-26.197-5.065-59.521-11.405-60.733-39.601C39.311,165.963,57.051,133.426,91.254,160.644L91.254,160.644z"/>
<path fill="#E00914" d="M199.191,221.652c4.323,0,7.555-1.291,9.834-3.916c1.733-1.943,2.6-4.504,2.6-7.604
c0-3.37-1.288-6.658-3.823-9.872c-1.167-1.458-2.487-2.884-3.848-4.271c-3.378-3.327-5.77-6.118-7.131-8.489
c-1.547-2.582-2.301-5.298-2.301-8.182c0-3.041,0.548-5.471,1.577-7.302c1.245-2.208,3.07-3.326,5.422-3.326
c3.256,0,5.365,1.998,6.421,5.908c0.467,1.755,0.678,3.71,0.678,5.789c0,2.819-0.278,5.54-0.842,8.235
c-0.07,0.384-0.116,0.751-0.116,1.129c0,1.388,0.681,2.09,2.063,2.09c1.528,0,2.605-1.388,3.243-4.175
c0.488-2.024,0.721-4.46,0.721-7.279c0-11.276-4.404-16.888-13.25-16.888c-4.226,0-7.625,1.167-10.158,3.56
c-1.312,1.22-2.344,2.689-3.075,4.438c-1.007,2.463-1.526,5.282-1.526,8.377c0,4.525,1.196,8.678,3.621,12.383
c1.658,2.581,4.072,5.465,7.345,8.635c1.142,1.178,2.228,2.279,3.227,3.381c2.182,2.42,3.238,4.672,3.238,6.756
c0,3.262-1.48,4.903-4.429,4.903c-2.651,0-4.366-1.388-5.076-4.196c-0.33-1.145-0.486-2.327-0.486-3.45
c0-1.858-0.751-2.792-2.252-2.792c-1.645,0-2.671,1.571-2.671,3.64c0,3.656,0.772,6.54,2.387,8.646
C192.557,220.361,195.419,221.652,199.191,221.652L199.191,221.652z M234.038,216.235c-3.008,0-5.238-1.755-6.71-5.249
c-1.296-3.121-1.947-7.345-1.947-12.691c0-10.963,3.138-16.428,9.402-16.428c2.889,0,4.95,1.782,6.267,5.373
c1.013,2.781,1.499,6.243,1.499,10.461c0,5.093-0.635,9.321-1.877,12.669C239.192,214.291,236.989,216.235,234.038,216.235
L234.038,216.235z M231.667,221.587c5.819,0,10.134-2.278,12.853-6.848c2.39-4.062,3.591-9.612,3.591-16.703
c0-5.563-0.872-10.164-2.632-13.749c-2.417-4.926-6.189-7.394-11.395-7.394c-6.011,0-10.487,2.273-13.425,6.874
c-2.603,3.991-3.918,9.451-3.918,16.348c0,5.897,1.034,10.66,3.124,14.365C222.519,219.238,226.48,221.587,231.667,221.587
L231.667,221.587z M263.008,221.728c0.889,0,1.974-0.141,3.211-0.491c1.218-0.33,2.233-0.768,2.983-1.41
c0.554-0.47,0.861-0.988,0.861-1.62c0-0.794-0.486-1.198-1.499-1.198c-0.324,0-0.821,0.075-1.477,0.265
c-0.426,0.118-0.918,0.184-1.474,0.184c-1.855,0-2.187-1.523-2.187-9.219v-38.548c0-1.62-0.284-2.9-0.77-3.915
c-0.732-1.523-2.001-2.274-3.751-2.274c-3.103,0-4.137,1.707-4.137,6.497v33.126c0,2.933,0.076,5.697,0.262,8.214
c0.21,2.717,0.772,4.925,1.663,6.681C257.958,220.485,260.049,221.728,263.008,221.728L263.008,221.728z M279.279,174.36
c1.653,0,2.825-0.61,3.594-1.755c0.545-0.777,0.805-1.717,0.805-2.797c0-1.193-0.4-2.317-1.205-3.327
c-0.767-1.01-1.831-1.534-3.122-1.534c-2.53,0-4.331,2.215-4.331,5.331C275.021,173,276.447,174.36,279.279,174.36L279.279,174.36z
M279.123,221.652c3.019,0,4.556-2.08,4.556-6.238v-32.023c0-4.828-1.129-6.497-4.129-6.497c-2.983,0-4.528,2.16-4.528,6.199v32.02
c0,1.102,0.089,2.133,0.232,3.143C275.583,220.556,276.849,221.652,279.123,221.652L279.123,221.652z M309.905,193.721
c0,2.9-0.237,5.936-0.683,9.073c-0.421,3.1-1.08,5.746-1.947,7.884c-1.571,3.965-3.542,5.936-5.935,5.936
c-3.029,0.162-3.988-6.729-3.988-14.009c0-4.963,1.482-20.43,8.587-20.43c1.949,0,3.138,0.47,3.634,1.447
c0.211,0.476,0.332,1.037,0.332,1.642V193.721z M318.535,169.997c0-3.208-0.138-6.497-4.131-6.497c-3.467,0-4.499,3.009-4.499,6.189
v7.458c0,0.572-0.078,0.875-0.259,0.875c-0.189,0-0.5-0.119-0.867-0.303c-1.083-0.572-2.279-0.826-3.521-0.826
c-4.698,0-8.352,2.673-11.006,8.073c-2.481,5.109-3.729,11.806-3.729,20.035c0,8.83,3.238,16.796,9.602,16.65
c3.375,0,6.186-1.756,8.435-5.229c0.662-1.068,1.04-1.642,1.153-1.642s0.191,0.232,0.191,0.632c0,3.191,1.032,6.238,4.499,6.238
c3.994,0,4.131-3.278,4.131-6.54V169.997z M348.777,221.652c3.964,0.075,4.061-3.322,4.129-6.54v-31.722
c-0.068-3.139-0.165-6.563-4.129-6.497c-1.385,0-2.443,0.427-3.075,1.226c-0.208,0.281-0.462,0.416-0.815,0.416
c-0.241,0-0.6-0.107-0.97-0.329c-1.501-0.864-2.951-1.313-4.355-1.313c-4.72,0-8.387,2.673-11.03,8.073
c-2.484,5.109-3.729,11.806-3.729,20.035c0,8.83,3.265,16.796,9.597,16.65c3.794,0,6.826-2.08,9.121-6.26
c0.259-0.496,0.448-0.729,0.567-0.729c0.116,0,0.167,0.275,0.167,0.75C344.254,218.605,345.299,221.652,348.777,221.652
L348.777,221.652z M335.616,216.613c-3.027,0.162-3.991-6.729-3.991-14.009c0-4.963,1.477-20.43,8.586-20.43
c2.7,0,4.042,0.967,4.042,2.911v10.342c0,1.879-0.216,4.336-0.664,7.366c-0.467,3.029-1.128,5.681-2.042,7.884
C339.999,214.643,338.006,216.613,335.616,216.613L335.616,216.613z M365.996,221.652c3.027,0,4.525-2.08,4.525-6.238v-9.996
c0-6.988,0.889-12.664,2.649-17c1.763-4.391,3.78-6.551,5.986-6.551c0.651,0,1.212,0.211,1.712,0.653
c0.632,0.572,1.24,0.87,1.828,0.87c1.62,0,2.887-1.664,2.887-3.44c0-1.977-2.322-3.057-5.16-3.057c-3.705,0-6.731,2.068-9.081,6.199
c-0.259,0.486-0.489,0.751-0.629,0.751c-0.122,0-0.192-0.265-0.192-0.805c0-4.104-1.499-6.146-4.525-6.146
c-3.894,0-4.128,3.262-4.128,6.448v31.771c0,1.069,0.097,2.133,0.235,3.143C362.429,220.556,363.739,221.652,365.996,221.652
L365.996,221.652z M393.254,174.36c1.645,0,2.841-0.61,3.616-1.755c0.516-0.777,0.799-1.717,0.799-2.797
c0-1.193-0.427-2.317-1.207-3.327c-0.789-1.01-1.823-1.534-3.138-1.534c-2.533,0-4.315,2.215-4.315,5.331
C389.01,173,390.414,174.36,393.254,174.36L393.254,174.36z M393.12,221.652c2.997,0,4.55-2.08,4.55-6.238v-32.023
c0-4.828-1.134-6.497-4.137-6.497c-2.997,0-4.523,2.16-4.523,6.199v32.02c0,1.102,0.067,2.133,0.229,3.143
C389.571,220.556,390.838,221.652,393.12,221.652L393.12,221.652z M423.867,193.721c0,2.9-0.205,5.936-0.656,9.073
c-0.418,3.1-1.077,5.746-1.95,7.884c-1.566,3.965-3.534,5.936-5.951,5.936c-3.029,0.162-3.999-6.729-3.999-14.009
c0-4.963,1.51-20.43,8.619-20.43c1.917,0,3.122,0.47,3.613,1.447c0.235,0.476,0.324,1.037,0.324,1.642V193.721z M432.529,169.997
c0-3.208-0.167-6.497-4.128-6.497c-3.473,0-4.534,3.009-4.534,6.189v7.458c0,0.572-0.043,0.875-0.254,0.875
c-0.184,0-0.467-0.119-0.867-0.303c-1.083-0.572-2.255-0.826-3.494-0.826c-4.72,0-8.378,2.673-11.003,8.073
c-2.511,5.109-3.731,11.806-3.731,20.035c0,8.83,3.237,16.796,9.575,16.65c3.4,0,6.187-1.756,8.444-5.229
c0.675-1.068,1.053-1.642,1.169-1.642c0.119,0,0.162,0.232,0.162,0.632c0,3.191,1.061,6.238,4.534,6.238
c3.961,0,4.128-3.278,4.128-6.54V169.997z M462.744,221.652c3.985,0.075,4.058-3.322,4.15-6.54v-31.722
c-0.092-3.139-0.165-6.563-4.15-6.497c-1.364,0-2.417,0.427-3.049,1.226c-0.21,0.281-0.494,0.416-0.823,0.416
c-0.257,0-0.607-0.107-0.961-0.329c-1.501-0.864-2.957-1.313-4.391-1.313c-4.714,0-8.373,2.673-10.995,8.073
c-2.492,5.109-3.737,11.806-3.737,20.035c0,8.83,3.24,16.796,9.578,16.65c3.82,0,6.848-2.08,9.121-6.26
c0.259-0.496,0.454-0.729,0.567-0.729c0.119,0,0.178,0.275,0.178,0.75C458.232,218.605,459.271,221.652,462.744,221.652
L462.744,221.652z M449.602,216.613c-3.024,0.162-4.001-6.729-4.001-14.009c0-4.963,1.499-20.43,8.605-20.43
c2.697,0,4.026,0.967,4.026,2.911v10.342c0,1.879-0.203,4.336-0.648,7.366c-0.467,3.029-1.131,5.681-2.044,7.884
C453.974,214.643,452,216.613,449.602,216.613L449.602,216.613z M492.913,193.721c0,2.9-0.208,5.936-0.656,9.073
c-0.427,3.1-1.08,5.746-1.95,7.884c-1.563,3.965-3.54,5.936-5.935,5.936c-3.027,0.162-4.013-6.729-4.013-14.009
c0-4.963,1.507-20.43,8.614-20.43c1.944,0,3.143,0.47,3.616,1.447c0.232,0.476,0.324,1.037,0.324,1.642V193.721z M501.573,169.997
c0-3.208-0.148-6.497-4.137-6.497c-3.461,0-4.523,3.009-4.523,6.189v7.458c0,0.572-0.048,0.875-0.237,0.875
c-0.203,0-0.492-0.119-0.867-0.303c-1.102-0.572-2.274-0.826-3.519-0.826c-4.717,0-8.373,2.673-11.003,8.073
c-2.484,5.109-3.729,11.806-3.729,20.035c0,8.83,3.24,16.796,9.57,16.65c3.405,0,6.224-1.756,8.449-5.229
c0.681-1.068,1.053-1.642,1.169-1.642c0.119,0,0.167,0.232,0.167,0.632c0,3.191,1.062,6.238,4.523,6.238
c3.988,0,4.137-3.278,4.137-6.54V169.997z"/>
<path fill="#2A2A2B" d="M190.7,230.309v11.546h-1.399v-11.546H190.7z M194.639,236.648v5.206h-1.334v-8.651h1.288v1.361h0.122
c0.213-0.454,0.516-0.805,0.913-1.086c0.424-0.265,0.964-0.395,1.62-0.395c0.864,0,1.574,0.275,2.111,0.816
c0.54,0.518,0.821,1.365,0.821,2.462v5.492h-1.336v-5.4c0-0.702-0.184-1.215-0.54-1.587c-0.348-0.4-0.824-0.589-1.458-0.589
c-0.632,0-1.145,0.21-1.569,0.637C194.853,235.314,194.639,235.903,194.639,236.648L194.639,236.648z M206.377,233.203v1.123h-1.855
v5.045c0,0.399,0.07,0.652,0.165,0.842c0.122,0.194,0.256,0.335,0.427,0.378c0.181,0.07,0.367,0.098,0.556,0.098
c0.138,0,0.281,0,0.375-0.027c0.092-0.026,0.162-0.026,0.213-0.043l0.281,1.188c-0.097,0.048-0.211,0.069-0.381,0.118
c-0.159,0.021-0.373,0.043-0.629,0.043c-0.378,0-0.721-0.064-1.099-0.237c-0.356-0.156-0.656-0.41-0.894-0.751
c-0.24-0.318-0.351-0.739-0.351-1.231v-5.422h-1.293v-1.123h1.293v-2.073h1.337v2.073H206.377z M211.915,242.044
c-0.826,0-1.55-0.189-2.144-0.557c-0.607-0.389-1.075-0.896-1.41-1.556c-0.324-0.675-0.486-1.457-0.486-2.344
c0-0.863,0.162-1.663,0.486-2.349c0.335-0.675,0.781-1.199,1.364-1.566c0.594-0.41,1.267-0.589,2.071-0.589
c0.442,0,0.885,0.07,1.336,0.232c0.418,0.141,0.845,0.4,1.199,0.724c0.346,0.33,0.653,0.772,0.861,1.34
c0.208,0.545,0.308,1.22,0.308,2.041v0.562h-6.284c0.046,0.918,0.303,1.62,0.789,2.111c0.494,0.497,1.134,0.751,1.909,0.751
c0.535,0,0.978-0.113,1.361-0.324c0.37-0.237,0.651-0.562,0.815-1.036l1.293,0.372c-0.216,0.664-0.61,1.172-1.218,1.583
C213.551,241.833,212.803,242.044,211.915,242.044L211.915,242.044z M209.217,236.838h4.95c0-0.734-0.219-1.334-0.665-1.831
c-0.416-0.47-0.982-0.729-1.706-0.729c-0.524,0-0.967,0.113-1.315,0.351c-0.375,0.238-0.688,0.567-0.894,0.939
C209.377,235.969,209.263,236.395,209.217,236.838L209.217,236.838z M217.54,241.854v-8.651h1.291v1.307h0.076
c0.154-0.416,0.435-0.767,0.861-1.053c0.427-0.254,0.87-0.395,1.41-0.395c0.089,0,0.208,0,0.351,0
c0.143,0.021,0.262,0.021,0.334,0.021v1.367c-0.021-0.033-0.143-0.033-0.311-0.06c-0.146-0.021-0.327-0.044-0.494-0.044
c-0.632,0-1.145,0.189-1.569,0.589c-0.429,0.378-0.604,0.87-0.604,1.458v5.46H217.54z M230.797,233.203l-3.189,8.651h-1.363
l-3.219-8.651h1.455l2.393,6.891h0.097l2.395-6.891H230.797z M235.857,242.044c-0.807,0-1.539-0.189-2.155-0.557
c-0.583-0.389-1.056-0.896-1.382-1.556c-0.33-0.675-0.491-1.457-0.491-2.344c0-0.863,0.162-1.663,0.491-2.349
c0.327-0.675,0.772-1.199,1.361-1.566c0.589-0.41,1.269-0.589,2.068-0.589c0.443,0,0.886,0.07,1.334,0.232
c0.416,0.141,0.848,0.4,1.193,0.724c0.354,0.33,0.665,0.772,0.87,1.34c0.21,0.545,0.305,1.22,0.305,2.041v0.562h-6.286
c0.046,0.918,0.302,1.62,0.796,2.111c0.492,0.497,1.129,0.751,1.896,0.751c0.545,0,0.988-0.113,1.364-0.324
c0.375-0.237,0.659-0.562,0.826-1.036l1.285,0.372c-0.216,0.664-0.607,1.172-1.215,1.583
C237.5,241.833,236.751,242.044,235.857,242.044L235.857,242.044z M233.166,236.838h4.952c0-0.734-0.21-1.334-0.664-1.831
c-0.419-0.47-1.007-0.729-1.704-0.729c-0.521,0-0.967,0.113-1.315,0.351c-0.375,0.238-0.686,0.567-0.891,0.939
C233.333,235.969,233.211,236.395,233.166,236.838L233.166,236.838z M242.83,236.648v5.206h-1.337v-8.651h1.293v1.361h0.089
c0.213-0.454,0.516-0.805,0.939-1.086c0.397-0.265,0.945-0.395,1.596-0.395c0.87,0,1.596,0.275,2.136,0.816
c0.535,0.518,0.793,1.365,0.793,2.462v5.492h-1.333v-5.4c0-0.702-0.162-1.215-0.516-1.587c-0.354-0.4-0.839-0.589-1.453-0.589
c-0.634,0-1.177,0.21-1.593,0.637C243.013,235.314,242.83,235.903,242.83,236.648L242.83,236.648z M254.303,242.044
c-0.821,0-1.526-0.189-2.117-0.578c-0.556-0.384-1.023-0.896-1.334-1.577c-0.327-0.68-0.467-1.452-0.467-2.301
c0-0.891,0.14-1.663,0.467-2.349c0.333-0.675,0.802-1.199,1.383-1.566c0.588-0.41,1.269-0.589,2.049-0.589
c0.607,0,1.14,0.119,1.636,0.352c0.492,0.205,0.889,0.535,1.199,0.935c0.324,0.399,0.519,0.896,0.581,1.437h-1.333
c-0.089-0.405-0.327-0.751-0.659-1.064c-0.346-0.291-0.815-0.464-1.404-0.464c-0.778,0-1.412,0.308-1.874,0.891
c-0.494,0.583-0.729,1.388-0.729,2.371c0,0.982,0.235,1.798,0.708,2.392c0.464,0.616,1.096,0.913,1.896,0.913
c0.513,0,0.964-0.135,1.336-0.394c0.378-0.287,0.613-0.638,0.727-1.129h1.333c-0.062,0.518-0.256,0.988-0.556,1.388
c-0.283,0.421-0.688,0.756-1.15,0.978C255.499,241.925,254.935,242.044,254.303,242.044L254.303,242.044z M259.652,241.854v-8.651
h1.312v8.651H259.652z M260.33,231.767c-0.281,0-0.494-0.092-0.678-0.28c-0.184-0.162-0.284-0.379-0.284-0.633
c0-0.253,0.1-0.469,0.284-0.637c0.184-0.184,0.397-0.248,0.678-0.248c0.256,0,0.472,0.064,0.664,0.248
c0.184,0.168,0.275,0.384,0.275,0.637c0,0.254-0.092,0.471-0.275,0.633C260.802,231.675,260.586,231.767,260.33,231.767
L260.33,231.767z M266.923,242.044c-0.775,0-1.453-0.189-2.039-0.557c-0.591-0.389-1.059-0.896-1.385-1.556
c-0.332-0.675-0.492-1.457-0.492-2.344c0-0.912,0.16-1.685,0.492-2.37c0.327-0.681,0.794-1.199,1.385-1.577
c0.586-0.378,1.264-0.557,2.039-0.557c0.772,0,1.477,0.179,2.065,0.557c0.589,0.378,1.029,0.896,1.364,1.577
c0.324,0.686,0.491,1.458,0.491,2.37c0,0.887-0.167,1.669-0.491,2.344c-0.335,0.659-0.775,1.167-1.364,1.556
C268.4,241.854,267.695,242.044,266.923,242.044L266.923,242.044z M266.923,240.845c0.588,0,1.077-0.156,1.477-0.438
c0.375-0.313,0.651-0.729,0.848-1.226c0.187-0.491,0.284-1.026,0.284-1.594c0-0.588-0.097-1.134-0.284-1.619
c-0.197-0.519-0.473-0.913-0.848-1.227c-0.4-0.291-0.889-0.464-1.477-0.464c-0.586,0-1.075,0.173-1.453,0.464
c-0.408,0.313-0.683,0.708-0.875,1.227c-0.187,0.485-0.256,1.031-0.256,1.619c0,0.567,0.07,1.103,0.256,1.594
c0.192,0.497,0.467,0.912,0.875,1.226C265.849,240.688,266.337,240.845,266.923,240.845L266.923,240.845z M266.313,232.031
l1.382-2.635h1.553l-1.763,2.635H266.313z M274.222,236.648v5.206h-1.342v-8.651h1.288v1.361h0.122
c0.189-0.454,0.491-0.805,0.918-1.086c0.424-0.265,0.937-0.395,1.596-0.395c0.889,0,1.593,0.275,2.128,0.816
c0.542,0.518,0.805,1.365,0.805,2.462v5.492h-1.318v-5.4c0-0.702-0.192-1.215-0.54-1.587c-0.356-0.4-0.848-0.589-1.453-0.589
c-0.629,0-1.172,0.21-1.569,0.637C274.427,235.314,274.222,235.903,274.222,236.648L274.222,236.648z M287.45,245.214
c-0.208,0-0.416,0-0.605-0.049c-0.17-0.021-0.283-0.07-0.359-0.092l0.329-1.177c0.492,0.118,0.894,0.118,1.223-0.027
c0.327-0.113,0.608-0.508,0.84-1.145l0.284-0.821l-3.191-8.7h1.431l2.398,6.891h0.089l2.4-6.891h1.423l-3.702,10.066
C289.51,244.576,288.671,245.214,287.45,245.214L287.45,245.214z M303.8,233.203v1.123h-1.917v7.528h-1.342v-7.528h-1.401v-1.123
h1.401v-1.199c0-0.491,0.122-0.912,0.351-1.242c0.238-0.329,0.516-0.566,0.894-0.729c0.375-0.189,0.775-0.265,1.196-0.265
c0.33,0,0.586,0.032,0.797,0.075c0.216,0.076,0.356,0.125,0.467,0.162l-0.392,1.156c-0.076-0.032-0.173-0.07-0.287-0.098
c-0.119-0.021-0.256-0.049-0.472-0.049c-0.416,0-0.748,0.092-0.935,0.33c-0.189,0.216-0.278,0.54-0.278,0.988v0.869H303.8z
M308.854,242.044c-0.802,0-1.488-0.189-2.071-0.557c-0.589-0.389-1.053-0.896-1.388-1.556c-0.324-0.675-0.486-1.457-0.486-2.344
c0-0.912,0.162-1.685,0.486-2.37c0.335-0.681,0.799-1.199,1.388-1.577c0.583-0.378,1.269-0.557,2.071-0.557
c0.767,0,1.445,0.179,2.031,0.557s1.056,0.896,1.385,1.577c0.334,0.686,0.497,1.458,0.497,2.37c0,0.887-0.162,1.669-0.497,2.344
c-0.33,0.659-0.799,1.167-1.385,1.556C310.299,241.854,309.622,242.044,308.854,242.044L308.854,242.044z M308.854,240.845
c0.575,0,1.072-0.156,1.445-0.438c0.378-0.313,0.653-0.729,0.842-1.226c0.194-0.491,0.286-1.026,0.286-1.594
c0-0.588-0.092-1.134-0.286-1.619c-0.189-0.519-0.464-0.913-0.842-1.227c-0.373-0.291-0.87-0.464-1.445-0.464
c-0.624,0-1.11,0.173-1.488,0.464c-0.37,0.313-0.659,0.708-0.84,1.227c-0.194,0.485-0.283,1.031-0.283,1.619
c0,0.567,0.089,1.103,0.283,1.594c0.181,0.497,0.47,0.912,0.84,1.226C307.745,240.688,308.231,240.845,308.854,240.845
L308.854,240.845z M314.782,241.854v-8.651h1.285v1.307h0.1c0.165-0.416,0.443-0.767,0.867-1.053
c0.396-0.254,0.864-0.395,1.385-0.395c0.095,0,0.211,0,0.375,0c0.135,0.021,0.262,0.021,0.33,0.021v1.367
c-0.049-0.033-0.143-0.033-0.305-0.06c-0.167-0.021-0.33-0.044-0.516-0.044c-0.632,0-1.15,0.189-1.55,0.589
c-0.421,0.378-0.634,0.87-0.634,1.458v5.46H314.782z M320.689,241.854v-8.651h1.299v1.361h0.113c0.195-0.471,0.473-0.821,0.87-1.086
s0.891-0.395,1.445-0.395c0.569,0,1.063,0.13,1.439,0.395s0.678,0.615,0.891,1.086h0.1c0.208-0.454,0.532-0.821,0.977-1.086
c0.448-0.265,0.988-0.395,1.604-0.395c0.767,0,1.404,0.249,1.896,0.734c0.494,0.481,0.746,1.237,0.746,2.242v5.794h-1.334v-5.794
c0-0.633-0.165-1.102-0.519-1.361c-0.351-0.281-0.767-0.421-1.239-0.421c-0.61,0-1.083,0.188-1.41,0.562
c-0.33,0.356-0.513,0.826-0.513,1.383v5.632h-1.336v-5.93c0-0.497-0.165-0.896-0.491-1.183c-0.308-0.291-0.735-0.464-1.223-0.464
c-0.356,0-0.681,0.092-0.988,0.286c-0.278,0.178-0.538,0.442-0.702,0.75c-0.189,0.33-0.286,0.702-0.286,1.14v5.4H320.689z
M337.044,242.065c-0.537,0-1.031-0.119-1.474-0.335c-0.454-0.173-0.821-0.48-1.083-0.886c-0.259-0.394-0.396-0.886-0.396-1.452
c0-0.492,0.121-0.887,0.308-1.193c0.184-0.309,0.443-0.567,0.772-0.729c0.332-0.189,0.699-0.303,1.104-0.399
c0.37-0.092,0.77-0.168,1.194-0.211c0.513-0.064,0.942-0.119,1.271-0.162c0.321-0.021,0.583-0.098,0.721-0.194
c0.165-0.07,0.235-0.227,0.235-0.442v-0.044c0-0.566-0.165-0.988-0.47-1.29c-0.284-0.309-0.751-0.476-1.364-0.476
c-0.629,0-1.129,0.141-1.499,0.427c-0.351,0.281-0.602,0.588-0.748,0.891l-1.272-0.442c0.238-0.54,0.516-0.945,0.918-1.247
c0.378-0.281,0.772-0.486,1.223-0.616c0.464-0.108,0.886-0.179,1.331-0.179c0.286,0,0.594,0.038,0.967,0.092
c0.351,0.065,0.702,0.217,1.05,0.422c0.332,0.194,0.616,0.496,0.851,0.912c0.232,0.405,0.329,0.962,0.329,1.643v5.702h-1.317v-1.166
h-0.07c-0.094,0.178-0.229,0.394-0.448,0.604c-0.21,0.216-0.489,0.395-0.842,0.54C337.981,241.995,337.561,242.065,337.044,242.065
L337.044,242.065z M337.255,240.866c0.518,0,0.961-0.092,1.339-0.297c0.348-0.216,0.632-0.476,0.823-0.799
c0.187-0.335,0.278-0.687,0.278-1.037v-1.215c-0.07,0.069-0.189,0.113-0.373,0.161c-0.189,0.065-0.405,0.119-0.659,0.168
c-0.232,0.016-0.472,0.064-0.705,0.086c-0.235,0.027-0.421,0.049-0.562,0.076c-0.354,0.043-0.686,0.113-0.958,0.211
c-0.313,0.097-0.548,0.254-0.732,0.447c-0.184,0.217-0.281,0.471-0.281,0.795c0,0.469,0.162,0.82,0.51,1.058
C336.294,240.753,336.712,240.866,337.255,240.866L337.255,240.866z M346.971,242.044c-0.799,0-1.507-0.189-2.084-0.578
c-0.6-0.384-1.04-0.896-1.367-1.577c-0.302-0.68-0.467-1.452-0.467-2.301c0-0.891,0.165-1.663,0.491-2.349
c0.332-0.675,0.775-1.199,1.361-1.566c0.589-0.41,1.267-0.589,2.039-0.589c0.613,0,1.172,0.119,1.666,0.352
c0.473,0.205,0.873,0.535,1.194,0.935c0.308,0.399,0.5,0.896,0.591,1.437h-1.337c-0.097-0.405-0.324-0.751-0.678-1.064
c-0.348-0.291-0.823-0.464-1.409-0.464c-0.772,0-1.413,0.308-1.879,0.891c-0.475,0.583-0.705,1.388-0.705,2.371
c0,0.982,0.23,1.798,0.705,2.392c0.467,0.616,1.107,0.913,1.879,0.913c0.516,0,0.958-0.135,1.333-0.394
c0.378-0.287,0.635-0.638,0.753-1.129h1.337c-0.092,0.518-0.284,0.988-0.564,1.388c-0.302,0.421-0.678,0.756-1.169,0.978
C348.161,241.925,347.626,242.044,346.971,242.044L346.971,242.044z M352.317,241.854v-8.651h1.336v8.651H352.317z M352.995,231.767
c-0.254,0-0.486-0.092-0.678-0.28c-0.165-0.162-0.262-0.379-0.262-0.633c0-0.253,0.097-0.469,0.262-0.637
c0.192-0.184,0.424-0.248,0.678-0.248c0.259,0,0.5,0.064,0.659,0.248c0.195,0.168,0.287,0.384,0.287,0.637
c0,0.254-0.092,0.471-0.287,0.633C353.494,231.675,353.254,231.767,352.995,231.767L352.995,231.767z M359.613,242.044
c-0.799,0-1.482-0.189-2.06-0.557c-0.589-0.389-1.059-0.896-1.388-1.556c-0.329-0.675-0.491-1.457-0.491-2.344
c0-0.912,0.162-1.685,0.491-2.37c0.33-0.681,0.799-1.199,1.388-1.577c0.578-0.378,1.261-0.557,2.06-0.557
c0.778,0,1.453,0.179,2.042,0.557c0.591,0.378,1.056,0.896,1.38,1.577c0.334,0.686,0.497,1.458,0.497,2.37
c0,0.887-0.162,1.669-0.497,2.344c-0.324,0.659-0.789,1.167-1.38,1.556C361.065,241.854,360.391,242.044,359.613,242.044
L359.613,242.044z M359.613,240.845c0.591,0,1.08-0.156,1.453-0.438c0.405-0.313,0.681-0.729,0.845-1.226
c0.192-0.491,0.284-1.026,0.284-1.594c0-0.588-0.092-1.134-0.284-1.619c-0.165-0.519-0.44-0.913-0.845-1.227
c-0.373-0.291-0.861-0.464-1.453-0.464c-0.607,0-1.077,0.173-1.482,0.464c-0.37,0.313-0.651,0.708-0.837,1.227
c-0.189,0.485-0.281,1.031-0.281,1.619c0,0.567,0.092,1.103,0.281,1.594c0.186,0.497,0.467,0.912,0.837,1.226
C358.536,240.688,359.005,240.845,359.613,240.845L359.613,240.845z M359.005,232.031l1.385-2.635h1.544l-1.758,2.635H359.005z
M366.887,236.648v5.206h-1.342v-8.651h1.293v1.361h0.119c0.213-0.454,0.513-0.805,0.937-1.086c0.399-0.265,0.937-0.395,1.596-0.395
c0.869,0,1.571,0.275,2.114,0.816c0.535,0.518,0.818,1.365,0.818,2.462v5.492h-1.339v-5.4c0-0.702-0.157-1.215-0.535-1.587
c-0.351-0.4-0.827-0.589-1.456-0.589c-0.637,0-1.147,0.21-1.577,0.637C367.101,235.314,366.887,235.903,366.887,236.648
L366.887,236.648z M379.322,245.116v-11.913h1.291v1.383h0.162c0.094-0.168,0.208-0.356,0.402-0.589
c0.156-0.237,0.418-0.442,0.745-0.638c0.33-0.184,0.775-0.275,1.339-0.275c0.731,0,1.385,0.179,1.922,0.557
c0.567,0.356,1.007,0.869,1.313,1.556c0.302,0.659,0.472,1.452,0.472,2.37c0,0.908-0.17,1.713-0.472,2.365
c-0.305,0.687-0.746,1.199-1.288,1.556c-0.562,0.367-1.199,0.557-1.917,0.557c-0.57,0-0.991-0.098-1.348-0.281
c-0.324-0.188-0.583-0.399-0.767-0.632c-0.165-0.232-0.308-0.442-0.402-0.61h-0.116v4.596H379.322z M380.628,237.54
c0,0.966,0.219,1.761,0.643,2.392c0.416,0.616,1.029,0.913,1.85,0.913c0.535,0,1.016-0.135,1.391-0.438
c0.37-0.286,0.651-0.686,0.837-1.198c0.189-0.497,0.287-1.054,0.287-1.669c0-0.616-0.097-1.178-0.287-1.669
c-0.186-0.491-0.467-0.864-0.815-1.145c-0.396-0.309-0.851-0.448-1.412-0.448c-0.821,0-1.434,0.308-1.85,0.891
C380.847,235.785,380.628,236.557,380.628,237.54L380.628,237.54z M391.542,242.065c-0.542,0-1.061-0.119-1.504-0.335
c-0.445-0.173-0.799-0.48-1.056-0.886c-0.259-0.394-0.397-0.886-0.397-1.452c0-0.492,0.097-0.887,0.305-1.193
c0.186-0.309,0.445-0.567,0.77-0.729c0.33-0.189,0.678-0.303,1.088-0.399c0.4-0.092,0.794-0.168,1.191-0.211
c0.54-0.064,0.961-0.119,1.288-0.162c0.33-0.021,0.57-0.098,0.732-0.194c0.143-0.07,0.208-0.227,0.208-0.442v-0.044
c0-0.566-0.135-0.988-0.442-1.29c-0.308-0.309-0.751-0.476-1.361-0.476c-0.629,0-1.155,0.141-1.504,0.427
c-0.351,0.281-0.61,0.588-0.748,0.891l-1.269-0.442c0.213-0.54,0.516-0.945,0.891-1.247c0.378-0.281,0.802-0.486,1.25-0.616
c0.437-0.108,0.888-0.179,1.328-0.179c0.289,0,0.589,0.038,0.964,0.092c0.356,0.065,0.712,0.217,1.061,0.422
c0.324,0.194,0.602,0.496,0.84,0.912c0.216,0.405,0.332,0.962,0.332,1.643v5.702h-1.342v-1.166h-0.04
c-0.095,0.178-0.265,0.394-0.473,0.604c-0.208,0.216-0.489,0.395-0.824,0.54C392.479,241.995,392.059,242.065,391.542,242.065
L391.542,242.065z M391.756,240.866c0.513,0,0.956-0.092,1.334-0.297c0.356-0.216,0.635-0.476,0.824-0.799
c0.189-0.335,0.253-0.687,0.253-1.037v-1.215c-0.04,0.069-0.154,0.113-0.345,0.161c-0.189,0.065-0.427,0.119-0.656,0.168
c-0.265,0.016-0.494,0.064-0.732,0.086c-0.216,0.027-0.397,0.049-0.562,0.076c-0.33,0.043-0.664,0.113-0.958,0.211
c-0.286,0.097-0.551,0.254-0.732,0.447c-0.162,0.217-0.254,0.471-0.254,0.795c0,0.469,0.162,0.82,0.511,1.058
C390.792,240.753,391.208,240.866,391.756,240.866L391.756,240.866z M397.945,241.854v-8.651h1.296v1.307h0.086
c0.148-0.416,0.424-0.767,0.848-1.053c0.424-0.254,0.891-0.395,1.404-0.395c0.243,0,0.448,0.038,0.637,0.092
c0.17,0.049,0.33,0.108,0.47,0.238l-0.448,1.117c-0.089-0.043-0.211-0.092-0.33-0.118c-0.138-0.021-0.278-0.044-0.438-0.044
c-0.64,0-1.158,0.189-1.577,0.589c-0.395,0.378-0.608,0.87-0.608,1.458v5.46H397.945z M406.58,242.065
c-0.538,0-1.029-0.119-1.475-0.335c-0.451-0.173-0.826-0.48-1.08-0.886c-0.262-0.394-0.402-0.886-0.402-1.452
c0-0.492,0.119-0.887,0.305-1.193c0.192-0.309,0.448-0.567,0.772-0.729c0.33-0.189,0.708-0.303,1.102-0.399
c0.378-0.092,0.802-0.168,1.202-0.211c0.51-0.064,0.937-0.119,1.267-0.162c0.329-0.021,0.583-0.098,0.726-0.194
c0.165-0.07,0.235-0.227,0.235-0.442v-0.044c0-0.566-0.162-0.988-0.47-1.29c-0.281-0.309-0.753-0.476-1.361-0.476
c-0.637,0-1.124,0.141-1.501,0.427c-0.348,0.281-0.61,0.588-0.753,0.891l-1.266-0.442c0.24-0.54,0.521-0.945,0.916-1.247
c0.373-0.281,0.777-0.486,1.223-0.616c0.47-0.108,0.888-0.179,1.339-0.179c0.278,0,0.613,0.038,0.967,0.092
c0.349,0.065,0.694,0.217,1.048,0.422c0.327,0.194,0.61,0.496,0.851,0.912c0.229,0.405,0.321,0.962,0.321,1.643v5.702h-1.313v-1.166
h-0.07c-0.092,0.178-0.232,0.394-0.446,0.604c-0.213,0.216-0.494,0.395-0.845,0.54C407.514,241.995,407.099,242.065,406.58,242.065
L406.58,242.065z M406.791,240.866c0.519,0,0.964-0.092,1.337-0.297c0.354-0.216,0.634-0.476,0.826-0.799
c0.186-0.335,0.278-0.687,0.278-1.037v-1.215c-0.07,0.069-0.189,0.113-0.378,0.161c-0.181,0.065-0.397,0.119-0.651,0.168
c-0.232,0.016-0.475,0.064-0.705,0.086c-0.235,0.027-0.424,0.049-0.564,0.076c-0.354,0.043-0.678,0.113-0.964,0.211
c-0.302,0.097-0.535,0.254-0.726,0.447c-0.192,0.217-0.281,0.471-0.281,0.795c0,0.469,0.165,0.82,0.513,1.058
C405.827,240.753,406.256,240.866,406.791,240.866L406.791,240.866z M418.809,230.309v11.546h-1.345v-11.546H418.809z
M423.777,242.065c-0.54,0-1.061-0.119-1.506-0.335c-0.438-0.173-0.792-0.48-1.056-0.886c-0.253-0.394-0.394-0.886-0.394-1.452
c0-0.492,0.097-0.887,0.308-1.193c0.184-0.309,0.44-0.567,0.77-0.729c0.329-0.189,0.683-0.303,1.077-0.399
c0.399-0.092,0.802-0.168,1.196-0.211c0.545-0.064,0.961-0.119,1.296-0.162c0.324-0.021,0.559-0.098,0.727-0.194
c0.146-0.07,0.213-0.227,0.213-0.442v-0.044c0-0.566-0.146-0.988-0.451-1.29c-0.294-0.309-0.748-0.476-1.358-0.476
c-0.635,0-1.145,0.141-1.499,0.427c-0.383,0.281-0.613,0.588-0.777,0.891l-1.24-0.442c0.208-0.54,0.513-0.945,0.886-1.247
c0.378-0.281,0.802-0.486,1.242-0.616c0.451-0.108,0.896-0.179,1.342-0.179c0.281,0,0.58,0.038,0.958,0.092
c0.348,0.065,0.705,0.217,1.058,0.422c0.33,0.194,0.613,0.496,0.845,0.912c0.206,0.405,0.321,0.962,0.321,1.643v5.702h-1.328v-1.166
h-0.049c-0.097,0.178-0.262,0.394-0.47,0.604c-0.208,0.216-0.497,0.395-0.821,0.54C424.717,241.995,424.271,242.065,423.777,242.065
L423.777,242.065z M423.988,240.866c0.516,0,0.961-0.092,1.339-0.297c0.354-0.216,0.629-0.476,0.821-0.799
c0.165-0.335,0.259-0.687,0.259-1.037v-1.215c-0.049,0.069-0.165,0.113-0.356,0.161c-0.192,0.065-0.413,0.119-0.659,0.168
c-0.259,0.016-0.486,0.064-0.721,0.086c-0.24,0.027-0.429,0.049-0.564,0.076c-0.33,0.043-0.653,0.113-0.961,0.211
c-0.313,0.097-0.537,0.254-0.724,0.447c-0.165,0.217-0.267,0.471-0.267,0.795c0,0.469,0.167,0.82,0.521,1.058
C423.002,240.753,423.454,240.866,423.988,240.866L423.988,240.866z M434.641,241.854v-8.651h1.336v8.651H434.641z M435.321,231.767
c-0.259,0-0.47-0.092-0.656-0.28c-0.194-0.162-0.286-0.379-0.286-0.633c0-0.253,0.092-0.469,0.286-0.637
c0.187-0.184,0.397-0.248,0.656-0.248c0.256,0,0.492,0.064,0.678,0.248c0.192,0.168,0.284,0.384,0.284,0.637
c0,0.254-0.092,0.471-0.284,0.633C435.813,231.675,435.577,231.767,435.321,231.767L435.321,231.767z M439.754,236.648v5.206h-1.342
v-8.651h1.291v1.361h0.122c0.187-0.454,0.492-0.805,0.916-1.086c0.421-0.265,0.937-0.395,1.593-0.395
c0.896,0,1.596,0.275,2.136,0.816c0.535,0.518,0.799,1.365,0.799,2.462v5.492h-1.313v-5.4c0-0.702-0.192-1.215-0.54-1.587
c-0.356-0.4-0.851-0.589-1.461-0.589c-0.634,0-1.164,0.21-1.571,0.637C439.965,235.314,439.754,235.903,439.754,236.648
L439.754,236.648z M451.228,242.044c-0.826,0-1.506-0.189-2.087-0.578c-0.589-0.384-1.062-0.896-1.364-1.577
c-0.305-0.68-0.47-1.452-0.47-2.301c0-0.891,0.165-1.663,0.491-2.349c0.305-0.675,0.786-1.199,1.364-1.566
c0.586-0.41,1.264-0.589,2.039-0.589c0.613,0,1.15,0.119,1.642,0.352c0.492,0.205,0.891,0.535,1.199,0.935
c0.321,0.399,0.516,0.896,0.588,1.437h-1.32c-0.116-0.405-0.327-0.751-0.678-1.064c-0.348-0.291-0.818-0.464-1.404-0.464
c-0.772,0-1.412,0.308-1.874,0.891c-0.476,0.583-0.71,1.388-0.71,2.371c0,0.982,0.213,1.798,0.71,2.392
c0.461,0.616,1.072,0.913,1.874,0.913c0.516,0,0.958-0.135,1.329-0.394c0.378-0.287,0.616-0.638,0.753-1.129h1.32
c-0.073,0.518-0.267,0.988-0.54,1.388c-0.311,0.421-0.705,0.756-1.177,0.978C452.418,241.925,451.857,242.044,451.228,242.044
L451.228,242.044z M457.911,230.309v11.546h-1.336v-11.546H457.911z M465.792,238.317v-5.114h1.339v8.651h-1.339v-1.447h-0.089
c-0.192,0.411-0.521,0.794-0.945,1.102c-0.418,0.324-0.985,0.459-1.617,0.459c-0.815,0-1.499-0.253-2.02-0.794
c-0.513-0.539-0.794-1.36-0.794-2.462v-5.509h1.342v5.417c0,0.637,0.184,1.128,0.535,1.501c0.356,0.373,0.794,0.567,1.361,0.567
c0.327,0,0.656-0.07,1.013-0.26c0.345-0.167,0.632-0.427,0.869-0.777C465.682,239.301,465.792,238.857,465.792,238.317
L465.792,238.317z"/>
<path fill="#2A2A2B" d="M475.694,235.147l-1.202,0.324c-0.113-0.303-0.3-0.589-0.581-0.843c-0.289-0.259-0.699-0.399-1.291-0.399
c-0.519,0-0.961,0.118-1.313,0.378c-0.354,0.232-0.543,0.54-0.543,0.907c0,0.341,0.138,0.589,0.378,0.777
c0.24,0.211,0.604,0.356,1.128,0.503l1.285,0.297c1.526,0.378,2.298,1.177,2.298,2.344c0,0.523-0.14,0.945-0.418,1.339
c-0.281,0.399-0.686,0.713-1.202,0.913c-0.516,0.237-1.104,0.356-1.76,0.356c-0.891,0-1.615-0.189-2.204-0.578
c-0.586-0.384-0.934-0.945-1.099-1.674l1.266-0.329c0.235,0.944,0.916,1.403,2.015,1.403c0.615,0,1.104-0.135,1.479-0.394
c0.354-0.26,0.538-0.584,0.538-0.967c0-0.605-0.421-1-1.286-1.21l-1.437-0.335c-0.793-0.184-1.38-0.491-1.733-0.891
c-0.378-0.373-0.559-0.896-0.559-1.48c0-0.491,0.138-0.912,0.395-1.29c0.283-0.373,0.656-0.675,1.15-0.892
c0.472-0.205,1.012-0.324,1.62-0.324c0.861,0,1.55,0.179,2.014,0.557C475.132,234.019,475.483,234.51,475.694,235.147
L475.694,235.147z M477.854,241.854v-8.651h1.336v8.651H477.854z M478.535,231.767c-0.262,0-0.47-0.092-0.659-0.28
c-0.192-0.162-0.284-0.379-0.284-0.633c0-0.253,0.092-0.469,0.284-0.637c0.189-0.184,0.397-0.248,0.659-0.248
c0.254,0,0.492,0.064,0.681,0.248c0.186,0.168,0.286,0.384,0.286,0.637c0,0.254-0.1,0.471-0.286,0.633
C479.026,231.675,478.789,231.767,478.535,231.767L478.535,231.767z M485.147,242.044c-0.796,0-1.474-0.189-2.068-0.557
c-0.581-0.389-1.026-0.896-1.35-1.556c-0.335-0.675-0.505-1.457-0.505-2.344c0-0.912,0.17-1.685,0.505-2.37
c0.324-0.681,0.77-1.199,1.35-1.577c0.594-0.378,1.272-0.557,2.068-0.557c0.778,0,1.456,0.179,2.044,0.557
c0.608,0.378,1.056,0.896,1.38,1.577c0.327,0.686,0.494,1.458,0.494,2.37c0,0.887-0.167,1.669-0.494,2.344
c-0.324,0.659-0.772,1.167-1.38,1.556C486.603,241.854,485.925,242.044,485.147,242.044L485.147,242.044z M485.147,240.845
c0.589,0,1.083-0.156,1.456-0.438c0.399-0.313,0.678-0.729,0.867-1.226c0.165-0.491,0.262-1.026,0.262-1.594
c0-0.588-0.097-1.134-0.262-1.619c-0.189-0.519-0.467-0.913-0.867-1.227c-0.373-0.291-0.867-0.464-1.456-0.464
c-0.61,0-1.077,0.173-1.474,0.464c-0.381,0.313-0.662,0.708-0.848,1.227c-0.189,0.485-0.278,1.031-0.278,1.619
c0,0.567,0.089,1.103,0.278,1.594c0.187,0.497,0.467,0.912,0.848,1.226C484.07,240.688,484.537,240.845,485.147,240.845
L485.147,240.845z M484.537,232.031l1.388-2.635h1.544l-1.755,2.635H484.537z M492.425,236.648v5.206h-1.315v-8.651h1.288v1.361
h0.095c0.213-0.454,0.516-0.805,0.937-1.086c0.402-0.265,0.939-0.395,1.596-0.395c0.867,0,1.59,0.275,2.117,0.816
c0.535,0.518,0.818,1.365,0.818,2.462v5.492h-1.345v-5.4c0-0.702-0.162-1.215-0.508-1.587c-0.356-0.4-0.848-0.589-1.455-0.589
c-0.637,0-1.172,0.21-1.599,0.637C492.63,235.314,492.425,235.903,492.425,236.648L492.425,236.648z"/>
<path fill="#163F75" d="M250.517,155.119c3.775,0,3.982-3.133,3.982-6.216v-30.609c0-3.148-0.208-6.318-3.982-6.318
c-3.402,0-4.35,2.98-4.35,5.983v7.484c0,3.641-0.205,7.162-0.626,10.58c-0.429,3.424-1.059,6.27-1.971,8.543
c-1.499,3.872-3.402,5.768-5.751,5.768c-2.552,0-3.818-3.71-3.818-11.092c0-7.037,0.259-13.35,0.753-18.961
c0.235-2.273,0.354-4.077,0.354-5.417c0-1.971-1.085-2.959-3.243-2.959c-1.201,0-2,0.454-2.459,1.313
c-1.296,2.44-1.95,10.893-1.95,25.322c0,11.26,3.005,16.579,9.194,16.579c3.259,0,5.983-1.663,8.098-4.995
c0.721-1.103,1.142-1.686,1.261-1.686c0.094,0,0.159,0.238,0.159,0.745C246.167,152.192,247.115,155.119,250.517,155.119
L250.517,155.119z M287.159,155.119c1.169,0,1.971-0.427,2.443-1.285c1.24-2.484,1.896-10.903,1.896-25.307
c0-3.435-0.259-6.275-0.772-8.619c-1.177-5.352-3.964-8.003-8.395-8.003c-3.664,0-6.572,1.928-8.708,5.772
c-0.281,0.551-0.521,0.778-0.659,0.778c-0.138,0-0.216-0.205-0.216-0.616c0-3.958-1.426-5.935-4.334-5.935
c-1.55,0-2.625,0.523-3.194,1.464c-0.532,0.912-0.796,2.506-0.796,4.779v30.598c0,1.054,0.07,2.063,0.21,3.02
c0.335,2.209,1.599,3.289,3.78,3.289c1.855,0,3.122-0.896,3.797-2.663c0.356-0.95,0.537-2.058,0.537-3.348v-7.928
c0-8.182,0.875-14.382,2.582-18.681c1.555-3.823,3.478-5.746,5.805-5.746c1.677,0,2.86,1.647,3.394,4.839
c0.333,1.923,0.476,4.315,0.476,7.178c-0.021,6.054-0.284,12.064-0.826,18.063c-0.232,2.274-0.354,4.078-0.354,5.4
c0,1.988,1.11,2.949,3.292,2.949H287.159z M316.507,128.123c0,2.808-0.21,5.719-0.634,8.748c-0.397,3.003-1.059,5.557-1.874,7.599
c-1.526,3.823-3.429,5.724-5.725,5.724c-2.935,0.141-3.85-6.475-3.85-13.512c0-4.784,1.439-19.679,8.284-19.679
c1.855,0,3.027,0.47,3.47,1.404c0.237,0.476,0.329,1.01,0.329,1.577V128.123z M324.837,105.268c0-3.094-0.146-6.264-3.964-6.264
c-3.362,0-4.366,2.916-4.366,5.983v7.177c0,0.54-0.064,0.848-0.235,0.848c-0.186,0-0.473-0.118-0.842-0.308
c-1.056-0.54-2.185-0.799-3.381-0.799c-4.525,0-8.065,2.608-10.606,7.793c-2.392,4.931-3.585,11.373-3.585,19.284
c0,8.517,3.121,16.212,9.221,16.072c3.284,0,5.979-1.696,8.139-5.05c0.656-1.031,1.01-1.566,1.123-1.566
c0.103,0,0.167,0.206,0.167,0.605c0,3.072,1.004,6.011,4.366,6.011c3.818,0,3.964-3.176,3.964-6.309V105.268z M354.839,155.055
c3.851,0.064,3.918-3.197,3.988-6.309v-30.565c-0.07-3.035-0.138-6.324-3.988-6.275c-1.307,0-2.317,0.405-2.935,1.198
c-0.205,0.266-0.467,0.405-0.796,0.405c-0.229,0-0.583-0.097-0.932-0.329c-1.434-0.82-2.841-1.274-4.229-1.274
c-4.528,0-8.047,2.608-10.601,7.793c-2.39,4.931-3.588,11.373-3.588,19.284c0,8.517,3.119,16.212,9.245,16.072
c3.653,0,6.589-2.021,8.768-6.033c0.257-0.469,0.448-0.701,0.567-0.701c0.094,0,0.156,0.254,0.156,0.724
C350.494,152.116,351.507,155.055,354.839,155.055L354.839,155.055z M342.175,150.193c-2.911,0.141-3.855-6.475-3.855-13.512
c0-4.784,1.437-19.679,8.284-19.679c2.605,0,3.891,0.935,3.891,2.813v9.947c0,1.831-0.2,4.197-0.626,7.107
c-0.445,2.938-1.083,5.486-1.971,7.599C346.396,148.293,344.495,150.193,342.175,150.193L342.175,150.193z M379.405,154.979
c2.762,0,5.02-0.75,6.802-2.252c1.785-1.507,3.024-3.461,3.64-5.881c0.114-0.324,0.138-0.572,0.138-0.751
c0-1.128-0.827-1.804-1.807-1.804c-0.821,0-1.434,0.416-1.809,1.329c-1.191,2.954-2.765,4.434-4.736,4.434
c-2.563,0-4.458-1.853-5.754-5.558c-0.843-2.419-1.264-5.146-1.264-8.273c0-7.544,1.013-12.961,3.027-16.266
c1.107-1.83,2.328-2.749,3.683-2.749c1.785,0,2.954,1.199,3.567,3.575c0.332,1.404,0.519,3.208,0.519,5.39
c0,0.988-0.068,1.923-0.262,2.911c-0.064,0.442-0.094,1.145-0.094,2.014c0,1.453,0.17,2.814,1.906,2.814c1.993,0,3-2.744,3-8.188
c0-9.191-3.151-13.793-9.378-13.793c-3.265,0-6.081,1.221-8.408,3.662c-4.01,4.223-6.027,11.098-6.027,20.629
c0,3.791,0.47,6.999,1.361,9.607c0.985,2.847,2.533,5.114,4.623,6.832C374.035,154.212,376.465,154.979,379.405,154.979
L379.405,154.979z M401.434,109.47c1.566,0,2.724-0.562,3.464-1.69c0.502-0.756,0.761-1.637,0.761-2.7
c0-1.14-0.378-2.225-1.158-3.181c-0.748-0.988-1.761-1.485-3-1.485c-2.465,0-4.174,2.111-4.174,5.136
C397.327,108.179,398.69,109.47,401.434,109.47L401.434,109.47z M401.263,155.055c2.917,0,4.396-2.021,4.396-6.011v-30.863
c0-4.649-1.083-6.275-3.969-6.275c-2.881,0-4.363,2.095-4.363,5.989v30.852c0,1.054,0.067,2.063,0.216,3.02
C397.861,153.975,399.084,155.055,401.263,155.055L401.263,155.055z M429.821,149.843c-2.89,0-5.047-1.712-6.475-5.093
c-1.245-2.997-1.855-7.053-1.855-12.215c0-10.531,3.024-15.846,9.051-15.846c2.778,0,4.763,1.739,6.032,5.195
c0.958,2.668,1.428,6.017,1.428,10.083c0,4.903-0.61,8.965-1.801,12.221C434.792,147.937,432.653,149.843,429.821,149.843
L429.821,149.843z M427.542,154.979c5.582,0,9.737-2.203,12.386-6.583c2.301-3.926,3.451-9.288,3.451-16.099
c0-5.368-0.821-9.813-2.527-13.252c-2.328-4.736-5.986-7.14-11.009-7.14c-5.773,0-10.088,2.214-12.926,6.643
c-2.511,3.845-3.775,9.104-3.775,15.764c0,5.659,1.004,10.255,3.019,13.819C418.728,152.705,422.527,154.979,427.542,154.979
L427.542,154.979z M425.924,108.67c1.288,0,3.213-0.799,5.773-2.349c2.549-1.545,3.869-3.116,3.869-4.661
c0-1.339-0.659-1.998-1.974-1.998c-1.239,0-3.051,1.129-5.395,3.402c-2.322,2.23-3.47,3.732-3.47,4.574
C424.728,108.319,425.122,108.67,425.924,108.67L425.924,108.67z M472.821,155.119c1.19,0,1.968-0.427,2.441-1.285
c1.239-2.484,1.898-10.903,1.898-25.307c0-3.435-0.259-6.275-0.756-8.619c-1.188-5.352-3.98-8.003-8.419-8.003
c-3.662,0-6.545,1.928-8.703,5.772c-0.278,0.551-0.491,0.778-0.629,0.778c-0.143,0-0.213-0.205-0.213-0.616
c0-3.958-1.458-5.935-4.364-5.935c-1.525,0-2.608,0.523-3.188,1.464c-0.545,0.912-0.799,2.506-0.799,4.779v30.598
c0,1.054,0.097,2.063,0.232,3.02c0.308,2.209,1.574,3.289,3.756,3.289c1.879,0,3.116-0.896,3.829-2.663
c0.343-0.95,0.535-2.058,0.535-3.348v-7.928c0-8.182,0.842-14.382,2.56-18.681c1.566-3.823,3.473-5.746,5.79-5.746
c1.717,0,2.886,1.647,3.429,4.839c0.327,1.923,0.464,4.315,0.464,7.178c-0.046,6.054-0.278,12.064-0.84,18.063
c-0.235,2.274-0.332,4.078-0.332,5.4c0,1.988,1.085,2.949,3.281,2.949H472.821z"/>
<g>
<path fill="#163F75" d="M206.898,156.113c3.626,0,4.701-3.09,4.701-6.249v-28.687c0-1.08,0.634-1.625,1.812-1.625l7.788,0.092
c1.807,0.092,2.806-0.908,2.806-2.085c0-1.086-0.362-1.814-1.094-2.263c-0.632-0.362-1.537-0.546-2.892-0.546
c-2.079,0-4.158,0.184-6.237,0.184c-1.456,0-2.182-0.453-2.182-2.711v-5.427c0-1.998,0.91-2.992,2.725-2.992
c0.996,0,2.352,0,3.523,0.098c1.094,0,8.689,0,9.502,0c2.714,0,3.988-1.27,3.988-2.452c0-1.62-1.091-2.533-3.076-2.803
c-0.913-0.184-1.636-0.184-2.362-0.184h-9.224h-8.238c-2.986,0-4.79,1.183-5.519,3.629c-0.27,1.172-0.448,2.532-0.448,4.158v43.252
c0,1.091,0.086,2.176,0.27,3.17C203.099,154.936,204.46,156.113,206.898,156.113L206.898,156.113z"/>
</g>
<g>
<defs>
<rect id="SVGID_23_" x="-476.587" y="-28.438" width="597.06" height="844.021"/>
</defs>
<clipPath id="SVGID_2_">
<use xlink:href="#SVGID_23_" overflow="visible"/>
</clipPath>
</g>
</svg>

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 62 KiB

BIN
assets/re/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 798 KiB

1
assets/re/RE - SVG.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 124 KiB

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