Compare commits
46 Commits
424071b97c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf09346f29 | ||
|
|
9461ee1e2e | ||
|
|
3bf92f015c | ||
|
|
67042b0941 | ||
|
|
7efab390da | ||
|
|
4611fb2363 | ||
|
|
d211382e4d | ||
|
|
e29752644a | ||
|
|
704d2407e8 | ||
|
|
5472043a33 | ||
|
|
0fdee073a6 | ||
|
|
6a09ce9ee5 | ||
|
|
af751d771d | ||
|
|
cd87ddb18e | ||
|
|
6d9cf918f9 | ||
|
|
a0db888f2e | ||
|
|
a0253b4f90 | ||
|
|
a660928085 | ||
|
|
30cd83ffc1 | ||
|
|
ca3af1a750 | ||
|
|
b3f039fbd5 | ||
|
|
430bf7a5da | ||
|
|
10bb7299a2 | ||
|
|
dda4b5320a | ||
|
|
44d1f4b9d9 | ||
|
|
a484ba7849 | ||
|
|
ddaba6866a | ||
|
|
fcf93ea3ea | ||
|
|
3e7109a0f1 | ||
|
|
6e9ad89f7b | ||
|
|
3ec5da0e54 | ||
|
|
dbcc60ad95 | ||
|
|
474b501fff | ||
|
|
c847ef3ef5 | ||
|
|
8dea04cae3 | ||
|
|
56443a739f | ||
|
|
df9b168577 | ||
|
|
83782ab6f8 | ||
|
|
b0cf273bc8 | ||
|
|
eaf71567a7 | ||
|
|
2e6fbd7801 | ||
|
|
61676e1423 | ||
|
|
ce679ec1c3 | ||
|
|
7c8b034756 | ||
|
|
c30688810a | ||
|
|
f2666c7d3c |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,6 +14,11 @@ supabase/.temp/
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Playwright — reportes/snapshots, pueden contener credenciales de .env.test en el DOM
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
|
||||
# Next.js build output
|
||||
.next/
|
||||
|
||||
|
||||
22
BACKLOG.md
22
BACKLOG.md
@@ -7,6 +7,28 @@
|
||||
|
||||
## 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
|
||||
|
||||
58
CHANGELOG.md
58
CHANGELOG.md
@@ -7,17 +7,57 @@ y el proyecto adhiere a [Versionado Semántico](https://semver.org/lang/es/).
|
||||
|
||||
---
|
||||
|
||||
## [No publicado] — Sprint 4: Formulario Wizard v3
|
||||
## [3.0.0] — 2026-06-27
|
||||
|
||||
En desarrollo en `main`. Cambio mayor: reescritura del formulario a wizard
|
||||
pregunta-por-pantalla con instancias de vínculo familiar.
|
||||
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.
|
||||
|
||||
### Por agregar
|
||||
- Wizard pregunta-por-pantalla para todo el formulario
|
||||
- Instrumento v3: 10 secciones, mini-wizard anidado para vínculos familiares
|
||||
- Columna `instance_id` en `answers` para datos multi-instancia
|
||||
- Autocomplete de barrios desde dataset INE/DGEEC
|
||||
- Subtítulos explicativos del "porqué" por sección
|
||||
### 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,7 +2,7 @@
|
||||
|
||||
> 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-20 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
|
||||
> Última actualización: 2026-06-24 · Versión del producto: 2.1.0 (prod) / 3.0.0 (desarrollo)
|
||||
|
||||
---
|
||||
|
||||
@@ -78,6 +78,12 @@ señal de inmadurez en seguridad.
|
||||
- **TECH-DEBT-008**: `/admin/responses` sin autenticación — mitigado por URL no publicada,
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -139,3 +145,4 @@ por inadecuación técnica y normativa, no por desconocimiento.
|
||||
| Fecha | Versión producto | Cambio |
|
||||
|---|---|---|
|
||||
| 2026-06-20 | 2.1.0 / 3.0.0-dev | Creación inicial. Estado de seguridad v2.1.0, roadmap de auditabilidad, decisión sobre blockchain. |
|
||||
| 2026-06-24 | 2.1.0 / 3.0.0-dev | Etapa 4G: submit v3 vía RPC SECURITY DEFINER, cierra INSERT directo de anon. Registra SEC-004 (riesgo residual de bypass vía REST, ver BACKLOG.md). |
|
||||
@@ -97,6 +97,11 @@ qi_zone text
|
||||
```
|
||||
> Regla: si `surveys.is_anonymous = true`, el INSERT que intente setear `respondent_id` debe fallar
|
||||
> (constraint/trigger). Es la barrera técnica de la regla no negociable #2 de `CLAUDE.md`.
|
||||
|
||||
> **Mapeo v3 (Etapa 4G, confirmado):** `qi_zone` ← `2.1` (departamento), `qi_age_bucket` ← `2.4`
|
||||
> (rango de edad). `qi_sector` ← `2.6` (nivel organizacional) — v3 no tiene una pregunta
|
||||
> equivalente a "área/gerencia" como v2 (`A4`); `2.6` es el cuasi-identificador organizacional
|
||||
> más cercano disponible en el instrumento actual, no un mapeo 1:1 con el campo de v2.
|
||||
> Los cuasi-identificadores se guardan en **bucket** (rango etario, no fecha de nacimiento) para
|
||||
> minimizar re-identificación.
|
||||
|
||||
|
||||
100
TECH_DEBT.md
100
TECH_DEBT.md
@@ -77,11 +77,95 @@
|
||||
|
||||
## [TECH-DEBT-008] /admin/responses sin autenticación
|
||||
|
||||
- **Estado:** ABIERTO.
|
||||
- **Descripción:** la vista de control `/admin/responses` no tiene auth. Por diseño
|
||||
explícito, excluye preguntas `is_sensitive=true` (14 preguntas de salud/
|
||||
discapacidad) y `text_long`. Para mostrar esas preguntas en la vista, primero
|
||||
implementar un gate de autenticación para staff (Basic Auth con env var como
|
||||
mínimo viable).
|
||||
- **Impacto actual:** ninguno en producción — la URL no está publicada.
|
||||
- **Bloqueante para:** ampliar visibilidad de datos en la vista de control.
|
||||
- **Estado:** RESUELTO (27 jun 2026, ETAPA_AUTH).
|
||||
- **Descripción:** la vista de control `/admin/responses` no tenía auth. Se implementó
|
||||
login con Supabase Auth (`@supabase/ssr`), `middleware.ts` protegiendo
|
||||
`/admin/:path*` y `/dashboard/:path*` con redirect a `/login` sin sesión, y la página
|
||||
migrada de `service_role` a la sesión del usuario (`anon` key + RLS). Sigue
|
||||
excluyendo preguntas `is_sensitive=true` y `text_long` — ese diseño no cambió.
|
||||
- **Nota:** la vista no distingue roles dentro de staff todavía (todo usuario con fila
|
||||
en `user_roles` ve lo mismo). Diferenciar `platform_admin` de `org_admin` ahí sería
|
||||
una etapa nueva, no parte de este cierre.
|
||||
|
||||
---
|
||||
|
||||
## [TECH-DEBT-010] E9 lee modalidad por JOIN a `answers` en vez de `qi_modalidad`
|
||||
|
||||
- **Estado:** ABIERTO — decisión consciente, no un descuido.
|
||||
- **Descripción:** `rpc_me_segmentacion` (E9, Mirada Empresa) segmenta por nivel
|
||||
organizacional y por modalidad de trabajo. El nivel (`2.6`) sí se promovió a
|
||||
`responses.qi_sector` en el submit (Etapa 4G) — E9 lee de ahí directo. La
|
||||
modalidad (`2.7`) NO tiene una columna `qi_modalidad` equivalente — promoverla
|
||||
hubiera significado reabrir `rpc_submit_response`, ya verificada contra
|
||||
Postgres real en 4G. Para no reabrirla, E9 resuelve modalidad con un JOIN a
|
||||
`answers`/`questions` por `code='2.7'` en tiempo de consulta.
|
||||
- **Costo del JOIN vs. la columna:** una pasada extra por `answers`/`questions`
|
||||
en cada llamada a E9 — aceptable para el volumen actual (decenas/cientos de
|
||||
respuestas por encuesta), pero más lento que leer una columna denormalizada
|
||||
si el dataset crece mucho o si la segmentación por modalidad se usa con
|
||||
frecuencia en varios widgets.
|
||||
- **Acción si se decide promover:** agregar `qi_modalidad text` a `responses`,
|
||||
poblarla en `rpc_submit_response` (requiere re-verificar esa RPC contra
|
||||
Postgres real, igual que se hizo en 4G), y migrar E9 a leerla directo —
|
||||
mismo patrón que se usó para `qi_sector`.
|
||||
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
|
||||
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
|
||||
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
|
||||
|
||||
---
|
||||
|
||||
## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
|
||||
|
||||
- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
|
||||
ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
|
||||
de etapa asumía columnas `questions.section_number`/`section_title` que NO
|
||||
existen. Verificado contra el esquema real (`supabase/migrations/
|
||||
20260531000001_create_tables.sql` — única definición de `questions`,
|
||||
`20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
|
||||
migración agrega esas columnas).
|
||||
- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
|
||||
pregunta pertenece a la sección X". La sección es una fila más en la misma
|
||||
tabla, con `type='section'`, intercalada por `position` entre las preguntas
|
||||
reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
|
||||
detallada de admin) infiere el agrupamiento recorriendo las filas en orden
|
||||
de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
|
||||
`type='section'`. El título de la sección es el `prompt` de esa fila — no
|
||||
hay un campo `title` separado.
|
||||
- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
|
||||
pensado para ser un "motor de formularios de encuestas" genérico — no toda
|
||||
encuesta futura va a tener secciones, y las que las tengan no necesariamente
|
||||
van a querer modelarlas como filas-marcador en la misma tabla que las
|
||||
preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
|
||||
(wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
|
||||
el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
|
||||
`type='section'` más cercana hacia atrás en `position` es la sección de esta
|
||||
pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
|
||||
promoverla a una función compartida — y si el motor crece a encuestas con
|
||||
estructuras más ricas (subsecciones, secciones condicionales, secciones que
|
||||
no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
|
||||
cada consumidor.
|
||||
- **Alternativa más genérica y escalable (no implementada, propuesta para
|
||||
evaluar cuando se priorice):** sacar la sección de `questions.type` y
|
||||
modelarla como entidad propia — opción simple: `questions.section_id uuid
|
||||
NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
|
||||
position)`; opción más liviana sin tabla nueva: `questions.section_title
|
||||
text NULL` + `questions.section_position int NULL` poblados directamente en
|
||||
cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
|
||||
Cualquiera de las dos deja "pertenece a la sección X" como un hecho
|
||||
consultable con una columna, no como un cálculo derivado de recorrer
|
||||
`position` — y ninguna asume que toda encuesta tiene secciones (el campo es
|
||||
nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
|
||||
desde las filas `type='section'` existentes, (3) actualizar
|
||||
`lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
|
||||
de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
|
||||
- **Acción tomada mientras tanto:** la vista detallada de admin
|
||||
(`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
|
||||
`lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
|
||||
cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
|
||||
actual.
|
||||
- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
|
||||
la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
|
||||
producto efectivamente evoluciona a soportar surveys con estructuras de
|
||||
sección más complejas que el caso actual, evaluar la migración a columna/
|
||||
tabla explícita antes de seguir apilando consumidores sobre el supuesto
|
||||
implícito.
|
||||
|
||||
106
app/actions/invite-user.ts
Normal file
106
app/actions/invite-user.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
'use server'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||
|
||||
export type InviteUserResult = { success: true } | { success: false; error: string }
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
// Cliente ligado a la sesión (cookies) — anon key, respeta RLS. Se usa solo
|
||||
// para identificar a quien llama y leer SU PROPIA fila de user_roles
|
||||
// (policy user_roles_select_own: user_id = auth.uid()). Nunca para las
|
||||
// escrituras de esta acción — esas requieren service_role (ver getSupabaseAdmin).
|
||||
async function getSessionClient() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Server Action no siempre puede mutar cookies — se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Invita a un usuario de empresa y lo asocia a su organización con rol org_admin.
|
||||
// Seguridad: quien llama debe ser platform_admin — se verifica leyendo user_roles
|
||||
// en la DB con la sesión real (auth.uid()), nunca confiando en datos del formData.
|
||||
// El rol del nuevo usuario es siempre 'org_admin', fijo en código — no es un
|
||||
// valor que el formulario pueda controlar para escalar privilegios.
|
||||
export async function inviteUser(formData: FormData): Promise<InviteUserResult> {
|
||||
const sessionClient = await getSessionClient()
|
||||
|
||||
// auth.getUser() valida el token contra el servidor de Auth — no decodifica
|
||||
// la cookie localmente (mismo patrón que middleware.ts).
|
||||
const { data: { user: caller } } = await sessionClient.auth.getUser()
|
||||
if (!caller) return { success: false, error: 'no_autenticado' }
|
||||
|
||||
// Verificación de autorización — lee la fila real de user_roles del llamante.
|
||||
const { data: callerRole, error: roleErr } = await sessionClient
|
||||
.from('user_roles')
|
||||
.select('app_role')
|
||||
.eq('user_id', caller.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (roleErr || callerRole?.app_role !== 'platform_admin') {
|
||||
return { success: false, error: 'forbidden' }
|
||||
}
|
||||
|
||||
const email = String(formData.get('email') ?? '').trim().toLowerCase()
|
||||
const orgId = String(formData.get('orgId') ?? '').trim()
|
||||
|
||||
if (!EMAIL_RE.test(email)) return { success: false, error: 'email_invalido' }
|
||||
if (!orgId) return { success: false, error: 'organizacion_requerida' }
|
||||
|
||||
const admin = getSupabaseAdmin()
|
||||
|
||||
// La organización debe existir — admin client porque ya está autorizado el
|
||||
// llamante (paso anterior) y queremos evitar depender del claim app_role del
|
||||
// JWT (puede estar stale si el rol cambió desde la última emisión de token).
|
||||
const { data: org } = await admin
|
||||
.from('organizations')
|
||||
.select('id')
|
||||
.eq('id', orgId)
|
||||
.maybeSingle()
|
||||
|
||||
if (!org) return { success: false, error: 'organizacion_no_encontrada' }
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? ''
|
||||
const { data: inviteData, error: inviteErr } = await admin.auth.admin.inviteUserByEmail(
|
||||
email,
|
||||
{ redirectTo: `${siteUrl}/auth/confirm` }
|
||||
)
|
||||
|
||||
if (inviteErr || !inviteData?.user) {
|
||||
return { success: false, error: `invite_failed: ${inviteErr?.message ?? 'sin detalle'}` }
|
||||
}
|
||||
|
||||
const newUserId = inviteData.user.id
|
||||
|
||||
// Insert inmediato — antes de que el usuario confirme nada. user_roles no
|
||||
// tiene policy de INSERT (RLS deniega por defecto), por eso requiere admin.
|
||||
const { error: roleInsertErr } = await admin
|
||||
.from('user_roles')
|
||||
.insert({ user_id: newUserId, app_role: 'org_admin', org_id: orgId })
|
||||
|
||||
if (roleInsertErr) {
|
||||
// Rollback — no dejar un usuario en auth.users sin rol asignado.
|
||||
await admin.auth.admin.deleteUser(newUserId)
|
||||
return { success: false, error: `role_insert_failed: ${roleInsertErr.message}` }
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
'use server'
|
||||
import { headers } from 'next/headers'
|
||||
import { createHash, randomUUID } from 'crypto'
|
||||
import { createHash } from 'crypto'
|
||||
import { createClient as createSupabaseClient } from '@supabase/supabase-js'
|
||||
import { buildFormSchema } from '@/lib/form-engine'
|
||||
import type { Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||
import { validateSubmission, type SubmitAnswerEntry } from '@/lib/submit-validation'
|
||||
|
||||
export type SubmitResult =
|
||||
| { success: true }
|
||||
@@ -9,103 +12,83 @@ export type SubmitResult =
|
||||
|
||||
export async function submitSurvey(payload: {
|
||||
surveyId: string
|
||||
answers: Record<string, unknown>
|
||||
questionMap: Record<string, string> // código → question_id UUID
|
||||
answers: SubmitAnswerEntry[]
|
||||
consentOperativo: boolean
|
||||
consentMacroN1: boolean
|
||||
}): Promise<SubmitResult> {
|
||||
// 1. ip_hash: server-only, nunca en texto plano
|
||||
// ip_hash: server-only, nunca en texto plano (dedup sin identidad)
|
||||
const headersList = await headers()
|
||||
const rawIp =
|
||||
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const rawIp = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
|
||||
const salt = process.env.IP_HASH_SALT ?? ''
|
||||
const ipHash = createHash('sha256').update(rawIp + salt).digest('hex')
|
||||
|
||||
// 2. Cliente Supabase con anon key (RLS policies permiten INSERT a anon)
|
||||
// NUNCA usar service_role — el anon key respeta RLS
|
||||
// Cliente con anon key — respeta RLS. NUNCA service_role (regla 9 CLAUDE.md).
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
const supabase = createSupabaseClient(supabaseUrl, supabaseAnonKey)
|
||||
|
||||
// 3. Leer survey para obtener org_id y consent_version_id
|
||||
// 1. Validación #1: survey existe y está activa
|
||||
const { data: survey, error: sErr } = await supabase
|
||||
.from('surveys')
|
||||
.select('org_id, consent_version_id')
|
||||
.select('id, org_id, status')
|
||||
.eq('id', payload.surveyId)
|
||||
.single()
|
||||
.eq('status', 'live')
|
||||
.maybeSingle()
|
||||
|
||||
if (sErr || !survey) return { success: false, error: 'survey_not_found' }
|
||||
if (sErr) return { success: false, error: 'survey_lookup_failed' }
|
||||
if (!survey) return { success: false, error: 'survey_not_found_or_not_live' }
|
||||
|
||||
// 4. Pre-generar UUIDs server-side para evitar .select().single() post-INSERT.
|
||||
//
|
||||
// DIAGNÓSTICO: el Supabase JS client usa RETURNING * en .select().single(), lo que
|
||||
// requiere SELECT policy para anon en consent_records/responses. Esa policy no existe
|
||||
// (correcto — anon no debe leer consent_records de otros respondentes).
|
||||
// Solución: pre-generar UUIDs con randomUUID() y NO hacer SELECT after INSERT.
|
||||
const consentId = randomUUID()
|
||||
const responseId = randomUUID()
|
||||
|
||||
// 5. INSERT consent_record (inmutable, ADR-003)
|
||||
// Sin .select() para no activar RETURNING y evitar el error RLS 42501.
|
||||
const { error: cErr } = await supabase
|
||||
.from('consent_records')
|
||||
.insert({
|
||||
id: consentId,
|
||||
consent_version_id: survey.consent_version_id,
|
||||
granted_scopes: {
|
||||
operativo: payload.consentOperativo,
|
||||
macro_n1: payload.consentMacroN1,
|
||||
},
|
||||
granted_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
if (cErr) return { success: false, error: `consent_insert_failed: ${cErr.message}` }
|
||||
|
||||
// 6. Extraer qi_* de las respuestas
|
||||
const qi_age_bucket =
|
||||
typeof payload.answers['A5'] === 'string' ? payload.answers['A5'] : null
|
||||
const qi_sector =
|
||||
typeof payload.answers['A4'] === 'string' ? payload.answers['A4'] : null
|
||||
const qi_zone =
|
||||
typeof payload.answers['A1'] === 'string' ? payload.answers['A1'] : null
|
||||
|
||||
// 7. INSERT response (respondent_id=null — encuesta anónima, regla 2 CLAUDE.md)
|
||||
const { error: rErr } = await supabase
|
||||
.from('responses')
|
||||
.insert({
|
||||
id: responseId,
|
||||
survey_id: payload.surveyId,
|
||||
company_id: survey.org_id,
|
||||
respondent_id: null,
|
||||
ip_hash: ipHash,
|
||||
consent_record_id: consentId,
|
||||
qi_age_bucket: qi_age_bucket || null,
|
||||
qi_sector: qi_sector || null,
|
||||
qi_zone: qi_zone || null,
|
||||
})
|
||||
|
||||
if (rErr) return { success: false, error: `response_insert_failed: ${rErr.message}` }
|
||||
|
||||
// 8. INSERT answers — una fila por pregunta respondida
|
||||
const answerRows = Object.entries(payload.answers)
|
||||
.map(([code, value]) => ({
|
||||
response_id: responseId,
|
||||
question_id: payload.questionMap[code],
|
||||
value,
|
||||
}))
|
||||
.filter(
|
||||
(row) =>
|
||||
row.question_id &&
|
||||
row.value !== null &&
|
||||
row.value !== undefined &&
|
||||
row.value !== '' &&
|
||||
!(Array.isArray(row.value) && row.value.length === 0)
|
||||
// 2. Carga el esquema completo de la encuesta — es contra ESTO que se valida,
|
||||
// nunca contra lo que el cliente afirma que vio.
|
||||
const { data: questionRows, error: qErr } = await supabase
|
||||
.from('questions')
|
||||
.select(
|
||||
'id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||
)
|
||||
.eq('survey_id', payload.surveyId)
|
||||
|
||||
if (answerRows.length > 0) {
|
||||
const { error: aErr } = await supabase.from('answers').insert(answerRows)
|
||||
if (aErr) return { success: false, error: `answers_insert_failed: ${aErr.message}` }
|
||||
if (qErr || !questionRows) return { success: false, error: 'schema_load_failed' }
|
||||
|
||||
const schema = buildFormSchema(questionRows as unknown as EngineQuestion[])
|
||||
|
||||
// 3. Validaciones #2-#7 — reusa lib/form-engine.ts, no reimplementa flujo.
|
||||
const result = validateSubmission(schema, payload.answers, payload.consentOperativo)
|
||||
if (!result.ok) {
|
||||
return { success: false, error: `validation_failed: ${result.errors.join(', ')}` }
|
||||
}
|
||||
|
||||
// 4. Cuasi-identificadores — códigos v3 (2.1 zona, 2.4 edad).
|
||||
// qi_sector: v3 no tiene una pregunta equivalente a "área/gerencia" de v2.
|
||||
// Se usa 2.6 (nivel organizacional) como proxy más cercano disponible —
|
||||
// decisión a confirmar con el director, no hay un mapeo exacto.
|
||||
const findDefault = (code: string): string | null => {
|
||||
const entry = payload.answers.find((e) => e.code === code && e.instanceId === 'default')
|
||||
return typeof entry?.value === 'string' ? entry.value : null
|
||||
}
|
||||
const qiZone = findDefault('2.1')
|
||||
const qiAgeBucket = findDefault('2.4')
|
||||
const qiSector = findDefault('2.6')
|
||||
|
||||
// 5. Persistencia atómica vía RPC SECURITY DEFINER — ver migración
|
||||
// 20260624000001_submit_rpc.sql para el detalle de por qué RPC y no
|
||||
// INSERTs sueltos desde acá.
|
||||
const { error: rpcErr } = await supabase.rpc('rpc_submit_response', {
|
||||
p_survey_id: payload.surveyId,
|
||||
p_ip_hash: ipHash,
|
||||
p_qi_age_bucket: qiAgeBucket,
|
||||
p_qi_sector: qiSector,
|
||||
p_qi_zone: qiZone,
|
||||
p_consent_operativo: payload.consentOperativo,
|
||||
p_consent_macro_n1: payload.consentMacroN1,
|
||||
p_answers: result.rows.map((r) => ({
|
||||
question_id: r.questionId,
|
||||
instance_id: r.instanceId,
|
||||
value: r.value,
|
||||
})),
|
||||
})
|
||||
|
||||
if (rpcErr) return { success: false, error: 'submit_failed' }
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
274
app/admin/responses/[id]/page.tsx
Normal file
274
app/admin/responses/[id]/page.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import Link from 'next/link'
|
||||
import { formatDate, renderValue } from '../admin-format'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface QuestionRow {
|
||||
id: string
|
||||
code: string
|
||||
prompt: string
|
||||
type: string
|
||||
position: number
|
||||
instance_of: string | null
|
||||
}
|
||||
|
||||
interface AnswerRow {
|
||||
question_id: string
|
||||
value: unknown
|
||||
instance_id: string
|
||||
}
|
||||
|
||||
interface ResponseMeta {
|
||||
id: string
|
||||
submitted_at: string
|
||||
qi_zone: string | null
|
||||
qi_sector: string | null
|
||||
qi_age_bucket: string | null
|
||||
survey_id: string
|
||||
}
|
||||
|
||||
interface DetailRow {
|
||||
code: string
|
||||
prompt: string
|
||||
rendered: string | null
|
||||
}
|
||||
|
||||
interface InstanceGroup {
|
||||
instanceId: string
|
||||
rows: DetailRow[]
|
||||
}
|
||||
|
||||
interface SectionGroup {
|
||||
title: string | null
|
||||
instances: InstanceGroup[]
|
||||
}
|
||||
|
||||
async function getSupabaseSession() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Server Component — no puede mutar cookies, se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function backHref(surveyId: string | undefined, page: string | undefined): string {
|
||||
const params = new URLSearchParams()
|
||||
if (surveyId) params.set('survey_id', surveyId)
|
||||
if (page) params.set('page', page)
|
||||
const qs = params.toString()
|
||||
return `/admin/responses${qs ? `?${qs}` : ''}`
|
||||
}
|
||||
|
||||
export default async function ResponseDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||
}) {
|
||||
const { id } = await params
|
||||
const { survey_id: backSurveyId, page: backPage } = await searchParams
|
||||
const back = backHref(backSurveyId, backPage)
|
||||
const supabase = await getSupabaseSession()
|
||||
|
||||
const { data: response, error: responseError } = await supabase
|
||||
.from('responses')
|
||||
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
|
||||
.eq('id', id)
|
||||
.maybeSingle()
|
||||
|
||||
if (responseError || !response) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
<h1 className="admin-title">Respuesta no encontrada</h1>
|
||||
<p className="admin-empty">
|
||||
No existe esta respuesta o no tenés permiso para verla.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = response as ResponseMeta
|
||||
|
||||
// Mismo filtro de sensibles que la lista (regla no negociable #4 CLAUDE.md) — NO diverge.
|
||||
const { data: questionsData, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position, instance_of')
|
||||
.eq('survey_id', meta.survey_id)
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.order('position', { ascending: true })
|
||||
|
||||
if (questionsError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
<h1 className="admin-title">Control de respuestas</h1>
|
||||
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||
<pre className="admin-error-detail">{questionsError.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const questions = (questionsData ?? []) as QuestionRow[]
|
||||
const answerQuestionIds = questions.filter((q) => q.type !== 'section').map((q) => q.id)
|
||||
|
||||
const { data: answersData, error: answersError } =
|
||||
answerQuestionIds.length > 0
|
||||
? await supabase
|
||||
.from('answers')
|
||||
.select('question_id, value, instance_id')
|
||||
.eq('response_id', id)
|
||||
.in('question_id', answerQuestionIds)
|
||||
: { data: [] as AnswerRow[], error: null }
|
||||
|
||||
if (answersError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
<h1 className="admin-title">Control de respuestas</h1>
|
||||
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||
<pre className="admin-error-detail">{answersError.message}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const answers = (answersData ?? []) as AnswerRow[]
|
||||
const answersByQuestion = new Map<string, AnswerRow[]>()
|
||||
answers.forEach((a) => {
|
||||
const list = answersByQuestion.get(a.question_id) ?? []
|
||||
list.push(a)
|
||||
answersByQuestion.set(a.question_id, list)
|
||||
})
|
||||
|
||||
// Agrupar por sección. No existe section_number/section_title en `questions`
|
||||
// (verificado — ver TECH_DEBT.md). Las secciones hoy son filas type='section'
|
||||
// intercaladas por position, mismo modelo que usa el wizard en
|
||||
// lib/form-engine.ts:146-148 — se replica esa lógica acá, no se inventa una
|
||||
// columna nueva.
|
||||
const sections: SectionGroup[] = []
|
||||
let current: SectionGroup = { title: null, instances: [] }
|
||||
let hasContent = false
|
||||
|
||||
function instanceGroupFor(section: SectionGroup, instanceId: string): InstanceGroup {
|
||||
let group = section.instances.find((g) => g.instanceId === instanceId)
|
||||
if (!group) {
|
||||
group = { instanceId, rows: [] }
|
||||
section.instances.push(group)
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
questions.forEach((q) => {
|
||||
if (q.type === 'section') {
|
||||
if (hasContent || current.title !== null) sections.push(current)
|
||||
current = { title: q.prompt, instances: [] }
|
||||
hasContent = false
|
||||
return
|
||||
}
|
||||
|
||||
const qAnswers = answersByQuestion.get(q.id)
|
||||
if (qAnswers && qAnswers.length > 0) {
|
||||
qAnswers.forEach((a) => {
|
||||
const group = instanceGroupFor(current, a.instance_id)
|
||||
group.rows.push({ code: q.code, prompt: q.prompt, rendered: renderValue(a.value, q.type) })
|
||||
})
|
||||
hasContent = true
|
||||
} else if (!q.instance_of) {
|
||||
// Pregunta normal sin respuesta — placeholder "sin respuesta" visible.
|
||||
// Si es instance_of y no hay respuestas, no se generaron instancias
|
||||
// (igual que el wizard no muestra pantallas de instancia sin generador
|
||||
// disparado) — no se fabrica un grupo "Familiar" vacío.
|
||||
const group = instanceGroupFor(current, 'default')
|
||||
group.rows.push({ code: q.code, prompt: q.prompt, rendered: null })
|
||||
hasContent = true
|
||||
}
|
||||
})
|
||||
if (hasContent || current.title !== null) sections.push(current)
|
||||
|
||||
sections.forEach((s) => {
|
||||
s.instances.sort((a, b) => {
|
||||
if (a.instanceId === 'default') return -1
|
||||
if (b.instanceId === 'default') return 1
|
||||
return a.instanceId.localeCompare(b.instanceId, undefined, { numeric: true })
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<Link href={back} className="admin-back-link">← Volver a la lista</Link>
|
||||
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Respuesta {meta.id.slice(0, 8)}</h1>
|
||||
<p className="admin-subtitle">
|
||||
{formatDate(meta.submitted_at)} · Departamento: {meta.qi_zone ?? '—'} · Sector:{' '}
|
||||
{meta.qi_sector ?? '—'} · Edad: {meta.qi_age_bucket ?? '—'}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{sections.map((section, si) => (
|
||||
<section key={si} className="admin-detail-section">
|
||||
{section.title && <h2 className="admin-detail-section-title">{section.title}</h2>}
|
||||
{section.instances.map((group) => (
|
||||
<div key={group.instanceId} className="admin-detail-instance">
|
||||
{group.instanceId !== 'default' && (
|
||||
<span className="admin-instance-badge">
|
||||
Familiar {group.instanceId.replace('familiar_', '')}
|
||||
</span>
|
||||
)}
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Código</th>
|
||||
<th>Pregunta</th>
|
||||
<th>Valor</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{group.rows.map((row, ri) => (
|
||||
<tr key={ri}>
|
||||
<td>{row.code}</td>
|
||||
<td className="admin-prompt">{row.prompt}</td>
|
||||
<td>
|
||||
{row.rendered === null ? (
|
||||
<span className="admin-nodata">sin respuesta</span>
|
||||
) : (
|
||||
row.rendered
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="admin-empty">Esta respuesta no tiene preguntas visibles para mostrar.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
27
app/admin/responses/admin-format.ts
Normal file
27
app/admin/responses/admin-format.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Compartido entre la lista (page.tsx) y la vista detallada ([id]/page.tsx) —
|
||||
// deben renderizar fecha y valor exactamente igual; antes vivían duplicados
|
||||
// en los dos archivos y ya habían divergido.
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
// null/undefined → null (renderizado como badge "sin dato"/"sin respuesta" por el caller)
|
||||
export function renderValue(value: unknown, type: string): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
|
||||
switch (type) {
|
||||
case 'single_choice':
|
||||
case 'text_short':
|
||||
return String(value).replace(/^"|"$/g, '')
|
||||
case 'multiple_choice':
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
case 'rating':
|
||||
return String(value)
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,15 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { ResponsesTable, type DisplayRow } from './responses-table'
|
||||
import { SurveySelect } from './survey-select'
|
||||
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||
import { formatDate, renderValue } from './admin-format'
|
||||
|
||||
// Server Component sin auth — herramienta interna de diagnóstico (Etapa 3V-B).
|
||||
// Usa SUPABASE_SERVICE_ROLE_KEY: vive solo acá, server-side, nunca al cliente.
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const MAX_SUBMISSIONS = 500
|
||||
const PAGE_SIZE = 50
|
||||
const PROMPT_MAX_LEN = 60
|
||||
|
||||
const SURVEY_OPTIONS = [
|
||||
{ label: 'Todos', id: null },
|
||||
{ label: 'InQuality', id: '30000000-0000-0000-0000-000000000002' },
|
||||
{ label: 'Fundación Solidaridad', id: '30000000-0000-0000-0000-000000000003' },
|
||||
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
|
||||
] as const
|
||||
|
||||
interface AllowedQuestion {
|
||||
id: string
|
||||
code: string
|
||||
@@ -36,56 +31,77 @@ interface ResponseRow {
|
||||
answers: AnswerRow[]
|
||||
}
|
||||
|
||||
function getSupabaseAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co'
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'placeholder'
|
||||
return createClient(url, serviceKey, { auth: { persistSession: false } })
|
||||
interface SurveyOption {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
async function getSupabaseSession() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Server Component — no puede mutar cookies, se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function truncate(text: string, max = PROMPT_MAX_LEN): string {
|
||||
return text.length > max ? `${text.slice(0, max)}...` : text
|
||||
}
|
||||
|
||||
// Spec 4.3 — null/undefined → null (renderizado como badge "sin dato")
|
||||
function renderValue(value: unknown, type: string): string | null {
|
||||
if (value === null || value === undefined) return null
|
||||
|
||||
switch (type) {
|
||||
case 'single_choice':
|
||||
case 'text_short':
|
||||
return String(value).replace(/^"|"$/g, '')
|
||||
case 'multiple_choice':
|
||||
if (Array.isArray(value)) return value.join(', ')
|
||||
return String(value)
|
||||
case 'rating':
|
||||
return String(value)
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
function pageHref(surveyId: string | undefined, page: number): string {
|
||||
const params = new URLSearchParams()
|
||||
if (surveyId) params.set('survey_id', surveyId)
|
||||
params.set('page', String(page))
|
||||
return `/admin/responses?${params.toString()}`
|
||||
}
|
||||
|
||||
export default async function AdminResponsesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ survey_id?: string }>
|
||||
searchParams: Promise<{ survey_id?: string; page?: string }>
|
||||
}) {
|
||||
const { survey_id: surveyId } = await searchParams
|
||||
const supabase = getSupabaseAdmin()
|
||||
const { survey_id: surveyId, page: pageParam } = await searchParams
|
||||
const page = Math.max(1, Number(pageParam) || 1)
|
||||
const supabase = await getSupabaseSession()
|
||||
|
||||
// 0 y 1 no dependen entre sí — en paralelo.
|
||||
// 0. Surveys para el filtro — dinámico, no hardcodeado. Solo trae lo que
|
||||
// RLS deja ver al usuario autenticado. Si falla, el filtro se degrada a
|
||||
// "sin opciones" en vez de tirar abajo la página entera — la lista de
|
||||
// respuestas no depende de esta query.
|
||||
// 1. Preguntas permitidas — text_long e is_sensitive=true se excluyen ACÁ,
|
||||
// antes de tocar la tabla answers. Sus valores nunca se consultan.
|
||||
const { data: questionsData, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position')
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.order('position', { ascending: true })
|
||||
const [
|
||||
{ data: surveysData, error: surveysError },
|
||||
{ data: questionsData, error: questionsError },
|
||||
] = await Promise.all([
|
||||
supabase.from('surveys').select('id, title').order('title'),
|
||||
supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position')
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.order('position', { ascending: true }),
|
||||
])
|
||||
|
||||
const surveys = (surveysData ?? []) as SurveyOption[]
|
||||
const selectedSurvey = surveys.find((s) => s.id === surveyId)
|
||||
|
||||
if (questionsError) {
|
||||
return (
|
||||
@@ -103,6 +119,7 @@ export default async function AdminResponsesPage({
|
||||
|
||||
// 2. Submissions con sus respuestas — answers!inner + .in() filtra el ARRAY
|
||||
// embebido a solo question_id permitidos (consulta, no post-filtro).
|
||||
// Paginado con .range() — page size 50, no LIMIT fijo sin paginación.
|
||||
let responsesQuery = supabase
|
||||
.from('responses')
|
||||
.select(
|
||||
@@ -120,10 +137,44 @@ export default async function AdminResponsesPage({
|
||||
responsesQuery = responsesQuery.eq('survey_id', surveyId)
|
||||
}
|
||||
|
||||
const { data: responsesData, error: responsesError } =
|
||||
// 3. Contadores totales (independientes de la página actual). submissionsCount
|
||||
// usa el MISMO answers!inner + .in() que responsesQuery — si contara todas
|
||||
// las responses sin ese filtro, una response sin ningún answer permitido
|
||||
// inflaría el total y totalPages quedaría desalineado con lo que .range()
|
||||
// realmente puede devolver (página "fantasma" al final).
|
||||
let submissionsCountQuery = supabase
|
||||
.from('responses')
|
||||
.select('*, answers!inner(question_id)', { count: 'exact', head: true })
|
||||
.in('answers.question_id', allowedIds)
|
||||
|
||||
if (surveyId) {
|
||||
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
|
||||
}
|
||||
|
||||
let answersCountQuery = supabase
|
||||
.from('answers')
|
||||
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
|
||||
.in('question_id', allowedIds)
|
||||
|
||||
if (surveyId) {
|
||||
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
|
||||
}
|
||||
|
||||
// Las tres son independientes entre sí — en paralelo.
|
||||
const [
|
||||
{ data: responsesData, error: responsesError },
|
||||
{ count: totalSubmissions },
|
||||
{ count: totalAnswers },
|
||||
] =
|
||||
allowedIds.length > 0
|
||||
? await responsesQuery.order('submitted_at', { ascending: false }).limit(MAX_SUBMISSIONS)
|
||||
: { data: [] as ResponseRow[], error: null }
|
||||
? await Promise.all([
|
||||
responsesQuery
|
||||
.order('submitted_at', { ascending: false })
|
||||
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1),
|
||||
submissionsCountQuery,
|
||||
answersCountQuery,
|
||||
])
|
||||
: [{ data: [] as ResponseRow[], error: null }, { count: 0 }, { count: 0 }]
|
||||
|
||||
if (responsesError) {
|
||||
return (
|
||||
@@ -136,30 +187,7 @@ export default async function AdminResponsesPage({
|
||||
}
|
||||
|
||||
const responses = (responsesData ?? []) as unknown as ResponseRow[]
|
||||
|
||||
// 3. Contadores totales (independientes del límite de 500 submissions),
|
||||
// filtrados por survey_id cuando hay filtro activo.
|
||||
let submissionsCountQuery = supabase
|
||||
.from('responses')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
|
||||
if (surveyId) {
|
||||
submissionsCountQuery = submissionsCountQuery.eq('survey_id', surveyId)
|
||||
}
|
||||
|
||||
const { count: totalSubmissions } = await submissionsCountQuery
|
||||
|
||||
let answersCountQuery = supabase
|
||||
.from('answers')
|
||||
.select('*, responses!inner(survey_id)', { count: 'exact', head: true })
|
||||
.in('question_id', allowedIds)
|
||||
|
||||
if (surveyId) {
|
||||
answersCountQuery = answersCountQuery.eq('responses.survey_id', surveyId)
|
||||
}
|
||||
|
||||
const { count: totalAnswers } =
|
||||
allowedIds.length > 0 ? await answersCountQuery : { count: 0 }
|
||||
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[] = []
|
||||
@@ -176,7 +204,8 @@ export default async function AdminResponsesPage({
|
||||
const isFirstInGroup = ai === 0
|
||||
rows.push({
|
||||
key: `${response.id}-${a.question_id}`,
|
||||
submissionNum: idx + 1,
|
||||
responseId: response.id,
|
||||
submissionNum: (page - 1) * PAGE_SIZE + idx + 1,
|
||||
isFirstInGroup,
|
||||
date: isFirstInGroup ? formatDate(response.submitted_at) : '',
|
||||
code: q.code,
|
||||
@@ -189,40 +218,57 @@ export default async function AdminResponsesPage({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<>
|
||||
<AdminNavBar active="responses" />
|
||||
<div className="admin-page" role="main">
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Control de respuestas</h1>
|
||||
<p className="admin-subtitle">
|
||||
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·
|
||||
actualizado {formatDate(new Date().toISOString())}
|
||||
{totalSubmissions ?? 0} submissions · {totalAnswers ?? 0} respuestas individuales ·{' '}
|
||||
{selectedSurvey ? selectedSurvey.title : 'Todos los surveys'} · actualizado{' '}
|
||||
{formatDate(new Date().toISOString())}
|
||||
</p>
|
||||
<nav className="admin-survey-nav" aria-label="Selección de empresa">
|
||||
{SURVEY_OPTIONS.map((opt) => (
|
||||
<a
|
||||
key={opt.label}
|
||||
href={opt.id ? `/admin/responses?survey_id=${opt.id}` : '/admin/responses'}
|
||||
className={
|
||||
opt.id === (surveyId ?? null)
|
||||
? 'admin-survey-link admin-survey-link-active'
|
||||
: 'admin-survey-link'
|
||||
}
|
||||
aria-current={opt.id === (surveyId ?? null) ? 'page' : undefined}
|
||||
>
|
||||
{opt.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<p className="admin-subtitle">
|
||||
Página {page} de {totalPages} · {totalSubmissions ?? 0} submissions totales
|
||||
</p>
|
||||
<div className="admin-survey-nav">
|
||||
<SurveySelect surveys={surveys} value={surveyId ?? ''} />
|
||||
</div>
|
||||
{surveysError && (
|
||||
<p className="admin-error" role="alert">
|
||||
No se pudo cargar la lista de surveys para el filtro — se muestra "Todos".
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-header-actions">
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
</div>
|
||||
<a href="/admin/responses" className="admin-refresh">Actualizar</a>
|
||||
</header>
|
||||
|
||||
<div className="admin-table-wrapper">
|
||||
<ResponsesTable rows={rows} />
|
||||
<ResponsesTable rows={rows} surveyId={surveyId} page={page} />
|
||||
{rows.length === 0 && (
|
||||
<p className="admin-empty">Sin respuestas registradas todavía.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(totalSubmissions ?? 0) > PAGE_SIZE && (
|
||||
<nav className="admin-pagination" aria-label="Paginación de respuestas">
|
||||
{page > 1 ? (
|
||||
<a href={pageHref(surveyId, page - 1)} className="admin-pagination-link">← Anterior</a>
|
||||
) : (
|
||||
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">← Anterior</span>
|
||||
)}
|
||||
<span className="admin-pagination-current">Página {page} de {totalPages}</span>
|
||||
{page < totalPages ? (
|
||||
<a href={pageHref(surveyId, page + 1)} className="admin-pagination-link">Siguiente →</a>
|
||||
) : (
|
||||
<span className="admin-pagination-link admin-pagination-disabled" aria-disabled="true">Siguiente →</span>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export interface DisplayRow {
|
||||
key: string
|
||||
responseId: string
|
||||
submissionNum: number
|
||||
isFirstInGroup: boolean
|
||||
date: string
|
||||
@@ -14,7 +16,15 @@ export interface DisplayRow {
|
||||
sector: string
|
||||
}
|
||||
|
||||
export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
export function ResponsesTable({
|
||||
rows,
|
||||
surveyId,
|
||||
page,
|
||||
}: {
|
||||
rows: DisplayRow[]
|
||||
surveyId?: string
|
||||
page: number
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set())
|
||||
|
||||
function toggle(submissionNum: number) {
|
||||
@@ -26,6 +36,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
})
|
||||
}
|
||||
|
||||
function detailHref(responseId: string): string {
|
||||
const params = new URLSearchParams()
|
||||
if (surveyId) params.set('survey_id', surveyId)
|
||||
params.set('page', String(page))
|
||||
return `/admin/responses/${responseId}?${params.toString()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
@@ -37,6 +54,7 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
<th>Valor</th>
|
||||
<th className="admin-col-optional">Departamento</th>
|
||||
<th className="admin-col-optional">Sector</th>
|
||||
<th>Ver</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -54,28 +72,32 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
<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}
|
||||
onKeyDown={
|
||||
row.isFirstInGroup
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggle(row.submissionNum)
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
role={row.isFirstInGroup ? 'button' : undefined}
|
||||
tabIndex={row.isFirstInGroup ? 0 : undefined}
|
||||
aria-expanded={row.isFirstInGroup ? isExpanded : undefined}
|
||||
>
|
||||
<td>
|
||||
{row.isFirstInGroup && (
|
||||
<span className="admin-toggle-icon" aria-hidden="true">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</span>
|
||||
{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
|
||||
)}
|
||||
{row.submissionNum}
|
||||
</td>
|
||||
<td>{row.date}</td>
|
||||
<td>{row.code}</td>
|
||||
@@ -89,6 +111,13 @@ export function ResponsesTable({ rows }: { rows: DisplayRow[] }) {
|
||||
</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>
|
||||
)
|
||||
})}
|
||||
|
||||
31
app/admin/responses/survey-select.tsx
Normal file
31
app/admin/responses/survey-select.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface SurveyOption {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export function SurveySelect({ surveys, value }: { surveys: SurveyOption[]; value: string }) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<select
|
||||
className="admin-survey-select"
|
||||
aria-label="Selección de encuesta"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value
|
||||
router.push(id ? `/admin/responses?survey_id=${id}` : '/admin/responses')
|
||||
}}
|
||||
>
|
||||
<option value="">Todos los surveys</option>
|
||||
{surveys.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
24
app/admin/sign-out-button.tsx
Normal file
24
app/admin/sign-out-button.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export function SignOutButton() {
|
||||
const router = useRouter()
|
||||
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
async function handleSignOut() {
|
||||
await supabase.auth.signOut()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={handleSignOut} className="admin-signout">
|
||||
Cerrar sesión
|
||||
</button>
|
||||
)
|
||||
}
|
||||
89
app/admin/usuarios/invite-form.tsx
Normal file
89
app/admin/usuarios/invite-form.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
import { useActionState, useEffect, useState } from 'react'
|
||||
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
|
||||
const BANNER_VISIBLE_MS = 4000
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
|
||||
forbidden: 'No tenés permisos para invitar usuarios.',
|
||||
email_invalido: 'El email no es válido.',
|
||||
organizacion_requerida: 'Elegí una organización.',
|
||||
organizacion_no_encontrada: 'La organización seleccionada no existe.',
|
||||
}
|
||||
|
||||
function describeError(code: string): string {
|
||||
if (code.startsWith('invite_failed'))
|
||||
return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}`
|
||||
if (code.startsWith('role_insert_failed'))
|
||||
return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}`
|
||||
return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.'
|
||||
}
|
||||
|
||||
export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
|
||||
INITIAL_STATE
|
||||
)
|
||||
const [bannerVisible, setBannerVisible] = useState(false)
|
||||
|
||||
// Banner visible al menos BANNER_VISIBLE_MS tras cada resultado nuevo, o
|
||||
// hasta que el usuario vuelva a tocar el formulario (lo que pase primero).
|
||||
useEffect(() => {
|
||||
const hasMessage = state.success || (!state.success && state.error !== '')
|
||||
setBannerVisible(hasMessage)
|
||||
if (!hasMessage) return
|
||||
const timer = setTimeout(() => setBannerVisible(false), BANNER_VISIBLE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [state])
|
||||
|
||||
return (
|
||||
<form
|
||||
action={formAction}
|
||||
className="invite-form"
|
||||
onChange={() => setBannerVisible(false)}
|
||||
>
|
||||
<div className="invite-form-row">
|
||||
<div className="login-field">
|
||||
<label htmlFor="email" className="login-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="orgId" className="login-label">Organización</label>
|
||||
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
|
||||
<option value="">Seleccioná una organización</option>
|
||||
{organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>{org.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={isPending} className="login-submit">
|
||||
{isPending ? 'Enviando…' : 'Enviar invitación'}
|
||||
</button>
|
||||
</div>
|
||||
{bannerVisible && !state.success && state.error && (
|
||||
<p role="alert" className="invite-banner invite-banner-error">
|
||||
{describeError(state.error)}
|
||||
</p>
|
||||
)}
|
||||
{bannerVisible && state.success && (
|
||||
<p role="status" className="invite-banner invite-banner-success">
|
||||
Invitación enviada correctamente.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
195
app/admin/usuarios/page.tsx
Normal file
195
app/admin/usuarios/page.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getSupabaseAdmin } from '@/lib/supabase'
|
||||
import { formatDate } from '../responses/admin-format'
|
||||
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||
import { InviteForm } from './invite-form'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
interface RoleRow {
|
||||
user_id: string
|
||||
app_role: string
|
||||
org_id: string | null
|
||||
organizations: { name: string } | null
|
||||
}
|
||||
|
||||
interface UserDisplayRow {
|
||||
userId: string
|
||||
email: string
|
||||
orgName: string
|
||||
role: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
async function getSupabaseSession() {
|
||||
const cookieStore = await cookies()
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// Server Component — no puede mutar cookies, se ignora
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// auth.users no está expuesto vía PostgREST (no es schema público) — no hay
|
||||
// forma de hacer un .from('auth.users') ni un join SQL directo desde
|
||||
// supabase-js. listUsers() es la API admin soportada para resolver el email;
|
||||
// pagina hasta agotar resultados (tope defensivo de 2000 usuarios).
|
||||
async function fetchAllUsersMap(admin: ReturnType<typeof getSupabaseAdmin>) {
|
||||
const map = new Map<string, { email: string; createdAt: string }>()
|
||||
const perPage = 200
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
|
||||
if (error || !data) break
|
||||
data.users.forEach((u) => {
|
||||
map.set(u.id, { email: u.email ?? '—', createdAt: u.created_at })
|
||||
})
|
||||
if (data.users.length < perPage) break
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export default async function AdminUsuariosPage() {
|
||||
const supabase = await getSupabaseSession()
|
||||
|
||||
// 1. Identificar a quien llama — defensivo, el middleware ya debería haber
|
||||
// redirigido a /login si no hay sesión.
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Acceso restringido</h1>
|
||||
<p className="admin-error">No se encontró una sesión activa.</p>
|
||||
<a href="/login" className="admin-back-link">← Iniciar sesión</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Verificar platform_admin leyendo la fila REAL en user_roles — esta
|
||||
// página usa getSupabaseAdmin() más abajo (bypasa RLS por completo), así
|
||||
// que el gate de autorización tiene que ir acá, no delegarse a las policies.
|
||||
const { data: callerRole } = await supabase
|
||||
.from('user_roles')
|
||||
.select('app_role')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle()
|
||||
|
||||
if (callerRole?.app_role !== 'platform_admin') {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Acceso restringido</h1>
|
||||
<p className="admin-error">
|
||||
Esta sección es exclusiva para administradores de plataforma.
|
||||
</p>
|
||||
<a href="/admin/responses" className="admin-back-link">← Volver</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Datos — admin client para todo a partir de acá. La autorización ya
|
||||
// quedó probada en el paso 2 con una lectura directa a la DB; usar admin
|
||||
// para el resto evita depender de policies/claims del JWT que podrían
|
||||
// haber quedado stale.
|
||||
const admin = getSupabaseAdmin()
|
||||
|
||||
const [{ data: orgsData, error: orgsError }, { data: rolesData, error: rolesError }] =
|
||||
await Promise.all([
|
||||
admin.from('organizations').select('id, name').order('name'),
|
||||
admin.from('user_roles').select('user_id, app_role, org_id, organizations(name)'),
|
||||
])
|
||||
|
||||
if (orgsError || rolesError) {
|
||||
return (
|
||||
<div className="admin-page" role="main">
|
||||
<h1 className="admin-title">Usuarios</h1>
|
||||
<p className="admin-error">No fue posible conectarse a la base de datos.</p>
|
||||
<pre className="admin-error-detail">
|
||||
{(orgsError ?? rolesError)?.message}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const organizations = (orgsData ?? []) as OrgOption[]
|
||||
const roles = (rolesData ?? []) as unknown as RoleRow[]
|
||||
const usersMap = await fetchAllUsersMap(admin)
|
||||
|
||||
const userRows: UserDisplayRow[] = roles.map((r) => {
|
||||
const u = usersMap.get(r.user_id)
|
||||
return {
|
||||
userId: r.user_id,
|
||||
email: u?.email ?? '—',
|
||||
orgName: r.organizations?.name ?? '—',
|
||||
role: r.app_role,
|
||||
createdAt: u?.createdAt ? formatDate(u.createdAt) : '—',
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminNavBar active="usuarios" />
|
||||
<div className="admin-page" role="main">
|
||||
<header className="admin-header">
|
||||
<div>
|
||||
<h1 className="admin-title">Usuarios</h1>
|
||||
<p className="admin-subtitle">{userRows.length} usuarios registrados</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="admin-detail-section">
|
||||
<h2 className="admin-detail-section-title">Invitar usuario</h2>
|
||||
<InviteForm organizations={organizations} />
|
||||
</section>
|
||||
|
||||
<section className="admin-detail-section">
|
||||
<h2 className="admin-detail-section-title">Usuarios existentes</h2>
|
||||
<div className="admin-table-wrapper">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Organización</th>
|
||||
<th>Rol</th>
|
||||
<th>Fecha de creación</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRows.map((row) => (
|
||||
<tr key={row.userId}>
|
||||
<td>{row.email}</td>
|
||||
<td>{row.orgName}</td>
|
||||
<td>{row.role}</td>
|
||||
<td>{row.createdAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{userRows.length === 0 && (
|
||||
<p className="admin-empty">Todavía no hay usuarios invitados.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -4,18 +4,25 @@ import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils'
|
||||
|
||||
// Route Handler server-side: llama a las RPC con el cliente Supabase.
|
||||
// El service_role nunca llega al browser — vive solo en este servidor.
|
||||
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen 3.4/7.1 internamente.
|
||||
|
||||
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen datos
|
||||
// sensibles internamente.
|
||||
//
|
||||
// Las 9 RPCs v2 (rpc_dashboard_kpis, etc.) se retiraron de este mapa junto
|
||||
// con sus widgets — referencian códigos de pregunta que no existen en v3
|
||||
// (Etapa 4H, migración de códigos, queda pendiente aparte). Mirada Empresa
|
||||
// usa las rpc_me_* nuevas, ya verificadas contra datos reales.
|
||||
const RPC_MAP: Record<string, string> = {
|
||||
kpis: 'rpc_dashboard_kpis',
|
||||
prevalencia: 'rpc_prevalencia_por_depto',
|
||||
heatmap: 'rpc_heatmap_intensidad',
|
||||
distribucion: 'rpc_perfil_poblacion',
|
||||
apoyos: 'rpc_apoyos_demandados',
|
||||
score: 'rpc_score_organizacional',
|
||||
piramide: 'rpc_piramide_demografica',
|
||||
intensidad: 'rpc_intensidad_vs_bienestar',
|
||||
saturacion: 'rpc_saturacion_cuidador',
|
||||
'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
|
||||
'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
|
||||
'me-ausentismo': 'rpc_me_ausentismo',
|
||||
'me-presentismo': 'rpc_me_presentismo',
|
||||
'me-punto-ciego': 'rpc_me_punto_ciego',
|
||||
'me-apoyos': 'rpc_me_apoyos_demandados',
|
||||
'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
|
||||
'me-carrera-congelada': 'rpc_me_carrera_congelada',
|
||||
'me-intensidad-carga': 'rpc_me_intensidad_carga',
|
||||
'me-segmentacion': 'rpc_me_segmentacion',
|
||||
'me-disposicion-red': 'rpc_me_disposicion_red',
|
||||
}
|
||||
|
||||
function getSupabaseServer() {
|
||||
@@ -45,10 +52,18 @@ export async function GET(
|
||||
const filters = queryToFilters(sp)
|
||||
const supabase = getSupabaseServer()
|
||||
|
||||
const { data, error } = await supabase.rpc(rpcName, {
|
||||
const rpcParams: Record<string, unknown> = {
|
||||
p_survey_id: surveyId,
|
||||
p_filters: filtersToRpc(filters),
|
||||
})
|
||||
p_filters: filtersToRpc(filters),
|
||||
}
|
||||
|
||||
// Único caso con parámetro extra: el umbral parametrizable de la estrella.
|
||||
if (widget === 'me-talento-riesgo') {
|
||||
const umbral = sp.get('umbral')
|
||||
if (umbral) rpcParams.p_umbral_senales = parseInt(umbral, 10)
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.rpc(rpcName, rpcParams)
|
||||
|
||||
if (error) {
|
||||
console.error(`[dashboard/${widget}] RPC error:`, error.message)
|
||||
|
||||
191
app/auth/confirm/page.tsx
Normal file
191
app/auth/confirm/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type Stage = 'validating' | 'ready' | 'submitting' | 'error'
|
||||
|
||||
const DEFAULT_LINK_ERROR = 'Pedile al administrador que te envíe una nueva invitación.'
|
||||
|
||||
// GoTrue redirige con #error=...&error_code=...&error_description=... cuando
|
||||
// el token ya fue usado, venció, o es inválido — en vez de emitir PASSWORD_RECOVERY.
|
||||
// Mapeo a mensajes accionables; default cubre códigos no contemplados acá.
|
||||
function describeLinkError(errorCode: string | null): string {
|
||||
switch (errorCode) {
|
||||
case 'otp_expired':
|
||||
return 'Este link ya fue usado o venció. ' + DEFAULT_LINK_ERROR
|
||||
case 'access_denied':
|
||||
return 'El acceso fue denegado para este link. ' + DEFAULT_LINK_ERROR
|
||||
default:
|
||||
return 'El link de invitación no es válido. ' + DEFAULT_LINK_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfirmPage() {
|
||||
const [stage, setStage] = useState<Stage>('validating')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [linkErrorMessage, setLinkErrorMessage] = useState(DEFAULT_LINK_ERROR)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// GoTrue ya nos dice en el fragmento si el token es inválido/vencido/usado
|
||||
// (#error=...&error_code=...) — leerlo evita esperar el timeout genérico.
|
||||
const hash = window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash
|
||||
const hashParams = new URLSearchParams(hash)
|
||||
const errorCode = hashParams.get('error_code')
|
||||
|
||||
if (errorCode) {
|
||||
setLinkErrorMessage(describeLinkError(errorCode))
|
||||
setStage('error')
|
||||
return
|
||||
}
|
||||
|
||||
// Supabase emite PASSWORD_RECOVERY tanto para recovery como para invite
|
||||
// cuando el token de la URL (#access_token) es válido.
|
||||
const { data: listener } = supabase.auth.onAuthStateChange((event) => {
|
||||
if (event === 'PASSWORD_RECOVERY') setStage('ready')
|
||||
})
|
||||
|
||||
// Carrera posible: si el token ya se procesó antes de montar el listener,
|
||||
// ya hay sesión activa aunque el evento no se vuelva a emitir.
|
||||
supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
setStage((s) => (s === 'validating' && session ? 'ready' : s))
|
||||
})
|
||||
|
||||
// Sin evento ni sesión tras unos segundos = token inválido/vencido/ausente
|
||||
// sin que GoTrue haya mandado un error explícito en el fragmento.
|
||||
const timeout = setTimeout(() => {
|
||||
setStage((s) => (s === 'validating' ? 'error' : s))
|
||||
}, 8000)
|
||||
|
||||
return () => {
|
||||
listener.subscription.unsubscribe()
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}, [supabase])
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const password = String(fd.get('password') ?? '')
|
||||
const confirmPassword = String(fd.get('confirmPassword') ?? '')
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('La contraseña debe tener al menos 8 caracteres.')
|
||||
return
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Las contraseñas no coinciden.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setStage('submitting')
|
||||
|
||||
const { error: updateErr } = await supabase.auth.updateUser({ password })
|
||||
|
||||
if (updateErr) {
|
||||
setError('No pudimos guardar tu contraseña. Probá de nuevo.')
|
||||
setStage('ready')
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/dashboard')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
if (stage === 'validating') {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Validando tu invitación...</h1>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'error') {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Link inválido o vencido</h1>
|
||||
<p role="alert" className="field-error login-error">
|
||||
{linkErrorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Creá tu contraseña</h1>
|
||||
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||
<div className="login-password-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={stage === 'submitting'}
|
||||
className="text-short-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-password-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="confirmPassword" className="login-label">Confirmar contraseña</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
disabled={stage === 'submitting'}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error login-error">{error}</p>
|
||||
)}
|
||||
<button type="submit" disabled={stage === 'submitting'} className="login-submit">
|
||||
{stage === 'submitting' ? 'Guardando…' : 'Crear contraseña'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { KpiCards } from '@/components/dashboard/kpi-cards'
|
||||
import { ParaguayMap } from '@/components/dashboard/paraguay-map'
|
||||
import { DonutCuidados } from '@/components/dashboard/donut-cuidados'
|
||||
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional'
|
||||
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget'
|
||||
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica'
|
||||
import { ApoyosBar } from '@/components/dashboard/apoyos-bar'
|
||||
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad'
|
||||
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
|
||||
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
||||
import { AdminNavBar } from '@/components/admin-nav-bar'
|
||||
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
|
||||
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
|
||||
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
|
||||
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
|
||||
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
|
||||
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
|
||||
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
|
||||
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
|
||||
// El survey_id se pasa como query param: /dashboard?survey_id=UUID
|
||||
@@ -22,23 +21,40 @@ const SURVEY_OPTIONS = [
|
||||
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
|
||||
]
|
||||
|
||||
type Lens = 'empresa' | 'pais' | 'humana'
|
||||
|
||||
const LENSES: { id: Lens; label: string; sub: string }[] = [
|
||||
{ id: 'empresa', label: 'Mirada Empresa', sub: 'rendimiento · rotación · clima' },
|
||||
{ id: 'pais', label: 'Mirada País', sub: 'desarrollo · vulnerabilidad · política' },
|
||||
{ id: 'humana', label: 'Mirada Humana', sub: 'bienestar · derechos · desarrollo' },
|
||||
]
|
||||
|
||||
function PlaceholderLens({ title, hint }: { title: string; hint: string }) {
|
||||
return (
|
||||
<div className="db-placeholder" role="status">
|
||||
<span className="db-placeholder-icon" aria-hidden="true">🔒</span>
|
||||
<p className="db-placeholder-title">{title} — Disponible próximamente</p>
|
||||
<p className="db-placeholder-hint">{hint}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const searchParams = useSearchParams()
|
||||
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
|
||||
const [filters, setFilters] = useState<DashboardFilters>({})
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
function handleDeptoClick(depto: string) {
|
||||
setFilters(f => ({ ...f, depto: depto || undefined }))
|
||||
}
|
||||
const [lens, setLens] = useState<Lens>('empresa')
|
||||
|
||||
return (
|
||||
<div className="db-page">
|
||||
<>
|
||||
<AdminNavBar active="dashboard" />
|
||||
<div className="db-page">
|
||||
<FilterSidebar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(c => !c)}
|
||||
onToggle={() => setSidebarCollapsed((c) => !c)}
|
||||
/>
|
||||
|
||||
<div className="db-main">
|
||||
@@ -66,35 +82,72 @@ export default function DashboardPage() {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* Fila 1: KPIs (span 12) */}
|
||||
<section className="db-section" aria-label="Indicadores clave">
|
||||
<KpiCards surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
<nav className="db-tabs" aria-label="Mirada del dashboard">
|
||||
{LENSES.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
type="button"
|
||||
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
|
||||
onClick={() => setLens(l.id)}
|
||||
aria-current={lens === l.id ? 'page' : undefined}
|
||||
>
|
||||
<span>{l.label}</span>
|
||||
<span className="db-tab-lens">{l.sub}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */}
|
||||
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos">
|
||||
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} />
|
||||
<DonutCuidados surveyId={surveyId} filters={filters} />
|
||||
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{lens === 'empresa' && (
|
||||
<>
|
||||
<div className="db-lens-intro">
|
||||
<strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el
|
||||
punto ciego y qué talento está en riesgo.
|
||||
</div>
|
||||
|
||||
{/* Fila 3: Heatmap (6) + Pirámide (6) */}
|
||||
<section className="db-grid db-section" aria-label="Intensidad y demografía">
|
||||
<HeatmapWidget surveyId={surveyId} filters={filters} />
|
||||
<PiramideDemografica surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
|
||||
<section className="db-grid db-section" aria-label="Indicadores núcleo">
|
||||
<TalentoRiesgoCard surveyId={surveyId} filters={filters} />
|
||||
<PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
|
||||
<PuntoCiegoCard surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
|
||||
<section className="db-grid db-section" aria-label="Apoyos e impacto">
|
||||
<ApoyosBar surveyId={surveyId} filters={filters} />
|
||||
<IntensidadProductividad surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
|
||||
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
|
||||
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
|
||||
<ApoyosBarME surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 5: Saturación del cuidador (12) */}
|
||||
<section className="db-grid db-section" aria-label="Saturación del cuidador">
|
||||
<TablaSaturacion surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
{/* Fila 3: segmentación (12, SEC) */}
|
||||
<section className="db-grid db-section" aria-label="Segmentación de la carga">
|
||||
<SegmentacionBar surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
|
||||
<section className="db-grid db-section" aria-label="Disposición a participar">
|
||||
<DisposicionRedCard surveyId={surveyId} filters={filters} />
|
||||
</section>
|
||||
|
||||
<p className="db-table-note" style={{ textAlign: 'right' }}>
|
||||
k≥5 aplicado · segmentos con n<5 suprimidos
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{lens === 'pais' && (
|
||||
<PlaceholderLens
|
||||
title="Mirada País"
|
||||
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
|
||||
/>
|
||||
)}
|
||||
|
||||
{lens === 'humana' && (
|
||||
<PlaceholderLens
|
||||
title="Mirada Humana"
|
||||
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
517
app/globals.css
517
app/globals.css
@@ -63,7 +63,11 @@ body {
|
||||
.skip-link:focus { left: 4px; }
|
||||
|
||||
/* ── Screen reader only ── */
|
||||
.sr-only {
|
||||
/* El legend/label de los widgets dentro del wizard se oculta visualmente —
|
||||
el enunciado ya se muestra una sola vez en .wizard-prompt-block. Se deja
|
||||
el texto accesible (nombre del fieldset/label) en vez de quitarlo. */
|
||||
.sr-only,
|
||||
.wizard-answer-area .question-legend {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
@@ -285,6 +289,37 @@ body {
|
||||
box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
|
||||
}
|
||||
|
||||
/* ── Barrio autocomplete ── */
|
||||
.barrio-autocomplete-wrapper { position: relative; }
|
||||
.barrio-autocomplete-list {
|
||||
/* position: fixed + top/left/width set by JS via getBoundingClientRect()
|
||||
so the list escapes any overflow:auto ancestor (wizard-answer-area) */
|
||||
position: fixed;
|
||||
max-width: calc(100vw - 32px);
|
||||
background: var(--color-surface);
|
||||
border: 1.5px solid var(--color-border-focus);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.10);
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
z-index: 30;
|
||||
}
|
||||
.barrio-autocomplete-item {
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.barrio-autocomplete-item:hover,
|
||||
.barrio-autocomplete-item--active {
|
||||
background: var(--color-primary-light);
|
||||
color: #494F4F;
|
||||
}
|
||||
|
||||
/* ── Textarea ── */
|
||||
.text-area {
|
||||
display: block;
|
||||
@@ -343,7 +378,7 @@ body {
|
||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
color: #494F4F;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-secondary:hover { background: var(--color-primary-light); }
|
||||
@@ -394,7 +429,7 @@ body {
|
||||
}
|
||||
.summary-code {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
color: #494F4F;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.summary-answer {
|
||||
@@ -453,7 +488,7 @@ body {
|
||||
.consent-checkbox-label:has(input:checked) { border-color: var(--color-primary); background: var(--color-primary-light); }
|
||||
.consent-checkbox-input { width: 20px; height: 20px; flex-shrink: 0; margin-top: 1px; accent-color: var(--color-primary); cursor: pointer; }
|
||||
.consent-checkbox-text { flex: 1; }
|
||||
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-primary); background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
|
||||
.consent-required-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: #494F4F; background: var(--color-primary-light); padding: 1px 6px; border-radius: 4px; }
|
||||
.consent-optional-badge { display: inline-block; font-size: 0.75rem; font-weight: 600; color: var(--color-text-muted); background: #f3f4f6; padding: 1px 6px; border-radius: 4px; }
|
||||
.consent-footer { display: flex; flex-direction: column; align-items: flex-start; gap: 10px; }
|
||||
.consent-required-hint { margin: 0; font-size: 0.875rem; color: var(--color-text-muted); }
|
||||
@@ -482,6 +517,170 @@ body {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
WIZARD (4D) — layout de altura estable (UX 1+2+3)
|
||||
1) viewport estable: enunciado anclado en el tercio superior, no centrado
|
||||
2) header (progreso) y footer (Anterior/Siguiente) fijos, fuera del área
|
||||
que cambia de tamaño — nunca "saltan" entre pantallas
|
||||
3) área de respuesta con altura máxima + scroll interno + fade inferior
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
.wizard-shell {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100dvh - 160px); /* deja lugar al header/footer de marca (layout.tsx) */
|
||||
}
|
||||
|
||||
.wizard-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 12px 0;
|
||||
}
|
||||
.wizard-topbar-brand {
|
||||
margin: 0 0 8px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
/* Stage: altura fija, no auto-crece con el contenido de la pregunta actual. */
|
||||
.wizard-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding-top: 48px; /* ancla el enunciado en el tercio superior, no centrado vertical */
|
||||
}
|
||||
|
||||
.wizard-section-screen {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
.wizard-section-number {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: #494F4F;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.wizard-section-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.wizard-section-desc {
|
||||
font-size: 1.0625rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
max-width: 480px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Enunciado: crece orgánicamente con el contenido, sin altura mínima fija. */
|
||||
.wizard-prompt-block {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.wizard-instance-badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary-hover);
|
||||
background: var(--color-primary-light);
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.wizard-prompt-code {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: #7A8FA0;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.wizard-prompt-text {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
line-height: 1.35;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.wizard-prompt-hint {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Área de respuesta: altura máxima + scroll interno + fade inferior cuando hay más abajo.
|
||||
flex: 0 1 auto (NO flex:1) — mide según su contenido, nunca se estira a llenar el alto
|
||||
disponible. Si creciera con flex:1, una pregunta corta (3 opciones) dejaría la caja con
|
||||
espacio vacío abajo y el fade ::after (sticky bottom:0) quedaría flotando justo después
|
||||
del contenido real, lejos del borde inferior visual de la caja — en vez de pegado a él. */
|
||||
.wizard-answer-area {
|
||||
flex: 0 1 auto;
|
||||
min-height: 0;
|
||||
max-height: 480px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.wizard-answer-area.has-overflow { padding-bottom: 44px; }
|
||||
/* El fade SOLO se pinta cuando .wizard-answer-area mide overflow real
|
||||
(clase has-overflow agregada por JS — ver wizard-form.tsx). CSS no tiene
|
||||
forma de detectar scrollHeight > clientHeight por sí solo: un ::after
|
||||
incondicional (o un mask-image fijo) se pinta igual sobre el final de la
|
||||
caja aunque el contenido entre completo, mostrando una franja/degradado
|
||||
fantasma en preguntas cortas. */
|
||||
.wizard-answer-area.has-overflow::after {
|
||||
content: '';
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: block;
|
||||
height: 28px;
|
||||
margin-top: -28px;
|
||||
background: linear-gradient(to bottom, transparent, var(--color-surface) 75%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.wizard-bottombar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: 16px 0;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.wizard-bottombar-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.wizard-prompt-text { font-size: 1.1875rem; }
|
||||
.wizard-section-title { font-size: 1.5rem; }
|
||||
.wizard-answer-area { max-height: 400px; }
|
||||
/* column-reverse: el botón primario (Siguiente/Enviar) queda arriba —
|
||||
el pulgar llega más fácil al centro de la pantalla que a la base. */
|
||||
.wizard-bottombar { flex-direction: column-reverse; }
|
||||
.wizard-bottombar .btn { width: 100%; }
|
||||
.wizard-bottombar-hint { order: 3; }
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ── */
|
||||
.loading-wrapper {
|
||||
max-width: 720px;
|
||||
@@ -546,27 +745,27 @@ body {
|
||||
.survey-footer-logo { height: 32px; width: auto; display: block; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
DASHBOARD — tema oscuro premium
|
||||
Fondo: #0F1923 · Widgets: #1A2535 · Teal: #43BBC8
|
||||
DASHBOARD — tema claro (marca Re)
|
||||
Fondo: #E7E6E5 · Widgets: #FFFFFF · Teal: #43BBC8
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
.db-page {
|
||||
display: flex; min-height: 100vh;
|
||||
background: #0F1923; color: #E7E6E5;
|
||||
background: #E7E6E5; color: #494F4F;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.db-main { flex: 1; padding: 24px; overflow-x: hidden; }
|
||||
.db-page-header { margin-bottom: 20px; }
|
||||
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; }
|
||||
.db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; }
|
||||
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #494F4F; margin: 0 0 4px; }
|
||||
.db-page-subtitle { font-size: 0.8125rem; color: #6B7878; margin: 0; }
|
||||
.db-active-filter { color: var(--color-re-teal); font-weight: 600; }
|
||||
.db-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.db-survey-link {
|
||||
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
|
||||
border: 1px solid rgba(67,187,200,0.3); color: #9ca3af; text-decoration: none;
|
||||
border: 1px solid rgba(67,187,200,0.3); color: #6B7878; text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.db-survey-link:hover { color: #E7E6E5; border-color: var(--color-re-teal); }
|
||||
.db-survey-link-active { background: var(--color-re-teal); color: #0F1923; border-color: var(--color-re-teal); }
|
||||
.db-survey-link: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;
|
||||
@@ -576,7 +775,7 @@ body {
|
||||
|
||||
/* ── Widget shell ── */
|
||||
.db-widget {
|
||||
background: #1A2535;
|
||||
background: #FFFFFF;
|
||||
border: 1px solid rgba(67,187,200,0.2);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
@@ -587,24 +786,24 @@ body {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #E7E6E5; margin: 0; }
|
||||
.db-widget-n { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #494F4F; margin: 0; }
|
||||
.db-widget-n { font-size: 0.75rem; color: #6B7878; }
|
||||
.db-widget-body { padding: 0 16px 16px; }
|
||||
|
||||
/* ── Suppressed / loading states ── */
|
||||
.db-suppressed {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 100px; text-align: center; color: #9ca3af; gap: 6px;
|
||||
min-height: 100px; text-align: center; color: #6B7878; gap: 6px;
|
||||
}
|
||||
.db-suppressed-icon { font-size: 1.5rem; }
|
||||
.db-suppressed-hint { font-size: 0.75rem; color: #6b7280; }
|
||||
.db-suppressed-hint { font-size: 0.75rem; color: #9CA8A8; }
|
||||
.db-suppressed-card {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 4px; color: #6b7280;
|
||||
gap: 4px; color: #9CA8A8;
|
||||
}
|
||||
.db-skeleton-block { background: #1e2d42; border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
|
||||
.db-skeleton-block { background: rgba(73,79,79,0.08); border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
|
||||
.db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; }
|
||||
.db-loading-page { padding: 24px; background: #0F1923; min-height: 100vh; }
|
||||
.db-loading-page { padding: 24px; background: #E7E6E5; min-height: 100vh; }
|
||||
|
||||
/* ── KPI row ── */
|
||||
.db-kpi-row {
|
||||
@@ -612,36 +811,36 @@ body {
|
||||
padding: 0;
|
||||
}
|
||||
.db-kpi-card {
|
||||
background: #1A2535; border: 1px solid rgba(67,187,200,0.2);
|
||||
background: #FFFFFF; border: 1px solid rgba(67,187,200,0.2);
|
||||
border-radius: 10px; padding: 16px; text-align: center;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
|
||||
/* Título de KPI: mayúsculas, bold, siempre visible */
|
||||
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #E7E6E5; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.db-kpi-title { font-size: 0.8125rem; font-weight: 700; color: #494F4F; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
/* Subtítulo de KPI: código de pregunta + descripción */
|
||||
.db-kpi-subtitle { font-size: 0.6875rem; color: #7A8FA0; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
|
||||
.db-kpi-subtitle { font-size: 0.6875rem; color: #8A9A9A; font-weight: 400; line-height: 1.4; margin-bottom: 4px; }
|
||||
.db-kpi-value {
|
||||
font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
|
||||
font-variant-numeric: tabular-nums; margin-top: 4px;
|
||||
}
|
||||
.db-kpi-value-suppressed { font-size: 1.25rem; color: #6b7280; }
|
||||
.db-kpi-note { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-kpi-value-suppressed { font-size: 1.25rem; color: #9CA8A8; }
|
||||
.db-kpi-note { font-size: 0.75rem; color: #6B7878; }
|
||||
.db-kpi-suppressed-block { display: flex; flex-direction: column; align-items: center; gap: 4px; margin-top: 8px; }
|
||||
/* Compat — se puede quitar si ya no se usa */
|
||||
.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; }
|
||||
.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-kpi-label { font-size: 0.8125rem; color: #494F4F; }
|
||||
.db-kpi-sub { font-size: 0.75rem; color: #6B7878; }
|
||||
|
||||
/* Subtítulo de widget-shell */
|
||||
.db-widget-title-group { display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.db-widget-subtitle { font-size: 0.75rem; color: #7A8FA0; font-weight: 400; margin: 0; line-height: 1.4; }
|
||||
.db-widget-subtitle { font-size: 0.75rem; color: #8A9A9A; font-weight: 400; margin: 0; line-height: 1.4; }
|
||||
|
||||
/* ── Mapa tile ── */
|
||||
.db-map-wrapper { position: relative; }
|
||||
.db-map-tooltip {
|
||||
position: absolute; top: -28px; left: 50%; transform: translateX(-50%);
|
||||
background: #0F1923; color: #E7E6E5; font-size: 0.75rem;
|
||||
background: #E7E6E5; color: #494F4F; font-size: 0.75rem;
|
||||
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3);
|
||||
white-space: nowrap; z-index: 10; pointer-events: none;
|
||||
}
|
||||
@@ -676,13 +875,13 @@ body {
|
||||
.db-gauge-wrapper { text-align: center; }
|
||||
.db-gauge-svg { width: 100%; max-width: 160px; }
|
||||
.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; }
|
||||
.db-gauge-hint { font-size: 0.7rem; color: #6b7280; margin: 0; }
|
||||
.db-gauge-hint { font-size: 0.7rem; color: #9CA8A8; margin: 0; }
|
||||
|
||||
/* ── Bar chart ── */
|
||||
.db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.db-bar-item { display: flex; align-items: center; gap: 8px; }
|
||||
.db-bar-label { font-size: 0.75rem; color: #E7E6E5; width: 180px; flex-shrink: 0; line-height: 1.3; }
|
||||
.db-bar-track { flex: 1; background: #2a3547; border-radius: 4px; height: 14px; overflow: hidden; }
|
||||
.db-bar-label { font-size: 0.75rem; color: #494F4F; width: 180px; flex-shrink: 0; line-height: 1.3; }
|
||||
.db-bar-track { flex: 1; background: rgba(73,79,79,0.10); border-radius: 4px; height: 14px; overflow: hidden; }
|
||||
.db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||||
.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; }
|
||||
|
||||
@@ -690,39 +889,39 @@ body {
|
||||
.db-heatmap-scroll { overflow-x: auto; }
|
||||
.db-heatmap-grid { display: grid; gap: 2px; }
|
||||
.db-heatmap-header {
|
||||
font-size: 0.625rem; color: #9ca3af; text-align: center; padding: 2px;
|
||||
font-size: 0.625rem; color: #6B7878; text-align: center; padding: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.db-heatmap-rowlabel { font-size: 0.625rem; color: #9ca3af; padding: 2px 4px; line-height: 1.2; }
|
||||
.db-heatmap-rowlabel { font-size: 0.625rem; color: #6B7878; padding: 2px 4px; line-height: 1.2; }
|
||||
.db-heatmap-cell {
|
||||
border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s;
|
||||
}
|
||||
.db-heatmap-cell:hover { filter: brightness(1.2); }
|
||||
.db-hexbin-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 8px; }
|
||||
.db-hexbin-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 8px; }
|
||||
|
||||
/* ── Pirámide ── */
|
||||
.db-piramide { display: flex; flex-direction: column; gap: 6px; }
|
||||
.db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.db-piramide-row { display: flex; align-items: center; gap: 6px; }
|
||||
.db-piramide-label { font-size: 0.7rem; color: #9ca3af; width: 90px; flex-shrink: 0; text-align: right; }
|
||||
.db-piramide-label { font-size: 0.7rem; color: #6B7878; width: 90px; flex-shrink: 0; text-align: right; }
|
||||
.db-piramide-bars { flex: 1; display: flex; gap: 2px; }
|
||||
.db-piramide-bar-wrap { display: flex; flex-direction: column; }
|
||||
.db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; }
|
||||
.db-piramide-n { font-size: 0.7rem; color: #9ca3af; width: 24px; text-align: right; }
|
||||
.db-piramide-n { font-size: 0.7rem; color: #6B7878; width: 24px; text-align: right; }
|
||||
|
||||
/* ── Tabla riesgo ── */
|
||||
.db-table-wrapper { overflow-x: auto; }
|
||||
.db-table-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 10px; }
|
||||
.db-table-note { font-size: 0.75rem; color: #6B7878; margin-bottom: 10px; }
|
||||
.db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||
.db-table th { color: #9ca3af; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
|
||||
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #E7E6E5; }
|
||||
.db-table th { color: #6B7878; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
|
||||
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #494F4F; }
|
||||
.db-table tr:last-child td { border-bottom: none; }
|
||||
.db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.db-sidebar {
|
||||
width: 220px; flex-shrink: 0; background: #14202e;
|
||||
width: 220px; flex-shrink: 0; background: #F0EFEE;
|
||||
border-right: 1px solid rgba(67,187,200,0.15);
|
||||
padding: 12px; transition: width 0.3s ease;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
@@ -735,12 +934,12 @@ body {
|
||||
}
|
||||
.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
|
||||
.db-filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #6B7878; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.db-filter-select {
|
||||
background: #1e2d42; border: 1px solid #2a3547; color: #E7E6E5;
|
||||
background: #FFFFFF; border: 1px solid #2a3547; color: #494F4F;
|
||||
border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%;
|
||||
}
|
||||
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #E7E6E5; cursor: pointer; }
|
||||
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #494F4F; cursor: pointer; }
|
||||
.db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; }
|
||||
.db-filter-reset {
|
||||
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
|
||||
@@ -750,6 +949,70 @@ body {
|
||||
}
|
||||
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
|
||||
|
||||
/* ── Tres miradas: tabs ── */
|
||||
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
|
||||
.db-tab {
|
||||
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #6B7878;
|
||||
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
|
||||
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
|
||||
}
|
||||
.db-tab:hover { color: #494F4F; }
|
||||
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
|
||||
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
|
||||
|
||||
/* ── Tres miradas: intro de cada lente ── */
|
||||
.db-lens-intro {
|
||||
background: #F0EFEE; border-left: 3px solid var(--color-re-teal);
|
||||
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
|
||||
font-size: 0.8125rem; color: #494F4F; line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Card destacada (estrella) ── */
|
||||
.db-widget-star {
|
||||
border-color: var(--color-re-teal);
|
||||
}
|
||||
.db-widget-star .db-widget-title { color: #494F4F; }
|
||||
|
||||
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
|
||||
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
|
||||
.db-big-stat-warn { color: #f0b429; }
|
||||
.db-big-stat-danger { color: #f06b6b; }
|
||||
.db-big-stat-sub { font-size: 0.75rem; color: #8A9A9A; margin-top: 6px; line-height: 1.4; }
|
||||
|
||||
/* ── Punto ciego: contraste de dos barras ── */
|
||||
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
|
||||
.db-gap-col { text-align: center; }
|
||||
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
|
||||
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
|
||||
.db-gap-lab { font-size: 0.6875rem; color: #8A9A9A; margin-top: 6px; }
|
||||
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #9A7200; margin-top: 10px; }
|
||||
|
||||
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
|
||||
.db-placeholder {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 8px; min-height: 220px; text-align: center; color: #8A9A9A;
|
||||
background: #F0EFEE; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
}
|
||||
.db-placeholder-icon { font-size: 1.75rem; }
|
||||
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #494F4F; }
|
||||
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
|
||||
|
||||
/* ── Nav compartido (dashboard / respuestas / usuarios) ── */
|
||||
.admin-nav-bar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px; padding: 10px 24px; background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border); flex-wrap: wrap;
|
||||
}
|
||||
.admin-nav { display: flex; gap: 4px; }
|
||||
.admin-nav-link {
|
||||
padding: 6px 14px; border-radius: var(--radius); font-size: 0.875rem;
|
||||
font-weight: 600; color: var(--color-text-muted); text-decoration: none;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.admin-nav-link:hover { background: var(--color-primary-light); color: var(--color-text); }
|
||||
.admin-nav-link-active { background: var(--color-primary-light); color: var(--color-primary-hover); }
|
||||
|
||||
/* ── Admin: control de respuestas (Etapa 3V) ── */
|
||||
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
.admin-header {
|
||||
@@ -759,19 +1022,46 @@ body {
|
||||
.admin-title { font-size: 1.375rem; font-weight: 700; color: var(--color-text); margin: 0 0 4px; }
|
||||
.admin-subtitle { font-size: 0.8125rem; color: var(--color-text-muted); margin: 0; }
|
||||
.admin-survey-nav { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
.admin-survey-link {
|
||||
font-size: 0.75rem; font-weight: 600; padding: 4px 12px; border-radius: 999px;
|
||||
border: 1px solid var(--color-border); color: var(--color-text-muted); text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.admin-survey-link:hover { color: var(--color-text); border-color: var(--color-primary); }
|
||||
.admin-survey-link-active { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
|
||||
.admin-refresh {
|
||||
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);
|
||||
@@ -792,6 +1082,10 @@ body {
|
||||
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);
|
||||
@@ -817,3 +1111,124 @@ body {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
}
|
||||
|
||||
/* ── Login ── */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
padding: 24px;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 40px 36px;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
}
|
||||
.login-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
.login-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.login-field { display: flex; flex-direction: column; gap: 6px; }
|
||||
.login-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.login-error { margin: 0; }
|
||||
.login-password-wrapper { position: relative; }
|
||||
.login-password-wrapper .text-short-input { padding-right: 40px; }
|
||||
.login-password-toggle {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 8px;
|
||||
transform: translateY(-50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-password-toggle:hover { color: var(--color-text); }
|
||||
.login-submit {
|
||||
margin-top: 8px;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.login-submit:hover:not(:disabled) { background: var(--color-primary-hover); }
|
||||
.login-submit:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.login-back-button { margin-top: 16px; }
|
||||
.login-forgot-link {
|
||||
align-self: center;
|
||||
margin-top: 4px;
|
||||
padding: 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.login-forgot-link:hover { color: var(--color-primary-hover); }
|
||||
|
||||
/* ── Invite form (/admin/usuarios) ── */
|
||||
.invite-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.invite-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
.invite-banner {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.invite-banner-success {
|
||||
background: var(--color-primary-light);
|
||||
color: var(--color-primary-hover);
|
||||
}
|
||||
.invite-banner-error {
|
||||
background: var(--color-error-bg);
|
||||
color: var(--color-error);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.invite-form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Admin sign-out ── */
|
||||
.admin-header-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.admin-signout {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.admin-signout:hover { color: var(--color-error); border-color: var(--color-error); }
|
||||
|
||||
197
app/login/page.tsx
Normal file
197
app/login/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type Mode = 'login' | 'reset' | 'reset-sent'
|
||||
|
||||
export default function LoginPage() {
|
||||
const [mode, setMode] = useState<Mode>('login')
|
||||
const [isPending, setIsPending] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [resetEmail, setResetEmail] = useState('')
|
||||
const router = useRouter()
|
||||
|
||||
const supabase = createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsPending(true)
|
||||
setError(null)
|
||||
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const { error: authError } = await supabase.auth.signInWithPassword({
|
||||
email: fd.get('email') as string,
|
||||
password: fd.get('password') as string,
|
||||
})
|
||||
|
||||
if (authError) {
|
||||
setError('Email o contraseña incorrectos')
|
||||
setIsPending(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push('/admin/responses')
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
// Reusa /auth/confirm: ya escucha PASSWORD_RECOVERY (lo armamos para el
|
||||
// flujo de invitación, pero Supabase emite el mismo evento acá).
|
||||
async function handleReset(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsPending(true)
|
||||
setError(null)
|
||||
|
||||
const fd = new FormData(e.currentTarget)
|
||||
const email = String(fd.get('email') ?? '')
|
||||
|
||||
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/confirm`,
|
||||
})
|
||||
|
||||
setIsPending(false)
|
||||
|
||||
if (resetError) {
|
||||
setError('No pudimos enviar el link. Probá de nuevo.')
|
||||
return
|
||||
}
|
||||
|
||||
setResetEmail(email)
|
||||
setMode('reset-sent')
|
||||
}
|
||||
|
||||
if (mode === 'reset-sent') {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Revisá tu email</h1>
|
||||
<p className="admin-subtitle">
|
||||
Si <strong>{resetEmail}</strong> tiene una cuenta, te enviamos un link para crear una
|
||||
contraseña nueva.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="login-submit login-back-button"
|
||||
onClick={() => { setMode('login'); setError(null) }}
|
||||
>
|
||||
Volver al login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'reset') {
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Recuperar contraseña</h1>
|
||||
<form onSubmit={handleReset} className="login-form" noValidate>
|
||||
<div className="login-field">
|
||||
<label htmlFor="reset-email" className="login-label">Email</label>
|
||||
<input
|
||||
id="reset-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error login-error">{error}</p>
|
||||
)}
|
||||
<button type="submit" disabled={isPending} className="login-submit">
|
||||
{isPending ? 'Enviando…' : 'Enviar link de recuperación'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-forgot-link"
|
||||
onClick={() => { setMode('login'); setError(null) }}
|
||||
>
|
||||
← Volver al login
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-card">
|
||||
<h1 className="login-title">Acceso interno</h1>
|
||||
<form onSubmit={handleSubmit} className="login-form" noValidate>
|
||||
<div className="login-field">
|
||||
<label htmlFor="email" className="login-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="password" className="login-label">Contraseña</label>
|
||||
<div className="login-password-wrapper">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="text-short-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-password-toggle"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
aria-label={showPassword ? 'Ocultar contraseña' : 'Mostrar contraseña'}
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error login-error">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="login-submit"
|
||||
>
|
||||
{isPending ? 'Ingresando…' : 'Ingresar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-forgot-link"
|
||||
onClick={() => { setMode('reset'); setError(null) }}
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
app/page.tsx
32
app/page.tsx
@@ -1,6 +1,13 @@
|
||||
import { createClient } from '@/lib/supabase'
|
||||
import type { BrandConfig, Question, Survey } from '@/lib/types'
|
||||
import { SurveyForm } from '@/components/survey-form'
|
||||
import type { BrandConfig, Survey } from '@/lib/types'
|
||||
import type { FormSchema, Question as EngineQuestion } from '@/lib/form-engine.types'
|
||||
import { buildFormSchema } from '@/lib/form-engine'
|
||||
import { WizardForm } from '@/components/wizard-form'
|
||||
|
||||
// Preguntas de consentimiento (S1, 1.1, 1.2): las resuelve ConsentScreen, no el
|
||||
// motor de flujo — su texto legal (ADR-003) no vive en el hint de estas filas
|
||||
// todavía. Se excluyen del FormSchema para que el wizard arranque en S2.
|
||||
const CONSENT_CODES = new Set(['S1', '1.1', '1.2'])
|
||||
|
||||
// Force dynamic rendering — page fetches from Supabase at request time.
|
||||
export const dynamic = 'force-dynamic'
|
||||
@@ -48,11 +55,12 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Fetch questions ordered by position
|
||||
// 2. Fetch questions ordered by position — incluye hint/instance_generator/instance_of (4D)
|
||||
const { data: questionRows, error: questionsError } = await supabase
|
||||
.from('questions')
|
||||
.select(
|
||||
'id, survey_id, code, type, prompt, position, is_sensitive, required, max_select, options, conditional_rules'
|
||||
'id, survey_id, code, type, prompt, position, is_sensitive, required, hint, ' +
|
||||
'max_select, options, conditional_rules, instance_generator, instance_of'
|
||||
)
|
||||
.eq('survey_id', surveyRow.id)
|
||||
.order('position', { ascending: true })
|
||||
@@ -66,9 +74,12 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
)
|
||||
}
|
||||
|
||||
const questions = questionRows as Question[]
|
||||
// Cast en dos pasos: el cliente Supabase no tiene un Database genérico configurado
|
||||
// (lib/supabase.ts), así que el tipo de fila que infiere para .select() no solapa
|
||||
// directamente con EngineQuestion. Las columnas pedidas arriba sí coinciden 1:1.
|
||||
const engineQuestions = questionRows as unknown as EngineQuestion[]
|
||||
|
||||
console.log(`[2B] Survey loaded: id=${surveyRow.id} | questions=${questions.length}`)
|
||||
console.log(`[4D] Survey loaded: id=${surveyRow.id} | questions=${engineQuestions.length}`)
|
||||
|
||||
const survey: Survey = {
|
||||
id: surveyRow.id,
|
||||
@@ -79,8 +90,13 @@ export default async function Page({ searchParams }: PageProps) {
|
||||
k_threshold: surveyRow.k_threshold,
|
||||
consent_version_id: surveyRow.consent_version_id,
|
||||
brand_config: (surveyRow.brand_config as BrandConfig) ?? null,
|
||||
questions,
|
||||
questions: [],
|
||||
}
|
||||
|
||||
return <SurveyForm survey={survey} questions={questions} />
|
||||
// El consentimiento (S1, 1.1, 1.2) lo resuelve ConsentScreen, no el motor — ver nota arriba.
|
||||
const schema: FormSchema = buildFormSchema(
|
||||
engineQuestions.filter((q) => !CONSENT_CODES.has(q.code))
|
||||
)
|
||||
|
||||
return <WizardForm survey={survey} schema={schema} />
|
||||
}
|
||||
|
||||
34
components/admin-nav-bar.tsx
Normal file
34
components/admin-nav-bar.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { SignOutButton } from '@/app/admin/sign-out-button'
|
||||
|
||||
interface AdminNavBarProps {
|
||||
active: 'dashboard' | 'responses' | 'usuarios'
|
||||
}
|
||||
|
||||
const LINKS = [
|
||||
{ id: 'dashboard', href: '/dashboard', label: 'Dashboard' },
|
||||
{ id: 'responses', href: '/admin/responses', label: 'Respuestas' },
|
||||
{ id: 'usuarios', href: '/admin/usuarios', label: 'Usuarios' },
|
||||
] as const
|
||||
|
||||
// Nav compartido entre /dashboard, /admin/responses y /admin/usuarios — las
|
||||
// tres pantallas detrás del login. Sin esto, cada una era una isla y el único
|
||||
// modo de llegar a otra era escribir la URL a mano.
|
||||
export function AdminNavBar({ active }: AdminNavBarProps) {
|
||||
return (
|
||||
<div className="admin-nav-bar">
|
||||
<nav className="admin-nav" aria-label="Navegación admin">
|
||||
{LINKS.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.href}
|
||||
className={`admin-nav-link${active === link.id ? ' admin-nav-link-active' : ''}`}
|
||||
aria-current={active === link.id ? 'page' : undefined}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<SignOutButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
components/dashboard/apoyos-bar-me.tsx
Normal file
52
components/dashboard/apoyos-bar-me.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type ApoyosMEData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
export function ApoyosBarME({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<ApoyosMEData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.meApoyos(surveyId, filters)
|
||||
.then((d) => setData(d as ApoyosMEData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const ok = status === 'ok' ? (data as ApoyosMEData) : null
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Apoyos más demandados"
|
||||
subtitle="9.2 · qué beneficios diseñar (máx. 3 opciones por persona)"
|
||||
status={status}
|
||||
n={ok?.n}
|
||||
colSpan={6}
|
||||
>
|
||||
{ok && (
|
||||
<ul className="db-bar-list" aria-label="Apoyos más demandados">
|
||||
{ok.apoyos.length === 0 && (
|
||||
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n<5 en todos).</li>
|
||||
)}
|
||||
{ok.apoyos.map((item) => (
|
||||
<li key={item.apoyo} className="db-bar-item">
|
||||
<span className="db-bar-label">{item.apoyo}</span>
|
||||
<div className="db-bar-track">
|
||||
<div className="db-bar-fill" style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
|
||||
</div>
|
||||
<span className="db-bar-pct">{item.pct}%</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type ApoyosData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
export function ApoyosBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<ApoyosData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.apoyos(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ApoyosData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
return (
|
||||
<WidgetShell title="Apoyos más demandados" subtitle="6.2 · apoyos de la empresa que los respondentes consideran que más los ayudarían" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<ul className="db-bar-list" aria-label="Apoyos más demandados">
|
||||
{data.data.length === 0 && (
|
||||
<li className="db-suppressed-hint">No hay datos suficientes por apoyo (n<5 en todos).</li>
|
||||
)}
|
||||
{data.data.map(item => (
|
||||
<li key={item.politica} className="db-bar-item">
|
||||
<span className="db-bar-label">{item.politica}</span>
|
||||
<div className="db-bar-track" aria-hidden="true">
|
||||
<div className="db-bar-fill"
|
||||
style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
|
||||
</div>
|
||||
<span className="db-bar-pct">{item.pct}%</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
53
components/dashboard/disposicion-red-card.tsx
Normal file
53
components/dashboard/disposicion-red-card.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type DisposicionRedData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
export function DisposicionRedCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<DisposicionRedData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.meDisposicionRed(surveyId, filters)
|
||||
.then((d) => setData(d as DisposicionRedData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
// Esta RPC tiene su propio shape de "suppressed" (n_grupo_apoyo/n_voluntariado,
|
||||
// no min_n/n genérico) — isSuppressed() solo mira `status`, sigue funcionando.
|
||||
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const ok = status === 'ok' ? (data as DisposicionRedData) : null
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Disposición a red de apoyo"
|
||||
subtitle="9.3 (grupo de apoyo) + 9.4 (voluntariado)"
|
||||
status={status}
|
||||
colSpan={4}
|
||||
>
|
||||
{ok && (
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
<div style={{ flex: 1, textAlign: 'center' }}>
|
||||
<div className="db-big-stat" style={{ fontSize: '1.625rem' }}>
|
||||
{ok.pct_grupo_apoyo !== null ? `${ok.pct_grupo_apoyo}%` : 'n<5'}
|
||||
</div>
|
||||
<p className="db-big-stat-sub">grupo de apoyo (n={ok.n_grupo_apoyo})</p>
|
||||
</div>
|
||||
<div style={{ flex: 1, textAlign: 'center' }}>
|
||||
<div className="db-big-stat" style={{ fontSize: '1.625rem' }}>
|
||||
{ok.pct_voluntariado !== null ? `${ok.pct_voluntariado}%` : 'n<5'}
|
||||
</div>
|
||||
<p className="db-big-stat-sub">voluntariado (n={ok.n_voluntariado})</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type DistribucionData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ key: 'disc_propia', label: 'Discapacidad propia', color: '#43BBC8' },
|
||||
{ key: 'familiar', label: 'Familiar con disc.', color: '#F8AD13' },
|
||||
{ key: 'adulto_mayor', label: 'Adulto mayor', color: '#EE761A' },
|
||||
{ key: 'ninguno', label: 'Sin rol de cuidado', color: '#3d506b' },
|
||||
] as const
|
||||
|
||||
export function DonutCuidados({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<DistribucionData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.distribucion(surveyId, filters)
|
||||
.then(d => {
|
||||
if (isSuppressed(d)) setSup(true)
|
||||
else setData(d as DistribucionData)
|
||||
}).catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
// SVG donut
|
||||
const R = 60; const cx = 75; const cy = 75; const stroke = 22
|
||||
let offset = 0
|
||||
const circumference = 2 * Math.PI * R
|
||||
const arcs = data ? SEGMENTS.map(s => {
|
||||
const pct = (data[s.key as keyof DistribucionData] as number) ?? 0
|
||||
const dash = (pct / 100) * circumference
|
||||
const gap = circumference - dash
|
||||
const arc = { ...s, dash, gap, offset: circumference * (1 - offset / 100) }
|
||||
offset += pct
|
||||
return arc
|
||||
}) : []
|
||||
|
||||
return (
|
||||
<WidgetShell title="Perfil de la población" subtitle="1.1 + 1.6 + 1.13 · discapacidad propia, cuidador familiar y de adulto mayor (no excluyentes)" status={status} n={data?.n} colSpan={3}>
|
||||
{data && (
|
||||
<div className="db-donut-wrapper">
|
||||
<svg viewBox="0 0 150 150" className="db-donut-svg" aria-hidden="true">
|
||||
{arcs.map(arc => (
|
||||
<circle key={arc.key}
|
||||
cx={cx} cy={cy} r={R}
|
||||
fill="none"
|
||||
stroke={arc.color}
|
||||
strokeWidth={stroke}
|
||||
strokeDasharray={`${arc.dash} ${arc.gap}`}
|
||||
strokeDashoffset={arc.offset}
|
||||
transform="rotate(-90, 75, 75)"
|
||||
/>
|
||||
))}
|
||||
<text x={cx} y={cy-6} textAnchor="middle" fill="white" fontSize="14" fontWeight="bold">
|
||||
{data.disc_propia + data.familiar + data.adulto_mayor > 0
|
||||
? `${Math.round(data.disc_propia + data.familiar + data.adulto_mayor)}%`
|
||||
: '0%'}
|
||||
</text>
|
||||
<text x={cx} y={cy+12} textAnchor="middle" fill="#9ca3af" fontSize="8">cuidadores</text>
|
||||
</svg>
|
||||
<ul className="db-donut-legend" aria-label="Leyenda del donut">
|
||||
{SEGMENTS.map(s => (
|
||||
<li key={s.key} className="db-legend-item">
|
||||
<span className="db-legend-dot" style={{ background: s.color }} />
|
||||
<span>{s.label}</span>
|
||||
<span className="db-legend-val">
|
||||
{(data[s.key as keyof DistribucionData] as number ?? 0)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type ScoreData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
const ZONA_COLOR = { baja: '#ef4444', desarrollo: '#F8AD13', consciente: '#43BBC8' }
|
||||
const ZONA_LABEL = { baja: 'Baja madurez', desarrollo: 'En desarrollo', consciente: 'Consciente' }
|
||||
|
||||
export function GaugeOrganizacional({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<ScoreData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.score(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ScoreData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
// SVG semicircle gauge
|
||||
const score = data?.score ?? 0
|
||||
const angle = (score / 100) * 180 // 0° = left, 180° = right
|
||||
const rad = (angle - 90) * Math.PI / 180
|
||||
const r = 60; const cx = 75; const cy = 80
|
||||
const needleX = cx + r * Math.cos(rad)
|
||||
const needleY = cy + r * Math.sin(rad)
|
||||
const color = data ? ZONA_COLOR[data.zona] : '#43BBC8'
|
||||
|
||||
return (
|
||||
<WidgetShell title="Madurez organizacional" subtitle="6.1 + 6.5 · comprensión percibida y expectativa hacia la empresa · score 0–100" status={status} n={data?.n} colSpan={3}>
|
||||
{data && (
|
||||
<div className="db-gauge-wrapper">
|
||||
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>
|
||||
{/* Fondo semicírculo */}
|
||||
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none" stroke="#2a3547" strokeWidth="18" strokeLinecap="round" />
|
||||
{/* Arco coloreado proporcional al score */}
|
||||
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none"
|
||||
stroke={color} strokeWidth="18" strokeLinecap="round"
|
||||
strokeDasharray={`${(score/100)*188.5} 188.5`} />
|
||||
{/* Aguja */}
|
||||
<line x1={cx} y1={cy} x2={needleX} y2={needleY} stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx={cx} cy={cy} r={4} fill="white" />
|
||||
{/* Score */}
|
||||
<text x={cx} y={cy-10} textAnchor="middle" fill="white" fontSize="18" fontWeight="bold">{score}</text>
|
||||
<text x={cx} y={cy+6} textAnchor="middle" fill="#9ca3af" fontSize="7">/ 100</text>
|
||||
</svg>
|
||||
<p className="db-gauge-zona" style={{ color }}>
|
||||
{ZONA_LABEL[data.zona]}
|
||||
</p>
|
||||
<p className="db-gauge-hint">
|
||||
0–33 baja · 34–66 desarrollo · 67–100 consciente
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type HeatmapData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
import { HORAS_ORDER, AUSENCIAS_ORDER } from '@/lib/dashboard-utils'
|
||||
|
||||
const SHORT_AUSENCIAS = ['Nunca','Pocas (1-3)','Varias (4-10)','Con freq.']
|
||||
|
||||
export function HeatmapWidget({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<HeatmapData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.heatmap(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as HeatmapData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
function cellColor(n: number | null): string {
|
||||
if (n === null) return '#1e2d42'
|
||||
const max = Math.max(...(data?.data.map(c => c.n ?? 0) ?? [1]), 1)
|
||||
const t = n / max
|
||||
return `rgba(67, 187, 200, ${0.15 + t * 0.85})`
|
||||
}
|
||||
|
||||
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.ausencias}`, c]))
|
||||
|
||||
return (
|
||||
<WidgetShell title="Horas de cuidado × Frecuencia de ausentismo" subtitle="2.2 × 5.2 · correlación entre intensidad de cuidado y ausentismo recurrente" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<div className="db-heatmap-scroll" role="table" aria-label="Heatmap de intensidad">
|
||||
<div className="db-heatmap-grid"
|
||||
style={{ gridTemplateColumns: `120px repeat(${AUSENCIAS_ORDER.length}, 1fr)` }}>
|
||||
{/* Header row */}
|
||||
<div role="columnheader" />
|
||||
{SHORT_AUSENCIAS.map(h => (
|
||||
<div key={h} className="db-heatmap-header" role="columnheader">{h}</div>
|
||||
))}
|
||||
{/* Data rows */}
|
||||
{HORAS_ORDER.map(horas => (
|
||||
<>
|
||||
<div key={`row-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
|
||||
{AUSENCIAS_ORDER.map(aus => {
|
||||
const cell = cellMap.get(`${horas}|${aus}`)
|
||||
const n = cell?.n ?? null
|
||||
const sup2 = cell?.suppressed ?? (!cell)
|
||||
return (
|
||||
<div key={`${horas}|${aus}`}
|
||||
className="db-heatmap-cell"
|
||||
style={{ background: cellColor(n) }}
|
||||
role="cell"
|
||||
aria-label={`${horas} / ${aus}: ${sup2 ? 'suprimido' : `n=${n}`}`}
|
||||
title={sup2 ? 'Datos insuficientes' : `n = ${n}`}>
|
||||
{!sup2 && n !== null && <span>{n}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type IntensidadData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
import { HORAS_ORDER } from '@/lib/dashboard-utils'
|
||||
|
||||
// Hexbin implementado como CSS Grid con celdas coloreadas por densidad.
|
||||
// NO es scatter de individuos — cada celda es un agregado con N≥5.
|
||||
const BIENESTAR_BINS = ['1','2','3','4','5']
|
||||
|
||||
export function IntensidadProductividad({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<IntensidadData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.intensidad(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as IntensidadData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
|
||||
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.bienestar}`, c]))
|
||||
|
||||
function cellColor(n: number | null): string {
|
||||
if (n === null) return '#1e2d42'
|
||||
const t = n / maxN
|
||||
return `rgba(67, 187, 200, ${0.1 + t * 0.9})`
|
||||
}
|
||||
|
||||
return (
|
||||
<WidgetShell title="Intensidad de cuidado vs. Bienestar" subtitle="2.2 × 4.3 · cruce agregado intensidad de cuidado × bienestar (bins con k≥5)" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<div className="db-hexbin-wrapper">
|
||||
<p className="db-hexbin-note">
|
||||
Cada celda = respondentes agregados. Celdas sin color: N < 5 (suprimido).
|
||||
</p>
|
||||
<div className="db-heatmap-scroll">
|
||||
<div className="db-heatmap-grid"
|
||||
style={{ gridTemplateColumns: `120px repeat(${BIENESTAR_BINS.length}, 1fr)` }}>
|
||||
<div role="columnheader" />
|
||||
{BIENESTAR_BINS.map(b => (
|
||||
<div key={b} className="db-heatmap-header" role="columnheader">Bienestar {b}</div>
|
||||
))}
|
||||
{HORAS_ORDER.map(horas => (
|
||||
<>
|
||||
<div key={`r-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
|
||||
{BIENESTAR_BINS.map(bien => {
|
||||
const cell = cellMap.get(`${horas}|${bien}`)
|
||||
const n = cell?.n ?? null
|
||||
const suppressed = cell?.suppressed ?? true
|
||||
return (
|
||||
<div key={`${horas}|${bien}`}
|
||||
className="db-heatmap-cell"
|
||||
style={{ background: cellColor(n) }}
|
||||
role="cell"
|
||||
title={suppressed ? 'Datos insuficientes' : `n = ${n}`}
|
||||
aria-label={suppressed ? 'suprimido' : `n=${n}`}>
|
||||
{!suppressed && n !== null && <span>{n}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type KpiData, type SuppressedPayload } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
|
||||
const KPI_DEFS = [
|
||||
{
|
||||
title: 'CUIDADORES ACTIVOS',
|
||||
subtitle: '1.6 + 1.13 · % de la plantilla con responsabilidades de cuidado de otros',
|
||||
getValue: (d: KpiData) => `${d.pct_cuidadores}%`,
|
||||
getN: (d: KpiData) => `n = ${d.n}`,
|
||||
},
|
||||
{
|
||||
title: 'FRECUENCIA DE AUSENTISMO',
|
||||
subtitle: '5.2 · % con ausencias laborales recurrentes (4 o más veces al año)',
|
||||
getValue: (d: KpiData) => `${d.pct_ausencias}%`,
|
||||
getN: () => 'ausencias frecuentes',
|
||||
},
|
||||
{
|
||||
title: 'BIENESTAR',
|
||||
subtitle: '4.3 · autorreporte de bienestar general · escala 1–5',
|
||||
getValue: (d: KpiData) => d.score_bienestar !== null ? d.score_bienestar.toFixed(1) : '—',
|
||||
getN: () => '/ 5',
|
||||
},
|
||||
] as const
|
||||
|
||||
interface KpiCardProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
value: string
|
||||
valueNote: string
|
||||
suppressed: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
function KpiCard({ title, subtitle, value, valueNote, suppressed, loading }: KpiCardProps) {
|
||||
return (
|
||||
<div className="db-kpi-card">
|
||||
<span className="db-kpi-title">{title}</span>
|
||||
<span className="db-kpi-subtitle">{subtitle}</span>
|
||||
|
||||
{loading && (
|
||||
<div className="db-skeleton-block" style={{ height: 36, margin: '10px 0 4px', borderRadius: 6 }} />
|
||||
)}
|
||||
{!loading && suppressed && (
|
||||
<div className="db-kpi-suppressed-block" role="status">
|
||||
<span className="db-suppressed-icon" aria-hidden="true">🔒</span>
|
||||
<span className="db-kpi-value db-kpi-value-suppressed">n < 5</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !suppressed && (
|
||||
<>
|
||||
<span className="db-kpi-value" aria-live="polite">{value}</span>
|
||||
<span className="db-kpi-note">{valueNote}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface Props { surveyId: string; filters: DashboardFilters }
|
||||
|
||||
export function KpiCards({ surveyId, filters }: Props) {
|
||||
const [data, setData] = useState<KpiData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.kpis(surveyId, filters)
|
||||
.then(d => setData(d as KpiData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
if (error) return <p className="db-error">Error al cargar KPIs</p>
|
||||
|
||||
const loading = data === null
|
||||
const suppressed = !loading && isSuppressed(data)
|
||||
const ok = !loading && !suppressed
|
||||
|
||||
return (
|
||||
<div className="db-kpi-row">
|
||||
{KPI_DEFS.map((def) => (
|
||||
<KpiCard
|
||||
key={def.title}
|
||||
title={def.title}
|
||||
subtitle={def.subtitle}
|
||||
value={ok ? def.getValue(data as KpiData) : '—'}
|
||||
valueNote={ok ? def.getN(data as KpiData) : ''}
|
||||
suppressed={suppressed}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type PrevalenciaData, type SuppressedPayload } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
// Tile-map de Paraguay (fallback aprobado en el prompt cuando no se consigue SVG fiel)
|
||||
// col/row en grilla 5×6
|
||||
const TILES = [
|
||||
{ name: 'Alto Paraguay', col: 2, row: 0 },
|
||||
{ name: 'Boquerón', col: 0, row: 1 },
|
||||
{ name: 'Presidente Hayes', col: 1, row: 1 },
|
||||
{ name: 'Concepción', col: 2, row: 1 },
|
||||
{ name: 'San Pedro', col: 3, row: 1 },
|
||||
{ name: 'Amambay', col: 4, row: 1 },
|
||||
{ name: 'Asunción (Capital)', col: 1, row: 2 },
|
||||
{ name: 'Cordillera', col: 2, row: 2 },
|
||||
{ name: 'Caaguazú', col: 3, row: 2 },
|
||||
{ name: 'Canindeyú', col: 4, row: 2 },
|
||||
{ name: 'Central', col: 1, row: 3 },
|
||||
{ name: 'Guairá', col: 2, row: 3 },
|
||||
{ name: 'Alto Paraná', col: 4, row: 3 },
|
||||
{ name: 'Ñeembucú', col: 0, row: 4 },
|
||||
{ name: 'Misiones', col: 1, row: 4 },
|
||||
{ name: 'Paraguarí', col: 2, row: 4 },
|
||||
{ name: 'Caazapá', col: 3, row: 4 },
|
||||
{ name: 'Itapúa', col: 2, row: 5 },
|
||||
]
|
||||
|
||||
function lerp(t: number, a: string, b: string): string {
|
||||
// simple teal interpolation: #E7E6E5 (light) → #43BBC8 (teal)
|
||||
const parseHex = (h: string) => [
|
||||
parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)
|
||||
]
|
||||
const ca = parseHex(a); const cb = parseHex(b)
|
||||
const r = Math.round(ca[0] + (cb[0]-ca[0])*t)
|
||||
const g = Math.round(ca[1] + (cb[1]-ca[1])*t)
|
||||
const bv= Math.round(ca[2] + (cb[2]-ca[2])*t)
|
||||
return `#${r.toString(16).padStart(2,'0')}${g.toString(16).padStart(2,'0')}${bv.toString(16).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
interface Props { surveyId: string; filters: DashboardFilters; onDeptoClick: (d: string) => void }
|
||||
|
||||
export function ParaguayMap({ surveyId, filters, onDeptoClick }: Props) {
|
||||
const [data, setData] = useState<PrevalenciaData | SuppressedPayload | null>(null)
|
||||
const [tooltip, setTooltip] = useState<{ name: string; pct: number | null; n: number | null } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null)
|
||||
queries.prevalencia(surveyId, filters)
|
||||
.then(d => setData(d as PrevalenciaData | SuppressedPayload))
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const deptoMap = new Map(
|
||||
(!data || isSuppressed(data)) ? [] :
|
||||
(data as PrevalenciaData).data.map(d => [d.depto, d])
|
||||
)
|
||||
|
||||
const status = !data ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined
|
||||
|
||||
return (
|
||||
<WidgetShell title="Prevalencia de cuidadores por departamento" subtitle="A1 · % cuidadores activos por departamento" status={status} n={n} colSpan={6}>
|
||||
<div className="db-map-wrapper" role="img" aria-label="Mapa de Paraguay con prevalencia de cuidadores por departamento">
|
||||
{tooltip && (
|
||||
<div className="db-map-tooltip" aria-live="polite">
|
||||
<strong>{tooltip.name}</strong>
|
||||
{tooltip.pct !== null
|
||||
? <> · {tooltip.pct}% · n={tooltip.n}</>
|
||||
: <> · Datos insuficientes</>}
|
||||
</div>
|
||||
)}
|
||||
<div className="db-tile-grid">
|
||||
{TILES.map(tile => {
|
||||
const d = deptoMap.get(tile.name)
|
||||
const suppressed = !d || d.suppressed
|
||||
const pct = d?.pct_cuidadores ?? null
|
||||
const color = suppressed ? '#2a3547' : lerp((pct ?? 0)/100, '#E7E6E5', '#43BBC8')
|
||||
const isSelected = filters.depto === tile.name
|
||||
return (
|
||||
<button
|
||||
key={tile.name}
|
||||
className={`db-tile${isSelected ? ' db-tile-selected' : ''}${suppressed ? ' db-tile-suppressed' : ''}`}
|
||||
style={{
|
||||
gridColumn: tile.col + 1,
|
||||
gridRow: tile.row + 1,
|
||||
background: color,
|
||||
}}
|
||||
onClick={() => onDeptoClick(isSelected ? '' : tile.name)}
|
||||
onMouseEnter={() => setTooltip({ name: tile.name, pct, n: d?.n ?? null })}
|
||||
onMouseLeave={() => setTooltip(null)}
|
||||
title={suppressed ? `${tile.name} — Datos insuficientes` : `${tile.name} — ${pct}% (n=${d?.n})`}
|
||||
aria-label={suppressed
|
||||
? `${tile.name}: datos insuficientes`
|
||||
: `${tile.name}: ${pct}% cuidadores, n=${d?.n}`}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<span className="db-tile-name">{tile.name.replace(' (Capital)','')}</span>
|
||||
{!suppressed && pct !== null && (
|
||||
<span className="db-tile-pct">{pct}%</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type PiramideData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
import { ETARIO_ORDER } from '@/lib/dashboard-utils'
|
||||
|
||||
const GENERO_COLOR: Record<string, string> = {
|
||||
'Femenino': '#EE761A',
|
||||
'Masculino': '#43BBC8',
|
||||
'No binario/Otro': '#F8AD13',
|
||||
'Prefiero no decir':'#6b7280',
|
||||
}
|
||||
|
||||
export function PiramideDemografica({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<PiramideData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.piramide(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PiramideData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
|
||||
|
||||
const generos = ['Femenino','Masculino','No binario/Otro']
|
||||
|
||||
return (
|
||||
<WidgetShell title="Pirámide demográfica" subtitle="A5 × A6 · rango etario por género (celdas con k≥5)" status={status} n={data?.n_total} colSpan={6}>
|
||||
{data && (
|
||||
<div className="db-piramide">
|
||||
<div className="db-piramide-legend" aria-label="Leyenda">
|
||||
{generos.map(g => (
|
||||
<span key={g} className="db-legend-item">
|
||||
<span className="db-legend-dot" style={{ background: GENERO_COLOR[g] }} />
|
||||
{g}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{ETARIO_ORDER.filter(e => e !== 'Prefiero no decir').map(etario => {
|
||||
const row = generos.map(gen => {
|
||||
const cell = data.data.find(c => c.etario === etario && c.genero === gen)
|
||||
return { gen, n: cell?.n ?? null, suppressed: cell?.suppressed ?? true }
|
||||
})
|
||||
return (
|
||||
<div key={etario} className="db-piramide-row" role="row" aria-label={etario}>
|
||||
<span className="db-piramide-label">{etario}</span>
|
||||
<div className="db-piramide-bars">
|
||||
{row.map(({ gen, n, suppressed }) => (
|
||||
<div key={gen} className="db-piramide-bar-wrap"
|
||||
style={{ flex: `0 0 ${Math.max(10, (n ?? 0) / maxN * 100)}%` }}>
|
||||
<div className="db-piramide-bar"
|
||||
style={{
|
||||
width: suppressed ? '100%' : `${(n ?? 0) / maxN * 100}%`,
|
||||
background: suppressed ? '#2a3547' : GENERO_COLOR[gen],
|
||||
opacity: suppressed ? 0.4 : 1,
|
||||
}}
|
||||
title={suppressed ? 'Datos insuficientes' : `${gen}: n=${n}`}
|
||||
role="cell"
|
||||
aria-label={suppressed ? `${gen}: suprimido` : `${gen}: ${n}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="db-piramide-n">
|
||||
{row.every(r => r.suppressed) ? '🔒' : row.reduce((s, r) => s + (r.n ?? 0), 0)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
42
components/dashboard/prevalencia-cuidadores-card.tsx
Normal file
42
components/dashboard/prevalencia-cuidadores-card.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type PrevalenciaCuidadoresData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
export function PrevalenciaCuidadoresCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<PrevalenciaCuidadoresData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.mePrevalencia(surveyId, filters)
|
||||
.then((d) => setData(d as PrevalenciaCuidadoresData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const ok = status === 'ok' ? (data as PrevalenciaCuidadoresData) : null
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Cuidadores en la plantilla"
|
||||
subtitle="4.1 · vínculo de cuidado activo (incluye hijo/a menor sin discapacidad — criterio UNFPA)"
|
||||
status={status}
|
||||
n={ok?.n}
|
||||
colSpan={4}
|
||||
>
|
||||
{ok && (
|
||||
<>
|
||||
<div className="db-big-stat">{ok.pct_cuidadores}%</div>
|
||||
<p className="db-big-stat-sub">tiene algún rol de cuidado</p>
|
||||
</>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
73
components/dashboard/punto-ciego-card.tsx
Normal file
73
components/dashboard/punto-ciego-card.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type PuntoCiegoData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
const MAX_BAR_HEIGHT = 70
|
||||
|
||||
export function PuntoCiegoCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<PuntoCiegoData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.mePuntoCiego(surveyId, filters)
|
||||
.then((d) => setData(d as PuntoCiegoData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const ok = status === 'ok' ? (data as PuntoCiegoData) : null
|
||||
|
||||
const cargaHeight = ok ? Math.max(6, (ok.carga_real_pct / 100) * MAX_BAR_HEIGHT) : 0
|
||||
const apoyoHeight =
|
||||
ok && ok.apoyo_percibido_pct !== null ? Math.max(6, (ok.apoyo_percibido_pct / 100) * MAX_BAR_HEIGHT) : 0
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Punto ciego organizacional"
|
||||
subtitle="9.1 vs. prevalencia real (4.1) — brecha entre carga y percepción"
|
||||
status={status}
|
||||
n={ok?.n}
|
||||
colSpan={4}
|
||||
>
|
||||
{ok && (
|
||||
<>
|
||||
<div className="db-gap-viz">
|
||||
<div className="db-gap-col">
|
||||
<div className="db-gap-val" style={{ color: 'var(--color-re-teal)' }}>
|
||||
{ok.carga_real_pct}%
|
||||
</div>
|
||||
<div className="db-gap-bar" style={{ height: cargaHeight, background: 'var(--color-re-teal)' }} />
|
||||
<div className="db-gap-lab">carga real</div>
|
||||
</div>
|
||||
<div className="db-gap-col">
|
||||
{ok.apoyo_percibido_pct !== null ? (
|
||||
<>
|
||||
<div className="db-gap-val" style={{ color: '#f06b6b' }}>
|
||||
{ok.apoyo_percibido_pct}%
|
||||
</div>
|
||||
<div className="db-gap-bar" style={{ height: apoyoHeight, background: '#f06b6b' }} />
|
||||
</>
|
||||
) : (
|
||||
<div className="db-gap-val" style={{ color: '#6b7280' }}>
|
||||
n<5
|
||||
</div>
|
||||
)}
|
||||
<div className="db-gap-lab">se siente apoyado</div>
|
||||
</div>
|
||||
</div>
|
||||
{ok.brecha_pp !== null && (
|
||||
<p className="db-gap-brecha">Brecha: {ok.brecha_pp} puntos porcentuales</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
74
components/dashboard/segmentacion-bar.tsx
Normal file
74
components/dashboard/segmentacion-bar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type SegmentacionData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
export function SegmentacionBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<SegmentacionData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.meSegmentacion(surveyId, filters)
|
||||
.then((d) => setData(d as SegmentacionData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const ok = status === 'ok' ? (data as SegmentacionData) : null
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Dónde se concentra la carga"
|
||||
subtitle="4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad) — % de cuidadores por segmento"
|
||||
status={status}
|
||||
n={ok?.n}
|
||||
colSpan={12}
|
||||
>
|
||||
{ok && (
|
||||
<div className="db-grid" style={{ gap: '24px' }}>
|
||||
<div style={{ gridColumn: 'span 6' }}>
|
||||
<p className="db-widget-subtitle" style={{ marginBottom: 8 }}>Por nivel organizacional</p>
|
||||
{ok.por_nivel.length === 0 && (
|
||||
<p className="db-suppressed-hint">Ningún nivel alcanza n≥5 con este filtro.</p>
|
||||
)}
|
||||
<ul className="db-bar-list" aria-label="Cuidadores por nivel organizacional">
|
||||
{ok.por_nivel.map((row) => (
|
||||
<li key={row.nivel} className="db-bar-item">
|
||||
<span className="db-bar-label">{row.nivel}</span>
|
||||
<div className="db-bar-track">
|
||||
<div className="db-bar-fill" style={{ width: `${row.pct}%`, background: '#5e7299' }} />
|
||||
</div>
|
||||
<span className="db-bar-pct">{row.pct}%</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 6' }}>
|
||||
<p className="db-widget-subtitle" style={{ marginBottom: 8 }}>Por modalidad de trabajo</p>
|
||||
{ok.por_modalidad.length === 0 && (
|
||||
<p className="db-suppressed-hint">Ninguna modalidad alcanza n≥5 con este filtro.</p>
|
||||
)}
|
||||
<ul className="db-bar-list" aria-label="Cuidadores por modalidad de trabajo">
|
||||
{ok.por_modalidad.map((row) => (
|
||||
<li key={row.modalidad} className="db-bar-item">
|
||||
<span className="db-bar-label">{row.modalidad}</span>
|
||||
<div className="db-bar-track">
|
||||
<div className="db-bar-fill" style={{ width: `${row.pct}%`, background: '#5e7299' }} />
|
||||
</div>
|
||||
<span className="db-bar-pct">{row.pct}%</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
126
components/dashboard/senales-impacto-bar.tsx
Normal file
126
components/dashboard/senales-impacto-bar.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type RiesgoRotacionData,
|
||||
type AusentismoData,
|
||||
type PresentismoData,
|
||||
type CarreraCongeladaData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
type Riesgo = RiesgoRotacionData | SuppressedPayload
|
||||
type Ausentismo = AusentismoData | SuppressedPayload
|
||||
type Presentismo = PresentismoData | SuppressedPayload
|
||||
type Carrera = CarreraCongeladaData | SuppressedPayload
|
||||
|
||||
interface Senal {
|
||||
label: string
|
||||
pct: number | null
|
||||
color: 'rose' | 'amber'
|
||||
}
|
||||
|
||||
// Combina E2 (riesgo) + E3 (ausentismo) + E4 (presentismo) — núcleo — y
|
||||
// E5 (carrera congelada) — [SEC] — en una sola card de 4 barras, igual al
|
||||
// mockup ("Señales de impacto laboral"). Cada señal se suprime POR FILA
|
||||
// (n<5 individual) sin tumbar las demás; la card entera solo se suprime
|
||||
// si las 4 vienen suprimidas a la vez.
|
||||
export function SenalesImpactoBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [riesgo, setRiesgo] = useState<Riesgo | null>(null)
|
||||
const [ausentismo, setAusentismo] = useState<Ausentismo | null>(null)
|
||||
const [presentismo, setPresentismo] = useState<Presentismo | null>(null)
|
||||
const [carrera, setCarrera] = useState<Carrera | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setRiesgo(null); setAusentismo(null); setPresentismo(null); setCarrera(null); setError(false)
|
||||
Promise.all([
|
||||
queries.meRiesgoRotacion(surveyId, filters),
|
||||
queries.meAusentismo(surveyId, filters),
|
||||
queries.mePresentismo(surveyId, filters),
|
||||
queries.meCarreraCongelada(surveyId, filters),
|
||||
])
|
||||
.then(([r, a, p, c]) => {
|
||||
setRiesgo(r as Riesgo)
|
||||
setAusentismo(a as Ausentismo)
|
||||
setPresentismo(p as Presentismo)
|
||||
setCarrera(c as Carrera)
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const loading = riesgo === null || ausentismo === null || presentismo === null || carrera === null
|
||||
const allSuppressed =
|
||||
!loading &&
|
||||
isSuppressed(riesgo) && isSuppressed(ausentismo) && isSuppressed(presentismo) && isSuppressed(carrera)
|
||||
|
||||
const status = error ? 'error' : loading ? 'loading' : allSuppressed ? 'suppressed' : 'ok'
|
||||
|
||||
const rows: Senal[] = !loading
|
||||
? [
|
||||
{
|
||||
label: 'Presentismo',
|
||||
pct: !isSuppressed(presentismo) ? presentismo.pct_presentismo : null,
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
label: 'Ausentismo frecuente',
|
||||
pct: !isSuppressed(ausentismo) ? ausentismo.pct_ausentismo_recurrente : null,
|
||||
color: 'amber',
|
||||
},
|
||||
{
|
||||
label: 'Riesgo de salida',
|
||||
pct: !isSuppressed(riesgo) ? riesgo.pct_riesgo : null,
|
||||
color: 'rose',
|
||||
},
|
||||
{
|
||||
label: 'Carrera congelada',
|
||||
pct: !isSuppressed(carrera) ? carrera.pct_carrera_congelada : null,
|
||||
color: 'rose',
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
const repN = !loading && !isSuppressed(riesgo) ? riesgo.n_cuidadores : undefined
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Señales de impacto laboral"
|
||||
subtitle="7.2 · 7.3 · 7.6 · 7.5 — sobre la población cuidadora"
|
||||
status={status}
|
||||
n={repN}
|
||||
colSpan={6}
|
||||
>
|
||||
{!loading && (
|
||||
<ul className="db-bar-list" aria-label="Señales de impacto laboral">
|
||||
{rows.map((row) => (
|
||||
<li key={row.label} className="db-bar-item">
|
||||
<span className="db-bar-label">{row.label}</span>
|
||||
{row.pct !== null ? (
|
||||
<>
|
||||
<div className="db-bar-track">
|
||||
<div
|
||||
className="db-bar-fill"
|
||||
style={{ width: `${row.pct}%`, background: row.color === 'rose' ? '#f06b6b' : '#f0b429' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="db-bar-pct">{row.pct}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="db-bar-track" />
|
||||
<span className="db-bar-pct" style={{ color: '#6b7280' }}>
|
||||
n<5
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { isSuppressed, type SaturacionData } from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import type { DashboardFilters } from '@/lib/dashboard-types'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
const SATURACION_COLOR: Record<string, string> = {
|
||||
'Alta saturación': '#ef4444',
|
||||
'Saturación media': '#F8AD13',
|
||||
'Baja saturación': '#43BBC8',
|
||||
}
|
||||
|
||||
export function TablaSaturacion({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<SaturacionData | null>(null)
|
||||
const [sup, setSup] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setSup(false)
|
||||
queries.saturacion(surveyId, filters)
|
||||
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as SaturacionData) })
|
||||
.catch(() => {})
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
|
||||
|
||||
return (
|
||||
<WidgetShell title="Saturación del cuidador" subtitle="4.3 + 5.1 + 5.2 · indicador compuesto de saturación (bienestar bajo + rendimiento afectado + ausentismo)" status={status} n={data?.n_total} colSpan={12}>
|
||||
{data && (
|
||||
<div className="db-table-wrapper">
|
||||
<p className="db-table-note">
|
||||
Alta: bienestar ≤2, rendimiento afectado casi todos los días y ausentismo frecuente.
|
||||
Media: dos de los tres criterios. Baja: uno o ninguno.
|
||||
</p>
|
||||
<table className="db-table" aria-label="Tabla de saturación del cuidador">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Segmento</th>
|
||||
<th>N</th>
|
||||
<th>% del total</th>
|
||||
<th>Apoyo más demandado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.segmentos.map(seg => (
|
||||
<tr key={seg.segmento}>
|
||||
<td>
|
||||
<span className="db-risk-badge"
|
||||
style={{ background: SATURACION_COLOR[seg.segmento] ?? '#6b7280' }}>
|
||||
{seg.segmento}
|
||||
</span>
|
||||
</td>
|
||||
<td>{seg.suppressed ? '—' : seg.n}</td>
|
||||
<td>{seg.suppressed ? '—' : `${seg.pct}%`}</td>
|
||||
<td>{seg.suppressed
|
||||
? <span className="db-suppressed-hint">Datos insuficientes (n<5)</span>
|
||||
: seg.apoyo ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
58
components/dashboard/talento-riesgo-card.tsx
Normal file
58
components/dashboard/talento-riesgo-card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
isSuppressed,
|
||||
type TalentoRiesgoData,
|
||||
type SuppressedPayload,
|
||||
type DashboardFilters,
|
||||
} from '@/lib/dashboard-types'
|
||||
import { queries } from '@/lib/dashboard-queries'
|
||||
import { WidgetShell } from './widget-shell'
|
||||
|
||||
// Umbrales de color SOLO visuales (no son la definición de la métrica, que
|
||||
// vive en la RPC vía p_umbral_senales) — afinar libremente si el director
|
||||
// prefiere otros cortes.
|
||||
function colorClass(pct: number): string {
|
||||
if (pct >= 30) return ' db-big-stat-danger'
|
||||
if (pct >= 15) return ' db-big-stat-warn'
|
||||
return ''
|
||||
}
|
||||
|
||||
export function TalentoRiesgoCard({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
|
||||
const [data, setData] = useState<TalentoRiesgoData | SuppressedPayload | null>(null)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setData(null); setError(false)
|
||||
queries.meTalentoRiesgo(surveyId, filters)
|
||||
.then((d) => setData(d as TalentoRiesgoData | SuppressedPayload))
|
||||
.catch(() => setError(true))
|
||||
}, [surveyId, JSON.stringify(filters)])
|
||||
|
||||
const status = error ? 'error' : data === null ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
|
||||
const ok = status === 'ok' ? (data as TalentoRiesgoData) : null
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Talento en riesgo"
|
||||
subtitle="7.6 + 7.3 + 7.5 · métrica compuesta sobre la población cuidadora"
|
||||
status={status}
|
||||
n={ok?.n_cuidadores}
|
||||
colSpan={4}
|
||||
variant="star"
|
||||
>
|
||||
{ok && (
|
||||
<>
|
||||
<div className={`db-big-stat${colorClass(ok.pct_talento_riesgo)}`}>
|
||||
{ok.pct_talento_riesgo}%
|
||||
</div>
|
||||
<p className="db-big-stat-sub">
|
||||
de la plantilla cuidadora cumple al menos {ok.umbral_senales} de 3 señales de fuga
|
||||
(riesgo de salida, ausentismo recurrente, carrera congelada). Promedio:{' '}
|
||||
{ok.criterios_promedio} señales por persona.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</WidgetShell>
|
||||
)
|
||||
}
|
||||
@@ -8,11 +8,16 @@ interface WidgetShellProps {
|
||||
n?: number
|
||||
children: React.ReactNode
|
||||
colSpan?: number
|
||||
/** 'star' resalta la card (borde + fondo distinto) para métricas destacadas. */
|
||||
variant?: 'star'
|
||||
}
|
||||
|
||||
export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6 }: WidgetShellProps) {
|
||||
export function WidgetShell({ title, subtitle, status, n, children, colSpan = 6, variant }: WidgetShellProps) {
|
||||
return (
|
||||
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}>
|
||||
<div
|
||||
className={`db-widget db-col-${colSpan}${variant === 'star' ? ' db-widget-star' : ''}`}
|
||||
style={{ gridColumn: `span ${colSpan}` }}
|
||||
>
|
||||
<div className="db-widget-header">
|
||||
<div className="db-widget-title-group">
|
||||
<h3 className="db-widget-title">{title}</h3>
|
||||
|
||||
@@ -1,106 +1,112 @@
|
||||
'use client'
|
||||
import type { FormAnswers, Question, QuestionOption, RatingOptions } from '@/lib/types'
|
||||
import type { AnswerValue, FormAnswers, Question, QuestionOption } from '@/lib/form-engine.types'
|
||||
import { RadioGroup } from './widgets/radio-group'
|
||||
import { CheckboxGroup } from './widgets/checkbox-group'
|
||||
import { RatingScale } from './widgets/rating-scale'
|
||||
import { TextAreaField } from './widgets/text-area-field'
|
||||
import { TextShortField } from './widgets/text-short-field'
|
||||
import { GeoSelectWidget } from './widgets/geo-select-widget'
|
||||
import { BarrioAutocompleteField } from './widgets/barrio-autocomplete-field'
|
||||
import { CiudadSelectField } from './widgets/ciudad-select-field'
|
||||
|
||||
interface QuestionRendererProps {
|
||||
question: Question
|
||||
answers: FormAnswers
|
||||
onChange: (code: string, value: FormAnswers[string]) => void
|
||||
errors: Record<string, string>
|
||||
value: AnswerValue
|
||||
onChange: (value: AnswerValue) => void
|
||||
error?: string
|
||||
answers?: FormAnswers
|
||||
}
|
||||
|
||||
function isArrayOptions(opts: unknown): opts is QuestionOption[] {
|
||||
return Array.isArray(opts)
|
||||
}
|
||||
const YES_NO_OPTIONS: QuestionOption[] = [
|
||||
{ value: 'true', label: 'Sí' },
|
||||
{ value: 'false', label: 'No' },
|
||||
]
|
||||
|
||||
function isRatingOptions(opts: unknown): opts is RatingOptions {
|
||||
return typeof opts === 'object' && opts !== null && 'min' in opts && 'max' in opts
|
||||
}
|
||||
|
||||
export function QuestionRenderer({
|
||||
question,
|
||||
answers,
|
||||
onChange,
|
||||
errors,
|
||||
}: QuestionRendererProps) {
|
||||
export function QuestionRenderer({ question, value, onChange, error, answers }: QuestionRendererProps) {
|
||||
const { code, type, prompt, required, max_select, options } = question
|
||||
const error = errors[code]
|
||||
const legend = (
|
||||
<>
|
||||
<span className="q-code">{code} ·</span> {prompt}
|
||||
</>
|
||||
)
|
||||
|
||||
// A1 (Departamento) renders a combined geo widget that also handles A2 (Ciudad)
|
||||
// Code badges for A1/A2 are rendered inside GeoSelectWidget.
|
||||
if (code === 'A1') {
|
||||
return (
|
||||
<GeoSelectWidget
|
||||
departmentValue={(answers['A1'] as string) ?? ''}
|
||||
cityValue={(answers['A2'] as string) ?? ''}
|
||||
onDepartmentChange={(v) => onChange('A1', v)}
|
||||
onCityChange={(v) => onChange('A2', v)}
|
||||
departmentError={errors['A1']}
|
||||
cityError={errors['A2']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// A2 is rendered inside the GeoSelectWidget above — skip here to avoid duplication
|
||||
if (code === 'A2') return null
|
||||
|
||||
const legend = <><span className="q-code">{code} ·</span> {prompt}</>
|
||||
|
||||
if (type === 'single_choice' && isArrayOptions(options)) {
|
||||
if (type === 'single_choice' && options) {
|
||||
if (code === '2.2') {
|
||||
return (
|
||||
<CiudadSelectField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
departamento={(answers?.['2.1:default'] as string) ?? null}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
options={options}
|
||||
value={answers[code] as string | undefined}
|
||||
value={(value as string) ?? undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'multiple_choice' && isArrayOptions(options)) {
|
||||
if (type === 'multiple_choice' && options) {
|
||||
return (
|
||||
<CheckboxGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
options={options}
|
||||
value={(answers[code] as string[]) ?? []}
|
||||
value={(value as string[]) ?? []}
|
||||
maxSelect={max_select}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'rating' && isRatingOptions(options)) {
|
||||
// yes_no resuelto con el RadioGroup existente — dos opciones fijas, sin widget nuevo.
|
||||
if (type === 'yes_no') {
|
||||
return (
|
||||
<RatingScale
|
||||
<RadioGroup
|
||||
name={code}
|
||||
legend={legend}
|
||||
ratingOptions={options}
|
||||
value={answers[code] as number | undefined}
|
||||
options={YES_NO_OPTIONS}
|
||||
value={(value as string) ?? undefined}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'text_short') {
|
||||
if (code === '2.3') {
|
||||
return (
|
||||
<BarrioAutocompleteField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
departamento={(answers?.['2.1:default'] as string) ?? null}
|
||||
ciudad={(answers?.['2.2:default'] as string) ?? null}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<TextShortField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
@@ -111,9 +117,9 @@ export function QuestionRenderer({
|
||||
<TextAreaField
|
||||
name={code}
|
||||
label={legend}
|
||||
value={(answers[code] as string) ?? ''}
|
||||
value={(value as string) ?? ''}
|
||||
required={required}
|
||||
onChange={(v) => onChange(code, v)}
|
||||
onChange={onChange}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
'use client'
|
||||
import { useCallback, useEffect, useRef, useState, useTransition } from 'react'
|
||||
import type { FormAnswers, Question, Survey } from '@/lib/types'
|
||||
import { clearDraft, loadDraft, saveDraft } from '@/lib/storage'
|
||||
import { getHiddenCodes, isQuestionVisible, ruleQuestionCodes } from '@/lib/conditional'
|
||||
import { submitSurvey } from '@/app/actions/submit-survey'
|
||||
import { ConsentScreen } from './consent-screen'
|
||||
import { ProgressBar } from './progress-bar'
|
||||
import { QuestionRenderer } from './question-renderer'
|
||||
|
||||
// Section metadata (code prefix → display title)
|
||||
const SECTIONS: { prefix: string; title: string }[] = [
|
||||
{ prefix: 'A', title: 'Datos Generales' },
|
||||
{ prefix: '1', title: 'Prevalencia: cuidado y discapacidad' },
|
||||
{ prefix: '2', title: 'Intensidad y rol del cuidado' },
|
||||
{ prefix: '3', title: 'Impacto económico del cuidado' },
|
||||
{ prefix: '4', title: 'Tiempo y descanso' },
|
||||
{ prefix: '5', title: 'Impacto en el trabajo' },
|
||||
{ prefix: '6', title: 'Conciencia organizacional' },
|
||||
{ prefix: '7', title: 'Para cerrar' },
|
||||
]
|
||||
|
||||
function getSectionPrefix(code: string): string {
|
||||
const m = code.match(/^([A-Za-z]+|\d+)/)
|
||||
return m ? m[1] : code
|
||||
}
|
||||
|
||||
type FormStage = 'consent' | 'form' | 'success' | 'error'
|
||||
|
||||
interface SurveyFormProps {
|
||||
survey: Survey
|
||||
questions: Question[]
|
||||
}
|
||||
|
||||
export function SurveyForm({ survey, questions }: SurveyFormProps) {
|
||||
const [stage, setStage] = useState<FormStage>('consent')
|
||||
const [consentOperativo, setConsentOperativo] = useState(false)
|
||||
const [consentMacroN1, setConsentMacroN1] = useState(false)
|
||||
const [answers, setAnswers] = useState<FormAnswers>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const prevAnswers = useRef<FormAnswers>({})
|
||||
|
||||
// Hydrate from localStorage on mount
|
||||
useEffect(() => {
|
||||
const draft = loadDraft(survey.id)
|
||||
if (Object.keys(draft).length > 0) setAnswers(draft)
|
||||
setHydrated(true)
|
||||
}, [survey.id])
|
||||
|
||||
// Persist to localStorage after hydration
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
saveDraft(survey.id, answers)
|
||||
prevAnswers.current = answers
|
||||
}, [answers, hydrated, survey.id])
|
||||
|
||||
const handleChange = useCallback(
|
||||
(code: string, value: FormAnswers[string]) => {
|
||||
setAnswers((prev) => {
|
||||
const updated = { ...prev, [code]: value }
|
||||
const toHide = getHiddenCodes(code, value, questions, prev)
|
||||
for (const hiddenCode of toHide) {
|
||||
updated[hiddenCode] = undefined as unknown as FormAnswers[string]
|
||||
}
|
||||
if (toHide.length > 0) {
|
||||
setA11yAnnounce('Se ocultaron preguntas relacionadas y se limpiaron sus respuestas.')
|
||||
} else {
|
||||
const appeared = questions.some(
|
||||
(q) =>
|
||||
q.conditional_rules?.some((r) => ruleQuestionCodes(r).includes(code)) &&
|
||||
isQuestionVisible(q, updated) &&
|
||||
!isQuestionVisible(q, prev)
|
||||
)
|
||||
if (appeared) setA11yAnnounce('Aparecieron nuevas preguntas según tu respuesta.')
|
||||
}
|
||||
return updated
|
||||
})
|
||||
setErrors((prev) => {
|
||||
if (!prev[code]) return prev
|
||||
const { [code]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
},
|
||||
[questions]
|
||||
)
|
||||
|
||||
function validate(): boolean {
|
||||
const newErrors: Record<string, string> = {}
|
||||
for (const q of questions) {
|
||||
if (!q.required) continue
|
||||
if (!isQuestionVisible(q, answers)) continue
|
||||
const val = answers[q.code]
|
||||
if (val === null || val === undefined || val === '' || (Array.isArray(val) && val.length === 0)) {
|
||||
newErrors[q.code] = 'Este campo es obligatorio.'
|
||||
}
|
||||
}
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (isPending) return
|
||||
if (!validate()) {
|
||||
const firstErrorCode = Object.keys(errors)[0]
|
||||
if (firstErrorCode) document.getElementById(firstErrorCode)?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
const questionMap = Object.fromEntries(questions.map((q) => [q.code, q.id]))
|
||||
|
||||
startTransition(async () => {
|
||||
const result = await submitSurvey({
|
||||
surveyId: survey.id,
|
||||
answers: answers as Record<string, unknown>,
|
||||
questionMap,
|
||||
consentOperativo,
|
||||
consentMacroN1,
|
||||
})
|
||||
if (result.success) {
|
||||
clearDraft(survey.id)
|
||||
setStage('success')
|
||||
} else {
|
||||
setSubmitError(result.error)
|
||||
setStage('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Stage: consent ──────────────────────────────────────────
|
||||
if (stage === 'consent') {
|
||||
return (
|
||||
<ConsentScreen
|
||||
brandConfig={survey.brand_config}
|
||||
onAccept={(op, macro) => {
|
||||
setConsentOperativo(op)
|
||||
setConsentMacroN1(macro)
|
||||
setStage('form')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stage: success ──────────────────────────────────────────
|
||||
if (stage === 'success') {
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
<div className="thank-you-container" role="main">
|
||||
<h1 className="thank-you-title">¡Gracias por participar!</h1>
|
||||
<p className="thank-you-body">
|
||||
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
|
||||
forma de vincular tus respuestas con tu identidad.
|
||||
</p>
|
||||
<p className="thank-you-body">
|
||||
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
|
||||
diseñar mejores apoyos para las personas cuidadoras.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stage: error ────────────────────────────────────────────
|
||||
if (stage === 'error') {
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
<div className="error-container" role="main">
|
||||
<h1 className="error-title">No se pudo enviar la encuesta</h1>
|
||||
<p>Hubo un error al guardar tus respuestas ({submitError}). Podés intentarlo de nuevo.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { setSubmitError(null); setStage('form') }}
|
||||
>
|
||||
Volver e intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Stage: form ─────────────────────────────────────────────
|
||||
const visibleQuestions = questions.filter((q) => isQuestionVisible(q, answers))
|
||||
const answeredCount = visibleQuestions.filter((q) => {
|
||||
const v = answers[q.code]
|
||||
return v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0)
|
||||
}).length
|
||||
|
||||
type Section = { prefix: string; title: string; questions: Question[] }
|
||||
const sectionMap = new Map<string, Section>()
|
||||
for (const q of visibleQuestions) {
|
||||
const prefix = getSectionPrefix(q.code)
|
||||
if (!sectionMap.has(prefix)) {
|
||||
const meta = SECTIONS.find((s) => s.prefix === prefix)
|
||||
sectionMap.set(prefix, { prefix, title: meta?.title ?? prefix, questions: [] })
|
||||
}
|
||||
sectionMap.get(prefix)!.questions.push(q)
|
||||
}
|
||||
|
||||
const bc = survey.brand_config
|
||||
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
<a href="#survey-form" className="skip-link">Ir al formulario</a>
|
||||
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
|
||||
{a11yAnnounce}
|
||||
</div>
|
||||
|
||||
{/* Header contextual con org/branch si está configurado */}
|
||||
{bc?.org_name && (
|
||||
<div className="brand-header" role="note" aria-label="Información de la empresa">
|
||||
<p className="brand-text">
|
||||
Estás respondiendo esto porque pertenecés a <strong>{bc.org_name}</strong>
|
||||
{bc.branch_name && (
|
||||
<> y participás en las oficinas de <strong>{bc.branch_name}</strong>
|
||||
{bc.branch_city && <> en <strong>{bc.branch_city}</strong></>}.</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="survey-header">
|
||||
<h1 className="survey-title">{survey.title}</h1>
|
||||
<p className="survey-anon-note">Esta encuesta es anónima. Tus respuestas no te identifican.</p>
|
||||
</header>
|
||||
|
||||
<div className="survey-progress-sticky" aria-label="Barra de progreso">
|
||||
<ProgressBar answered={answeredCount} total={visibleQuestions.length} />
|
||||
</div>
|
||||
|
||||
<main id="survey-form">
|
||||
<form onSubmit={handleSubmit} noValidate aria-label={`Encuesta: ${survey.title}`}>
|
||||
{Array.from(sectionMap.values()).map((section) => (
|
||||
<section
|
||||
key={section.prefix}
|
||||
className="survey-section"
|
||||
aria-labelledby={`section-${section.prefix}`}
|
||||
>
|
||||
<div className="section-header">
|
||||
<h2 id={`section-${section.prefix}`} className="section-title">Sección {section.prefix} · {section.title}</h2>
|
||||
</div>
|
||||
{section.questions.map((q) => (
|
||||
<div key={q.id} className="question-wrapper" data-code={q.code}>
|
||||
<QuestionRenderer
|
||||
question={q}
|
||||
answers={answers}
|
||||
onChange={handleChange}
|
||||
errors={errors}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="form-footer">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={isPending}
|
||||
aria-disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
170
components/widgets/barrio-autocomplete-field.tsx
Normal file
170
components/widgets/barrio-autocomplete-field.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
import { getGeoData, type GeoData } from '@/lib/geo-cache'
|
||||
|
||||
interface BarrioAutocompleteFieldProps {
|
||||
name: string
|
||||
label: ReactNode
|
||||
value: string
|
||||
required: boolean
|
||||
departamento?: string | null
|
||||
ciudad?: string | null
|
||||
onChange: (value: string) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function BarrioAutocompleteField({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
required,
|
||||
departamento,
|
||||
ciudad,
|
||||
onChange,
|
||||
error,
|
||||
}: BarrioAutocompleteFieldProps) {
|
||||
const [geoData, setGeoData] = useState<GeoData | null>(null)
|
||||
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIdx, setActiveIdx] = useState(-1)
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getGeoData()
|
||||
.then((data) => { setGeoData(data); setGeoStatus('ready') })
|
||||
.catch(() => setGeoStatus('error'))
|
||||
}, [])
|
||||
|
||||
// Close dropdown on scroll so the fixed-position list doesn't drift from the input
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = () => setOpen(false)
|
||||
window.addEventListener('scroll', close, { passive: true, capture: true })
|
||||
return () => window.removeEventListener('scroll', close, { capture: true })
|
||||
}, [open])
|
||||
|
||||
function computeDropdownStyle() {
|
||||
if (!inputRef.current) return
|
||||
const rect = inputRef.current.getBoundingClientRect()
|
||||
setDropdownStyle({
|
||||
top: rect.bottom + 4,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
})
|
||||
}
|
||||
|
||||
const barrios: string[] = (() => {
|
||||
if (!geoData) return []
|
||||
if (departamento && ciudad) {
|
||||
return geoData[departamento]?.[ciudad] ?? []
|
||||
}
|
||||
if (departamento) {
|
||||
return Object.values(geoData[departamento] ?? {}).flat()
|
||||
}
|
||||
return Object.values(geoData).flatMap((d) => Object.values(d).flat())
|
||||
})()
|
||||
|
||||
const query = value.trim().toLowerCase()
|
||||
const suggestions =
|
||||
query.length >= 2
|
||||
? barrios.filter((b) => b.toLowerCase().includes(query)).slice(0, 10)
|
||||
: []
|
||||
|
||||
function handleInput(v: string) {
|
||||
onChange(v)
|
||||
computeDropdownStyle()
|
||||
setOpen(true)
|
||||
setActiveIdx(-1)
|
||||
}
|
||||
|
||||
function select(barrio: string) {
|
||||
onChange(barrio)
|
||||
setOpen(false)
|
||||
setActiveIdx(-1)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open || suggestions.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIdx((i) => Math.max(i - 1, -1))
|
||||
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault()
|
||||
select(suggestions[activeIdx])
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const listId = `${name}-suggestions`
|
||||
|
||||
return (
|
||||
<div className="question-fieldset">
|
||||
<label className="question-legend" htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</label>
|
||||
<div className="barrio-autocomplete-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id={name}
|
||||
name={name}
|
||||
value={value}
|
||||
required={required}
|
||||
aria-required={required}
|
||||
aria-describedby={error ? `${name}-error` : undefined}
|
||||
aria-invalid={!!error}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={open && suggestions.length > 0 ? listId : undefined}
|
||||
aria-activedescendant={activeIdx >= 0 ? `${name}-opt-${activeIdx}` : undefined}
|
||||
autoComplete="off"
|
||||
className="text-short-input"
|
||||
ref={inputRef}
|
||||
disabled={geoStatus === 'loading'}
|
||||
placeholder={
|
||||
geoStatus === 'loading' ? 'Cargando opciones...' :
|
||||
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
|
||||
undefined
|
||||
}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul
|
||||
id={listId}
|
||||
role="listbox"
|
||||
className="barrio-autocomplete-list"
|
||||
aria-label="Sugerencias de barrios"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{suggestions.map((barrio, i) => (
|
||||
<li
|
||||
key={barrio}
|
||||
id={`${name}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIdx}
|
||||
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
|
||||
onMouseDown={() => select(barrio)}
|
||||
onTouchEnd={(e) => { e.preventDefault(); select(barrio) }}
|
||||
>
|
||||
{barrio}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -40,13 +40,14 @@ export function CheckboxGroup({
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</legend>
|
||||
{maxSelect !== null ? (
|
||||
{/* El hint estático ("Podés marcar más de una opción") ya vive en
|
||||
wizard-prompt-block (question.hint) — acá solo el contador vivo,
|
||||
que es información que no está en ningún otro lado. */}
|
||||
{maxSelect !== null && (
|
||||
<p className="max-select-hint" aria-live="polite">
|
||||
Elegí hasta {maxSelect} opciones
|
||||
{value.length > 0 && ` · ${value.length}/${maxSelect} seleccionadas`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="max-select-hint">Podés marcar más de una opción</p>
|
||||
)}
|
||||
<div className="options-list" role="list">
|
||||
{options.map((opt) => {
|
||||
|
||||
174
components/widgets/ciudad-select-field.tsx
Normal file
174
components/widgets/ciudad-select-field.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
import { getGeoData, type GeoData } from '@/lib/geo-cache'
|
||||
|
||||
interface CiudadSelectFieldProps {
|
||||
name: string
|
||||
label: ReactNode
|
||||
value: string
|
||||
required: boolean
|
||||
departamento?: string | null
|
||||
onChange: (value: string) => void
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function CiudadSelectField({
|
||||
name,
|
||||
label,
|
||||
value,
|
||||
required,
|
||||
departamento,
|
||||
onChange,
|
||||
error,
|
||||
}: CiudadSelectFieldProps) {
|
||||
const [geoData, setGeoData] = useState<GeoData | null>(null)
|
||||
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [activeIdx, setActiveIdx] = useState(-1)
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Stable ref to always call the latest onChange without it in effect deps
|
||||
const onChangeRef = useRef(onChange)
|
||||
onChangeRef.current = onChange
|
||||
|
||||
const prevDeptoRef = useRef(departamento)
|
||||
|
||||
useEffect(() => {
|
||||
getGeoData()
|
||||
.then((data) => { setGeoData(data); setGeoStatus('ready') })
|
||||
.catch(() => setGeoStatus('error'))
|
||||
}, [])
|
||||
|
||||
// Clear city when departamento changes so no orphan value remains
|
||||
useEffect(() => {
|
||||
if (prevDeptoRef.current !== departamento) {
|
||||
onChangeRef.current('')
|
||||
setOpen(false)
|
||||
}
|
||||
prevDeptoRef.current = departamento
|
||||
}, [departamento])
|
||||
|
||||
// Close fixed dropdown on any scroll (input moves, list stays)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const close = () => setOpen(false)
|
||||
window.addEventListener('scroll', close, { passive: true, capture: true })
|
||||
return () => window.removeEventListener('scroll', close, { capture: true })
|
||||
}, [open])
|
||||
|
||||
function computeDropdownStyle() {
|
||||
if (!inputRef.current) return
|
||||
const rect = inputRef.current.getBoundingClientRect()
|
||||
setDropdownStyle({ top: rect.bottom + 4, left: rect.left, width: rect.width })
|
||||
}
|
||||
|
||||
const ciudades: string[] = (() => {
|
||||
if (!geoData) return []
|
||||
if (departamento) return Object.keys(geoData[departamento] ?? {})
|
||||
return Object.values(geoData).flatMap((d) => Object.keys(d))
|
||||
})()
|
||||
|
||||
const query = value.trim().toLowerCase()
|
||||
const suggestions =
|
||||
query.length >= 2
|
||||
? ciudades.filter((c) => c.toLowerCase().includes(query)).slice(0, 10)
|
||||
: []
|
||||
|
||||
function handleInput(v: string) {
|
||||
onChange(v)
|
||||
computeDropdownStyle()
|
||||
setOpen(true)
|
||||
setActiveIdx(-1)
|
||||
}
|
||||
|
||||
function select(ciudad: string) {
|
||||
onChange(ciudad)
|
||||
setOpen(false)
|
||||
setActiveIdx(-1)
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (!open || suggestions.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setActiveIdx((i) => Math.min(i + 1, suggestions.length - 1))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setActiveIdx((i) => Math.max(i - 1, -1))
|
||||
} else if (e.key === 'Enter' && activeIdx >= 0) {
|
||||
e.preventDefault()
|
||||
select(suggestions[activeIdx])
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
const listId = `${name}-suggestions`
|
||||
|
||||
return (
|
||||
<div className="question-fieldset">
|
||||
<label className="question-legend" htmlFor={name}>
|
||||
{label}
|
||||
{required && <span className="required-mark" aria-hidden="true"> *</span>}
|
||||
{required && <span className="sr-only"> (obligatorio)</span>}
|
||||
</label>
|
||||
<div className="barrio-autocomplete-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id={name}
|
||||
name={name}
|
||||
value={value}
|
||||
required={required}
|
||||
aria-required={required}
|
||||
aria-describedby={error ? `${name}-error` : undefined}
|
||||
aria-invalid={!!error}
|
||||
aria-autocomplete="list"
|
||||
aria-controls={open && suggestions.length > 0 ? listId : undefined}
|
||||
aria-activedescendant={activeIdx >= 0 ? `${name}-opt-${activeIdx}` : undefined}
|
||||
autoComplete="off"
|
||||
className="text-short-input"
|
||||
ref={inputRef}
|
||||
disabled={geoStatus === 'loading'}
|
||||
placeholder={
|
||||
geoStatus === 'loading' ? 'Cargando opciones...' :
|
||||
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
|
||||
undefined
|
||||
}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
/>
|
||||
{open && suggestions.length > 0 && (
|
||||
<ul
|
||||
id={listId}
|
||||
role="listbox"
|
||||
className="barrio-autocomplete-list"
|
||||
aria-label="Sugerencias de ciudades"
|
||||
style={dropdownStyle}
|
||||
>
|
||||
{suggestions.map((ciudad, i) => (
|
||||
<li
|
||||
key={ciudad}
|
||||
id={`${name}-opt-${i}`}
|
||||
role="option"
|
||||
aria-selected={i === activeIdx}
|
||||
className={`barrio-autocomplete-item${i === activeIdx ? ' barrio-autocomplete-item--active' : ''}`}
|
||||
onMouseDown={() => select(ciudad)}
|
||||
onTouchEnd={(e) => { e.preventDefault(); select(ciudad) }}
|
||||
>
|
||||
{ciudad}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
338
components/wizard-form.tsx
Normal file
338
components/wizard-form.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
'use client'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useTransition } from 'react'
|
||||
import type { BrandConfig, Survey } from '@/lib/types'
|
||||
import type { AnswerValue, FormAnswers, FormSchema, Screen } from '@/lib/form-engine.types'
|
||||
import {
|
||||
answerKey,
|
||||
applyAnswer,
|
||||
expandInstances,
|
||||
initialState,
|
||||
isRequiredAndUnanswered,
|
||||
next,
|
||||
back,
|
||||
resolveScreens,
|
||||
} from '@/lib/form-engine'
|
||||
import { loadDraft, saveDraft, clearDraft } from '@/lib/storage'
|
||||
import { submitSurvey } from '@/app/actions/submit-survey'
|
||||
import { ConsentScreen } from './consent-screen'
|
||||
import { ProgressBar } from './progress-bar'
|
||||
import { QuestionRenderer } from './question-renderer'
|
||||
|
||||
type Stage = 'consent' | 'form' | 'success' | 'error'
|
||||
|
||||
interface WizardFormProps {
|
||||
survey: Survey
|
||||
schema: FormSchema
|
||||
}
|
||||
|
||||
function isEmpty(v: AnswerValue): boolean {
|
||||
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
|
||||
}
|
||||
|
||||
function valueFor(screen: Screen, answers: FormAnswers): AnswerValue {
|
||||
if (!screen.question) return null
|
||||
const v = answers[answerKey(screen.question.code, screen.instanceId ?? undefined)]
|
||||
return v === undefined ? null : v
|
||||
}
|
||||
|
||||
/** Resume en la primera pantalla sin responder (o la sección que la precede). */
|
||||
function resumeScreenId(schema: FormSchema, answers: FormAnswers): string {
|
||||
const screens = resolveScreens(schema, answers)
|
||||
const firstUnanswered = screens.findIndex(
|
||||
(s) => s.kind === 'question' && isEmpty(valueFor(s, answers))
|
||||
)
|
||||
if (firstUnanswered <= 0) return screens[0]?.id ?? 'end'
|
||||
const prev = screens[firstUnanswered - 1]
|
||||
return prev.kind === 'section' ? prev.id : screens[firstUnanswered].id
|
||||
}
|
||||
|
||||
export function WizardForm({ survey, schema }: WizardFormProps) {
|
||||
const [stage, setStage] = useState<Stage>('consent')
|
||||
const [consentOperativo, setConsentOperativo] = useState(false)
|
||||
const [consentMacroN1, setConsentMacroN1] = useState(false)
|
||||
const [state, setState] = useState(() => initialState(schema))
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [hydrated, setHydrated] = useState(false)
|
||||
const [a11yAnnounce, setA11yAnnounce] = useState('')
|
||||
const answerAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [hasOverflow, setHasOverflow] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
useEffect(() => {
|
||||
const draft = loadDraft(survey.id)
|
||||
if (Object.keys(draft).length > 0) {
|
||||
setState({ answers: draft, currentScreenId: resumeScreenId(schema, draft) })
|
||||
}
|
||||
setHydrated(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [survey.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return
|
||||
const timer = setTimeout(() => saveDraft(survey.id, state.answers), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [state.answers, hydrated, survey.id])
|
||||
|
||||
const screens = resolveScreens(schema, state.answers)
|
||||
const idx = screens.findIndex((s) => s.id === state.currentScreenId)
|
||||
const screen = screens[idx] ?? screens[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (!screen) return
|
||||
if (screen.kind === 'section') setA11yAnnounce(`Sección: ${screen.question?.prompt}`)
|
||||
else if (screen.kind === 'question') setA11yAnnounce(`Pregunta ${screen.question?.code}: ${screen.question?.prompt}`)
|
||||
else setA11yAnnounce('Fin del formulario')
|
||||
}, [screen?.id])
|
||||
|
||||
// El fade de wizard-answer-area solo debe aparecer si hay overflow real —
|
||||
// CSS no puede expresar esa condición (no hay selector para "este elemento
|
||||
// scrollea"), así que se mide el DOM. Se remide al cambiar de pantalla
|
||||
// (cambia todo el contenido) y al resize (puede cambiar cuántas opciones entran).
|
||||
useLayoutEffect(() => {
|
||||
const el = answerAreaRef.current
|
||||
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
|
||||
}, [screen?.id])
|
||||
|
||||
useEffect(() => {
|
||||
function recalc() {
|
||||
const el = answerAreaRef.current
|
||||
setHasOverflow(el ? el.scrollHeight > el.clientHeight : false)
|
||||
}
|
||||
window.addEventListener('resize', recalc)
|
||||
return () => window.removeEventListener('resize', recalc)
|
||||
}, [])
|
||||
|
||||
if (stage === 'consent') {
|
||||
return (
|
||||
<ConsentScreen
|
||||
brandConfig={survey.brand_config}
|
||||
onAccept={(op, macro) => {
|
||||
setConsentOperativo(op)
|
||||
setConsentMacroN1(macro)
|
||||
setStage('form')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'success') {
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
<div className="thank-you-container" role="main">
|
||||
<h1 className="thank-you-title">¡Gracias por participar!</h1>
|
||||
<p className="thank-you-body">
|
||||
Tu respuesta fue registrada correctamente. Recordá que la encuesta es anónima: no hay
|
||||
forma de vincular tus respuestas con tu identidad.
|
||||
</p>
|
||||
<p className="thank-you-body">
|
||||
Los resultados agregados ayudarán a {survey.brand_config?.org_name ?? 'tu empresa'} a
|
||||
diseñar mejores apoyos para las personas cuidadoras.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (stage === 'error') {
|
||||
return (
|
||||
<div className="survey-wrapper">
|
||||
<div className="error-container" role="main">
|
||||
<h1 className="error-title">No se pudo enviar la encuesta</h1>
|
||||
<p>Hubo un error al guardar tus respuestas. Podés intentarlo de nuevo.</p>
|
||||
{submitError && <pre className="no-survey-hint">{submitError}</pre>}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={() => { setSubmitError(null); setStage('form') }}
|
||||
>
|
||||
Volver e intentar de nuevo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!screen) return null
|
||||
|
||||
const currentValue = valueFor(screen, state.answers)
|
||||
const questionScreens = screens.filter((s) => s.kind !== 'end')
|
||||
const ordinal = Math.min(idx + 1, questionScreens.length)
|
||||
const sectionScreens = screens.filter((s) => s.kind === 'section')
|
||||
const sectionOrdinal = screen.kind === 'section' ? sectionScreens.findIndex((s) => s.id === screen.id) + 1 : null
|
||||
|
||||
// Contexto de instancia ("Familiar X de N") — derivado, no decide visibilidad ni navegación.
|
||||
let instanceCtx: { label: string; ordinal: number; total: number } | null = null
|
||||
if (screen.instanceId && screen.question?.instance_of) {
|
||||
const generator = schema.questions.find((q) => q.code === screen.question!.instance_of)
|
||||
if (generator) {
|
||||
const instances = expandInstances(generator, state.answers)
|
||||
const ord = instances.findIndex((i) => i.instanceId === screen.instanceId)
|
||||
if (ord >= 0) {
|
||||
instanceCtx = { label: instances[ord].optionValue, ordinal: ord + 1, total: instances.length }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(value: AnswerValue) {
|
||||
if (!screen.question) return
|
||||
const code = screen.question.code
|
||||
const instanceId = screen.instanceId ?? undefined
|
||||
|
||||
setState((prev) => applyAnswer(prev, schema, code, value, instanceId))
|
||||
|
||||
setErrors((prev) => {
|
||||
if (!prev[code]) return prev
|
||||
const { [code]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
if (isRequiredAndUnanswered(screen, state.answers)) {
|
||||
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
|
||||
return
|
||||
}
|
||||
saveDraft(survey.id, state.answers)
|
||||
setState((s) => next(s, schema))
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
saveDraft(survey.id, state.answers)
|
||||
setState((s) => back(s, schema))
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (isPending) return
|
||||
// state.answers usa claves "code:instanceId" — se descompone una sola vez
|
||||
// acá, al borde cliente/servidor; el validador (lib/submit-validation.ts)
|
||||
// recibe la forma explícita {code, instanceId, value}[].
|
||||
const answers = Object.entries(state.answers).map(([key, value]) => {
|
||||
const sep = key.indexOf(':')
|
||||
return { code: key.slice(0, sep), instanceId: key.slice(sep + 1), value }
|
||||
})
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await submitSurvey({
|
||||
surveyId: survey.id,
|
||||
answers,
|
||||
consentOperativo,
|
||||
consentMacroN1,
|
||||
})
|
||||
if (result.success) {
|
||||
clearDraft(survey.id)
|
||||
setStage('success')
|
||||
} else {
|
||||
setSubmitError(result.error)
|
||||
setStage('error')
|
||||
}
|
||||
} catch {
|
||||
// Falla de red al invocar el Server Action (fetch failed) — no es un
|
||||
// error que submitSurvey pueda devolver como result, hay que capturarlo acá.
|
||||
setSubmitError('No pudimos enviar tu respuesta. Verificá tu conexión e intentá de nuevo.')
|
||||
setStage('error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const bc: BrandConfig | null = survey.brand_config
|
||||
|
||||
return (
|
||||
<div className="wizard-shell">
|
||||
<div aria-live="polite" aria-atomic="true" className="sr-only" role="status">
|
||||
{a11yAnnounce}
|
||||
</div>
|
||||
|
||||
<div className="wizard-topbar">
|
||||
{bc?.org_name && (
|
||||
<p className="brand-text wizard-topbar-brand">
|
||||
Encuesta de <strong>{bc.org_name}</strong>
|
||||
</p>
|
||||
)}
|
||||
<ProgressBar answered={ordinal} total={questionScreens.length} />
|
||||
</div>
|
||||
|
||||
<main className="wizard-stage" id="wizard-stage">
|
||||
{screen.kind === 'section' && screen.question && (
|
||||
<div className="wizard-section-screen" role="region" aria-labelledby="wizard-section-title">
|
||||
<p className="wizard-section-number">
|
||||
Sección {sectionOrdinal} de {sectionScreens.length}
|
||||
</p>
|
||||
<h2 id="wizard-section-title" className="wizard-section-title">
|
||||
{screen.question.prompt}
|
||||
</h2>
|
||||
{screen.question.hint && <p className="wizard-section-desc">{screen.question.hint}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screen.kind === 'question' && screen.question && (
|
||||
<>
|
||||
<div className="wizard-prompt-block">
|
||||
{instanceCtx && (
|
||||
<span className="wizard-instance-badge">
|
||||
{instanceCtx.label} · familiar {instanceCtx.ordinal} de {instanceCtx.total}
|
||||
</span>
|
||||
)}
|
||||
<span className="wizard-prompt-code">{screen.question.code}</span>
|
||||
<h2 className="wizard-prompt-text">{screen.question.prompt}</h2>
|
||||
{screen.question.hint && <p className="wizard-prompt-hint">{screen.question.hint}</p>}
|
||||
</div>
|
||||
<div
|
||||
ref={answerAreaRef}
|
||||
className={`wizard-answer-area${hasOverflow ? ' has-overflow' : ''}`}
|
||||
>
|
||||
<QuestionRenderer
|
||||
question={screen.question}
|
||||
value={currentValue}
|
||||
onChange={handleChange}
|
||||
error={errors[screen.question.code]}
|
||||
answers={state.answers}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{screen.kind === 'end' && (
|
||||
<div className="wizard-section-screen">
|
||||
<h2 className="wizard-section-title">Llegaste al final</h2>
|
||||
<p className="wizard-section-desc">
|
||||
Revisá tus respuestas con "Anterior" si querés corregir algo, o enviá la
|
||||
encuesta cuando estés listo/a.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="wizard-bottombar">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={handleBack}
|
||||
disabled={idx === 0 || isPending}
|
||||
aria-disabled={idx === 0 || isPending}
|
||||
>
|
||||
Anterior
|
||||
</button>
|
||||
<span className="wizard-bottombar-hint" aria-hidden="true">
|
||||
{ordinal}/{questionScreens.length}
|
||||
</span>
|
||||
{screen.kind !== 'end' ? (
|
||||
<button type="button" className="btn btn-primary" onClick={handleNext}>
|
||||
Siguiente
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending}
|
||||
aria-disabled={isPending}
|
||||
>
|
||||
{isPending ? 'Enviando…' : 'Enviar encuesta'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
dashboard-propuesta/seed_prueba_50.sql
Normal file
57
dashboard-propuesta/seed_prueba_50.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- SEED DE PRUEBA v2 — 50 respuestas + modalidad (2.7). Determinista seed=42.
|
||||
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
|
||||
UPDATE surveys SET status='live' WHERE id='30000000-0000-0000-0000-000000000001';
|
||||
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-000', '55 años o más', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-001', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Reducir tu jornada o pasar a medio tiempo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-002', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Apoyo económico o convenios/descuentos", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-003', '25 a 34 años', 'Gerencial', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-004', '55 años o más', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad", "Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (4–10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-005', '55 años o más', 'Auxiliar', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Reducir tu jornada o pasar a medio tiempo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Acceso a beneficios para terapias o salud"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-006', '25 a 34 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar", "Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Redes de apoyo / grupos de pares", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-007', '35 a 44 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-008', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad", "Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (4–10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Información sobre recursos y servicios"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-009', 'Menos de 25 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Flexibilidad horaria o trabajo remoto", "Información sobre recursos y servicios", "Capacitación y autocuidado"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Capacitación y autocuidado", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-011', '35 a 44 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Dejar un trabajo anterior"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-012', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad", "Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares", "Acceso a beneficios para terapias o salud", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-013', '25 a 34 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (4–10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Capacitación y autocuidado", "Flexibilidad horaria o trabajo remoto"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-016', '25 a 34 años', 'Directivo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-017', '55 años o más', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (4–10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (4–10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios", "Flexibilidad horaria o trabajo remoto"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-019', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Capacitación y autocuidado"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-020', '55 años o más', 'Jefatura', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad", "Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Nunca o casi nunca"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Capacitación y autocuidado", "Información sobre recursos y servicios"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-021', '45 a 54 años', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"3–4"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado", "Redes de apoyo / grupos de pares", "Acceso a beneficios para terapias o salud"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-023', '25 a 34 años', 'Operativo', 'Itapúa', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad", "Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Información sobre recursos y servicios"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado", "Redes de apoyo / grupos de pares"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"De forma permanente / sin horario"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Capacitación y autocuidado"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-026', '25 a 34 años', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Rechazar un ascenso o nueva responsabilidad"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Flexibilidad horaria o trabajo remoto", "Capacitación y autocuidado", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-027', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Sí, definitivamente"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Pensar seriamente en dejar el trabajo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-028', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad", "Familiar menor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Algo probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Parcialmente, hay cierta comprensión"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Acceso a beneficios para terapias o salud", "Flexibilidad horaria o trabajo remoto"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-029', '55 años o más', 'Auxiliar', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"1–2"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Reducir tu jornada o pasar a medio tiempo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Flexibilidad horaria o trabajo remoto", "Capacitación y autocuidado", "Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-030', '35 a 44 años', 'Operativo', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Otro / Especificar"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Faltar o llegar tarde de forma reiterada"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Capacitación y autocuidado"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Tal vez, si hay más personas en la misma situación"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Dejar un trabajo anterior"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar mayor con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"Todos los días"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"Más de 6 horas"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"Más de 6 horas"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Pocas veces (1–3 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Muy probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Sí, me siento acompañado/a"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos", "Contención/apoyo emocional o psicológico"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Hijo menor con discapacidad", "Hijo adulto con discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"5–6"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"5–6"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Casi todos los días"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Varias veces (4–10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"Nunca hemos tocado el tema"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"Prefiero mantenerlo en privado"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Declinar viajes, horas extra o capacitaciones"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Apoyo económico o convenios/descuentos"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-034', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Familiar menor con discapacidad", "Hijo menor sin discapacidad"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"1–2"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"3–4"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Algunas veces al mes"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Nunca"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Poco probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"No"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Pensar seriamente en dejar el trabajo"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Contención/apoyo emocional o psicológico", "Redes de apoyo / grupos de pares"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-035', '45 a 54 años', 'Jefatura', 'Itapúa', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Padre/madre con movilidad reducida o 100% dependiente"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"},{"question_id":"46cbb853-1e5d-4a9f-926f-da4bc48f8ab2","instance_id":"default","value":"3–4"},{"question_id":"6ea42090-189d-42ad-a376-0ff6122b3483","instance_id":"default","value":"5–6"},{"question_id":"b5618201-987a-4c56-9b17-827d73641ff1","instance_id":"default","value":"1–2"},{"question_id":"76265bd5-3f14-4cf9-824d-fe7ad2e1b9ae","instance_id":"default","value":"Varias veces por semana"},{"question_id":"d218a76d-1de1-43cc-8f57-6cb17c5fab22","instance_id":"default","value":"Con frecuencia (más de 10 al año)"},{"question_id":"75dad1d9-a533-445c-b34b-61adbedb36cc","instance_id":"default","value":"Nada probable"},{"question_id":"51b13d59-f64a-4abc-92b6-29f5fa697709","instance_id":"default","value":"No, es algo bastante invisible"},{"question_id":"63087765-f05a-49f3-980e-55db2563cac8","instance_id":"default","value":"No por ahora"},{"question_id":"ac53e514-681a-474e-b1e3-9807a8a5d412","instance_id":"default","value":"Sí"},{"question_id":"b80d200f-f7c4-4c09-b320-b10c30c82dfb","instance_id":"default","value":["Ninguna de las anteriores"]},{"question_id":"8b14956a-5392-40d9-8608-517ddefa487e","instance_id":"default","value":["Redes de apoyo / grupos de pares", "Capacitación y autocuidado"]}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-036', '25 a 34 años', 'Jefatura', 'Itapúa', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-037', '35 a 44 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-038', '35 a 44 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-040', '45 a 54 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Híbrido"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-042', '25 a 34 años', 'Jefatura', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-043', 'Menos de 25 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-044', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-045', '25 a 34 años', 'Auxiliar', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-046', '25 a 34 años', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-047', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Presencial"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-048', '55 años o más', 'Operativo', 'Central', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
|
||||
SELECT public.rpc_submit_response('30000000-0000-0000-0000-000000000001'::uuid, 'seed-prueba-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)', true, false, '[{"question_id":"19140912-78e0-4e47-b1eb-3a5e09221443","instance_id":"default","value":["Ninguna de las anteriores / No tengo vinculación de cuidado"]},{"question_id":"140acde8-bdad-40de-968f-9404c580195b","instance_id":"default","value":"Remoto (home office)"}]'::jsonb);
|
||||
|
||||
-- LIMPIEZA: TRUNCATE answers, responses, consent_records CASCADE;
|
||||
-- UPDATE surveys SET status='draft' WHERE id='30000000-0000-0000-0000-000000000001';
|
||||
156
docs/dashboard/PROPUESTA_DASHBOARD_v3.md
Normal file
156
docs/dashboard/PROPUESTA_DASHBOARD_v3.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Propuesta de arquitectura de métricas — Dashboard v3
|
||||
## "Datos País sobre el Cuidado" · documento para validar con el equipo
|
||||
|
||||
> **Estado: PROPUESTA para discusión.** Ninguna métrica está implementada. Cada una está
|
||||
> anclada a preguntas reales del instrumento v3 y es construible con queries sobre `answers`.
|
||||
> Donde un dato NO existe en el instrumento, se marca como **[REQUIERE PREGUNTA]**.
|
||||
> Las atribuciones a marcos internacionales se hacen solo donde son verificables; donde es
|
||||
> una analogía conceptual y no un estándar oficial, se dice explícitamente.
|
||||
|
||||
---
|
||||
|
||||
## Marco conceptual (fundamento, con fuentes)
|
||||
|
||||
La economía del cuidado tiene marcos de referencia consolidados que dan sustento a las métricas:
|
||||
|
||||
- **Marco de las "5R" de la OIT** (Reconocer, Reducir, Redistribuir, Recompensar, Representar
|
||||
el trabajo de cuidado) — adoptado formalmente por la OIT en junio 2024 en la Resolución sobre
|
||||
trabajo decente y economía del cuidado. Las 3R originales son de Diane Elson (2009).
|
||||
- **CEPAL — "Sociedad del cuidado"**: propone medir el cuidado como sector económico y construir
|
||||
sistemas de información sobre las desigualdades. La CEPAL lo identifica como uno de los diez
|
||||
sectores promisorios para una recuperación transformadora.
|
||||
- **Paraguay — Política Nacional de Cuidados (PNCUPA)**, aprobada 2022, y su **Plan de Acción
|
||||
2025-2030**. El anteproyecto **SINACUP** busca un Sistema Nacional de Cuidados basado en
|
||||
corresponsabilidad entre Estado, familias, comunidad y **sector privado**. La población
|
||||
objetivo: niñez (0-13), personas con discapacidad de cualquier edad, y personas mayores
|
||||
dependientes — exactamente las que mapea la pregunta 4.1 del instrumento.
|
||||
- **Dato país de referencia:** el trabajo de cuidado no remunerado representó el **22,4% del PIB**
|
||||
paraguayo (Encuesta de Uso del Tiempo 2016). El Censo 2022 registró 299.839 personas con
|
||||
discapacidad permanente. Proyección de envejecimiento: la proporción de adultos mayores se
|
||||
duplica de 8,6% (2025) a 16,4% (2050).
|
||||
- **Mirada empresa:** investigación de Harvard Business School (Joseph Fuller, "Healthy Outcomes")
|
||||
documenta que el cuidado impacta productividad, rotación y costos; estudios (Johns Hopkins, 2022)
|
||||
cuantifican absentismo y **presentismo** (estar presente con productividad reducida). La métrica
|
||||
clave es que las empresas suelen NO ver el problema: solo ~24% de empleadores reconoce que el
|
||||
cuidado afecta el desempeño, mientras 3 de 4 empleados cuidadores dicen que su trabajo se vio
|
||||
afectado. Este "punto ciego" es el que el dashboard ataca.
|
||||
|
||||
---
|
||||
|
||||
## Las tres miradas
|
||||
|
||||
El dashboard se organiza en tres lentes sobre los mismos datos. Una respuesta de encuesta
|
||||
alimenta las tres — cambia la pregunta que cada lente le hace a los datos.
|
||||
|
||||
```
|
||||
MIRADA EMPRESA → "¿qué me cuesta y qué gano gestionando el cuidado de mi gente?"
|
||||
MIRADA PAÍS → "¿qué revela esto sobre la economía del cuidado en Paraguay?"
|
||||
MIRADA HUMANA → "¿cómo está la persona que cuida y la que es cuidada?"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MIRADA EMPRESA
|
||||
**Audiencia:** RRHH, dirección, empresas con certificación tipo Great Place to Work.
|
||||
**Pregunta rectora:** rendimiento, sostenibilidad del talento, riesgo de rotación, clima.
|
||||
|
||||
| # | Métrica | Qué responde | Preguntas v3 | Construible |
|
||||
|---|---|---|---|---|
|
||||
| E1 | **Prevalencia de cuidadores en la plantilla** | % de empleados con algún rol de cuidado | 4.1 (vínculo ≠ "Ninguna") | ✅ |
|
||||
| E2 | **Riesgo de rotación por cuidado** | % que considera probable dejar/reducir su trabajo | 7.6 (Algo/Muy probable) | ✅ |
|
||||
| E3 | **Ausentismo atribuible al cuidado** | % con faltas recurrentes por cuidado | 7.3 (4+ veces/año) | ✅ |
|
||||
| E4 | **Presentismo (productividad reducida)** | % cuya concentración/rendimiento se ve afectado | 7.2 (varias veces/semana o más) | ✅ |
|
||||
| E5 | **Carrera congelada** | % que rechazó ascenso / redujo jornada / declinó oportunidades | 7.5 | ✅ |
|
||||
| E6 | **Punto ciego organizacional** | brecha entre carga real y percepción de apoyo de la empresa | 9.1 (No/Nunca) vs prevalencia E1 | ✅ |
|
||||
| E7 | **Demanda de apoyos priorizada** | qué apoyos pedirían (para diseñar beneficios) | 9.2 | ✅ |
|
||||
| E8 | **Intensidad de carga** | horas/semana de cuidado de la plantilla cuidadora | 6.1 × 6.2 (+ 6.4 guardia) | ✅ |
|
||||
| E9 | **Segmentación por nivel y modalidad** | dónde se concentra la carga (jerarquía, presencial/remoto) | cruce con 2.6, 2.7 | ✅ |
|
||||
| E10 | **Disposición a red de apoyo interna** | % dispuesto a grupo de apoyo / voluntariado | 9.3, 9.4 | ✅ |
|
||||
|
||||
**Métrica estrella sugerida — "Talento en riesgo":** combinación de E2+E3+E5 — el % de la
|
||||
plantilla cuidadora que muestra señales de fuga (alta probabilidad de salida + ausentismo +
|
||||
carrera congelada). Es el número que traduce cuidado a lenguaje de negocio (costo de reemplazo).
|
||||
|
||||
---
|
||||
|
||||
## MIRADA PAÍS
|
||||
**Audiencia:** estudio país, Fundación Solidaridad, incidencia en política pública.
|
||||
**Pregunta rectora:** desarrollo, vulnerabilidad, impacto económico, insumo para el SINACUP.
|
||||
|
||||
| # | Métrica | Qué responde | Preguntas v3 | Construible |
|
||||
|---|---|---|---|---|
|
||||
| P1 | **Mapa de prevalencia de cuidado por departamento** | dónde se concentra la carga de cuidado | 4.1 × 2.1 | ✅ |
|
||||
| P2 | **Perfil de la población dependiente cuidada** | composición: niñez / discapacidad / adulto mayor | 4.1 (por instancia) | ✅ |
|
||||
| P3 | **Carga económica privada del cuidado** | cuánto gastan los hogares de su bolsillo | 8.1 | ✅ |
|
||||
| P4 | **Vulnerabilidad económica del cuidador** | relación gasto de cuidado vs franja salarial | 8.1 vs 8.4 | ✅ |
|
||||
| P5 | **Red de soporte del cuidado** | quién sostiene (familia, contratado, estatal, comunidad) | 5.3, 8.3 | ✅ |
|
||||
| P6 | **Brecha de género en el cuidado** | distribución de la carga por género | 4.1/cuidado × 2.5 | ✅ |
|
||||
| P7 | **Dependencia del subsidio estatal** | % que apoya gastos con subsidios del Estado | 8.3 (subsidios estatales) | ✅ |
|
||||
| P8 | **Cuidado de larga duración** | antigüedad en el rol (cronicidad) | 5.4 | ✅ |
|
||||
| P9 | **Tecnología asistiva y certificación** | acceso a dispositivos / certificado de discapacidad | 3.3, 4.3, 3.4 | ✅ |
|
||||
|
||||
**Conexión con política pública:** P2, P5 y P7 son insumo directo para el SINACUP (mapean
|
||||
demanda de cuidado y dependencia del Estado). P4 conecta con el riesgo de empobrecimiento por
|
||||
cuidado, central en la PNCUPA.
|
||||
|
||||
**[REQUIERE PREGUNTA] — tiempo de cuidado no remunerado:** para conectar con el indicador
|
||||
estrella de CEPAL/uso del tiempo (el 22,4% del PIB), faltaría una pregunta de "horas de cuidado
|
||||
no remunerado por semana" más granular. Hoy 6.1×6.2 lo aproxima en rangos; una pregunta de horas
|
||||
semanales permitiría comparar con la Encuesta de Uso del Tiempo nacional.
|
||||
|
||||
---
|
||||
|
||||
## MIRADA HUMANA
|
||||
**Audiencia:** lectura de bienestar y derechos; alineamiento con marcos de organismos.
|
||||
**Pregunta rectora:** desarrollo humano, bienestar del cuidador y la persona cuidada.
|
||||
**Nota de honestidad:** estos indicadores se INSPIRAN en marcos de UNICEF/UNFPA/UNESCO sobre
|
||||
derechos del niño, envejecimiento y educación inclusiva. NO afirmo que sean indicadores oficiales
|
||||
de esos organismos — son lecturas propias del instrumento que dialogan con esas agendas. La
|
||||
validación de cualquier alineamiento formal corresponde al equipo y los organismos.
|
||||
|
||||
| # | Métrica | Qué responde | Preguntas v3 | Marco que dialoga |
|
||||
|---|---|---|---|---|
|
||||
| H1 | **Agotamiento del cuidador** | nivel de descanso / sobrecarga | 7.1, 7.4 (salud física/emocional) | OIT 5R (Reducir) |
|
||||
| H2 | **Aspectos de vida sacrificados** | qué pierde la persona por cuidar | 7.4 | Desarrollo humano (PNUD) |
|
||||
| H3 | **Escolarización con barreras (niñez)** | niñez cuidada que enfrenta barreras de aprendizaje | **[REQUIERE PREGUNTA]** ver abajo | UNICEF / UNESCO educación inclusiva |
|
||||
| H4 | **Autonomía de la persona dependiente** | uso de dispositivos / nivel de dependencia | 4.3, 6.4 (guardia permanente) | Convención Derechos PcD |
|
||||
| H5 | **Soledad del cuidado** | cuida sin red de apoyo (carga en solitario) | 5.3 (solo/sin apoyo) | OIT 5R (Redistribuir) |
|
||||
| H6 | **Inclusión laboral de PcD propia** | empleados con discapacidad propia y cómo les afecta | 3.1, 3.5 | Convención Derechos PcD |
|
||||
| H7 | **Disposición solidaria** | voluntariado / participación comunitaria | 9.3, 9.4 | Capital social |
|
||||
|
||||
**[REQUIERE PREGUNTA] — H3 escolarización:** el mockup original del instrumento mencionaba
|
||||
"si es niño/a en edad escolar: ¿está escolarizado y enfrenta barreras de aprendizaje?" como
|
||||
posible dato por instancia de vínculo. En el seed v3 actual NO está. Si el equipo quiere la
|
||||
mirada UNICEF/UNESCO sobre niñez, **sugiero agregar una pregunta de instancia** (cuando el
|
||||
familiar es niño/a): "¿Está escolarizado/a? ¿Enfrenta barreras de aprendizaje?" — sería 4.4
|
||||
(instance_of 4.1), construible con la arquitectura de instancias que ya existe.
|
||||
|
||||
---
|
||||
|
||||
## Decisión metodológica pendiente (para Maria) — métrica por persona vs por instancia
|
||||
|
||||
Las preguntas de vínculo (4.2/4.3) admiten dos lecturas, y conviene presentar AMBAS rotuladas:
|
||||
- **Por persona:** "% de empleados con AL MENOS un familiar con discapacidad X" (prevalencia
|
||||
en plantilla — denominador: respondentes). Para RRHH.
|
||||
- **Por instancia:** "del total de familiares cuidados, % con discapacidad X" (composición de
|
||||
la población cuidada — denominador: familiares declarados). Para estudio país.
|
||||
|
||||
**k-anonimato sobre la métrica por instancia:** el umbral k≥5 debe seguir aplicándose sobre
|
||||
`count(DISTINCT response_id)` (personas), NO sobre instancias — para que un segmento no parezca
|
||||
tener 5 "familiares" que en realidad vienen de 2 personas. A validar con metodología.
|
||||
|
||||
---
|
||||
|
||||
## Construcción técnica (todas las métricas)
|
||||
- Cada métrica es una RPC `SECURITY DEFINER` con k≥5 en dos niveles (igual patrón que las RPCs
|
||||
existentes), filtrable por `survey_id` y por el flag `p_all` (UUID cero = todos los surveys).
|
||||
- Las métricas de instancia (P2, parte de H4, H3 futura) usan `instance_id LIKE 'familiar_%'`
|
||||
y el índice `idx_answers_instance` que ya existe (creado en 4A, nunca usado aún).
|
||||
- Las cuasi-identificadoras (departamento, género, nivel) ya están como `qi_*` en `responses`.
|
||||
|
||||
## Resumen de gaps de datos (lo que el instrumento NO captura hoy)
|
||||
1. **[REQUIERE PREGUNTA] Horas de cuidado no remunerado semanales** (mirada país, comparar con
|
||||
Uso del Tiempo nacional / 22,4% PIB). Hoy aproximado por rangos 6.1×6.2.
|
||||
2. **[REQUIERE PREGUNTA] Escolarización y barreras de aprendizaje de niñez cuidada** (mirada
|
||||
humana, diálogo UNICEF/UNESCO). Sugerencia: pregunta de instancia 4.4 condicional a vínculo niño.
|
||||
3. Todo lo demás es construible con el instrumento v3 actual.
|
||||
159
docs/dashboard/SPEC_MIRADA_EMPRESA.md
Normal file
159
docs/dashboard/SPEC_MIRADA_EMPRESA.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Especificación técnica — Mirada Empresa (Dashboard v3)
|
||||
## Contrato para implementación · go-live
|
||||
|
||||
> Especificación de las 10 métricas de la Mirada Empresa, cada una con su query
|
||||
> construible sobre el instrumento v3, regla de k-anonimato y shape de retorno.
|
||||
> **Pendiente de validación del equipo** (especialmente la definición de CUIDADOR).
|
||||
> Patrón: RPC `SECURITY DEFINER`, filtrable por `survey_id` y flag `p_all` (UUID cero),
|
||||
> igual que las RPCs existentes. Reusa `_dash_responses` y `_dash_parse_filters`.
|
||||
|
||||
---
|
||||
|
||||
## Constante parametrizada — definición de CUIDADOR
|
||||
**Decisión CERRADA (Maria, criterio UNFPA):** "Hijo menor sin discapacidad" SÍ cuenta como
|
||||
cuidador activo. El cuidado de niñez es trabajo de cuidado, coherente con el enfoque de la
|
||||
economía del cuidado y con un criterio explícitamente solicitado por UNFPA.
|
||||
|
||||
```
|
||||
CUIDADOR_ACTIVO := respuesta a 4.1 contiene AL MENOS una opción
|
||||
distinta de "Ninguna de las anteriores / No tengo vinculación de cuidado"
|
||||
```
|
||||
|
||||
Es decir: las opciones 1..7 cuentan como cuidador; solo la opción 8 ("Ninguna...") excluye.
|
||||
Todas las métricas que usan "cuidador" como población heredan esta definición desde este
|
||||
único punto. Registrar en SEED_SPEC.md: "CUIDADOR incluye hijo menor sin discapacidad —
|
||||
criterio UNFPA, validado por Maria (metodología), jun 2026."
|
||||
|
||||
**Nota de comparabilidad con v2:** esto ensancha la población "cuidador" respecto al dashboard
|
||||
v2 (donde criar un niño sano no activaba los triggers de discapacidad/adulto mayor). Es un
|
||||
cambio deliberado de definición, no un error de migración. Cualquier comparación con datos v2
|
||||
debe tenerlo en cuenta.
|
||||
|
||||
---
|
||||
|
||||
## Núcleo mínimo viable (si el tiempo aprieta en implementación)
|
||||
Marcadas **[NÚCLEO]** las imprescindibles para el cliente; **[SEC]** las secundarias.
|
||||
|
||||
---
|
||||
|
||||
## E1 — Prevalencia de cuidadores en la plantilla [NÚCLEO]
|
||||
**Responde:** ¿qué % de mis empleados tiene algún rol de cuidado?
|
||||
**Pregunta v3:** 4.1
|
||||
**Query (concepto):**
|
||||
```sql
|
||||
v_total := count(*) FROM _dash_responses(...) -- denominador: toda la plantilla encuestada
|
||||
v_cuid := count(DISTINCT response_id) WHERE q.code='4.1'
|
||||
AND <CUIDADOR_ACTIVO> -- 4.1 contiene opción de cuidado
|
||||
RETURN pct_cuidadores = round(v_cuid/v_total*100,1)
|
||||
```
|
||||
**k≥5:** si v_total < 5 → suppressed.
|
||||
**Retorno:** `{status, n, pct_cuidadores}`
|
||||
|
||||
---
|
||||
|
||||
## E2 — Riesgo de rotación por cuidado [NÚCLEO]
|
||||
**Responde:** ¿qué % de cuidadores considera probable dejar/reducir su trabajo?
|
||||
**Pregunta v3:** 7.6 (single_choice: Nada/Poco/Algo/Muy probable)
|
||||
**Query:** denominador = cuidadores activos; numerador = 7.6 IN ('Algo probable','Muy probable').
|
||||
**k≥5:** sobre cuidadores; si < 5 → suppressed.
|
||||
**Retorno:** `{status, n_cuidadores, pct_riesgo, desglose:{algo, muy}}`
|
||||
|
||||
---
|
||||
|
||||
## E3 — Ausentismo atribuible al cuidado [NÚCLEO]
|
||||
**Responde:** ¿qué % faltó al trabajo por cuidado de forma recurrente?
|
||||
**Pregunta v3:** 7.3 (Nunca / Pocas 1-3 / Varias 4-10 / Con frecuencia +10)
|
||||
**Query:** numerador = 7.3 IN ('Varias veces (4–10 al año)','Con frecuencia (más de 10 al año)').
|
||||
**Retorno:** `{status, n, pct_ausentismo_recurrente, desglose por categoría}`
|
||||
|
||||
---
|
||||
|
||||
## E4 — Presentismo (productividad reducida) [NÚCLEO]
|
||||
**Responde:** ¿a qué % se le afecta la concentración/rendimiento por cuidar?
|
||||
**Pregunta v3:** 7.2 (Nunca / Algunas veces al mes / Varias veces por semana / Casi todos los días)
|
||||
**Query:** numerador = 7.2 IN ('Varias veces por semana','Casi todos los días').
|
||||
**Retorno:** `{status, n, pct_presentismo, desglose}`
|
||||
|
||||
---
|
||||
|
||||
## E5 — Carrera congelada [SEC]
|
||||
**Responde:** ¿qué % tuvo que frenar su carrera por cuidar?
|
||||
**Pregunta v3:** 7.5 (multiple_choice: reducir jornada / rechazar ascenso / declinar / cambiar puesto / etc.)
|
||||
**Query:** numerador = respondentes cuyo 7.5 contiene alguna opción distinta de "Ninguna de las anteriores".
|
||||
**Retorno:** `{status, n, pct_carrera_congelada, top_opciones[]}`
|
||||
|
||||
---
|
||||
|
||||
## E6 — Punto ciego organizacional [NÚCLEO]
|
||||
**Responde:** brecha entre la carga real de cuidado y cuántos se sienten apoyados.
|
||||
**Preguntas v3:** 4.1 (prevalencia, = E1) vs 9.1 (¿la empresa comprende?)
|
||||
**Query:** carga_real = pct_cuidadores (E1); se_siente_apoyado = % con 9.1 = 'Sí, me siento acompañado/a'.
|
||||
La métrica es la BRECHA entre ambos.
|
||||
**Retorno:** `{status, n, carga_real_pct, apoyo_percibido_pct, brecha_pp}`
|
||||
**Nota visual:** es el widget de contraste de dos barras del mockup.
|
||||
|
||||
---
|
||||
|
||||
## E7 — Demanda de apoyos priorizada [NÚCLEO]
|
||||
**Responde:** ¿qué apoyos pediría la gente? (para diseñar beneficios)
|
||||
**Pregunta v3:** 9.2 (multiple_choice, máx 3)
|
||||
**Query:** desagregar con jsonb_array_elements_text, contar por opción, ordenar desc.
|
||||
**k≥5 por fila:** cada opción con cnt < 5 se omite (igual que rpc_apoyos_demandados v2).
|
||||
**Retorno:** `{status, n, apoyos:[{apoyo, n, pct}] ordenado}`
|
||||
|
||||
---
|
||||
|
||||
## E8 — Intensidad de carga [SEC]
|
||||
**Responde:** ¿cuánta carga horaria soporta la plantilla cuidadora?
|
||||
**Preguntas v3:** 6.1 (días/semana) × 6.2 (horas/día), + 6.4 (guardia)
|
||||
**Query:** distribución de combinaciones; o un índice de intensidad (días×horas en rangos).
|
||||
**Nota:** como son rangos (no números), se reporta distribución por bucket, no un promedio exacto.
|
||||
**Retorno:** `{status, n, distribucion_intensidad[], pct_guardia_permanente}`
|
||||
|
||||
---
|
||||
|
||||
## E9 — Segmentación por nivel y modalidad [SEC]
|
||||
**Responde:** ¿dónde se concentra la carga? (jerarquía, presencial/remoto)
|
||||
**Preguntas v3:** CUIDADOR cruzado con 2.6 (nivel) y 2.7 (modalidad)
|
||||
**Query:** pct_cuidadores por cada valor de 2.6 y de 2.7.
|
||||
**k≥5 por segmento:** cada nivel/modalidad con n < 5 → suppressed esa fila.
|
||||
**Retorno:** `{status, por_nivel:[{nivel,n,pct}], por_modalidad:[{modalidad,n,pct}]}`
|
||||
|
||||
---
|
||||
|
||||
## E10 — Disposición a red de apoyo interna [SEC]
|
||||
**Responde:** ¿cuántos participarían de un grupo de apoyo / voluntariado?
|
||||
**Preguntas v3:** 9.3 (grupo de apoyo) + 9.4 (voluntariado)
|
||||
**Query:** % con 9.3 IN ('Sí, definitivamente','Tal vez...') ; % con 9.4 = 'Sí'.
|
||||
**Retorno:** `{status, n, pct_grupo_apoyo, pct_voluntariado}`
|
||||
|
||||
---
|
||||
|
||||
## Métrica estrella compuesta — "Talento en riesgo" [NÚCLEO]
|
||||
**Responde:** el número que traduce cuidado a costo de negocio.
|
||||
**Preguntas v3:** 7.6 + 7.3 + 7.5 (sobre población cuidadora)
|
||||
**Definición propuesta (validar):** % de cuidadores que cumple AL MENOS 2 de 3 señales:
|
||||
- riesgo de salida (7.6 IN Algo/Muy probable)
|
||||
- ausentismo recurrente (7.3 IN 4-10 / +10)
|
||||
- carrera congelada (7.5 contiene alguna opción de freno)
|
||||
**Decisión pendiente:** ¿umbral de 2 de 3, o ponderado? Validar con el equipo.
|
||||
**Retorno:** `{status, n_cuidadores, pct_talento_riesgo, criterios_promedio}`
|
||||
|
||||
---
|
||||
|
||||
## Reglas transversales (todas las RPCs)
|
||||
1. **k-anonimato k≥5** en dos niveles: total (suppressed si n<5) y por-segmento (fila omitida/null si <5).
|
||||
2. **Filtro survey_id + p_all:** delegado a `_dash_responses` (UUID cero = todos). No reimplementar.
|
||||
3. **No exponer datos sensibles** (3.x salud propia, 4.2/4.3 disc. familiar) en Mirada Empresa.
|
||||
Mirada Empresa usa solo: 4.1 (existencia de vínculo, no el detalle médico), 6.x, 7.x, 9.x, 2.6, 2.7.
|
||||
4. **Shape consistente:** `{status:'ok'|'suppressed', n, ...campos}`. Status suppressed nunca expone n<5 desglosado.
|
||||
5. Reusar el patrón de migración 012 (las RPCs que SÍ migran limpio sirven de molde).
|
||||
|
||||
## Lo que Mirada Empresa NO necesita (queda para sprint de dashboard)
|
||||
- Bienestar tipo rating (4.3 v2 no existe en v3) — no se usa acá.
|
||||
- Score organizacional con 6.5 — no se usa acá.
|
||||
- Métricas de instancia (4.2/4.3 detalle médico del familiar) — son de Mirada País/Humana.
|
||||
- Las preguntas faltantes (horas no remuneradas, escolarización) — son de País/Humana.
|
||||
|
||||
## Gaps de datos para Mirada Empresa
|
||||
Ninguno. Las 10 métricas + la estrella son 100% construibles con el instrumento v3 actual.
|
||||
275
docs/dashboard/dashboard_tres_miradas_mockup.html
Normal file
275
docs/dashboard/dashboard_tres_miradas_mockup.html
Normal file
@@ -0,0 +1,275 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard 3 Miradas — Mockup Propuesta</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@2.44.0/tabler-icons.min.css">
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#0b1220;color:#e7eef7;padding:1.5rem}
|
||||
.wrap{max-width:1100px;margin:0 auto}
|
||||
.head{display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;flex-wrap:wrap;gap:10px}
|
||||
.head h1{font-size:22px;font-weight:600}
|
||||
.sub{color:#7d8da5;font-size:13px;margin-bottom:1.2rem}
|
||||
.badge-prop{display:inline-block;background:#3a2a08;color:#f0b429;font-size:11px;font-weight:600;padding:3px 10px;border-radius:20px;border:1px solid #6b4d12;margin-left:8px}
|
||||
.org-sel{display:flex;gap:6px}
|
||||
.org-sel button{background:#16203a;color:#9fb0cc;border:1px solid #243352;border-radius:20px;padding:5px 14px;font-size:12px;cursor:pointer}
|
||||
.org-sel button.active{background:#43BBC8;color:#04161a;border-color:#43BBC8;font-weight:600}
|
||||
.tabs{display:flex;gap:4px;margin-bottom:1.2rem;border-bottom:1px solid #1d2a44}
|
||||
.tab{padding:10px 20px;font-size:14px;font-weight:500;color:#7d8da5;cursor:pointer;border-bottom:2px solid transparent;display:flex;align-items:center;gap:7px}
|
||||
.tab:hover{color:#cdd9ec}
|
||||
.tab.active{color:#43BBC8;border-bottom-color:#43BBC8}
|
||||
.tab .lens{font-size:11px;color:#5e6f8c;display:block;font-weight:400}
|
||||
.lens-intro{background:#101a30;border-left:3px solid #43BBC8;border-radius:0 8px 8px 0;padding:10px 14px;margin-bottom:1.2rem;font-size:13px;color:#9fb0cc;line-height:1.5}
|
||||
.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:12px}
|
||||
.card{background:#101a30;border:1px solid #1d2a44;border-radius:12px;padding:16px;position:relative}
|
||||
.card.c3{grid-column:span 3}.card.c4{grid-column:span 4}.card.c6{grid-column:span 6}.card.c8{grid-column:span 8}.card.c12{grid-column:span 12}
|
||||
.card h3{font-size:12px;font-weight:600;color:#9fb0cc;text-transform:uppercase;letter-spacing:.04em;margin-bottom:3px}
|
||||
.card .qref{font-size:10px;color:#5e6f8c;margin-bottom:10px}
|
||||
.big{font-size:38px;font-weight:700;color:#43BBC8;line-height:1}
|
||||
.big.warn{color:#f0b429}.big.danger{color:#f06b6b}
|
||||
.big-sub{font-size:12px;color:#7d8da5;margin-top:4px}
|
||||
.star{border-color:#43BBC8;background:linear-gradient(135deg,#0e1b2e,#10243000)}
|
||||
.star h3{color:#43BBC8}
|
||||
.bar-row{display:flex;align-items:center;gap:8px;margin-bottom:7px;font-size:12px}
|
||||
.bar-row .lab{width:130px;color:#9fb0cc;text-align:right;flex-shrink:0}
|
||||
.bar-track{flex:1;background:#16203a;border-radius:4px;height:16px;overflow:hidden}
|
||||
.bar-fill{height:100%;background:#43BBC8;border-radius:4px}
|
||||
.bar-fill.amber{background:#f0b429}.bar-fill.rose{background:#f06b6b}.bar-fill.slate{background:#5e7299}
|
||||
.bar-row .val{width:42px;color:#cdd9ec;font-weight:600}
|
||||
.donut-wrap{display:flex;align-items:center;gap:18px}
|
||||
.donut{width:120px;height:120px;flex-shrink:0}
|
||||
.legend{font-size:12px}
|
||||
.legend div{display:flex;align-items:center;gap:7px;margin-bottom:5px;color:#9fb0cc}
|
||||
.legend .dot{width:9px;height:9px;border-radius:50%}
|
||||
.dual{display:flex;gap:14px}
|
||||
.dual>div{flex:1;background:#0c1426;border-radius:8px;padding:12px;border:1px solid #1a2640}
|
||||
.dual .dtag{font-size:10px;color:#5e6f8c;text-transform:uppercase;letter-spacing:.04em;margin-bottom:4px}
|
||||
.dual .dn{font-size:26px;font-weight:700;color:#43BBC8}
|
||||
.dual .dbase{font-size:11px;color:#7d8da5;margin-top:2px}
|
||||
.gap-viz{display:flex;align-items:flex-end;gap:20px;justify-content:center;padding:10px 0}
|
||||
.gap-col{text-align:center}
|
||||
.gap-bar{width:60px;border-radius:6px 6px 0 0;margin:0 auto}
|
||||
.gap-lab{font-size:11px;color:#9fb0cc;margin-top:6px}
|
||||
.gap-val{font-size:13px;font-weight:600;margin-bottom:4px}
|
||||
.note{font-size:11px;color:#5e6f8c;margin-top:10px;font-style:italic}
|
||||
.req{background:#2a1208;border:1px dashed #6b3d12;color:#f0975a}
|
||||
.req .big{color:#f0975a}
|
||||
.footnote{margin-top:1.5rem;padding:12px;background:#101a30;border-radius:8px;font-size:11px;color:#7d8da5;line-height:1.6}
|
||||
.k{font-size:10px;color:#4a5a76;text-align:right;margin-top:6px}
|
||||
@media(max-width:900px){.card.c3,.card.c4,.card.c6,.card.c8{grid-column:span 12}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1>Economía del Cuidado — Paraguay <span class="badge-prop">PROPUESTA · datos de ejemplo</span></h1>
|
||||
</div>
|
||||
<div class="org-sel">
|
||||
<button class="active">Todos</button>
|
||||
<button>InQuality</button>
|
||||
<button>Fundación</button>
|
||||
<button>Demo</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sub">Mockup de validación · k-anonimato k≥5 · datos ilustrativos, NO reales · n = 142 (ejemplo)</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="showLens('empresa',this)"><i class="ti ti-building"></i> <span>Mirada Empresa<span class="lens">rendimiento · rotación · clima</span></span></div>
|
||||
<div class="tab" onclick="showLens('pais',this)"><i class="ti ti-map-2"></i> <span>Mirada País<span class="lens">desarrollo · vulnerabilidad · política</span></span></div>
|
||||
<div class="tab" onclick="showLens('humana',this)"><i class="ti ti-heart-handshake"></i> <span>Mirada Humana<span class="lens">bienestar · derechos · desarrollo</span></span></div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MIRADA EMPRESA ===== -->
|
||||
<div id="empresa" class="lens-content">
|
||||
<div class="lens-intro"><strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el punto ciego y qué talento está en riesgo. Inspirada en la investigación de Harvard Business School (Fuller) sobre el impacto del cuidado en productividad y rotación.</div>
|
||||
<div class="grid">
|
||||
<div class="card c4 star">
|
||||
<h3><i class="ti ti-alert-triangle"></i> Talento en riesgo</h3>
|
||||
<div class="qref">7.6 + 7.3 + 7.5 · métrica compuesta</div>
|
||||
<div class="big danger">18.3%</div>
|
||||
<div class="big-sub">de la plantilla cuidadora muestra señales de fuga (alta probabilidad de salida + ausentismo + carrera congelada)</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Cuidadores en la plantilla</h3>
|
||||
<div class="qref">4.1 · vínculo de cuidado activo</div>
|
||||
<div class="big">54.2%</div>
|
||||
<div class="big-sub">tiene algún rol de cuidado</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Punto ciego organizacional</h3>
|
||||
<div class="qref">9.1 vs prevalencia real</div>
|
||||
<div class="gap-viz">
|
||||
<div class="gap-col"><div class="gap-val" style="color:#43BBC8">54%</div><div class="gap-bar" style="height:70px;background:#43BBC8"></div><div class="gap-lab">carga real</div></div>
|
||||
<div class="gap-col"><div class="gap-val" style="color:#f06b6b">27%</div><div class="gap-bar" style="height:35px;background:#f06b6b"></div><div class="gap-lab">se siente apoyado</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card c6">
|
||||
<h3>Señales de impacto laboral</h3>
|
||||
<div class="qref">7.2 / 7.3 / 7.6</div>
|
||||
<div class="bar-row"><span class="lab">Presentismo</span><div class="bar-track"><div class="bar-fill amber" style="width:41%"></div></div><span class="val">41%</span></div>
|
||||
<div class="bar-row"><span class="lab">Ausentismo frecuente</span><div class="bar-track"><div class="bar-fill amber" style="width:27%"></div></div><span class="val">27%</span></div>
|
||||
<div class="bar-row"><span class="lab">Riesgo de salida</span><div class="bar-track"><div class="bar-fill rose" style="width:23%"></div></div><span class="val">23%</span></div>
|
||||
<div class="bar-row"><span class="lab">Carrera congelada</span><div class="bar-track"><div class="bar-fill rose" style="width:19%"></div></div><span class="val">19%</span></div>
|
||||
</div>
|
||||
<div class="card c6">
|
||||
<h3>Apoyos más demandados</h3>
|
||||
<div class="qref">9.2 · qué beneficios diseñar (máx 3)</div>
|
||||
<div class="bar-row"><span class="lab">Flexibilidad horaria</span><div class="bar-track"><div class="bar-fill" style="width:68%"></div></div><span class="val">68%</span></div>
|
||||
<div class="bar-row"><span class="lab">Apoyo económico</span><div class="bar-track"><div class="bar-fill" style="width:54%"></div></div><span class="val">54%</span></div>
|
||||
<div class="bar-row"><span class="lab">Apoyo psicológico</span><div class="bar-track"><div class="bar-fill" style="width:47%"></div></div><span class="val">47%</span></div>
|
||||
<div class="bar-row"><span class="lab">Convenios/descuentos</span><div class="bar-track"><div class="bar-fill" style="width:39%"></div></div><span class="val">39%</span></div>
|
||||
</div>
|
||||
<div class="card c12">
|
||||
<h3>Dónde se concentra la carga</h3>
|
||||
<div class="qref">4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad)</div>
|
||||
<div class="bar-row"><span class="lab">Operativo</span><div class="bar-track"><div class="bar-fill slate" style="width:62%"></div></div><span class="val">62%</span></div>
|
||||
<div class="bar-row"><span class="lab">Jefatura</span><div class="bar-track"><div class="bar-fill slate" style="width:51%"></div></div><span class="val">51%</span></div>
|
||||
<div class="bar-row"><span class="lab">Gerencial</span><div class="bar-track"><div class="bar-fill slate" style="width:44%"></div></div><span class="val">44%</span></div>
|
||||
<div class="note">Permite a RRHH ver si el cuidado afecta más a ciertos niveles — insumo para políticas diferenciadas.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="k">k≥5 aplicado · segmentos con n<5 suprimidos</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MIRADA PAÍS ===== -->
|
||||
<div id="pais" class="lens-content" style="display:none">
|
||||
<div class="lens-intro"><strong>Mirada País.</strong> Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP). Dialoga con la Política Nacional de Cuidados del Paraguay (PNCUPA, 2022) y el marco de la CEPAL sobre la sociedad del cuidado.</div>
|
||||
<div class="grid">
|
||||
<div class="card c4">
|
||||
<h3>Carga económica privada</h3>
|
||||
<div class="qref">8.1 · gasto mensual de bolsillo</div>
|
||||
<div class="big">Gs. 2.8M</div>
|
||||
<div class="big-sub">gasto mensual mediano en cuidado</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Vulnerabilidad económica</h3>
|
||||
<div class="qref">8.1 vs 8.4 · gasto/ingreso</div>
|
||||
<div class="big warn">31%</div>
|
||||
<div class="big-sub">destina más del 30% de su ingreso al cuidado</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Dependencia del Estado</h3>
|
||||
<div class="qref">8.3 · subsidios estatales</div>
|
||||
<div class="big">12%</div>
|
||||
<div class="big-sub">apoya los gastos con subsidios estatales</div>
|
||||
</div>
|
||||
<div class="card c6">
|
||||
<h3>Perfil de la población dependiente cuidada</h3>
|
||||
<div class="qref">4.1 · por instancia de vínculo</div>
|
||||
<div class="donut-wrap">
|
||||
<svg class="donut" viewBox="0 0 42 42">
|
||||
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#16203a" stroke-width="6"/>
|
||||
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#43BBC8" stroke-width="6" stroke-dasharray="38 62" stroke-dashoffset="25"/>
|
||||
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#f0b429" stroke-width="6" stroke-dasharray="29 71" stroke-dashoffset="-13"/>
|
||||
<circle cx="21" cy="21" r="15.9" fill="none" stroke="#f06b6b" stroke-width="6" stroke-dasharray="33 67" stroke-dashoffset="-42"/>
|
||||
</svg>
|
||||
<div class="legend">
|
||||
<div><span class="dot" style="background:#43BBC8"></span> Niñez (0-13) — 38%</div>
|
||||
<div><span class="dot" style="background:#f0b429"></span> Persona con discapacidad — 29%</div>
|
||||
<div><span class="dot" style="background:#f06b6b"></span> Adulto mayor dependiente — 33%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="note">Las tres poblaciones objetivo del SINACUP. Métrica por instancia (familiares declarados), no por persona.</div>
|
||||
</div>
|
||||
<div class="card c6">
|
||||
<h3>Quién sostiene el cuidado</h3>
|
||||
<div class="qref">5.3 + 8.3 · red de soporte</div>
|
||||
<div class="bar-row"><span class="lab">Otro familiar</span><div class="bar-track"><div class="bar-fill" style="width:58%"></div></div><span class="val">58%</span></div>
|
||||
<div class="bar-row"><span class="lab">Cuidador contratado</span><div class="bar-track"><div class="bar-fill" style="width:34%"></div></div><span class="val">34%</span></div>
|
||||
<div class="bar-row"><span class="lab">Centro de cuidado</span><div class="bar-track"><div class="bar-fill" style="width:22%"></div></div><span class="val">22%</span></div>
|
||||
<div class="bar-row"><span class="lab">Comunidad/vecinos</span><div class="bar-track"><div class="bar-fill slate" style="width:14%"></div></div><span class="val">14%</span></div>
|
||||
</div>
|
||||
<div class="card c8">
|
||||
<h3>Prevalencia de cuidado por departamento</h3>
|
||||
<div class="qref">4.1 × 2.1 · concentración geográfica</div>
|
||||
<div class="bar-row"><span class="lab">Central</span><div class="bar-track"><div class="bar-fill" style="width:61%"></div></div><span class="val">61%</span></div>
|
||||
<div class="bar-row"><span class="lab">Asunción</span><div class="bar-track"><div class="bar-fill" style="width:48%"></div></div><span class="val">48%</span></div>
|
||||
<div class="bar-row"><span class="lab">Alto Paraná</span><div class="bar-track"><div class="bar-fill" style="width:39%"></div></div><span class="val">39%</span></div>
|
||||
<div class="bar-row"><span class="lab">Itapúa</span><div class="bar-track"><div class="bar-fill slate" style="width:0%"></div></div><span class="val" style="color:#5e6f8c">n<5</span></div>
|
||||
<div class="note">Itapúa suprimido por k-anonimato (n<5). Solo se muestran departamentos con datos suficientes.</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Cuidado de larga duración</h3>
|
||||
<div class="qref">5.4 · cronicidad</div>
|
||||
<div class="big">46%</div>
|
||||
<div class="big-sub">cuida hace más de 3 años</div>
|
||||
</div>
|
||||
<div class="card c12 req">
|
||||
<h3><i class="ti ti-circle-plus"></i> Tiempo de cuidado no remunerado — requiere nueva pregunta</h3>
|
||||
<div class="qref">aproximado hoy por 6.1×6.2 en rangos</div>
|
||||
<div class="big-sub" style="color:#f0975a">Para conectar con el indicador de CEPAL/Uso del Tiempo (el cuidado no remunerado fue 22,4% del PIB paraguayo en 2016), convendría una pregunta de horas semanales de cuidado no remunerado. Hoy se aproxima con rangos de días×horas, pero no es comparable con la Encuesta de Uso del Tiempo nacional.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="k">k≥5 sobre count(DISTINCT response_id), también en métricas por instancia</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== MIRADA HUMANA ===== -->
|
||||
<div id="humana" class="lens-content" style="display:none">
|
||||
<div class="lens-intro"><strong>Mirada Humana.</strong> Bienestar de quien cuida y de quien es cuidado. <em>Estos indicadores dialogan con agendas de UNICEF, UNFPA y la Convención de Derechos de PcD; no son indicadores oficiales de esos organismos, son lecturas propias del instrumento.</em></div>
|
||||
<div class="grid">
|
||||
<div class="card c4 star">
|
||||
<h3><i class="ti ti-battery-2"></i> Agotamiento del cuidador</h3>
|
||||
<div class="qref">7.1 + 7.4 · sobrecarga</div>
|
||||
<div class="big danger">37%</div>
|
||||
<div class="big-sub">descansa muy poco y el agotamiento afecta su desempeño</div>
|
||||
</div>
|
||||
<div class="card c8">
|
||||
<h3>Aspectos de vida sacrificados por cuidar</h3>
|
||||
<div class="qref">7.4 · qué pierde la persona</div>
|
||||
<div class="bar-row"><span class="lab">Tiempo libre</span><div class="bar-track"><div class="bar-fill rose" style="width:71%"></div></div><span class="val">71%</span></div>
|
||||
<div class="bar-row"><span class="lab">Sueño y descanso</span><div class="bar-track"><div class="bar-fill rose" style="width:58%"></div></div><span class="val">58%</span></div>
|
||||
<div class="bar-row"><span class="lab">Salud emocional</span><div class="bar-track"><div class="bar-fill amber" style="width:49%"></div></div><span class="val">49%</span></div>
|
||||
<div class="bar-row"><span class="lab">Vida social</span><div class="bar-track"><div class="bar-fill amber" style="width:44%"></div></div><span class="val">44%</span></div>
|
||||
<div class="bar-row"><span class="lab">Desarrollo profesional</span><div class="bar-track"><div class="bar-fill" style="width:33%"></div></div><span class="val">33%</span></div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Soledad del cuidado</h3>
|
||||
<div class="qref">5.3 · cuida sin red</div>
|
||||
<div class="big warn">21%</div>
|
||||
<div class="big-sub">no tiene quién lo releve mientras trabaja</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Cuidado permanente</h3>
|
||||
<div class="qref">6.4 · guardia sin horario</div>
|
||||
<div class="big warn">28%</div>
|
||||
<div class="big-sub">debe estar disponible de forma permanente</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Inclusión laboral de PcD</h3>
|
||||
<div class="qref">3.1 · discapacidad propia</div>
|
||||
<div class="big">9%</div>
|
||||
<div class="big-sub">de la plantilla tiene discapacidad propia</div>
|
||||
</div>
|
||||
<div class="card c4">
|
||||
<h3>Disposición solidaria</h3>
|
||||
<div class="qref">9.3 + 9.4 · voluntariado</div>
|
||||
<div class="big">43%</div>
|
||||
<div class="big-sub">participaría en redes de apoyo o voluntariado</div>
|
||||
</div>
|
||||
<div class="card c12 req">
|
||||
<h3><i class="ti ti-circle-plus"></i> Escolarización de niñez cuidada — requiere nueva pregunta</h3>
|
||||
<div class="qref">propuesta: 4.4 (instance_of 4.1, condicional a vínculo niño)</div>
|
||||
<div class="big-sub" style="color:#f0975a">Para la lectura que dialoga con UNICEF/UNESCO sobre educación inclusiva, faltaría capturar si la niñez cuidada está escolarizada y enfrenta barreras de aprendizaje. La arquitectura de instancias ya existente lo soporta: sería una pregunta de instancia que aparece solo cuando el vínculo es un niño/a. Sugerencia para validar con el equipo.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="k">k≥5 aplicado · datos sensibles agregados</div>
|
||||
</div>
|
||||
|
||||
<div class="footnote">
|
||||
<strong>Nota de honestidad metodológica.</strong> Todos los números son datos de ejemplo ilustrativos, NO datos reales. Cada métrica está anclada a preguntas reales del instrumento v3 (referencia bajo cada título) y es construible con queries sobre la tabla <code>answers</code>, respetando k≥5. Las métricas marcadas en naranja <strong>requieren agregar una pregunta</strong> al instrumento. Los marcos internacionales (OIT 5R, CEPAL, PNCUPA, UNICEF/UNFPA) se citan como referencia conceptual; el alineamiento formal con cualquier organismo debe validarlo el equipo. Propuesta para discusión, no especificación final.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showLens(id, el){
|
||||
document.querySelectorAll('.lens-content').forEach(c=>c.style.display='none');
|
||||
document.getElementById(id).style.display='block';
|
||||
document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
971
docs/dashboard/seed_prueba_50.sql
Normal file
971
docs/dashboard/seed_prueba_50.sql
Normal file
@@ -0,0 +1,971 @@
|
||||
-- SEED DE PRUEBA — 50 respuestas + modalidad (2.7). Determinista seed=42.
|
||||
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
|
||||
--
|
||||
-- Resuelve los question_id por code DENTRO de un bloque DO $$ — nunca
|
||||
-- hardcodeados. Antes este archivo tenía los UUIDs literales y se
|
||||
-- desactualizaba cada vez que la DB se reinicializaba (gen_random_uuid()
|
||||
-- en supabase/seed.sql genera UUIDs nuevos en cada corrida). Con esto,
|
||||
-- el seed funciona contra cualquier instancia sin importar cuándo se
|
||||
-- recreó, siempre que el catálogo de preguntas (supabase/seed.sql) ya
|
||||
-- esté aplicado.
|
||||
--
|
||||
-- Idempotente: limpia SOLO las filas que este script genera (ip_hash con el
|
||||
-- prefijo 'seed-prueba-'), no un TRUNCATE de las tablas completas — answers/
|
||||
-- responses/consent_records pueden tener datos reales de otras encuestas
|
||||
-- (InQuality, Fundación Solidaridad) que este script no debe tocar.
|
||||
|
||||
-- Único bloque DO $$ — sin statements sueltos antes/después que psql
|
||||
-- pueda seguir ejecutando como filas independientes si algo de acá adentro
|
||||
-- falla. Si un PERFORM revienta, todo el bloque aborta y se ve UN error,
|
||||
-- no cincuenta.
|
||||
DO $$
|
||||
DECLARE
|
||||
s_id uuid := '30000000-0000-0000-0000-000000000001';
|
||||
q_2_7 uuid;
|
||||
q_4_1 uuid;
|
||||
q_6_1 uuid;
|
||||
q_6_2 uuid;
|
||||
q_6_4 uuid;
|
||||
q_7_2 uuid;
|
||||
q_7_3 uuid;
|
||||
q_7_5 uuid;
|
||||
q_7_6 uuid;
|
||||
q_9_1 uuid;
|
||||
q_9_2 uuid;
|
||||
q_9_3 uuid;
|
||||
q_9_4 uuid;
|
||||
BEGIN
|
||||
-- Resolver UUIDs de questions por code, en el momento de ejecutarse.
|
||||
SELECT id INTO q_2_7 FROM public.questions
|
||||
WHERE code = '2.7' AND survey_id = s_id;
|
||||
SELECT id INTO q_4_1 FROM public.questions
|
||||
WHERE code = '4.1' AND survey_id = s_id;
|
||||
SELECT id INTO q_6_1 FROM public.questions
|
||||
WHERE code = '6.1' AND survey_id = s_id;
|
||||
SELECT id INTO q_6_2 FROM public.questions
|
||||
WHERE code = '6.2' AND survey_id = s_id;
|
||||
SELECT id INTO q_6_4 FROM public.questions
|
||||
WHERE code = '6.4' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_2 FROM public.questions
|
||||
WHERE code = '7.2' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_3 FROM public.questions
|
||||
WHERE code = '7.3' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_5 FROM public.questions
|
||||
WHERE code = '7.5' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_6 FROM public.questions
|
||||
WHERE code = '7.6' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_1 FROM public.questions
|
||||
WHERE code = '9.1' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_2 FROM public.questions
|
||||
WHERE code = '9.2' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_3 FROM public.questions
|
||||
WHERE code = '9.3' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_4 FROM public.questions
|
||||
WHERE code = '9.4' AND survey_id = s_id;
|
||||
|
||||
-- Si alguna no se resolvió, frenar acá con un mensaje claro en vez de
|
||||
-- dejar que rpc_submit_response falle más abajo con invalid_answer_reference.
|
||||
IF q_2_7 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '2.7';
|
||||
END IF;
|
||||
IF q_4_1 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '4.1';
|
||||
END IF;
|
||||
IF q_6_1 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '6.1';
|
||||
END IF;
|
||||
IF q_6_2 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '6.2';
|
||||
END IF;
|
||||
IF q_6_4 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '6.4';
|
||||
END IF;
|
||||
IF q_7_2 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.2';
|
||||
END IF;
|
||||
IF q_7_3 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.3';
|
||||
END IF;
|
||||
IF q_7_5 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.5';
|
||||
END IF;
|
||||
IF q_7_6 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.6';
|
||||
END IF;
|
||||
IF q_9_1 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.1';
|
||||
END IF;
|
||||
IF q_9_2 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.2';
|
||||
END IF;
|
||||
IF q_9_3 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.3';
|
||||
END IF;
|
||||
IF q_9_4 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.4';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.responses
|
||||
WHERE survey_id = s_id
|
||||
AND ip_hash LIKE 'seed-prueba-%';
|
||||
-- answers cae por ON DELETE CASCADE (responses.id). consent_records de
|
||||
-- estas filas queda huérfano a propósito — es evidencia de consentimiento
|
||||
-- (ADR-003), no se borra aunque la response que lo usaba se borre.
|
||||
|
||||
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||
|
||||
-- seed-prueba-000
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-000', '55 años o más', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-001
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-001', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-002
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-002', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-003
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-003', '25 a 34 años', 'Gerencial', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-004
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-004', '55 años o más', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-005
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-005', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-006
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-006', '25 a 34 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar', 'Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Redes de apoyo / grupos de pares', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-007
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-007', '35 a 44 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-008
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-008', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-009
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-009', 'Menos de 25 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Información sobre recursos y servicios', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-010
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-011
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-011', '35 a 44 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-012
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-012', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-013
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-013', '25 a 34 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-014
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-015
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-016
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-016', '25 a 34 años', 'Directivo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-017
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-017', '55 años o más', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-018
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-019
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-019', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-020
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-020', '55 años o más', 'Jefatura', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado', 'Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-021
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-021', '45 a 54 años', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-022
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-023
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-023', '25 a 34 años', 'Operativo', 'Itapúa',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-024
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-025
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-026
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-026', '25 a 34 años', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Rechazar un ascenso o nueva responsabilidad']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-027
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-027', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-028
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-028', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-029
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-029', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-030
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-030', '35 a 44 años', 'Operativo', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-031
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-032
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-033
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-034
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-034', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-035
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-035', '45 a 54 años', 'Jefatura', 'Itapúa',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-036
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-036', '25 a 34 años', 'Jefatura', 'Itapúa',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-037
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-037', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-038
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-038', '35 a 44 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-039
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-040
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-040', '45 a 54 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-041
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-042
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-042', '25 a 34 años', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-043
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-043', 'Menos de 25 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-044
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-044', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-045
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-045', '25 a 34 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-046
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-046', '25 a 34 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-047
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-047', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-048
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-048', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-049
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
|
||||
-- Survey queda en 'live' (ya lo estaba desde el UPDATE de arriba; este
|
||||
-- segundo UPDATE es explícito para que quede claro al releer el script,
|
||||
-- no depende de que nada de arriba lo haya dejado sin tocar).
|
||||
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||
END $$;
|
||||
|
||||
-- Para limpiar manualmente este lote de prueba sin tocar otras encuestas:
|
||||
-- DELETE FROM public.responses WHERE survey_id = '30000000-0000-0000-0000-000000000001' AND ip_hash LIKE 'seed-prueba-%';
|
||||
975
docs/dashboard/seed_prueba_fundacion_solidaridad.sql
Normal file
975
docs/dashboard/seed_prueba_fundacion_solidaridad.sql
Normal file
@@ -0,0 +1,975 @@
|
||||
-- SEED DE PRUEBA — Fundación Solidaridad — 50 respuestas + modalidad (2.7).
|
||||
-- Clon de docs/dashboard/seed_prueba_50.sql (mismo patrón, mismas distribuciones
|
||||
-- determinísticas seed=42) apuntado a survey_id de Fundación Solidaridad
|
||||
-- ('30000000-0000-0000-0000-000000000003') en vez de Empresa Demo.
|
||||
-- DATOS SINTÉTICOS — no son respuestas reales de cuidadores de la Fundación.
|
||||
-- E1=72.0% E2=38.9% E3=36.1% E4=41.7% · estrella umbral2=30.6% umbral1=88.9%
|
||||
--
|
||||
-- Resuelve los question_id por code DENTRO de un bloque DO $$ — nunca
|
||||
-- hardcodeados. Antes este archivo tenía los UUIDs literales y se
|
||||
-- desactualizaba cada vez que la DB se reinicializaba (gen_random_uuid()
|
||||
-- en supabase/seed.sql genera UUIDs nuevos en cada corrida). Con esto,
|
||||
-- el seed funciona contra cualquier instancia sin importar cuándo se
|
||||
-- recreó, siempre que el catálogo de preguntas (supabase/seed.sql) ya
|
||||
-- esté aplicado.
|
||||
--
|
||||
-- Idempotente: limpia SOLO las filas que este script genera (ip_hash con el
|
||||
-- prefijo 'seed-prueba-fs-'), no un TRUNCATE de las tablas completas — answers/
|
||||
-- responses/consent_records pueden tener datos reales de otras encuestas
|
||||
-- (Empresa Demo, InQuality) que este script no debe tocar.
|
||||
|
||||
-- Único bloque DO $$ — sin statements sueltos antes/después que psql
|
||||
-- pueda seguir ejecutando como filas independientes si algo de acá adentro
|
||||
-- falla. Si un PERFORM revienta, todo el bloque aborta y se ve UN error,
|
||||
-- no cincuenta.
|
||||
DO $$
|
||||
DECLARE
|
||||
s_id uuid := '30000000-0000-0000-0000-000000000003';
|
||||
q_2_7 uuid;
|
||||
q_4_1 uuid;
|
||||
q_6_1 uuid;
|
||||
q_6_2 uuid;
|
||||
q_6_4 uuid;
|
||||
q_7_2 uuid;
|
||||
q_7_3 uuid;
|
||||
q_7_5 uuid;
|
||||
q_7_6 uuid;
|
||||
q_9_1 uuid;
|
||||
q_9_2 uuid;
|
||||
q_9_3 uuid;
|
||||
q_9_4 uuid;
|
||||
BEGIN
|
||||
-- Resolver UUIDs de questions por code, en el momento de ejecutarse.
|
||||
SELECT id INTO q_2_7 FROM public.questions
|
||||
WHERE code = '2.7' AND survey_id = s_id;
|
||||
SELECT id INTO q_4_1 FROM public.questions
|
||||
WHERE code = '4.1' AND survey_id = s_id;
|
||||
SELECT id INTO q_6_1 FROM public.questions
|
||||
WHERE code = '6.1' AND survey_id = s_id;
|
||||
SELECT id INTO q_6_2 FROM public.questions
|
||||
WHERE code = '6.2' AND survey_id = s_id;
|
||||
SELECT id INTO q_6_4 FROM public.questions
|
||||
WHERE code = '6.4' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_2 FROM public.questions
|
||||
WHERE code = '7.2' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_3 FROM public.questions
|
||||
WHERE code = '7.3' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_5 FROM public.questions
|
||||
WHERE code = '7.5' AND survey_id = s_id;
|
||||
SELECT id INTO q_7_6 FROM public.questions
|
||||
WHERE code = '7.6' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_1 FROM public.questions
|
||||
WHERE code = '9.1' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_2 FROM public.questions
|
||||
WHERE code = '9.2' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_3 FROM public.questions
|
||||
WHERE code = '9.3' AND survey_id = s_id;
|
||||
SELECT id INTO q_9_4 FROM public.questions
|
||||
WHERE code = '9.4' AND survey_id = s_id;
|
||||
|
||||
-- Si alguna no se resolvió, frenar acá con un mensaje claro en vez de
|
||||
-- dejar que rpc_submit_response falle más abajo con invalid_answer_reference.
|
||||
IF q_2_7 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '2.7';
|
||||
END IF;
|
||||
IF q_4_1 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '4.1';
|
||||
END IF;
|
||||
IF q_6_1 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '6.1';
|
||||
END IF;
|
||||
IF q_6_2 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '6.2';
|
||||
END IF;
|
||||
IF q_6_4 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '6.4';
|
||||
END IF;
|
||||
IF q_7_2 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.2';
|
||||
END IF;
|
||||
IF q_7_3 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.3';
|
||||
END IF;
|
||||
IF q_7_5 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.5';
|
||||
END IF;
|
||||
IF q_7_6 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '7.6';
|
||||
END IF;
|
||||
IF q_9_1 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.1';
|
||||
END IF;
|
||||
IF q_9_2 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.2';
|
||||
END IF;
|
||||
IF q_9_3 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.3';
|
||||
END IF;
|
||||
IF q_9_4 IS NULL THEN
|
||||
RAISE EXCEPTION 'UUID no encontrado para code %', '9.4';
|
||||
END IF;
|
||||
|
||||
DELETE FROM public.responses
|
||||
WHERE survey_id = s_id
|
||||
AND ip_hash LIKE 'seed-prueba-fs-%';
|
||||
-- answers cae por ON DELETE CASCADE (responses.id). consent_records de
|
||||
-- estas filas queda huérfano a propósito — es evidencia de consentimiento
|
||||
-- (ADR-003), no se borra aunque la response que lo usaba se borre.
|
||||
|
||||
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||
|
||||
-- seed-prueba-fs-000
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-000', '55 años o más', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-001
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-001', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-002
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-002', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-003
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-003', '25 a 34 años', 'Gerencial', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-004
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-004', '55 años o más', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-005
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-005', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-006
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-006', '25 a 34 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar', 'Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Redes de apoyo / grupos de pares', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-007
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-007', '35 a 44 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-008
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-008', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-009
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-009', 'Menos de 25 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Información sobre recursos y servicios', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-010
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-010', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-011
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-011', '35 a 44 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-012
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-012', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-013
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-013', '25 a 34 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-014
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-014', '35 a 44 años', 'Gerencial', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-015
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-015', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-016
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-016', '25 a 34 años', 'Directivo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-017
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-017', '55 años o más', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-018
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-018', '45 a 54 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-019
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-019', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-020
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-020', '55 años o más', 'Jefatura', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad', 'Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Nunca o casi nunca'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado', 'Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-021
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-021', '45 a 54 años', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares', 'Acceso a beneficios para terapias o salud']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-022
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-022', '45 a 54 años', 'Jefatura', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-023
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-023', '25 a 34 años', 'Operativo', 'Itapúa',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Información sobre recursos y servicios']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-024
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-024', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado', 'Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-025
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-025', '25 a 34 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'De forma permanente / sin horario'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-026
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-026', '25 a 34 años', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Rechazar un ascenso o nueva responsabilidad']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-027
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-027', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Sí, definitivamente'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-028
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-028', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Familiar menor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Algo probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Parcialmente, hay cierta comprensión'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Acceso a beneficios para terapias o salud', 'Flexibilidad horaria o trabajo remoto']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-029
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-029', '55 años o más', 'Auxiliar', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Reducir tu jornada o pasar a medio tiempo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Flexibilidad horaria o trabajo remoto', 'Capacitación y autocuidado', 'Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-030
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-030', '35 a 44 años', 'Operativo', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Otro / Especificar']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Faltar o llegar tarde de forma reiterada']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-031
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-031', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Tal vez, si hay más personas en la misma situación'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Dejar un trabajo anterior']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-032
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-032', '25 a 34 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar mayor con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', 'Todos los días'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', 'Más de 6 horas'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Pocas veces (1–3 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Muy probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Sí, me siento acompañado/a'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos', 'Contención/apoyo emocional o psicológico']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-033
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-033', 'Menos de 25 años', 'Auxiliar', 'Alto Paraná',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Hijo menor con discapacidad', 'Hijo adulto con discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Casi todos los días'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Varias veces (4–10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'Nunca hemos tocado el tema'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'Prefiero mantenerlo en privado'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Declinar viajes, horas extra o capacitaciones']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Apoyo económico o convenios/descuentos']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-034
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-034', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Familiar menor con discapacidad', 'Hijo menor sin discapacidad']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Algunas veces al mes'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Nunca'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Poco probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'No'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Pensar seriamente en dejar el trabajo']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Contención/apoyo emocional o psicológico', 'Redes de apoyo / grupos de pares']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-035
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-035', '45 a 54 años', 'Jefatura', 'Itapúa',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Padre/madre con movilidad reducida o 100% dependiente']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial'),
|
||||
jsonb_build_object('question_id', q_6_1, 'instance_id', 'default', 'value', '3–4'),
|
||||
jsonb_build_object('question_id', q_6_2, 'instance_id', 'default', 'value', '5–6'),
|
||||
jsonb_build_object('question_id', q_6_4, 'instance_id', 'default', 'value', '1–2'),
|
||||
jsonb_build_object('question_id', q_7_2, 'instance_id', 'default', 'value', 'Varias veces por semana'),
|
||||
jsonb_build_object('question_id', q_7_3, 'instance_id', 'default', 'value', 'Con frecuencia (más de 10 al año)'),
|
||||
jsonb_build_object('question_id', q_7_6, 'instance_id', 'default', 'value', 'Nada probable'),
|
||||
jsonb_build_object('question_id', q_9_1, 'instance_id', 'default', 'value', 'No, es algo bastante invisible'),
|
||||
jsonb_build_object('question_id', q_9_3, 'instance_id', 'default', 'value', 'No por ahora'),
|
||||
jsonb_build_object('question_id', q_9_4, 'instance_id', 'default', 'value', 'Sí'),
|
||||
jsonb_build_object('question_id', q_7_5, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores']::text[]),
|
||||
jsonb_build_object('question_id', q_9_2, 'instance_id', 'default', 'value', ARRAY['Redes de apoyo / grupos de pares', 'Capacitación y autocuidado']::text[])
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-036
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-036', '25 a 34 años', 'Jefatura', 'Itapúa',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-037
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-037', '35 a 44 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-038
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-038', '35 a 44 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-039
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-039', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-040
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-040', '45 a 54 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Híbrido')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-041
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-041', '35 a 44 años', 'Operativo', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-042
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-042', '25 a 34 años', 'Jefatura', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-043
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-043', 'Menos de 25 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-044
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-044', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-045
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-045', '25 a 34 años', 'Auxiliar', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-046
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-046', '25 a 34 años', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-047
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-047', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Presencial')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-048
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-048', '55 años o más', 'Operativo', 'Central',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
-- seed-prueba-fs-049
|
||||
PERFORM public.rpc_submit_response(
|
||||
s_id, 'seed-prueba-fs-049', '35 a 44 años', 'Auxiliar', 'Asunción (Capital)',
|
||||
true, false,
|
||||
jsonb_build_array(
|
||||
jsonb_build_object('question_id', q_4_1, 'instance_id', 'default', 'value', ARRAY['Ninguna de las anteriores / No tengo vinculación de cuidado']::text[]),
|
||||
jsonb_build_object('question_id', q_2_7, 'instance_id', 'default', 'value', 'Remoto (home office)')
|
||||
)
|
||||
);
|
||||
|
||||
-- Survey queda en 'live' (ya lo estaba desde el UPDATE de arriba; este
|
||||
-- segundo UPDATE es explícito para que quede claro al releer el script,
|
||||
-- no depende de que nada de arriba lo haya dejado sin tocar).
|
||||
UPDATE surveys SET status='live' WHERE id = s_id;
|
||||
END $$;
|
||||
|
||||
-- Para limpiar manualmente este lote de prueba sin tocar otras encuestas:
|
||||
-- DELETE FROM public.responses WHERE survey_id = '30000000-0000-0000-0000-000000000003' AND ip_hash LIKE 'seed-prueba-fs-%';
|
||||
169
docs/estado/ESTADO_PROYECTO_26JUN2026.md
Normal file
169
docs/estado/ESTADO_PROYECTO_26JUN2026.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
||||
## Documento de continuidad — 26 junio 2026
|
||||
|
||||
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
|
||||
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
||||
|
||||
---
|
||||
|
||||
## 1. Versionado y ramas
|
||||
- **Producción:** **3.0.0** — wizard v3 + dashboard tres miradas desplegado · tag `v3.0.0` · commit `fcf93ea`
|
||||
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||
- **3 surveys en `live`** — reactivados al desplegar v3 (26 jun 2026)
|
||||
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprint 4 — Wizard v3 — COMPLETO Y EN PRODUCCIÓN
|
||||
|
||||
| Etapa | Estado |
|
||||
|---|---|
|
||||
| 4.0 Preservación v2 | ✅ |
|
||||
| 4A instance_id | ✅ |
|
||||
| 4B esquema declarativo + seed v3 | ✅ |
|
||||
| 4C motor de flujo (`lib/form-engine.ts`, puro, 20+ tests) | ✅ |
|
||||
| 4D wizard renderer (layout estable, header/footer fijos) | ✅ |
|
||||
| 4E instancias familiares (mini-wizard emergente del motor) | ✅ |
|
||||
| 4F autocomplete barrios + filtro ciudad por departamento | ✅ |
|
||||
| 4G submit + validación server-side (RPC `rpc_submit_response`) | ✅ |
|
||||
| 4I validación e2e + tag v3.0.0 + deploy + surveys live | ✅ |
|
||||
|
||||
### Decisiones arquitectónicas clave (no reabrir)
|
||||
- **ADR-005:** lógica de flujo en datos (esquema), nunca en frontend.
|
||||
- Motor puro en cliente; reusado server-side para validación de submit.
|
||||
- Único camino de escritura: RPC `rpc_submit_response` (SECURITY DEFINER).
|
||||
- `instance_id` por answer — instancias familiares persistidas correctamente.
|
||||
- Validación server-side: 7 checks (preguntas visibles, valores válidos, instancias legítimas,
|
||||
consentimiento, requeridas presentes). Rechaza el submit completo si algo falla.
|
||||
|
||||
### Autocomplete geo (4F)
|
||||
- `public/data/geo_paraguay_full.json` — 8152 barrios, jerarquía depto → ciudad → barrios.
|
||||
- Pregunta 2.3 (barrio): autocomplete con `position: fixed` + `getBoundingClientRect()`.
|
||||
- Pregunta 2.2 (ciudad): filtra por departamento (2.1) usando el mismo JSON. Limpia valor
|
||||
al cambiar departamento. Texto libre siempre permitido como fallback.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dashboard v3 — Mirada Empresa en producción
|
||||
|
||||
**Decisión vigente:** NO migrar RPCs v2. Dashboard nuevo de tres miradas
|
||||
(OIT 5R, CEPAL, PNCUPA Paraguay).
|
||||
|
||||
### Estado
|
||||
- **Mirada Empresa:** ✅ COMPLETA EN PRODUCCIÓN
|
||||
- 11 RPCs (`rpc_me_*`) + helper `_dash_es_cuidador` verificados contra 50 respuestas reales.
|
||||
- 9 widgets funcionando: Talento en riesgo, Prevalencia, Punto ciego, Señales de impacto
|
||||
(presentismo 41.7%, ausentismo 36.1%, riesgo 38.9%, carrera congelada 47.2%),
|
||||
Apoyos demandados (ordenados por demanda), Segmentación (nivel y modalidad),
|
||||
Disposición a red de apoyo.
|
||||
- Tema claro marca Re: fondo `#E7E6E5`, cards `#FFFFFF`, texto `#494F4F`.
|
||||
- Migraciones aplicadas: `20260625000001` (fix `_dash_responses`) +
|
||||
`20260625000002` (11 RPCs) + `20260626000001` (fix bug `cnt` Opción B).
|
||||
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA
|
||||
a sprint de dashboard post-deadline.
|
||||
|
||||
### Bug conocido abierto
|
||||
- `rpc_me_carrera_congelada`: "Ninguna de las anteriores" aparece en `top_opciones`
|
||||
a pesar del filtro en el subquery. El filtro opera sobre el array completo, no sobre
|
||||
los elementos individuales del `jsonb_array_elements_text`. No bloqueante para el
|
||||
release — registrado para próximo sprint.
|
||||
|
||||
### Documentos (en `docs/dashboard/`)
|
||||
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados
|
||||
- `dashboard_tres_miradas_mockup.html` — mockup funcional 3 pestañas
|
||||
- `SPEC_MIRADA_EMPRESA.md` — contrato técnico 10 métricas + estrella
|
||||
|
||||
### Decisiones del dashboard (no reabrir)
|
||||
- CUIDADOR_ACTIVO incluye "hijo menor sin discapacidad" (criterio UNFPA, validado por Maria jun 2026).
|
||||
- Mirada Empresa NO expone datos sensibles (3.x, 4.2, 4.3).
|
||||
- k-anonimato k≥5 sobre `count(DISTINCT response_id)` — también en métricas por instancia.
|
||||
- 2 gaps de datos para sprint dashboard: horas cuidado no remunerado (País),
|
||||
escolarización niñez (Humana).
|
||||
|
||||
---
|
||||
|
||||
## 4. Datos de prueba en VPS
|
||||
- **51 respuestas** en survey Demo (`30000000-0000-0000-0000-000000000001`) — 50 del seed
|
||||
determinista (seed=42) + 1 del e2e de 4I.
|
||||
- Survey Demo en `live`. Datos NO limpiados — el director los necesita para mostrar al equipo.
|
||||
- Script de limpieza cuando corresponda:
|
||||
```bash
|
||||
docker exec supabase-db psql -U postgres -d postgres -c "
|
||||
TRUNCATE answers, responses, consent_records CASCADE;
|
||||
UPDATE surveys SET status='draft' WHERE id='30000000-0000-0000-0000-000000000001';"
|
||||
```
|
||||
- ⚠️ El seed corrió dos veces en sesión anterior (detectado y corregido). Agregar guard
|
||||
anti-duplicación al `seed_prueba_50.sql` — anotado en BACKLOG.
|
||||
|
||||
---
|
||||
|
||||
## 5. Deuda técnica y seguridad
|
||||
|
||||
| ID | Descripción | Prioridad |
|
||||
|---|---|---|
|
||||
| BUG-001 | "Ninguna de las anteriores" aparece en `rpc_me_carrera_congelada` | Próximo sprint dashboard |
|
||||
| TECH-DEBT-006 | Backup remoto (S3 o VPS alternativo o Google Drive) | Antes de datos reales no desechables |
|
||||
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales no desechables |
|
||||
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de `qi_modalidad` | Baja |
|
||||
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
|
||||
| SEC-003 | TRUNCATE evade inmutabilidad ADR-003 | Antes de datos reales no desechables |
|
||||
| SEC-004 | RPC bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
|
||||
| BACKLOG-001 | `seed_prueba_50.sql` idempotente (guard anti-duplicación) | Baja |
|
||||
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline |
|
||||
|
||||
**Riesgo aceptado explícitamente (26 jun 2026):** TECH-DEBT-006, TECH-DEBT-009 y SEC-003
|
||||
aplican cuando haya datos reales no desechables. Los datos actuales son de empresas voluntarias
|
||||
y grupos de control — desechables si es necesario.
|
||||
|
||||
---
|
||||
|
||||
## 6. Infraestructura
|
||||
|
||||
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
|
||||
- App: `economia-cuidado.inqualityhq.com` · API: `db.inqualityhq.com` (Kong + SSL)
|
||||
- Repo: `git.inquality.com.py/marcos/motor-de-encuestas` (rama `main`)
|
||||
- Repo local: `/Users/marcos/InQ/Motor de encuestas`
|
||||
- Migraciones SIEMPRE vía CLI del VPS:
|
||||
`docker exec -i supabase-db psql -U postgres -d postgres < archivo.sql`
|
||||
- Deploy: Dokploy → Rebuild + "Clean cache"
|
||||
- Anon key Kong (diagnóstico):
|
||||
`eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjIwMDAwMDAwMDB9.6D79NWu1KfeOzEZXT6Amf-NmG0T9uk8SK9xNd32oLaY`
|
||||
|
||||
---
|
||||
|
||||
## 7. Contexto comercial
|
||||
- 2 clientes inminentes. Mirada Empresa es la que le habla al cliente que paga.
|
||||
- InQuality y Fundación Solidaridad en joint venture.
|
||||
- Datos actuales: empresas voluntarias y grupos de control (desechables).
|
||||
- Datos reales no desechables: avisará el director — en ese momento activar deuda de seguridad.
|
||||
|
||||
---
|
||||
|
||||
## 8. Próximos sprints sugeridos
|
||||
|
||||
1. **Sprint dashboard** — Miradas País y Humana, fix BUG-001 carrera congelada,
|
||||
preguntas faltantes (horas no remuneradas, escolarización), decisión `qi_modalidad`.
|
||||
2. **Sprint compliance** — SEC-002 (audit log, hashing, cifrado), SEC-003, TECH-DEBT-006/009.
|
||||
3. **Sprint motor genérico** — BACKLOG-002 Fase 2, extracción del intérprete.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prompt para nueva sesión
|
||||
|
||||
```
|
||||
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de formularios
|
||||
soberano (Paraguay). Yo soy el product owner (Marcos).
|
||||
Claude Code (VS Code, contexto separado) hace la implementación; vos planificás,
|
||||
especificás y auditás.
|
||||
|
||||
Estado completo: docs/estado/ESTADO_PROYECTO_26JUN2026.md
|
||||
|
||||
Producción: v3.0.0 desplegada · 3 surveys en live · tag v3.0.0 (commit fcf93ea)
|
||||
Formulario v3: COMPLETO (motor, wizard, instancias, submit server-side, autocomplete geo)
|
||||
Dashboard: Mirada Empresa funcional en producción · País/Humana diferidas
|
||||
Datos en VPS: 51 respuestas en survey Demo (no limpiar — para mostrar al equipo)
|
||||
|
||||
Bug abierto: "Ninguna de las anteriores" en rpc_me_carrera_congelada (no bloqueante)
|
||||
|
||||
Próximo paso: [COMPLETAR]
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
||||
## Documento de continuidad — 20 junio 2026
|
||||
## Documento de continuidad — 27 junio 2026
|
||||
|
||||
> Director (Product Owner): Marcos · Claude (Projects) = arquitecto/copiloto · Claude Code = implementación
|
||||
> Metodología: Maria Mersan · Contacto Fundación: Valeria Arce
|
||||
@@ -7,125 +7,141 @@
|
||||
---
|
||||
|
||||
## 1. Versionado y ramas
|
||||
|
||||
- **Versión en producción:** 2.1.0 (formulario lineal, dashboard multitenante)
|
||||
- **Versión en desarrollo:** 3.0.0 (wizard, en `main`)
|
||||
- **Rama de preservación:** `v2.1.0-stable` (código + dump DB) + tag `v2.1.0`
|
||||
- Convención: SemVer + CHANGELOG.md (raíz) + tags en cada versión estable
|
||||
- ADRs en `docs/adr/`. ADR-005 = esquema declarativo + estrategia dos fases.
|
||||
- **Producción:** 3.0.0 (wizard + dashboard tres miradas) — tag `v3.0.0` pusheado, los 3 surveys
|
||||
en `live`. Release v3 completado (etapa 4I).
|
||||
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprint 4 — Wizard v3 — EN CURSO
|
||||
## 2. Sprint 4 — Wizard v3 — COMPLETO, release hecho
|
||||
|
||||
Arquitectura (ADR-005): el formulario es un **esquema declarativo** (datos); el frontend
|
||||
es un **renderer** sin reglas de negocio; el motor interpreta el esquema.
|
||||
Estrategia dos fases: Fase 1 (este sprint) formulario-como-esquema; Fase 2 (BACKLOG-002)
|
||||
motor genérico universal.
|
||||
| Etapa | Estado |
|
||||
|---|---|
|
||||
| 4.0 Preservación v2 | ✅ |
|
||||
| 4A instance_id | ✅ prod |
|
||||
| 4B esquema declarativo + seed v3 | ✅ prod |
|
||||
| 4C motor de flujo (lib/form-engine.ts, puro) | ✅ |
|
||||
| 4D wizard renderer (layout estable) | ✅ |
|
||||
| 4E instancias (emergente del motor) | ✅ |
|
||||
| 4G submit + validación server-side (RPC transaccional) | ✅ verificado vs Postgres real |
|
||||
| 4F autocomplete barrios (UX) | ✅ |
|
||||
| Widgets Mirada Empresa (frontend) | ✅ |
|
||||
| Tema claro dashboard (marca Re) | ✅ |
|
||||
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ✅ |
|
||||
| ETAPA_AUTH — login/middleware/sign-out para `/admin` y `/dashboard` | ✅ (27 jun) |
|
||||
|
||||
### Etapas
|
||||
| Etapa | Nombre | Estado |
|
||||
|---|---|---|
|
||||
| 4.0 | Preservación v2 (tag, rama, dump, CHANGELOG) | ✅ |
|
||||
| 4A | Persistencia `instance_id` en answers | ✅ aplicada en prod |
|
||||
| 4B | Esquema declarativo + seed v3 (10 secciones, 150 questions) | ✅ aplicada en prod |
|
||||
| 4C | Motor de resolución de flujo (puro, lib/, 16 tests) | ✅ commiteada |
|
||||
| 4D | Frontend renderer (consume el motor, una preg/pantalla) | ⏳ siguiente |
|
||||
| 4E | Instancias de vínculo (mini-wizard familiares) | pendiente |
|
||||
| 4F | Autocomplete barrios (requiere geo_paraguay_full.json) | pendiente |
|
||||
| 4G | Submit con instance_id + **validación server-side (no negociable)** | pendiente |
|
||||
| 4H | RPCs de vínculo (agregados por instancia) | pendiente |
|
||||
| 4I | Validación e2e + tag v3.0.0 + ADR + BACKLOG Fase 2 | pendiente |
|
||||
|
||||
Orden: 4D → (4E ∥ 4F) → 4G → 4H → 4I
|
||||
|
||||
### Estado técnico v3 en la DB (ya aplicado en prod)
|
||||
- `answers.instance_id text DEFAULT 'default'`, constraint `(response_id, question_id, instance_id)`
|
||||
- `questions` extendida: `instance_generator`, `instance_of`, `hint`
|
||||
- 150 questions (50 × 3 surveys), instrumento v3 de 10 secciones
|
||||
- 4.1 = `instance_generator`; 4.2/4.3 = `instance_of='4.1'`
|
||||
- Datos de prueba v2 borrados (TRUNCATE); v3 arranca limpio
|
||||
|
||||
### Motor de flujo (4C) — lib/form-engine.ts
|
||||
- Módulo puro, sin React/DOM. Tests en `lib/form-engine.test.mts` (16/16).
|
||||
- Operadores: `eq`, `eq_any`, `not_in` (escalares); `contains`, `eq_sole` (array multiple_choice)
|
||||
- Acciones: `show`, `hide` (hide = veto absoluto sobre show del mismo array)
|
||||
- Reglas múltiples en un array = OR
|
||||
- ID de instancia: `familiar_${posición fija de opción}` (determinista, estable)
|
||||
- Secciones = pantallas propias; se ocultan solo si su fila type='section' tiene regla
|
||||
|
||||
### Recorridos del formulario (validados en mockup y tests)
|
||||
- Completo: todos los vínculos → todas las secciones
|
||||
- Solo niño sin discapacidad (`eq_sole` op 1): liviano → 5.3, 8.4, 9.1, 9.4
|
||||
- Ninguna (`contains` op 8): salta a Sección 10
|
||||
- 3.1='No': oculta 3.2-3.5
|
||||
El formulario v3 funciona end-to-end: motor, wizard, instancias, submit con validación
|
||||
server-side (RPC `rpc_submit_response`, único camino de escritura, verificado). Dashboard
|
||||
Mirada Empresa con sus 13 widgets, consumiendo las RPCs `rpc_me_*` reales. `/admin/responses`
|
||||
y `/dashboard` ahora requieren sesión autenticada (antes sin auth — cierra TECH-DEBT-008).
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisiones arquitectónicas clave (no reabrir)
|
||||
## 2.1 Incidente VPS (27 jun) — recuperado
|
||||
|
||||
1. Lógica de flujo en datos (esquema), no en frontend (ADR-005)
|
||||
2. Frontend = renderer sin reglas de negocio
|
||||
3. Motor de flujo (4C) corre en cliente, módulo puro testeable
|
||||
4. **Validación de integridad del submit = server-side, NO negociable (4G)**
|
||||
La validación de cliente es UX, no seguridad. Datos de salud sensibles.
|
||||
5. Secciones como pantallas propias (reversible — es comportamiento del motor)
|
||||
6. Se extiende `questions`, no se crea tabla de definición (ADR-005)
|
||||
7. Migraciones vía CLI del VPS (`docker exec -i supabase-db psql`)
|
||||
8. consent_records inmutable (ADR-003); el seed nunca toca datos de respuesta
|
||||
Una segunda instancia de Dokploy desplegada desde el mismo repo (con `container_name` fijos en
|
||||
docker-compose.yml) colisionó con la producción y vació el esquema `public` de `supabase-db`.
|
||||
**Sin pérdida de datos reales** (solo seed demo). Se restauró: migraciones completas + seed +
|
||||
surveys reactivados a `live` + usuario `platform_admin` creado. Verificado end-to-end: login,
|
||||
sign-out, redirect sin sesión, formulario público (3 links `?s=<survey_id>`) — todo OK.
|
||||
Detalle completo en memoria `project_vps_container_name_incident`. El director ya separó la
|
||||
segunda instancia a un repo Gitea propio sin `container_name` fijos, para que no se repita.
|
||||
|
||||
---
|
||||
|
||||
## 3. Dashboard v3 — "tres miradas"
|
||||
|
||||
**Decisión:** NO migrar las RPCs v2 (referencian códigos inexistentes en v3). En su lugar,
|
||||
dashboard nuevo de tres miradas, fundamentado en marcos reales (OIT 5R, CEPAL, PNCUPA Paraguay).
|
||||
|
||||
### Estado
|
||||
- **Mirada Empresa:** ✅ RPCs + widgets de frontend completos y en producción. E1-E10 + estrella
|
||||
"Talento en riesgo", k≥5 en segmentación. Tema claro aplicado.
|
||||
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
|
||||
sprint de dashboard post-deadline (sin cambios desde el último estado).
|
||||
|
||||
### Documentos (en docs/dashboard/)
|
||||
- `PROPUESTA_DASHBOARD_v3.md` — las 3 miradas, ~30 métricas, marcos citados honestamente
|
||||
- `dashboard_tres_miradas_mockup.html` — mockup funcional 3 pestañas
|
||||
- `SPEC_MIRADA_EMPRESA.md` — contrato técnico de las 10 métricas + estrella
|
||||
|
||||
### Decisiones del dashboard
|
||||
- CUIDADOR_ACTIVO incluye "hijo menor sin discapacidad" (criterio UNFPA, validado por Maria).
|
||||
Definido en helper `_dash_es_cuidador`.
|
||||
- Mirada Empresa NO expone datos sensibles (3.x, 4.2, 4.3). Solo 4.1 existencia, 6.x, 7.x, 9.x, 2.6, 2.7.
|
||||
- Métrica por persona vs por instancia: presentar ambas rotuladas; k≥5 sobre count(DISTINCT response_id).
|
||||
- 2 gaps de datos marcados [REQUIERE PREGUNTA]: horas cuidado no remunerado (País),
|
||||
escolarización niñez (Humana). Para el sprint de dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deuda técnica y seguridad
|
||||
|
||||
~~TECH-DEBT-008 (`/admin/responses` sin autenticación)~~ — **resuelto** por ETAPA_AUTH (27 jun).
|
||||
|
||||
| ID | Descripción | Prioridad |
|
||||
|---|---|---|
|
||||
| TECH-DEBT-006 | Backup remoto S3 | Antes de datos reales |
|
||||
| TECH-DEBT-009 | Rotar secrets post-CVE | Antes de datos reales |
|
||||
| SEC-002 | Sprint de compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
|
||||
| SEC-003 | **Agujero TRUNCATE evade inmutabilidad ADR-003** — cerrar antes de datos reales | Antes de datos reales |
|
||||
| BACKLOG-002 | Fase 2: motor genérico de formularios | Post-deadline |
|
||||
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
|
||||
| SEC-001 | Next.js — vuln "Cache Key Confusion" vigente en 15.3.8; última mayor es 16.2.9 (breaking) | Media |
|
||||
| TECH-001 | `npm audit`: 4 vulns (1 moderate, 3 high) — `form-data`, `next`, `undici` | Pendiente revisión |
|
||||
| SEC-002 | Sprint compliance (audit log, hashing, cifrado en reposo) | Sprint dedicado |
|
||||
| SEC-003 | Agujero TRUNCATE evade ADR-003 | Antes de datos reales |
|
||||
| SEC-004 | RPC validable por bypass REST (coherencia de flujo en Server Action) | Sprint futuro |
|
||||
| BACKLOG-002 | Fase 2: motor genérico | Post-deadline |
|
||||
|
||||
COMPLIANCE.md documenta estado de seguridad, roadmap de auditabilidad, y la decisión
|
||||
fundamentada de NO usar blockchain.
|
||||
*(SEC-001/TECH-001 verificados 27 jun vía `npm view next version` / `npm audit --json` —
|
||||
los números en BACKLOG.md/TECH_DEBT.md estaban desactualizados, ej. citaban Next 15.3.3
|
||||
y "2 vulnerabilidades" cuando ya corre 15.3.8 con 4.)*
|
||||
|
||||
---
|
||||
|
||||
## 5. Infraestructura (sin cambios)
|
||||
## 5. Infraestructura
|
||||
- Next.js 15.3.8 · Supabase self-hosted · Dokploy v0.29.4 · VPS inq-mart-2 (172.20.5.11)
|
||||
- App: `economia-cuidado.inqualityhq.com` · API: `db.inqualityhq.com`
|
||||
- Repos Gitea: `git.inquality.com.py/marcos/motor-de-encuestas`
|
||||
- Backups GFS locales 3AM
|
||||
- App: economia-cuidado.inqualityhq.com · API: db.inqualityhq.com
|
||||
- Repos Gitea: git.inquality.com.py/marcos/motor-de-encuestas
|
||||
- Para sembrar datos de prueba: `seed_prueba_50.sql` (en docs/dashboard/), 50 respuestas
|
||||
deterministas vía RPC. Usa question_id (UUIDs), NO question_code. Incluye su limpieza.
|
||||
|
||||
---
|
||||
|
||||
## 6. Contexto comercial
|
||||
- 2 clientes inminentes en 10-20 días (motivan el deadline de v3)
|
||||
- InQuality y Fundación Solidaridad ya en joint venture del producto
|
||||
- Datos de prueba descartables (existe dump en v2.1.0-stable)
|
||||
## 6. Próximos pasos
|
||||
|
||||
**Go-live (widgets → 4F → 4I) está cerrado.** v3.0.0 en producción, surveys `live`, auth para
|
||||
staff funcionando. No queda ninguna etapa de Sprint 4 pendiente — el roadmap pre-escrito se
|
||||
agotó. El próximo paso requiere decisión del director entre:
|
||||
|
||||
1. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
|
||||
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad (TECH-DEBT-010).
|
||||
2. **Sprint de compliance/seguridad (SEC-002):** audit log append-only, hashing de integridad,
|
||||
cifrado en reposo — bloquea escalar a más empresas/datos reales.
|
||||
3. **Mantenimiento de dependencias:** SEC-001 (Next.js) + TECH-001 (`npm audit`, 4 vulns).
|
||||
4. Otro ítem de BACKLOG.md/TECH_DEBT.md no listado arriba.
|
||||
|
||||
Ninguno de estos se decide unilateralmente (Nivel 3 — tocan planificación/seguridad).
|
||||
|
||||
---
|
||||
|
||||
## 7. Próximo paso inmediato
|
||||
**Etapa 4D — Frontend renderer.** El componente consume el motor (4C) y renderiza
|
||||
una pregunta por pantalla. Incluye la tarea 4.5 pospuesta de 4C (query de app/page.tsx
|
||||
con hint/instance_generator/instance_of). Reemplaza lib/conditional.ts y la lógica
|
||||
de secciones hardcodeada de survey-form.tsx v2.
|
||||
## 7. Contexto comercial
|
||||
- 2 clientes inminentes en ~10-20 días (deadline v3). Mirada Empresa es la que le habla al cliente que paga.
|
||||
- InQuality y Fundación Solidaridad en joint venture.
|
||||
|
||||
---
|
||||
|
||||
## 8. Prompt para nueva sesión
|
||||
```
|
||||
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de
|
||||
formularios soberano (Paraguay). Yo soy el product owner. Estamos en Sprint 4
|
||||
construyendo el formulario wizard v3 (esquema declarativo, ADR-005).
|
||||
Sos el arquitecto/copiloto técnico de "Datos País sobre el Cuidado", motor de formularios
|
||||
soberano (Paraguay). Yo soy el product owner. Sprint 4: wizard v3 (ADR-005, esquema declarativo).
|
||||
|
||||
Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md en el repo.
|
||||
Sprint: sprints/sprint-4/ (BRIEF + prompts de etapa + reportes).
|
||||
Completadas: 4.0, 4A, 4B, 4C. Siguiente: 4D (frontend renderer).
|
||||
Estado completo: docs/estado/ESTADO_PROYECTO_JUNIO_2026.md.
|
||||
Formulario v3: COMPLETO y verificado (motor, wizard, submit con validación server-side).
|
||||
Dashboard: rediseño de "tres miradas". Mirada Empresa = RPCs implementadas y verificadas
|
||||
contra datos reales; faltan sus widgets. País/Humana diferidas a sprint posterior.
|
||||
|
||||
El motor de flujo ya existe y está testeado (lib/form-engine.ts, 16 tests).
|
||||
La lógica vive en datos; el frontend es renderer sin reglas.
|
||||
Pendiente go-live: widgets Mirada Empresa → 4F (barrios) → 4I (release).
|
||||
Los 3 surveys en draft (v3 no desplegado aún).
|
||||
|
||||
Próximo paso: [COMPLETAR]
|
||||
```
|
||||
142
docs/guias/GUIA_PRUEBA_FORMULARIO_Y_DASHBOARD.md
Normal file
142
docs/guias/GUIA_PRUEBA_FORMULARIO_Y_DASHBOARD.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Guía — Probar el formulario y leer el dashboard
|
||||
## Datos País sobre el Cuidado
|
||||
|
||||
> Dos públicos en un documento: **Sección 1** es para el equipo técnico que va a
|
||||
> probar el formulario. **Sección 2** es para Maria Mersan (metodología) — cómo
|
||||
> leer el dashboard de Mirada Empresa.
|
||||
|
||||
---
|
||||
|
||||
## Sección 1 — Plan de pruebas del formulario (equipo técnico)
|
||||
|
||||
### El link
|
||||
|
||||
```
|
||||
https://economia-cuidado.inqualityhq.com/?s=30000000-0000-0000-0000-000000000001
|
||||
```
|
||||
|
||||
Esta es la encuesta **"Empresa Demo S.A."** — un sandbox pensado exactamente para
|
||||
esto. **Pueden responderla las veces que quieran.** No es la encuesta real de
|
||||
ningún cliente: nada de lo que carguen ahí se mezcla con datos reales.
|
||||
|
||||
> ⚠️ No usen el link de Fundación Solidaridad ni el de InQuality para pruebas.
|
||||
> El sistema es **anónimo por diseño** (no guarda qué respuesta es de quién), así
|
||||
> que una respuesta de prueba enviada a una encuesta real queda mezclada para
|
||||
> siempre con datos reales, sin forma de distinguirla ni borrarla después.
|
||||
|
||||
### Qué van a ver al entrar
|
||||
|
||||
1. **Pantalla de consentimiento** — checkbox operativo (obligatorio) + checkbox de
|
||||
estudio país (opcional). Sin esto no se entra al formulario.
|
||||
2. **El wizard** — 10 secciones, 40 preguntas. La mayoría son **obligatorias**
|
||||
(recién activado) excepto la pregunta de barrio (2.3), que es texto libre
|
||||
opcional.
|
||||
3. **Pantalla final** — botón "Enviar encuesta".
|
||||
|
||||
### Checklist de cosas a probar
|
||||
|
||||
- [ ] Completar el formulario entero y enviarlo — confirmar que llega la pantalla
|
||||
de agradecimiento.
|
||||
- [ ] Intentar avanzar sin responder una pregunta obligatoria — debe bloquear con
|
||||
mensaje "Este campo es obligatorio."
|
||||
- [ ] Dejar el campo de barrio (2.3) vacío y avanzar — **no** debe bloquear (es
|
||||
opcional).
|
||||
- [ ] Escribir un barrio que no exista en la lista — debe aceptarse como texto
|
||||
libre, sin error.
|
||||
- [ ] Seleccionar Departamento → confirmar que Ciudad se filtra por ese
|
||||
departamento.
|
||||
- [ ] Ir hacia atrás con "Anterior" y adelante de nuevo — los valores que ya
|
||||
cargaron deben seguir ahí.
|
||||
- [ ] Cerrar la pestaña a mitad de formulario, volver a abrir el link — debe
|
||||
ofrecer retomar donde quedaron (guardado automático).
|
||||
- [ ] Probar en el celular (o achicando la ventana del navegador) — los botones y
|
||||
el texto no se deben cortar ni superponer.
|
||||
- [ ] Si pueden, prueben con el wifi cortado a mitad de un envío — debe mostrar
|
||||
un mensaje de error claro, no una pantalla rota.
|
||||
|
||||
Cualquier cosa rara (texto que no carga, botón que no responde, algo que se ve
|
||||
mal en el celular) — repórtenlo con captura de pantalla y en qué pregunta pasó.
|
||||
|
||||
### Dónde ver lo que enviaron
|
||||
|
||||
- **Lista de respuestas:** `/admin/responses` (filtro por encuesta "Empresa Demo
|
||||
S.A."). Necesita login.
|
||||
- **Dashboard:** `/dashboard?survey_id=30000000-0000-0000-0000-000000000001`
|
||||
(ver Sección 2 para cómo leerlo). Con pocas respuestas de prueba, varios
|
||||
números van a aparecer suprimidos — es el umbral de privacidad k≥5 funcionando
|
||||
como debe (ver más abajo), no un bug.
|
||||
|
||||
---
|
||||
|
||||
## Sección 2 — Cómo leer el dashboard (para Maria)
|
||||
|
||||
### Acceso
|
||||
|
||||
`https://economia-cuidado.inqualityhq.com/dashboard` — requiere login.
|
||||
|
||||
**Importante sobre roles, antes de entrar:** hoy el sistema tiene dos roles
|
||||
posibles — `platform_admin` (ve todas las organizaciones, incluido el selector
|
||||
"Todos") y `org_admin` (ve solo la organización a la que está asociado). El
|
||||
flujo de invitación que ya existe (`/admin/usuarios`) **solo puede crear
|
||||
usuarios `org_admin`** — no hay forma, hoy, de autoinvitarse como
|
||||
`platform_admin`. Si necesitás ver **varias** organizaciones desde tu propia
|
||||
cuenta (no solo Fundación Solidaridad), avisale a Marcos para que te dé acceso
|
||||
`platform_admin` directo en la base — el self-service todavía no lo cubre.
|
||||
|
||||
### Qué mirar primero
|
||||
|
||||
Arriba del dashboard hay un selector de organización. Para ver los datos que
|
||||
acabamos de cargar:
|
||||
|
||||
```
|
||||
/dashboard?survey_id=30000000-0000-0000-0000-000000000003
|
||||
```
|
||||
|
||||
(o click en "Fundación Solidaridad" en la barra superior).
|
||||
|
||||
> 🧪 **Estos son datos sintéticos, no respuestas reales de cuidadores de la
|
||||
> Fundación.** Se generaron para que el dashboard tenga algo que mostrar
|
||||
> mientras se valida el diseño — no son representativos de la población real
|
||||
> de la Fundación todavía. Cuando empecemos a recolectar datos reales, te
|
||||
> avisamos explícitamente (así lo acordamos con Marcos).
|
||||
|
||||
Las pestañas **Mirada País** y **Mirada Humana** todavía no están construidas
|
||||
(dicen "Disponible próximamente") — hoy solo **Mirada Empresa** tiene datos.
|
||||
|
||||
### Los 7 indicadores de Mirada Empresa
|
||||
|
||||
Cada tarjeta está anclada a una o más preguntas del instrumento (el número de
|
||||
pregunta aparece como subtítulo en cada tarjeta, para que puedan rastrear de
|
||||
dónde sale el número).
|
||||
|
||||
| Tarjeta (como aparece en pantalla) | Pregunta(s) | Qué responde | Cómo leerla |
|
||||
|---|---|---|---|
|
||||
| **Talento en riesgo** | 7.6 + 7.3 + 7.5 | Métrica compuesta: % de la plantilla cuidadora con señales combinadas de fuga (probabilidad de irse/reducir + ausentismo + carrera frenada). | Es el número "traductor a negocio" — cuanto más alto, más costo de reemplazo en riesgo. Pensado para abrir la conversación con RRHH/dirección, no como diagnóstico aislado. |
|
||||
| **Cuidadores en la plantilla** | 4.1 | % de empleados con algún vínculo de cuidado activo (incluye hijo/a menor sin discapacidad, criterio UNFPA). | Es la prevalencia base — el "cuánta gente de mi plantilla está en esto" antes de mirar cualquier otro indicador. |
|
||||
| **Punto ciego organizacional** | 9.1 vs. 4.1 | Brecha entre la carga real de cuidado (prevalencia) y la percepción de que la empresa lo nota/apoya. | Si la brecha es grande, hay gente cuidando sin que la organización lo sepa — es el hallazgo central que justifica el dashboard (ver marco conceptual en `docs/dashboard/PROPUESTA_DASHBOARD_v3.md`). |
|
||||
| **Señales de impacto laboral** | 7.2, 7.3, 7.6, 7.5 | Desglose de presentismo, ausentismo, probabilidad de salida y carrera frenada — los componentes de "Talento en riesgo" mostrados por separado. | Útil para ver CUÁL señal pesa más en este segmento (¿es ausentismo o es que están rechazando ascensos?). |
|
||||
| **Apoyos más demandados** | 9.2 | Qué beneficios pedirían (hasta 3 opciones por persona). | Es el insumo directo para diseñar beneficios — léelo como un ranking, no como un % que suma 100%. |
|
||||
| **Dónde se concentra la carga** | 4.1 cruzado con 2.6 (nivel) y 2.7 (modalidad) | % de cuidadores por segmento (jerarquía × modalidad de trabajo). | Mirar si la carga se concentra en niveles operativos vs. gerenciales, y presencial vs. remoto — relevante para política de flexibilidad. |
|
||||
| **Disposición a red de apoyo** | 9.3 + 9.4 | % dispuesto a sumarse a un grupo de apoyo interno / hacer voluntariado. | Mide apetito para iniciativas comunitarias internas, no necesidad — alguien puede necesitar apoyo y no querer un grupo. |
|
||||
|
||||
### El umbral de privacidad (k≥5)
|
||||
|
||||
Todo el dashboard aplica **k-anonimato k≥5**: ningún segmento con menos de 5
|
||||
personas se muestra desagregado (aparece suprimido). Esto es una regla de
|
||||
diseño no negociable del proyecto (protege a quien respondió, especialmente
|
||||
tratándose de datos de salud/discapacidad) — si ves un segmento "vacío" o con
|
||||
guion, probablemente es esto, no un error de carga. Con el lote de 50
|
||||
respuestas sintéticas la mayoría de los cortes generales van a tener datos,
|
||||
pero cruces muy específicos (ej. un departamento + un nivel jerárquico puntual)
|
||||
pueden quedar por debajo del umbral.
|
||||
|
||||
### Decisión metodológica pendiente (marcada para vos en la propuesta original)
|
||||
|
||||
El documento `docs/dashboard/PROPUESTA_DASHBOARD_v3.md` (sección "Decisión
|
||||
metodológica pendiente — para Maria") deja abierta una definición: las métricas
|
||||
de vínculo de cuidado (4.1) se pueden leer **por persona** ("% de empleados con
|
||||
al menos un familiar con discapacidad X") o **por instancia** ("del total de
|
||||
familiares cuidados, % con discapacidad X"). Hoy el dashboard usa la lectura
|
||||
por persona. Si para tu análisis necesitás la lectura por instancia, es un
|
||||
cambio de método de cálculo a definir y construir — avisame cuando lo hayas
|
||||
decidido.
|
||||
@@ -4,21 +4,43 @@
|
||||
import type { DashboardFilters } from './dashboard-types'
|
||||
import { filtersToQuery } from './dashboard-utils'
|
||||
|
||||
async function fetchWidget<T>(widget: string, surveyId: string, filters: DashboardFilters): Promise<T> {
|
||||
async function fetchWidget<T>(
|
||||
widget: string,
|
||||
surveyId: string,
|
||||
filters: DashboardFilters,
|
||||
extra?: Record<string, string>
|
||||
): Promise<T> {
|
||||
const qs = filtersToQuery(surveyId, filters)
|
||||
const res = await fetch(`/api/dashboard/${widget}?${qs}`, { cache: 'no-store' })
|
||||
const extraQs = extra ? `&${new URLSearchParams(extra).toString()}` : ''
|
||||
const res = await fetch(`/api/dashboard/${widget}?${qs}${extraQs}`, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`Widget ${widget}: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Mirada Empresa — una entrada por métrica (E1-E10 + estrella).
|
||||
export const queries = {
|
||||
kpis: (sid: string, f: DashboardFilters) => fetchWidget('kpis', sid, f),
|
||||
prevalencia: (sid: string, f: DashboardFilters) => fetchWidget('prevalencia', sid, f),
|
||||
heatmap: (sid: string, f: DashboardFilters) => fetchWidget('heatmap', sid, f),
|
||||
distribucion: (sid: string, f: DashboardFilters) => fetchWidget('distribucion', sid, f),
|
||||
apoyos: (sid: string, f: DashboardFilters) => fetchWidget('apoyos', sid, f),
|
||||
score: (sid: string, f: DashboardFilters) => fetchWidget('score', sid, f),
|
||||
piramide: (sid: string, f: DashboardFilters) => fetchWidget('piramide', sid, f),
|
||||
intensidad: (sid: string, f: DashboardFilters) => fetchWidget('intensidad', sid, f),
|
||||
saturacion: (sid: string, f: DashboardFilters) => fetchWidget('saturacion', sid, f),
|
||||
mePrevalencia: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-prevalencia', sid, f),
|
||||
meRiesgoRotacion: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-riesgo-rotacion', sid, f),
|
||||
meAusentismo: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-ausentismo', sid, f),
|
||||
mePresentismo: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-presentismo', sid, f),
|
||||
mePuntoCiego: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-punto-ciego', sid, f),
|
||||
meApoyos: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-apoyos', sid, f),
|
||||
// p_umbral_senales es parametrizable en la RPC — el widget puede pasarlo,
|
||||
// si no se pasa nada la RPC usa su propio default (2 de 3).
|
||||
meTalentoRiesgo: (sid: string, f: DashboardFilters, umbral?: number) =>
|
||||
fetchWidget('me-talento-riesgo', sid, f, umbral !== undefined ? { umbral: String(umbral) } : undefined),
|
||||
meCarreraCongelada: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-carrera-congelada', sid, f),
|
||||
meIntensidadCarga: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-intensidad-carga', sid, f),
|
||||
meSegmentacion: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-segmentacion', sid, f),
|
||||
meDisposicionRed: (sid: string, f: DashboardFilters) =>
|
||||
fetchWidget('me-disposicion-red', sid, f),
|
||||
}
|
||||
|
||||
@@ -9,51 +9,6 @@ export interface DashboardFilters {
|
||||
// Estado de cada widget
|
||||
export type WidgetStatus = 'loading' | 'ok' | 'suppressed' | 'error'
|
||||
|
||||
// Payloads de cada RPC
|
||||
export interface KpiData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
pct_cuidadores: number
|
||||
score_bienestar: number | null // avg raw 4.3 (1.0–5.0)
|
||||
pct_ausencias: number
|
||||
}
|
||||
|
||||
export interface DeptoData {
|
||||
depto: string
|
||||
n: number | null
|
||||
pct_cuidadores: number | null
|
||||
suppressed: boolean
|
||||
}
|
||||
export interface PrevalenciaData { status: 'ok'; n_total: number; data: DeptoData[] }
|
||||
|
||||
export interface HeatmapCell { horas: string; ausencias: string; n: number | null; suppressed: boolean }
|
||||
export interface HeatmapData { status: 'ok'; n_total: number; data: HeatmapCell[] }
|
||||
|
||||
export interface DistribucionData {
|
||||
status: 'ok'; n: number
|
||||
disc_propia: number; familiar: number; adulto_mayor: number; ninguno: number
|
||||
}
|
||||
|
||||
export interface ApoyoItem { politica: string; n: number; pct: number }
|
||||
export interface ApoyosData { status: 'ok'; n_total: number; data: ApoyoItem[] }
|
||||
|
||||
export interface ScoreData { status: 'ok'; n: number; score: number; zona: 'baja' | 'desarrollo' | 'consciente' }
|
||||
|
||||
export interface PiramideCell { etario: string; genero: string; n: number | null; suppressed: boolean }
|
||||
export interface PiramideData { status: 'ok'; n_total: number; data: PiramideCell[] }
|
||||
|
||||
export interface IntensidadCell { horas: string; bienestar: string; n: number | null; suppressed: boolean }
|
||||
export interface IntensidadData { status: 'ok'; n_total: number; data: IntensidadCell[] }
|
||||
|
||||
export interface SaturacionSegmento {
|
||||
segmento: string
|
||||
n: number | null
|
||||
pct: number | null
|
||||
apoyo: string | null
|
||||
suppressed: boolean
|
||||
}
|
||||
export interface SaturacionData { status: 'ok'; n_total: number; segmentos: SaturacionSegmento[] }
|
||||
|
||||
export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number }
|
||||
|
||||
export type RpcResult<T> = T | SuppressedPayload
|
||||
@@ -61,3 +16,123 @@ export type RpcResult<T> = T | SuppressedPayload
|
||||
export function isSuppressed(r: unknown): r is SuppressedPayload {
|
||||
return typeof r === 'object' && r !== null && (r as Record<string, unknown>)['status'] === 'suppressed'
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// Mirada Empresa — shapes tal cual los devuelven las rpc_me_*
|
||||
// (migración 20260625000002_dashboard_mirada_empresa.sql, verificada
|
||||
// contra Postgres real). NO copiar del SPEC_MIRADA_EMPRESA.md a ciegas:
|
||||
// el SQL real difiere un poco del shape ilustrativo de la spec (ej. E6
|
||||
// agrega apoyo_percibido_suppressed; E7 usa "apoyos", no "data").
|
||||
// ════════════════════════════════════════════════════════════
|
||||
|
||||
// E1 — Prevalencia de cuidadores
|
||||
export interface PrevalenciaCuidadoresData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
pct_cuidadores: number
|
||||
}
|
||||
|
||||
// E2 — Riesgo de rotación
|
||||
export interface RiesgoRotacionData {
|
||||
status: 'ok'
|
||||
n_cuidadores: number
|
||||
pct_riesgo: number
|
||||
desglose: { algo: number; muy: number }
|
||||
}
|
||||
|
||||
// E3 — Ausentismo
|
||||
export interface AusentismoData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
pct_ausentismo_recurrente: number
|
||||
desglose: { nunca: number; pocas: number; varias: number; frecuente: number }
|
||||
}
|
||||
|
||||
// E4 — Presentismo
|
||||
export interface PresentismoData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
pct_presentismo: number
|
||||
desglose: {
|
||||
nunca_o_casi_nunca: number
|
||||
algunas_al_mes: number
|
||||
varias_por_semana: number
|
||||
casi_todos_los_dias: number
|
||||
}
|
||||
}
|
||||
|
||||
// E6 — Punto ciego organizacional
|
||||
// apoyo_percibido_pct puede ser null con status='ok' si SU población (9.1)
|
||||
// no llega a 5, aunque el total (carga_real) sí — dos poblaciones, dos
|
||||
// supresiones independientes (ver comentario en la RPC).
|
||||
export interface PuntoCiegoData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
carga_real_pct: number
|
||||
apoyo_percibido_pct: number | null
|
||||
apoyo_percibido_suppressed: boolean
|
||||
brecha_pp: number | null
|
||||
}
|
||||
|
||||
// E7 — Apoyos demandados
|
||||
export interface ApoyoMEItem { apoyo: string; n: number; pct: number }
|
||||
export interface ApoyosMEData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
apoyos: ApoyoMEItem[]
|
||||
}
|
||||
|
||||
// ★ Talento en riesgo
|
||||
export interface TalentoRiesgoData {
|
||||
status: 'ok'
|
||||
n_cuidadores: number
|
||||
umbral_senales: number
|
||||
pct_talento_riesgo: number
|
||||
criterios_promedio: number
|
||||
}
|
||||
|
||||
// E5 — Carrera congelada [SEC]
|
||||
export interface CarreraCongeladaItem { opcion: string; n: number; pct: number }
|
||||
export interface CarreraCongeladaData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
pct_carrera_congelada: number
|
||||
top_opciones: CarreraCongeladaItem[]
|
||||
}
|
||||
|
||||
// E8 — Intensidad de carga [SEC]
|
||||
export interface IntensidadCargaCell {
|
||||
dias_semana: string
|
||||
horas_dia: string
|
||||
n: number | null
|
||||
suppressed: boolean
|
||||
}
|
||||
export interface IntensidadCargaData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
distribucion_intensidad: IntensidadCargaCell[]
|
||||
pct_guardia_permanente: number
|
||||
}
|
||||
|
||||
// E9 — Segmentación por nivel y modalidad [SEC]
|
||||
export interface SegmentoNivelItem { nivel: string; n: number; pct: number }
|
||||
export interface SegmentoModalidadItem { modalidad: string; n: number; pct: number }
|
||||
export interface SegmentacionData {
|
||||
status: 'ok'
|
||||
n: number
|
||||
por_nivel: SegmentoNivelItem[]
|
||||
por_modalidad: SegmentoModalidadItem[]
|
||||
}
|
||||
|
||||
// E10 — Disposición a red de apoyo [SEC]
|
||||
// Shape "suppressed" de esta RPC difiere del genérico (usa
|
||||
// n_grupo_apoyo/n_voluntariado, no min_n/n) — isSuppressed() solo mira
|
||||
// `status`, así que sigue funcionando; no leer .n de un resultado
|
||||
// suprimido de esta RPC en particular.
|
||||
export interface DisposicionRedData {
|
||||
status: 'ok'
|
||||
n_grupo_apoyo: number
|
||||
pct_grupo_apoyo: number | null
|
||||
n_voluntariado: number
|
||||
pct_voluntariado: number | null
|
||||
}
|
||||
|
||||
@@ -33,18 +33,4 @@ export function filtersToRpc(filters: DashboardFilters): Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export const HORAS_ORDER = ['Menos de 1h','Entre 1 y 3h','Entre 3 y 6h','Más de 6h','Varía mucho']
|
||||
export const AUSENCIAS_ORDER = ['Nunca','Pocas veces (1–3 veces al año)','Varias veces (4–10 veces al año)','Con frecuencia (más de 10 veces al año)']
|
||||
export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir']
|
||||
|
||||
export function sortByOrder<T extends Record<string, unknown>>(
|
||||
arr: T[],
|
||||
key: keyof T,
|
||||
order: string[]
|
||||
): T[] {
|
||||
return [...arr].sort((a, b) => {
|
||||
const ia = order.indexOf(a[key] as string)
|
||||
const ib = order.indexOf(b[key] as string)
|
||||
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
resolveScreens,
|
||||
initialState,
|
||||
setAnswer,
|
||||
applyAnswer,
|
||||
isRequiredAndUnanswered,
|
||||
next,
|
||||
back,
|
||||
} from './form-engine.ts'
|
||||
@@ -342,3 +344,92 @@ describe('resolveVisibility — combinación show/hide', () => {
|
||||
assert.equal(resolveVisibility(question, { '3.1:default': 'No' }), false)
|
||||
})
|
||||
})
|
||||
|
||||
// ── applyAnswer — integridad de datos al remover una instancia ──────────
|
||||
// Esta lógica vivía en wizard-form.tsx (handleChange) hasta ahora; se movió
|
||||
// al motor para que sea testeable como módulo puro (ver conversación 4E).
|
||||
describe('applyAnswer — limpieza de respuestas al remover una instancia', () => {
|
||||
test('quitar un vínculo de 4.1 borra las respuestas de esa instancia y deja intactas las del resto', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
let state = { answers: {} as FormAnswers, currentScreenId: 'question:4.1' }
|
||||
|
||||
// Selecciona 2 vínculos → familiar_2 (índice 1) y familiar_5 (índice 4)
|
||||
state = applyAnswer(state, schema, '4.1', [VINCULOS[1], VINCULOS[4]])
|
||||
|
||||
// Responde 4.2/4.3 para AMBAS instancias
|
||||
state = applyAnswer(state, schema, '4.2', ['Visual'], 'familiar_2')
|
||||
state = applyAnswer(state, schema, '4.3', ['Bastón blanco'], 'familiar_2')
|
||||
state = applyAnswer(state, schema, '4.2', ['Auditiva'], 'familiar_5')
|
||||
state = applyAnswer(state, schema, '4.3', ['Audífonos'], 'familiar_5')
|
||||
|
||||
assert.deepEqual(state.answers['4.2:familiar_2'], ['Visual'])
|
||||
assert.deepEqual(state.answers['4.3:familiar_2'], ['Bastón blanco'])
|
||||
assert.deepEqual(state.answers['4.2:familiar_5'], ['Auditiva'])
|
||||
assert.deepEqual(state.answers['4.3:familiar_5'], ['Audífonos'])
|
||||
|
||||
// Quita el vínculo de familiar_2 — deja solo familiar_5 en 4.1
|
||||
state = applyAnswer(state, schema, '4.1', [VINCULOS[4]])
|
||||
|
||||
// Las respuestas de la instancia removida desaparecen del estado
|
||||
assert.equal('4.2:familiar_2' in state.answers, false)
|
||||
assert.equal('4.3:familiar_2' in state.answers, false)
|
||||
|
||||
// Las del familiar que queda permanecen intactas, sin reindexarse
|
||||
assert.deepEqual(state.answers['4.2:familiar_5'], ['Auditiva'])
|
||||
assert.deepEqual(state.answers['4.3:familiar_5'], ['Audífonos'])
|
||||
})
|
||||
|
||||
test('aplicar la misma respuesta sin cambiar visibilidad no borra nada (caso sin disappeared)', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
let state = { answers: {} as FormAnswers, currentScreenId: 'question:3.1' }
|
||||
state = applyAnswer(state, schema, '3.1', 'Sí')
|
||||
state = applyAnswer(state, schema, '3.2', ['Física / motora'])
|
||||
state = applyAnswer(state, schema, '3.1', 'Sí') // mismo valor, no cambia nada visible
|
||||
assert.deepEqual(state.answers['3.2:default'], ['Física / motora'])
|
||||
})
|
||||
})
|
||||
|
||||
// ── isRequiredAndUnanswered — incluye preguntas de instancia ─────────────
|
||||
// El seed v3 actual no marca ninguna pregunta de instancia (4.2/4.3) como
|
||||
// required — este test usa un esquema sintético mínimo para cubrir el
|
||||
// camino igual, ya que el código no debe asumir que nunca va a pasar.
|
||||
function buildRequiredInstanceSchema() {
|
||||
const rows: Question[] = [
|
||||
q({
|
||||
code: 'G1', type: 'multiple_choice', prompt: 'Generador', position: 1,
|
||||
instance_generator: true,
|
||||
options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }],
|
||||
}),
|
||||
q({ code: 'G1.1', type: 'text_short', prompt: 'Dato requerido por instancia', position: 2, instance_of: 'G1', required: true }),
|
||||
]
|
||||
return buildFormSchema(rows)
|
||||
}
|
||||
|
||||
describe('isRequiredAndUnanswered — cubre preguntas de instancia required', () => {
|
||||
test('cada instancia de una pregunta required se evalúa por separado', () => {
|
||||
const schema = buildRequiredInstanceSchema()
|
||||
const answers: FormAnswers = { 'G1:default': ['a', 'b'] }
|
||||
const screens = resolveScreens(schema, answers)
|
||||
|
||||
const screenA = screens.find((s) => s.id === 'question:G1.1:familiar_1')
|
||||
const screenB = screens.find((s) => s.id === 'question:G1.1:familiar_2')
|
||||
assert.ok(screenA && screenB, 'deben existir 2 pantallas de instancia para G1.1')
|
||||
|
||||
// Sin responder ninguna instancia → unanswered en ambas
|
||||
assert.equal(isRequiredAndUnanswered(screenA!, answers), true)
|
||||
assert.equal(isRequiredAndUnanswered(screenB!, answers), true)
|
||||
|
||||
// Responder SOLO la instancia 1 → deja de exigirse esa, la otra sigue pendiente
|
||||
const partial = { ...answers, 'G1.1:familiar_1': 'dato' }
|
||||
assert.equal(isRequiredAndUnanswered(screenA!, partial), false)
|
||||
assert.equal(isRequiredAndUnanswered(screenB!, partial), true)
|
||||
})
|
||||
|
||||
test('preguntas no-required nunca bloquean, tengan o no respuesta', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const screens = resolveScreens(schema, {})
|
||||
const screen = screens.find((s) => s.id === 'question:3.1')
|
||||
assert.ok(screen)
|
||||
assert.equal(isRequiredAndUnanswered(screen!, {}), false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -176,6 +176,53 @@ export function setAnswer(
|
||||
}
|
||||
}
|
||||
|
||||
/** Exportada porque también la usa el validador de submit server-side (4G). */
|
||||
export function isAnswerEmpty(v: AnswerValue | undefined): boolean {
|
||||
return v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica una respuesta y limpia las respuestas de pantallas que dejaron de
|
||||
* ser visibles como CONSECUENCIA de ese cambio (ej.: sacar un vínculo de 4.1
|
||||
* borra las respuestas de 4.2/4.3 de esa instancia puntual; las de las demás
|
||||
* instancias quedan intactas porque conservan su propio answerKey). Esto es
|
||||
* consecuencia de flujo (ADR-005), no presentación — por eso vive en el
|
||||
* motor y no en el componente.
|
||||
*/
|
||||
export function applyAnswer(
|
||||
state: WizardState,
|
||||
schema: FormSchema,
|
||||
code: string,
|
||||
value: AnswerValue,
|
||||
instanceId: string = DEFAULT_INSTANCE
|
||||
): WizardState {
|
||||
const updated = setAnswer(state, code, value, instanceId)
|
||||
const before = resolveScreens(schema, state.answers)
|
||||
const after = resolveScreens(schema, updated.answers)
|
||||
const afterIds = new Set(after.map((s) => s.id))
|
||||
const disappeared = before.filter((s) => !afterIds.has(s.id) && s.question)
|
||||
|
||||
if (disappeared.length === 0) return updated
|
||||
|
||||
const clearedAnswers = { ...updated.answers }
|
||||
for (const s of disappeared) {
|
||||
delete clearedAnswers[answerKey(s.question!.code, s.instanceId ?? undefined)]
|
||||
}
|
||||
return { ...updated, answers: clearedAnswers }
|
||||
}
|
||||
|
||||
/**
|
||||
* true si la pantalla es una pregunta `required` y todavía no tiene
|
||||
* respuesta — incluye preguntas de instancia (cada instancia se evalúa por
|
||||
* su propio answerKey, independiente de las demás). El seed v3 actual no
|
||||
* marca ninguna pregunta de instancia como required, pero el camino queda
|
||||
* cubierto para cuando exista una.
|
||||
*/
|
||||
export function isRequiredAndUnanswered(screen: Screen, answers: FormAnswers): boolean {
|
||||
if (!screen.question?.required) return false
|
||||
return isAnswerEmpty(answers[answerKey(screen.question.code, screen.instanceId ?? undefined)])
|
||||
}
|
||||
|
||||
/**
|
||||
* Avanza una pantalla. Recalcula la lista de pantallas visibles desde las
|
||||
* respuestas actuales y busca la pantalla actual por ID (nunca por índice
|
||||
|
||||
16
lib/geo-cache.ts
Normal file
16
lib/geo-cache.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// departamento → ciudad → barrios[]
|
||||
export type GeoData = Record<string, Record<string, string[]>>
|
||||
|
||||
let geoData: GeoData | null = null
|
||||
let geoPromise: Promise<GeoData> | null = null
|
||||
|
||||
export async function getGeoData(): Promise<GeoData> {
|
||||
if (geoData) return geoData
|
||||
if (!geoPromise) {
|
||||
geoPromise = fetch('/data/geo_paraguay_full.json')
|
||||
.then((r) => r.json())
|
||||
.then((data: GeoData) => { geoData = data; return data })
|
||||
.catch((err) => { geoPromise = null; throw err })
|
||||
}
|
||||
return geoPromise
|
||||
}
|
||||
232
lib/submit-validation.test.mts
Normal file
232
lib/submit-validation.test.mts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Tests Node puros (sin navegador, sin DB) del validador server-side de submit.
|
||||
// Ejecutar: node --test lib/submit-validation.test.mts
|
||||
import { test, describe } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildFormSchema } from './form-engine.ts'
|
||||
import { validateSubmission } from './submit-validation.ts'
|
||||
import type { ConditionalRule, Question } from './form-engine.types.ts'
|
||||
import type { SubmitAnswerEntry } from './submit-validation.ts'
|
||||
|
||||
function q(partial: Partial<Question> & Pick<Question, 'code' | 'type' | 'prompt' | 'position'>): Question {
|
||||
return {
|
||||
id: partial.code,
|
||||
is_sensitive: false,
|
||||
required: false,
|
||||
hint: null,
|
||||
options: null,
|
||||
max_select: null,
|
||||
conditional_rules: null,
|
||||
instance_generator: false,
|
||||
instance_of: null,
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
const r_show_si_3_1: ConditionalRule[] = [
|
||||
{ if_question: '3.1', op: 'eq', value: 'Sí', action: 'show' },
|
||||
]
|
||||
|
||||
const VINCULOS = ['Hijo menor con discapacidad', 'Familiar mayor con discapacidad', 'Otro / Especificar']
|
||||
|
||||
function buildFixtureSchema() {
|
||||
const rows: Question[] = [
|
||||
q({ code: 'S2', type: 'section', prompt: 'Datos generales', position: 20 }),
|
||||
q({
|
||||
code: '2.5', type: 'single_choice', prompt: 'Género', position: 25, required: true,
|
||||
options: [{ value: 'Femenino', label: 'Femenino' }, { value: 'Masculino', label: 'Masculino' }],
|
||||
}),
|
||||
|
||||
q({ code: 'S3', type: 'section', prompt: 'Prevalencia', position: 30 }),
|
||||
q({
|
||||
code: '3.1', type: 'single_choice', prompt: '¿Tenés discapacidad?', position: 31,
|
||||
options: [{ value: 'Sí', label: 'Sí' }, { value: 'No', label: 'No' }],
|
||||
}),
|
||||
q({
|
||||
code: '3.2', type: 'multiple_choice', prompt: 'Tipo', position: 32, conditional_rules: r_show_si_3_1,
|
||||
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
|
||||
}),
|
||||
|
||||
q({ code: 'S4', type: 'section', prompt: 'Vinculación familiar', position: 40 }),
|
||||
q({
|
||||
code: '4.1', type: 'multiple_choice', prompt: 'Vínculos', position: 41, instance_generator: true,
|
||||
options: VINCULOS.map((v) => ({ value: v, label: v })),
|
||||
}),
|
||||
q({
|
||||
code: '4.2', type: 'multiple_choice', prompt: 'Tipo familiar', position: 42, instance_of: '4.1',
|
||||
max_select: 2,
|
||||
options: [{ value: 'Visual', label: 'Visual' }, { value: 'Auditiva', label: 'Auditiva' }],
|
||||
}),
|
||||
]
|
||||
return buildFormSchema(rows)
|
||||
}
|
||||
|
||||
// ── Happy path: 2 familiares ──────────────────────────────────────────────
|
||||
test('happy path: submit válido con 2 familiares — payload completo con instance_id', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '3.1', instanceId: 'default', value: 'No' }, // oculta 3.2 — no se manda
|
||||
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0], VINCULOS[1]] },
|
||||
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual'] },
|
||||
{ code: '4.2', instanceId: 'familiar_2', value: ['Auditiva'] },
|
||||
]
|
||||
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, true)
|
||||
if (!result.ok) return
|
||||
|
||||
// ── Dato concreto pedido en el reporte: filas reales con instance_id ──
|
||||
console.log('\nPayload validado → filas listas para la RPC (questionId/instanceId/value):')
|
||||
console.log(JSON.stringify(result.rows, null, 2))
|
||||
|
||||
assert.equal(result.rows.length, 5)
|
||||
const f1 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_1')
|
||||
const f2 = result.rows.find((r) => r.questionId === '4.2' && r.instanceId === 'familiar_2')
|
||||
assert.deepEqual(f1?.value, ['Visual'])
|
||||
assert.deepEqual(f2?.value, ['Auditiva'])
|
||||
})
|
||||
|
||||
describe('rechazos — las 7 validaciones', () => {
|
||||
test('rechaza respuesta a una pregunta que el flujo nunca debió mostrar (3.2 con 3.1=No)', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '3.1', instanceId: 'default', value: 'No' },
|
||||
{ code: '3.2', instanceId: 'default', value: ['Visual'] }, // 3.1='No' la oculta
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.startsWith('question_should_be_hidden:3.2')))
|
||||
})
|
||||
|
||||
test('rechaza valor fuera de las opciones válidas', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '3.1', instanceId: 'default', value: 'Tal vez' }, // no es 'Sí' ni 'No'
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.includes('value_not_in_options') && e.includes('3.1')))
|
||||
})
|
||||
|
||||
test('rechaza max_select excedido (4.2 tiene max_select=2)', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] },
|
||||
{ code: '4.2', instanceId: 'familiar_1', value: ['Visual', 'Auditiva', 'Visual'] }, // 3 > max_select(2)
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.includes('exceeds_max_select')))
|
||||
})
|
||||
|
||||
test('rechaza instancia inventada (familiar que 4.1 no generó)', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '4.1', instanceId: 'default', value: [VINCULOS[0]] }, // genera solo familiar_1
|
||||
{ code: '4.2', instanceId: 'familiar_2', value: ['Visual'] }, // familiar_2 NO existe
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.startsWith('invalid_instance:4.2:familiar_2')))
|
||||
})
|
||||
|
||||
test('rechaza instance_id en una pregunta que no es de instancia', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'familiar_1', value: 'Femenino' }, // 2.5 no es instance_of nada
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.startsWith('unexpected_instance_id:2.5')))
|
||||
})
|
||||
|
||||
test('rechaza sin consentimiento operativo', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [{ code: '2.5', instanceId: 'default', value: 'Femenino' }]
|
||||
const result = validateSubmission(schema, entries, false)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.includes('consent_operativo_required'))
|
||||
})
|
||||
|
||||
test('rechaza pregunta requerida visible sin responder', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
// 2.5 es required=true y no se manda
|
||||
const entries: SubmitAnswerEntry[] = [{ code: '3.1', instanceId: 'default', value: 'No' }]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.startsWith('required_missing:2.5')))
|
||||
})
|
||||
|
||||
test('rechaza pregunta que no existe en el esquema (code inventado)', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.startsWith('unknown_question:99.9')))
|
||||
})
|
||||
|
||||
test('rechaza respuesta a una fila type=section', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: 'S4', instanceId: 'default', value: 'algo' },
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.startsWith('section_is_not_answerable:S4')))
|
||||
})
|
||||
|
||||
test('rechaza valor de tipo incorrecto (string donde se espera array)', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '4.1', instanceId: 'default', value: 'Hijo menor con discapacidad' as unknown as string[] },
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.some((e) => e.includes('expected_array_value')))
|
||||
})
|
||||
|
||||
test('reporta TODOS los errores encontrados, no solo el primero', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '99.9', instanceId: 'default', value: 'inyectado' },
|
||||
{ code: 'S4', instanceId: 'default', value: 'algo' },
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, false)
|
||||
if (result.ok) return
|
||||
assert.ok(result.errors.length >= 2, `esperaba >=2 errores, hubo ${result.errors.length}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('valores vacíos', () => {
|
||||
test('una entrada con valor vacío no se valida ni se persiste, pero no bloquea el submit', () => {
|
||||
const schema = buildFixtureSchema()
|
||||
const entries: SubmitAnswerEntry[] = [
|
||||
{ code: '2.5', instanceId: 'default', value: 'Femenino' },
|
||||
{ code: '3.1', instanceId: 'default', value: '' }, // vacío — se ignora, no se evalúa su visibilidad
|
||||
]
|
||||
const result = validateSubmission(schema, entries, true)
|
||||
assert.equal(result.ok, true)
|
||||
if (!result.ok) return
|
||||
assert.equal(result.rows.some((r) => r.questionId === '3.1'), false)
|
||||
})
|
||||
})
|
||||
150
lib/submit-validation.ts
Normal file
150
lib/submit-validation.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Validación de integridad server-side del submit (Etapa 4G).
|
||||
// REUSA lib/form-engine.ts (resolveVisibility, resolveScreens, expandInstances) —
|
||||
// no reimplementa lógica de flujo. Corre en el servidor (Server Action), nunca
|
||||
// en el cliente: "la validación de cliente es UX, NO seguridad" (BRIEF decisión 15).
|
||||
//
|
||||
// Lo que esta función NO hace: persistir nada. Solo decide si el payload recibido
|
||||
// es coherente con el esquema + el propio flujo declarado por el payload, y si es
|
||||
// así, devuelve las filas listas para insertar (mapeadas a question_id).
|
||||
|
||||
import type { AnswerValue, FormAnswers, FormSchema, Question } from './form-engine.types'
|
||||
// Extensión .ts explícita (no solo en .mts): a diferencia de form-engine.ts —
|
||||
// que solo importa *tipos* de form-engine.types (se borran en runtime, Node
|
||||
// nunca necesita resolverlos) — este archivo importa FUNCIONES reales y
|
||||
// corre tanto bajo Next.js (Server Action) como bajo Node puro (tests). Node
|
||||
// exige extensión explícita para resolver imports de valor; tsconfig.json
|
||||
// habilita allowImportingTsExtensions para permitir esto sin romper tsc.
|
||||
import { answerKey, expandInstances, isAnswerEmpty, resolveScreens, resolveVisibility } from './form-engine.ts'
|
||||
|
||||
export interface SubmitAnswerEntry {
|
||||
code: string
|
||||
/** 'default' para preguntas normales; 'familiar_N' para preguntas de instancia. */
|
||||
instanceId: string
|
||||
value: AnswerValue
|
||||
}
|
||||
|
||||
export interface ValidatedRow {
|
||||
questionId: string
|
||||
instanceId: string
|
||||
value: AnswerValue
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| { ok: true; rows: ValidatedRow[] }
|
||||
| { ok: false; errors: string[] }
|
||||
|
||||
function validateValueShape(question: Question, value: AnswerValue): string | null {
|
||||
switch (question.type) {
|
||||
case 'yes_no':
|
||||
return value === 'true' || value === 'false' ? null : 'invalid_yes_no_value'
|
||||
|
||||
case 'single_choice': {
|
||||
if (typeof value !== 'string') return 'expected_string_value'
|
||||
if (question.options && !question.options.some((o) => o.value === value)) {
|
||||
return 'value_not_in_options'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'multiple_choice': {
|
||||
if (!Array.isArray(value)) return 'expected_array_value'
|
||||
if (!value.every((v) => typeof v === 'string')) return 'expected_string_array'
|
||||
if (question.max_select !== null && value.length > question.max_select) {
|
||||
return 'exceeds_max_select'
|
||||
}
|
||||
if (question.options) {
|
||||
const valid = new Set(question.options.map((o) => o.value))
|
||||
if (!value.every((v) => valid.has(v as string))) return 'value_not_in_options'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
case 'text_short':
|
||||
case 'text_long':
|
||||
return typeof value === 'string' ? null : 'expected_string_value'
|
||||
|
||||
// rating/nps/matrix/ranking/date/slider: no usados por el instrumento v3 hoy.
|
||||
// No se valida su forma específica — si en el futuro se usan, agregar el caso.
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Corre las 7 validaciones del punto 4 de ETAPA_4G_PROMPT.md contra el
|
||||
* esquema cargado de la DB. Si CUALQUIERA falla, devuelve ok:false con la
|
||||
* lista completa de errores encontrados (no solo el primero) — el caller
|
||||
* rechaza el submit entero, sin insertar nada.
|
||||
*/
|
||||
export function validateSubmission(
|
||||
schema: FormSchema,
|
||||
entries: SubmitAnswerEntry[],
|
||||
consentOperativo: boolean
|
||||
): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const byCode = new Map(schema.questions.map((q) => [q.code, q] as const))
|
||||
|
||||
// Reconstruye el mismo FormAnswers que tuvo el wizard — las funciones del
|
||||
// motor lo necesitan para evaluar reglas/instancias/visibilidad.
|
||||
const answers: FormAnswers = {}
|
||||
for (const e of entries) answers[answerKey(e.code, e.instanceId)] = e.value
|
||||
|
||||
// 6. Consentimiento operativo requerido presente
|
||||
if (!consentOperativo) errors.push('consent_operativo_required')
|
||||
|
||||
const nonEmpty = entries.filter((e) => !isAnswerEmpty(e.value))
|
||||
|
||||
// 2. Preguntas legítimas · 3. Valores válidos · 5. Instancias legítimas
|
||||
for (const e of nonEmpty) {
|
||||
const q = byCode.get(e.code)
|
||||
if (!q) { errors.push(`unknown_question:${e.code}`); continue }
|
||||
if (q.type === 'section') { errors.push(`section_is_not_answerable:${e.code}`); continue }
|
||||
|
||||
if (q.instance_of) {
|
||||
const generator = byCode.get(q.instance_of)
|
||||
const instances = generator ? expandInstances(generator, answers) : []
|
||||
if (!instances.some((i) => i.instanceId === e.instanceId)) {
|
||||
errors.push(`invalid_instance:${e.code}:${e.instanceId}`)
|
||||
continue
|
||||
}
|
||||
} else if (e.instanceId !== 'default') {
|
||||
errors.push(`unexpected_instance_id:${e.code}:${e.instanceId}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const shapeError = validateValueShape(q, e.value)
|
||||
if (shapeError) errors.push(`${shapeError}:${e.code}`)
|
||||
}
|
||||
if (errors.length > 0) return { ok: false, errors }
|
||||
|
||||
// 4. Visibilidad coherente — cada respuesta corresponde a algo que el
|
||||
// flujo (con las respuestas recibidas) debía mostrar. Usa resolveVisibility
|
||||
// del motor directamente — ninguna lógica de show/hide se repite acá.
|
||||
for (const e of nonEmpty) {
|
||||
const q = byCode.get(e.code)!
|
||||
if (!resolveVisibility(q, answers)) {
|
||||
errors.push(`question_should_be_hidden:${e.code}:${e.instanceId}`)
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) return { ok: false, errors }
|
||||
|
||||
// 7. Requeridas presentes — recorre las pantallas que el motor resuelve
|
||||
// como visibles dadas estas respuestas, exige valor donde required=true.
|
||||
const screens = resolveScreens(schema, answers)
|
||||
for (const s of screens) {
|
||||
if (s.kind !== 'question' || !s.question?.required) continue
|
||||
const v = answers[answerKey(s.question.code, s.instanceId ?? undefined)]
|
||||
if (isAnswerEmpty(v)) {
|
||||
errors.push(`required_missing:${s.question.code}:${s.instanceId ?? 'default'}`)
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) return { ok: false, errors }
|
||||
|
||||
const rows: ValidatedRow[] = nonEmpty.map((e) => ({
|
||||
questionId: byCode.get(e.code)!.id,
|
||||
instanceId: e.instanceId,
|
||||
value: e.value,
|
||||
}))
|
||||
|
||||
return { ok: true, rows }
|
||||
}
|
||||
@@ -11,3 +11,14 @@ export function createClient() {
|
||||
|
||||
return createSupabaseClient(url, anonKey)
|
||||
}
|
||||
|
||||
// service_role — bypasa RLS por completo (regla 9 CLAUDE.md). Solo se importa
|
||||
// desde Server Components / Server Actions ('use server'), nunca desde código
|
||||
// que se bundlea al cliente. No exportar este cliente ni su resultado hacia props
|
||||
// de un Client Component.
|
||||
export function getSupabaseAdmin() {
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY ?? 'placeholder'
|
||||
|
||||
return createSupabaseClient(url, serviceKey)
|
||||
}
|
||||
|
||||
43
middleware.ts
Normal file
43
middleware.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request })
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value }) =>
|
||||
request.cookies.set(name, value)
|
||||
)
|
||||
supabaseResponse = NextResponse.next({ request })
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// getUser() valida el token contra el servidor — no confiar solo en la cookie local
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (!user) {
|
||||
const loginUrl = new URL('/login', request.url)
|
||||
return NextResponse.redirect(loginUrl)
|
||||
}
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/admin/:path*', '/dashboard/:path*'],
|
||||
}
|
||||
141
package-lock.json
generated
141
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"next": "15.3.8",
|
||||
"react": "^19.0.0",
|
||||
@@ -18,6 +19,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/cli": "^4.11.3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -1040,6 +1042,22 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -1047,9 +1065,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.107.0.tgz",
|
||||
"integrity": "sha512-XA7x+WIeIvuC3GTZ2ey67QcBbGw4n+o5B7M+dMm9KT1lL3wX1B52DfEWW00WuPt/LnniJLLIn1WIm9YPtuxzKQ==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.108.2.tgz",
|
||||
"integrity": "sha512-tNaQmBgodDZwgB40mRwVbxFy8IDYwjdpcZ0BYrWiwlULCSQoJj4QoG4zgJT7QRPXcqipefNOzvO/qAu4dF98ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1183,9 +1201,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.107.0.tgz",
|
||||
"integrity": "sha512-iMtRUmEj1KOgQd/a3MR4hnBlPnZc62DW8+z8aPpnzbxWkexEZUVL2fSgvvp15gqFg1V55e2yMGqgK+yhSQxp5w==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.108.2.tgz",
|
||||
"integrity": "sha512-RNUX8EiBy3iLwAX19jtRzLyePnl11/fHcgwDHLnpKcDSXt/5qBnh3LUwAtIjT21Q66QsmNUR2esrHziLCpNubw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1195,15 +1213,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
||||
"version": "0.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.4.tgz",
|
||||
"integrity": "sha512-Gt0pqoXuIqX/8dvG0OKp/wMCobXNH3klNbUPBNyOfN0YA1IswrM3HyWFMOPk1Jy+BRaIyDPcFx4jLBwHNmlyfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.107.0.tgz",
|
||||
"integrity": "sha512-7ARs47/tyIjX7T0Ive20d4NY8zQYXsP5/P07jJWxffSIM2gpnSnGRnL/Fe15GPbdjsW2sTYeckHcyaoKbM6yWQ==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.108.2.tgz",
|
||||
"integrity": "sha512-GQ28/Y8hk3CFmkb3kXH1h/AQx6JIYSQfO0CJMRVBcEKZoNy6C45cXAZ4fcJvRC5Id0cs6xnkUV0+c0rIocigsw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
@@ -1213,9 +1231,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.107.0.tgz",
|
||||
"integrity": "sha512-cF2KYdR3JIn9YlWGeluY9S0G+otqTdL6hB8GzpatlEIY6fZudCcyFo6Dc3+X9tjeb+x9XcIyNAk9qhNAknjH1A==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.108.2.tgz",
|
||||
"integrity": "sha512-aAGxCSUemZvQIibnCdvNvgaKib28I4rfrNjKbQ9cG1uBLwUsI7hVpGXgEbypCCDhLjQlDTAiJlu7rgljYUT73g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.2",
|
||||
@@ -1225,10 +1243,22 @@
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.0.tgz",
|
||||
"integrity": "sha512-d9XV5XzJvzzZbeAIM7fWTCUYxQJZ2Ru6ny3dJHmHGp/LIrJ+o9FpD7N9Rf/UhhWEvHXSoDe8SI32Z2ouOdMjBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@supabase/supabase-js": "^2.108.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.107.0.tgz",
|
||||
"integrity": "sha512-/X8OOVwKBn8aVKuHAGOz2yLA0d2OauqhVuy4mNtN+o7wttHOgx1/j+pqOzlsjmhOHrYykF6AJNZhs3gKZzcMUw==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.108.2.tgz",
|
||||
"integrity": "sha512-TVZPQxXGxY2+A6yTtm77zUHsh70lBhYUEaJL8RQC+BghcX/ygiMG/rmXrNVBce30/WAeNPa8FiG8HbqlGeV05g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
@@ -1239,16 +1269,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.107.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.107.0.tgz",
|
||||
"integrity": "sha512-ChKzdlWVweMUUhr0U79JhMmgm1haS/C5JquaiCDr70JaGARRtjjoY9rkIheXWybXxTSNzRiQs3Sk8IAg1HS3ZA==",
|
||||
"version": "2.108.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.108.2.tgz",
|
||||
"integrity": "sha512-hFhnPveb5JQg4a0QYicM0swT253YHMdfeRAl2BKHOlI5VAzuHxUGSr8RbwNLYNPauWOgQMS1H8sz8bvYlgwUfQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.107.0",
|
||||
"@supabase/functions-js": "2.107.0",
|
||||
"@supabase/postgrest-js": "2.107.0",
|
||||
"@supabase/realtime-js": "2.107.0",
|
||||
"@supabase/storage-js": "2.107.0"
|
||||
"@supabase/auth-js": "2.108.2",
|
||||
"@supabase/functions-js": "2.108.2",
|
||||
"@supabase/postgrest-js": "2.108.2",
|
||||
"@supabase/realtime-js": "2.108.2",
|
||||
"@supabase/storage-js": "2.108.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -1789,6 +1819,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
@@ -2109,6 +2152,20 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -2976,6 +3033,38 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@supabase/ssr": "^0.12.0",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"next": "15.3.8",
|
||||
"react": "^19.0.0",
|
||||
@@ -25,6 +26,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/cli": "^4.11.3",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
|
||||
14
playwright.config.ts
Normal file
14
playwright.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
// .env.test nunca se commitea (ver .gitignore) — credenciales de un usuario
|
||||
// dedicado de e2e, nunca las del director.
|
||||
process.loadEnvFile('.env.test')
|
||||
|
||||
export default defineConfig({
|
||||
testDir: 'tests/e2e',
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL,
|
||||
},
|
||||
// Local contra producción — no corre en CI (backlog).
|
||||
reporter: 'list',
|
||||
})
|
||||
8690
public/data/geo_paraguay_full.json
Normal file
8690
public/data/geo_paraguay_full.json
Normal file
File diff suppressed because it is too large
Load Diff
20
scripts/apply_all_migrations.sh
Executable file
20
scripts/apply_all_migrations.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Aplica todas las migraciones de supabase/migrations/ en orden de timestamp.
|
||||
# Uso: scripts/apply_all_migrations.sh
|
||||
set -e
|
||||
|
||||
MIGRATIONS_DIR="$(dirname "$0")/../supabase/migrations"
|
||||
|
||||
for f in $(ls "$MIGRATIONS_DIR"/*.sql | sort); do
|
||||
echo "Applying: $f"
|
||||
# -v ON_ERROR_STOP=1: sin esto, psql sigue ejecutando statements después de un
|
||||
# error SQL y termina con exit code 0 igual — set -e nunca vería el fallo.
|
||||
if docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$f"; then
|
||||
echo "Done: $f"
|
||||
else
|
||||
echo "ERROR applying: $f" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "All migrations applied."
|
||||
45
scripts/setup_all.sh
Executable file
45
scripts/setup_all.sh
Executable file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
# Recuperación completa de la DB tras una reinicialización (deploy que vació
|
||||
# supabase-db). Orden:
|
||||
# 1. Todas las migraciones (scripts/apply_all_migrations.sh)
|
||||
# 2. Catálogo de encuestas (supabase/seed.sql) — sin esto, las migraciones
|
||||
# dejan organizations/surveys/questions vacías y nada de lo que sigue
|
||||
# tiene contra qué referenciar (setup_first_admin.sql sí funcionaría,
|
||||
# pero seed_prueba_50.sql fallaría: rpc_submit_response necesita
|
||||
# questions.id reales, que solo existen después de este paso).
|
||||
# 3. Usuario platform_admin (scripts/setup_first_admin.sql)
|
||||
# 4. Datos de prueba (docs/dashboard/seed_prueba_50.sql)
|
||||
#
|
||||
# Uso:
|
||||
# ADMIN_EMAIL='admin@inquality.com.py' ADMIN_PASSWORD='...' scripts/setup_all.sh
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
ROOT_DIR="$SCRIPT_DIR/.."
|
||||
|
||||
if [ -z "$ADMIN_EMAIL" ] || [ -z "$ADMIN_PASSWORD" ]; then
|
||||
echo "ERROR: ADMIN_EMAIL y ADMIN_PASSWORD son obligatorios (variables de entorno)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "== 1/4: Aplicando todas las migraciones =="
|
||||
"$SCRIPT_DIR/apply_all_migrations.sh"
|
||||
|
||||
echo "== 2/4: Sembrando catálogo de encuestas (supabase/seed.sql) =="
|
||||
# ADVERTENCIA propia del archivo: "SOLO ENTORNOS DE DESARROLLO — nunca ejecutar
|
||||
# en producción". Esa advertencia es para NO re-correrlo contra una DB con
|
||||
# datos reales (DELETE+INSERT de questions con UUIDs nuevos puede chocar con
|
||||
# answers existentes via FK). En este escenario de recuperación la DB está
|
||||
# vacía (reinicializada por el deploy) — no hay answers/responses que puedan
|
||||
# quedar huérfanas. Si la DB NO está realmente vacía, frenar antes de este paso.
|
||||
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$ROOT_DIR/supabase/seed.sql"
|
||||
|
||||
echo "== 3/4: Creando usuario platform_admin =="
|
||||
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres \
|
||||
-v email="$ADMIN_EMAIL" -v password="$ADMIN_PASSWORD" \
|
||||
< "$ROOT_DIR/scripts/setup_first_admin.sql"
|
||||
|
||||
echo "== 4/4: Sembrando datos de prueba (seed_prueba_50.sql) =="
|
||||
docker exec -i supabase-db psql -v ON_ERROR_STOP=1 -U postgres -d postgres < "$ROOT_DIR/docs/dashboard/seed_prueba_50.sql"
|
||||
|
||||
echo "Setup completo."
|
||||
45
scripts/setup_first_admin.sql
Normal file
45
scripts/setup_first_admin.sql
Normal file
@@ -0,0 +1,45 @@
|
||||
-- ============================================================
|
||||
-- Script: crear primer usuario platform_admin
|
||||
--
|
||||
-- Verificado contra GoTrue v2.189.0 (supabase/gotrue:v2.189.0)
|
||||
-- Schema de auth.users puede variar entre versiones de GoTrue.
|
||||
-- Si falla con "column does not exist", verificar el schema real:
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_schema='auth' AND table_name='users'
|
||||
-- ORDER BY ordinal_position;
|
||||
--
|
||||
-- USO:
|
||||
-- docker exec -i supabase-db psql -U postgres -d postgres \
|
||||
-- -v email='admin@ejemplo.com' -v password='CAMBIAR' \
|
||||
-- < scripts/setup_first_admin.sql
|
||||
--
|
||||
-- NOTA: las variables psql solo funcionan en SQL plano, no en DO $$.
|
||||
-- Este script usa un CTE INSERT...RETURNING para encadenar ambos inserts.
|
||||
--
|
||||
-- CAMBIAR CONTRASEÑA LUEGO DEL PRIMER LOGIN:
|
||||
-- docker exec -i supabase-db psql -U postgres -d postgres \
|
||||
-- -c "UPDATE auth.users SET encrypted_password = extensions.crypt('NuevaContraseña', extensions.gen_salt('bf')) WHERE email = 'admin@ejemplo.com';"
|
||||
-- ============================================================
|
||||
|
||||
WITH new_user AS (
|
||||
INSERT INTO auth.users (
|
||||
id, instance_id, aud, role, email, encrypted_password,
|
||||
email_confirmed_at, created_at, updated_at,
|
||||
raw_app_meta_data, raw_user_meta_data,
|
||||
confirmation_token, recovery_token,
|
||||
email_change_token_new, email_change,
|
||||
is_super_admin
|
||||
) VALUES (
|
||||
gen_random_uuid(),
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'authenticated', 'authenticated',
|
||||
:'email',
|
||||
extensions.crypt(:'password', extensions.gen_salt('bf')),
|
||||
now(), now(), now(),
|
||||
'{"provider":"email","providers":["email"]}',
|
||||
'{}', '', '', '', '', false
|
||||
) RETURNING id
|
||||
)
|
||||
INSERT INTO public.user_roles (user_id, app_role, org_id)
|
||||
SELECT id, 'platform_admin', '10000000-0000-0000-0000-000000000002'
|
||||
FROM new_user;
|
||||
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
129
sprints/sprint-4/ETAPA_4D_PROMPT.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# ETAPA 4D — Frontend renderer: wizard pregunta-por-pantalla
|
||||
|
||||
## 1. Objetivo
|
||||
Reemplazar el formulario de una sola página (v2) por un wizard que consume el motor
|
||||
de flujo (4C) y muestra una pregunta por pantalla, con layout de altura estable.
|
||||
El componente es un RENDERER: dibuja la pantalla que el motor resuelve; no contiene
|
||||
lógica de flujo (ADR-005).
|
||||
|
||||
## 2. Contexto previo
|
||||
- Motor de flujo listo y testeado: `lib/form-engine.ts` (resolveScreens, next, back,
|
||||
resolveVisibility) + `lib/form-engine.types.ts`. 16/16 tests.
|
||||
- Widgets de input AISLADOS y reutilizables en `components/widgets/`:
|
||||
`radio-group`, `checkbox-group`, `text-short-field`, `text-area-field`
|
||||
(interfaz genérica value/onChange/error — se reutilizan tal cual).
|
||||
- `question-renderer.tsx` despacha por `type` — reutilizable con 2 cambios (ver tareas).
|
||||
- `survey-form.tsx` (una sola página, secciones por regex) se REESCRIBE entero.
|
||||
- `app/page.tsx` query NO trae `hint`/`instance_generator`/`instance_of` (tarea 4.5 pospuesta de 4C).
|
||||
- Gaps detectados: falta widget `yes_no`; hay hardcode A1/A2 en question-renderer a quitar.
|
||||
- Comportamiento de referencia (NO de arquitectura): mockup `wizard_formulario_mapeo_v3.html`.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- El componente consume el motor; NO reimplementa visibilidad/navegación/instancias.
|
||||
- Una pregunta por pantalla. Secciones = pantallas propias (intro con número + descripción).
|
||||
- Reutilizar los widgets de `components/widgets/` existentes.
|
||||
- Identificación de pantalla por ID estable (el motor ya lo provee).
|
||||
- **Layout de altura estable (UX 1+2+3), parte de esta etapa:**
|
||||
- (1) Viewport de altura estable: el enunciado se ancla en una posición consistente
|
||||
(tercio superior), NO centrado vertical. La caja no crece/encoge con el contenido.
|
||||
- (2) Header y footer FIJOS: progreso arriba, navegación (Anterior/Siguiente) abajo,
|
||||
ambos fuera del área que cambia de tamaño. No "saltan" entre pantallas.
|
||||
- (3) Área de respuesta con altura máxima y SCROLL INTERNO cuando hay muchas opciones,
|
||||
con affordance visual (degradado sutil en borde inferior) que indica "hay más abajo".
|
||||
- Usar los tokens de marca existentes (el verde RE, variables CSS/Tailwind del proyecto).
|
||||
NO inventar colores nuevos.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Ampliar la query y construir el FormSchema (app/page.tsx)
|
||||
- Agregar `hint, instance_generator, instance_of` al `.select(...)`.
|
||||
- Transformar las filas de `questions` (incluidas las `type='section'`) en el `FormSchema`
|
||||
que consume el motor.
|
||||
- Pasar el schema al nuevo componente wizard.
|
||||
|
||||
### 4.2 Nuevo componente wizard (reemplaza survey-form.tsx)
|
||||
- Mantiene el `WizardState` del motor (respuestas con instancias, pantalla actual).
|
||||
- En cada render: pide al motor la pantalla actual y la dibuja.
|
||||
- Navegación: botones Anterior/Siguiente llaman `next`/`back` del motor.
|
||||
- Maneja los tipos de pantalla: sección-intro, pregunta, pregunta-de-instancia, fin.
|
||||
- Para pantallas de instancia: muestra el badge de contexto ("Familiar X de N").
|
||||
|
||||
### 4.3 Layout de altura estable (UX 1+2+3)
|
||||
Implementar el layout descrito en las decisiones. Estructura sugerida:
|
||||
```
|
||||
[header fijo: logo + progreso] ← no scrollea
|
||||
[viewport estable]
|
||||
[sección-header (si aplica)]
|
||||
[enunciado anclado: código + pregunta + hint] ← posición consistente
|
||||
[área de respuesta: max-height + scroll interno + fade inferior]
|
||||
[footer fijo: Anterior | indicador | Siguiente] ← no scrollea
|
||||
```
|
||||
- El enunciado aparece en el mismo lugar pantalla tras pantalla.
|
||||
- El área de respuesta scrollea internamente; header/footer nunca se mueven.
|
||||
|
||||
### 4.4 question-renderer: ajustes
|
||||
- Quitar el hardcode A1/A2 (no aplica a v3; 2.1/2.2 son single_choice genéricos).
|
||||
- Agregar el caso `yes_no` (lo usan 1.1/1.2). Crear `widgets/yes-no-field.tsx` si no existe,
|
||||
o resolver yes_no con el RadioGroup existente si es lo más simple — proponé cuál.
|
||||
- Migrar las shapes de `lib/types.ts` (v2) a `lib/form-engine.types.ts` (v3) donde el
|
||||
renderer reciba datos del motor.
|
||||
|
||||
### 4.5 Progreso
|
||||
- La barra/indicador de progreso se calcula desde el motor (pantalla actual / total visible),
|
||||
no desde "preguntas respondidas". Reutilizar `progress-bar.tsx` si encaja.
|
||||
|
||||
### 4.6 Consentimiento
|
||||
- Reutilizar `consent-screen.tsx` como pantalla 1 (Sección 1), o integrarlo como la primera
|
||||
pantalla del wizard — proponé cuál es más limpio dado que ahora todo es wizard.
|
||||
|
||||
## 5. Entregables (DoD)
|
||||
- [ ] Wizard funcional una-pregunta-por-pantalla consumiendo el motor.
|
||||
- [ ] Layout de altura estable: header/footer fijos, enunciado anclado, scroll interno + fade.
|
||||
- [ ] Query ampliada con las 3 columnas; FormSchema construido y pasado al componente.
|
||||
- [ ] yes_no renderiza; hardcode A1/A2 removido.
|
||||
- [ ] Progreso calculado desde el motor.
|
||||
- [ ] Widgets de v2 reutilizados (no reescritos).
|
||||
- [ ] **Validación visual del director** contra el mockup + criterios de UX estable.
|
||||
- [ ] Build sin errores; sin lógica de flujo en el componente (solo consume el motor).
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Wizard consume el motor (resolveScreens/next/back) ✓ — sin lógica de flujo propia
|
||||
- Layout estable: header/footer fijos ✓, enunciado anclado ✓, scroll interno + fade ✓
|
||||
- Query ampliada (hint/instance_generator/instance_of) ✓
|
||||
- yes_no resuelto vía [RadioGroup | widget nuevo] ✓
|
||||
- Hardcode A1/A2 removido ✓
|
||||
- Widgets reutilizados: [lista]
|
||||
- Build: [resultado]
|
||||
- Capturas/descripción visual de 3 pantallas: pregunta corta, pregunta con muchas opciones
|
||||
(scroll interno), sección-intro
|
||||
|
||||
Asumido / Decisiones de diseño:
|
||||
- [consentimiento como pantalla / yes_no / etc.]
|
||||
|
||||
Hallazgos no esperados:
|
||||
- [lo que aparezca]
|
||||
```
|
||||
Reporte con datos concretos, no "se ve bien" (Patrón C.4). Describí el comportamiento del
|
||||
layout estable: ¿el enunciado queda en el mismo lugar entre una pregunta de 3 opciones y
|
||||
una de 15? ¿El footer se mantiene fijo?
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No poner lógica de flujo/visibilidad/instancias en el componente (vive en el motor).
|
||||
- ❌ No reescribir los widgets de input (reutilizarlos).
|
||||
- ❌ No implementar el submit todavía (es 4G) ni la validación server-side.
|
||||
- ❌ No implementar el autocomplete de barrios (es 4F) — barrio queda como text_short por ahora.
|
||||
- ❌ No inventar colores fuera de los tokens de marca.
|
||||
- ❌ No tocar v2.1.0-stable, el motor (4C), ni el seed.
|
||||
|
||||
## 8. Versionado
|
||||
Commit sugerido:
|
||||
```
|
||||
feat(wizard): renderer pregunta-por-pantalla con layout estable [4D]
|
||||
|
||||
Reemplaza el formulario de una página por wizard que consume el motor (4C).
|
||||
Layout de altura estable: header/footer fijos, enunciado anclado, scroll
|
||||
interno con affordance. Reutiliza widgets de input. Agrega yes_no, quita
|
||||
hardcode A1/A2. Query ampliada con columnas declarativas de v3.
|
||||
```
|
||||
121
sprints/sprint-4/ETAPA_4G_PROMPT.md
Normal file
121
sprints/sprint-4/ETAPA_4G_PROMPT.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# ETAPA 4G — Submit con instance_id + validación de integridad server-side
|
||||
|
||||
## 1. Objetivo
|
||||
Escribir el submit de v3: persiste las respuestas (incluyendo instancias) y VALIDA la
|
||||
integridad en el servidor contra el esquema antes de insertar. La validación server-side
|
||||
es REQUISITO NO NEGOCIABLE (BRIEF decisión 15). El cliente no es confiable; datos de salud.
|
||||
|
||||
## 2. Contexto previo
|
||||
- El submit de v2 (`app/actions/submit-survey.ts`) NO está conectado y está desactualizado:
|
||||
- `WITH CHECK (true)` en RLS de answers → acepta cualquier insert (sin validación real).
|
||||
- payload plano `Record<code,value>` — sin noción de `instance_id`.
|
||||
- códigos qi_* apuntan a A1/A4/A5 (v2), no existen en v3 (son 2.1/2.4/2.5).
|
||||
- 3 INSERTs sueltos sin transacción → si answers falla, responses/consent quedan huérfanos.
|
||||
- El motor `lib/form-engine.ts` es PURO → se puede correr SERVER-SIDE para validar.
|
||||
NO reescribir la lógica de flujo: reusar el motor.
|
||||
- `answers.instance_id` existe (4A), constraint UNIQUE(response_id, question_id, instance_id).
|
||||
- El estado del wizard ya tiene claves `code:instanceId` (4C/4D).
|
||||
- Se usa anon key respetando RLS (regla 9 de CLAUDE.md) — NUNCA service_role.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- Validación de integridad SERVER-SIDE, no negociable.
|
||||
- Reusar `lib/form-engine.ts` en el servidor para validar coherencia de flujo — no duplicar lógica.
|
||||
- El submit rechaza el envío ENTERO si algo no valida — no inserta datos parciales ni inyectados.
|
||||
- El payload del cliente pasa a ser consciente de instancia: `{code, instanceId, value}[]`
|
||||
(o equivalente que preserve la clave compuesta).
|
||||
- Transaccionalidad: las inserciones (consent_records → responses → answers) deben ser atómicas.
|
||||
Evaluar función RPC transaccional (SECURITY DEFINER con validaciones) vs. inserts en el
|
||||
Server Action. Proponé el enfoque más robusto dado que anon no puede hacer múltiples
|
||||
inserts atómicos fácilmente — una RPC transaccional es probablemente lo correcto.
|
||||
- Códigos qi_* actualizados a v3 (2.1 zona, 2.4 edad, 2.5 sector — verificar contra el seed).
|
||||
|
||||
## 4. Validaciones server-side mandatorias
|
||||
Por cada submit recibido, el servidor valida CONTRA EL ESQUEMA de la DB (no contra el cliente):
|
||||
1. **Survey válido y activo:** el `survey_id` existe y está en estado que admite respuestas.
|
||||
2. **Preguntas legítimas:** cada `code` recibido existe y pertenece a ESE survey.
|
||||
3. **Valores válidos:** cada valor es una opción válida de su pregunta (para single/multiple_choice),
|
||||
respeta `max_select`, tipo correcto.
|
||||
4. **Visibilidad coherente:** usando el motor (`resolveScreens`/`resolveVisibility`) con las
|
||||
respuestas recibidas, cada respuesta enviada corresponde a una pregunta que DEBÍA estar
|
||||
visible. Rechazar respuestas a preguntas que el flujo nunca debió mostrar (ej. responder
|
||||
3.2 cuando 3.1='No' las oculta).
|
||||
5. **Instancias legítimas:** cada `instance_id` (familiar_N) corresponde a una instancia
|
||||
realmente generada por su `instance_generator` (4.1) según la respuesta real recibida para 4.1.
|
||||
Rechazar instancias inventadas.
|
||||
6. **Consentimiento requerido presente:** el consentimiento operativo (requerido) está.
|
||||
7. **Requeridas presentes:** las preguntas required visibles tienen respuesta.
|
||||
|
||||
Si CUALQUIERA falla: rechazar el submit completo, no insertar nada, devolver error claro.
|
||||
|
||||
## 5. Tareas
|
||||
### 5.1 Definir el contrato del payload v3
|
||||
Shape consciente de instancia. El wizard ya tiene `state.answers` con claves `code:instanceId`.
|
||||
Definir el payload que el cliente envía y el validador consume.
|
||||
|
||||
### 5.2 Validador server-side (reusando el motor)
|
||||
Módulo/función que recibe el payload + carga el esquema desde la DB + corre las 7 validaciones
|
||||
del punto 4, reusando `lib/form-engine.ts`. Puro y testeable donde se pueda.
|
||||
|
||||
### 5.3 Persistencia transaccional
|
||||
Implementar la inserción atómica (consent_records → responses → answers con instance_id).
|
||||
Proponer e implementar RPC transaccional o mecanismo equivalente. Si una RPC SECURITY DEFINER
|
||||
es el camino, documentar por qué y qué valida internamente.
|
||||
|
||||
### 5.4 Conectar el wizard al submit
|
||||
El botón "Enviar (próximamente)" pasa a enviar de verdad. Manejar estados éxito/error
|
||||
(los stage que 4D dejó sin implementar). Mensajes de error accionables, nunca trace crudo.
|
||||
|
||||
### 5.5 Tests
|
||||
- Validación: submit con respuesta a pregunta oculta → rechazado.
|
||||
- Validación: valor fuera de opciones → rechazado.
|
||||
- Validación: instancia inventada → rechazada.
|
||||
- Validación: sin consentimiento requerido → rechazado.
|
||||
- Happy path: submit completo válido (con instancias) → persiste correctamente con instance_id.
|
||||
- Atomicidad: fallo a mitad → no quedan datos parciales.
|
||||
|
||||
## 6. Entregables (DoD)
|
||||
- [ ] Validación server-side completa (7 validaciones) reusando el motor.
|
||||
- [ ] Submit rechaza envío entero si algo no valida; sin datos parciales.
|
||||
- [ ] Persistencia transaccional (sin huérfanos ante fallo).
|
||||
- [ ] instance_id persistido correctamente por instancia.
|
||||
- [ ] Códigos qi_* actualizados a v3.
|
||||
- [ ] Wizard conectado al submit, estados éxito/error funcionando.
|
||||
- [ ] Tests de las validaciones + happy path + atomicidad.
|
||||
- [ ] anon key, nunca service_role (salvo dentro de RPC SECURITY DEFINER justificada).
|
||||
- [ ] **Validación del director** antes de aplicar a producción.
|
||||
|
||||
## 7. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Validación server-side: 7/7 implementadas, reusando lib/form-engine.ts ✓
|
||||
- Casos rechazados probados: pregunta oculta, valor inválido, instancia falsa, sin consentimiento
|
||||
- Persistencia transaccional vía [RPC / mecanismo] ✓ — atomicidad probada
|
||||
- instance_id persistido: [ejemplo real de filas answers con familiar_1/familiar_2]
|
||||
- Códigos qi_* actualizados a v3 ✓
|
||||
- Wizard conectado, estados éxito/error ✓
|
||||
- Tests: N/N
|
||||
Asumido / decisiones:
|
||||
- [enfoque transaccional y por qué]
|
||||
Hallazgos:
|
||||
- [lo que aparezca]
|
||||
```
|
||||
Reporte con datos concretos (C.4): mostrá filas reales de answers con instance_id tras un
|
||||
submit de prueba con 2 familiares.
|
||||
|
||||
## 8. Qué NO hacer
|
||||
- ❌ No confiar en validación de cliente para integridad (es UX, no seguridad).
|
||||
- ❌ No duplicar la lógica de flujo — reusar el motor server-side.
|
||||
- ❌ No insertar datos parciales si la validación falla.
|
||||
- ❌ No usar service_role fuera de una RPC SECURITY DEFINER justificada y documentada.
|
||||
- ❌ No aplicar a producción sin validación del director.
|
||||
- ❌ No tocar v2.1.0-stable, el seed, ni el motor (salvo para CONSUMIRLO en validación).
|
||||
|
||||
## 9. Versionado
|
||||
```
|
||||
feat(submit): submit v3 con validación de integridad server-side [4G]
|
||||
|
||||
Validación server-side (no negociable) reusando el motor: rechaza respuestas
|
||||
a preguntas ocultas, valores inválidos, instancias falsas, sin consentimiento.
|
||||
Persistencia transaccional con instance_id por instancia. Conecta el wizard.
|
||||
Cierra la falla de WITH CHECK(true): el cliente ya no es fuente de verdad.
|
||||
```
|
||||
98
sprints/sprint-4/ETAPA_4H_PROMPT.md
Normal file
98
sprints/sprint-4/ETAPA_4H_PROMPT.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# ETAPA 4H — Migrar las RPCs del dashboard de códigos v2 a v3
|
||||
|
||||
## 1. Objetivo
|
||||
Las 9 RPCs del dashboard referencian códigos de pregunta de v2 (`1.6`, `1.13`, `A6`,
|
||||
`A7`, `3.4`, `6.2`, etc.) que NO existen en el instrumento v3. Corriendo contra el seed
|
||||
v3 devuelven 0 en casi todo. Esta etapa migra los códigos v2→v3 para que el dashboard
|
||||
vuelva a funcionar con el instrumento nuevo. Es REPARACIÓN, no funcionalidad nueva.
|
||||
|
||||
## 2. Contexto previo
|
||||
- Las RPCs viven en `supabase/migrations/20260605140001_012_dashboard_rpc_v2.sql`
|
||||
+ parche `20260611000001_dash_responses_all.sql` (flag p_all).
|
||||
- 9 RPCs públicas + 2 helpers (`_dash_responses`, `_dash_parse_filters`).
|
||||
- k-anonimato k≥5 en dos niveles (total y por-segmento) — NO tocar la lógica de k,
|
||||
solo los códigos de pregunta.
|
||||
- Flag p_all = UUID sentinela `00000000-...` en `_dash_responses` — NO tocar.
|
||||
- El instrumento v3 está en el seed (códigos 2.1-10.1, 10 secciones). El mapeo de
|
||||
códigos v2→v3 hay que derivarlo del seed v3 y del mockup validado.
|
||||
- Esta etapa NO incluye los datos de vínculo por instancia (4.2/4.3) — eso es la etapa
|
||||
siguiente (vínculo). Acá solo se migran los códigos de las preguntas NO instanciadas.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- Solo se migran CÓDIGOS de pregunta v2→v3 y los VALORES esperados si cambiaron
|
||||
(ej. opciones de respuesta que se reescribieron en v3).
|
||||
- NO se cambia la lógica de k-anonimato, ni el flag p_all, ni la estructura de las RPCs.
|
||||
- NO se agregan las RPCs de vínculo (es la etapa siguiente).
|
||||
- Si una métrica de v2 ya no tiene pregunta equivalente en v3, se REPORTA (no se inventa
|
||||
un mapeo) — decisión del director/metodología.
|
||||
- Migración nueva, no se editan las migraciones viejas de RPCs.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Construir la tabla de mapeo v2→v3
|
||||
Antes de tocar SQL, derivar del seed v3 y documentar el mapeo de cada código v2 usado
|
||||
en las RPCs a su equivalente v3. Ejemplos a resolver (verificar contra el seed real):
|
||||
- `1.6`/`1.13` (triggers de cuidado v2) → ¿qué los representa en v3? (probablemente 4.1
|
||||
con sus opciones de vínculo, o las preguntas de cuidado de S5-S7)
|
||||
- `A6`/`A7` (demográficas v2) → 2.x en v3
|
||||
- `3.4` (salario v2) → 8.4 en v3
|
||||
- `6.2` (apoyos v2) → 9.2 en v3
|
||||
- etc. — mapear TODOS los códigos que aparezcan en las 9 RPCs.
|
||||
Reportar esta tabla ANTES de escribir el SQL, porque algunos mapeos requieren confirmación
|
||||
(las métricas de "cuidador activo" cambian: v2 usaba 3 triggers, v3 usa 4.1 multi-opción).
|
||||
|
||||
### 4.2 Migración nueva con las RPCs actualizadas
|
||||
Crear `supabase/migrations/2026XXXXXXXXXX_dashboard_rpc_v3.sql` que recree las 9 RPCs
|
||||
con los códigos v3. Mantener idéntica: estructura, lógica de k≥5, manejo de p_all,
|
||||
shape del JSON de retorno (para no romper los widgets).
|
||||
|
||||
### 4.3 Verificar valores de respuesta esperados
|
||||
Algunas RPCs comparan contra valores literales (`a.value #>> '{}' = 'Sí'`). Si las opciones
|
||||
de respuesta cambiaron en v3 (wording), actualizar esos literales. Verificar contra el seed.
|
||||
|
||||
### 4.4 Reportar métricas sin equivalente
|
||||
Si alguna métrica v2 no tiene pregunta v3 equivalente, listarla para decisión — no inventar.
|
||||
|
||||
## 5. Entregables (DoD)
|
||||
- [ ] Tabla de mapeo v2→v3 documentada y reportada ANTES del SQL.
|
||||
- [ ] Migración que recrea las 9 RPCs con códigos v3.
|
||||
- [ ] Lógica de k≥5, p_all y shape de retorno SIN cambios.
|
||||
- [ ] Literales de valores de respuesta actualizados donde el wording cambió.
|
||||
- [ ] Métricas sin equivalente v3 reportadas (no inventadas).
|
||||
- [ ] NO incluye RPCs de vínculo (etapa siguiente).
|
||||
- [ ] NO aplicada a producción (solo archivo) hasta validación del director.
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Tabla de mapeo v2→v3: [código v2 → código v3, por cada uno]
|
||||
- 9 RPCs migradas ✓
|
||||
- k≥5 sin cambios ✓ / p_all sin cambios ✓ / shape de retorno sin cambios ✓
|
||||
- Literales de valores actualizados: [cuáles]
|
||||
|
||||
Métricas sin equivalente v3 (requieren decisión):
|
||||
- [lista o "ninguna"]
|
||||
|
||||
Asumido:
|
||||
- [mapeos que asumió y conviene confirmar]
|
||||
|
||||
NO aplicado a producción.
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No cambiar la lógica de k-anonimato, p_all, ni el shape del JSON de retorno.
|
||||
- ❌ No agregar las RPCs de vínculo por instancia (es la etapa siguiente).
|
||||
- ❌ No inventar mapeos para métricas sin equivalente — reportar.
|
||||
- ❌ No editar las migraciones viejas de RPCs.
|
||||
- ❌ No aplicar a producción sin validación.
|
||||
- ❌ No tocar v2.1.0-stable ni el motor.
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
fix(dashboard): migrar RPCs de códigos v2 a v3 [4H]
|
||||
|
||||
Las 9 RPCs del dashboard referenciaban códigos v2 inexistentes en v3
|
||||
(devolvían 0 con el seed nuevo). Migrados a códigos v3. k-anonimato,
|
||||
flag p_all y shape de retorno sin cambios. No incluye datos de vínculo
|
||||
por instancia (etapa siguiente).
|
||||
```
|
||||
113
sprints/sprint-4/ETAPA_4I_PROMPT.md
Normal file
113
sprints/sprint-4/ETAPA_4I_PROMPT.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# ETAPA 4I — Validación e2e + Release v3.0.0
|
||||
|
||||
## 1. Objetivo
|
||||
Validar el wizard v3 end-to-end y hacer el release a producción. Los 3 surveys
|
||||
pasan de `draft` a `live`. Esta etapa es irreversible en varios pasos — seguir
|
||||
el orden estricto de fases y detenerse si algo falla.
|
||||
|
||||
## 2. Estado de entrada
|
||||
- Último commit: `3e7109a0` (4F ciudad-select)
|
||||
- Commits no desplegados desde último deploy:
|
||||
`8dea04c` fix cnt RPCs · `dbcc60a` tema claro · `3ec5da0` autocomplete barrios ·
|
||||
`3e7109a0` ciudad-select
|
||||
- VPS: seed de 50 respuestas activo (Demo en `live`) — NO limpiar, el director
|
||||
quiere mostrar el dashboard al equipo con esos datos
|
||||
- Los 3 surveys están en `draft`
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Deploy vía Dokploy (rebuild + clean cache)
|
||||
- Migraciones SIEMPRE vía CLI del VPS — ninguna migración pendiente en esta etapa
|
||||
- Los 3 surveys se reactivan a `live` solo DESPUÉS de verificar producción
|
||||
- Tag `v3.0.0` solo DESPUÉS de reactivar surveys
|
||||
- No hay migración de datos v2 → v3 (arranque limpio)
|
||||
|
||||
## 4. Fases — orden estricto, detenerse si algo falla
|
||||
|
||||
### FASE 1 — Validación e2e local (antes de tocar producción)
|
||||
Correr el wizard completo localmente y verificar:
|
||||
- [ ] Consentimiento → flujo completo → submit exitoso
|
||||
- [ ] Una respuesta con al menos 1 familiar (instancia) persiste con instance_id
|
||||
- [ ] El motor no muestra preguntas que el flujo oculta
|
||||
- [ ] El autocomplete de barrio funciona (dropdown visible, no tapado)
|
||||
- [ ] El filtro ciudad-departamento funciona (2.1 → 2.2 filtra, cambiar 2.1 limpia 2.2)
|
||||
- [ ] Submit devuelve confirmación, no error
|
||||
|
||||
Si CUALQUIER item falla: DETENER. Reportar el fallo y esperar instrucción.
|
||||
|
||||
### FASE 2 — Deploy en Dokploy
|
||||
- Trigger rebuild en Dokploy con "Clean cache"
|
||||
- Verificar que el BUILD_ID nuevo aparece en los logs
|
||||
- Verificar que la app responde en `economia-cuidado.inqualityhq.com`
|
||||
- Verificar que el dashboard sigue mostrando los 50 datos sembrados
|
||||
- Verificar que el wizard abre y llega a la pregunta 2.3 sin errores de consola
|
||||
|
||||
Si CUALQUIER item falla: DETENER. Reportar el fallo y esperar instrucción.
|
||||
|
||||
### FASE 3 — Reactivar surveys + Tag
|
||||
Solo ejecutar si Fase 1 y Fase 2 están completas y verificadas.
|
||||
|
||||
Reactivar los 3 surveys en el VPS:
|
||||
```sql
|
||||
UPDATE surveys SET status = 'live'
|
||||
WHERE id IN (
|
||||
SELECT id FROM surveys WHERE status = 'draft'
|
||||
);
|
||||
```
|
||||
|
||||
Verificar que quedaron en `live`:
|
||||
```sql
|
||||
SELECT id, name, status FROM surveys ORDER BY name;
|
||||
```
|
||||
|
||||
Tag de release en el repo local y push:
|
||||
```bash
|
||||
git tag -a v3.0.0 -m "Release v3.0.0 — wizard declarativo + dashboard tres miradas"
|
||||
git push origin v3.0.0
|
||||
```
|
||||
|
||||
Actualizar CHANGELOG.md con las etapas 4C→4I completadas.
|
||||
|
||||
## 5. DoD completo
|
||||
- [ ] `git log --name-only -1` antes de empezar — confirmar commit de entrada
|
||||
- [ ] Fase 1 completa sin fallos
|
||||
- [ ] Fase 2 completa — app en producción respondiendo
|
||||
- [ ] Surveys en `live` — verificado con SELECT
|
||||
- [ ] Tag `v3.0.0` pusheado a Gitea
|
||||
- [ ] CHANGELOG.md actualizado
|
||||
- [ ] `git log --name-only -1` del commit de CHANGELOG
|
||||
|
||||
## 6. Qué reportar
|
||||
Por fase, en orden:
|
||||
FASE 1 — e2e local:
|
||||
|
||||
Submit con familiar: [response_id y instance_id del registro insertado]
|
||||
Items del checklist: ✓/✗ cada uno
|
||||
|
||||
FASE 2 — Deploy:
|
||||
|
||||
BUILD_ID nuevo: [valor]
|
||||
App responde: ✓/✗
|
||||
Dashboard con datos: ✓/✗
|
||||
Wizard sin errores de consola: ✓/✗
|
||||
|
||||
FASE 3 — Release:
|
||||
|
||||
Surveys en live: [output del SELECT]
|
||||
Tag v3.0.0: [output de git tag]
|
||||
CHANGELOG: [secciones agregadas]
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No reactivar surveys antes de verificar producción (Fase 2 primero)
|
||||
- ❌ No taggear antes de reactivar surveys (Fase 3 es la última)
|
||||
- ❌ No limpiar el seed de 50 respuestas — el director los necesita para mostrar al equipo
|
||||
- ❌ No aplicar migraciones — no hay ninguna pendiente en esta etapa
|
||||
- ❌ No tocar `v2.1.0-stable`
|
||||
- ❌ No continuar si algo falla — detenerse y reportar
|
||||
|
||||
## 8. Versionado
|
||||
chore(release): CHANGELOG v3.0.0 — wizard declarativo + dashboard tres miradas
|
||||
Etapas 4C→4I completadas. Motor de flujo puro, wizard renderer,
|
||||
|
||||
instancias familiares, submit con validación server-side, dashboard
|
||||
|
||||
tres miradas (Mirada Empresa funcional), autocomplete barrios/ciudad.
|
||||
148
sprints/sprint-4/ETAPA_AUTH_PROMPT.md
Normal file
148
sprints/sprint-4/ETAPA_AUTH_PROMPT.md
Normal file
@@ -0,0 +1,148 @@
|
||||
# ETAPA AUTH — Supabase Auth + protección /admin
|
||||
|
||||
## 1. Objetivo
|
||||
Implementar autenticación email/password con Supabase Auth y proteger
|
||||
la ruta /admin/*. El dashboard y las vistas de admin quedan detrás de login.
|
||||
Sin esta etapa no se avanza a vista admin mejorada ni vista detallada.
|
||||
|
||||
## 2. Contexto
|
||||
- `current_app_role()` y `current_org_id()` ya leen del JWT — el esquema está listo.
|
||||
- No hay middleware.ts ni sistema de auth hoy — /admin/responses está abierto.
|
||||
- Organizations existentes: Empresa Demo (…0001), InQuality (…0002),
|
||||
Fundación Solidaridad (…0003).
|
||||
- No hay usuarios en auth.users — hay que crear el primero como parte de esta etapa.
|
||||
- La app usa anon key para el formulario público — eso NO cambia.
|
||||
- /admin/* usa service_role hoy — eso SÍ cambia: pasa a usar sesión del usuario.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Supabase Auth nativo, email/password. Sin OAuth por ahora.
|
||||
- Dos roles: `platform_admin` (acceso total) y `org_admin` (acceso a su org).
|
||||
- Roles van como custom claims en el JWT: `app_role` y `org_id`.
|
||||
- Middleware Next.js protege /admin/* — redirige a /login si no hay sesión válida.
|
||||
- Página de login en /login — simple, solo email/password, sin registro público.
|
||||
- El formulario público (/encuesta/*) NO se toca — sigue con anon key.
|
||||
- Las RPCs del dashboard usan current_app_role() y current_org_id() del JWT —
|
||||
cuando el usuario esté autenticado, RLS funciona automáticamente.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Diagnóstico previo — mecanismo de custom claims
|
||||
ANTES de implementar, reportar qué mecanismo de JWT customization está disponible
|
||||
en esta versión de Supabase self-hosted. Puede ser:
|
||||
- Hook SQL en `auth.hooks`
|
||||
- Override de función `auth.jwt()`
|
||||
- Edge Function hook
|
||||
No implementar nada hasta confirmar el mecanismo y reportarlo.
|
||||
|
||||
### 4.2 Migración — tabla user_roles + custom claims hook
|
||||
Migración `20260627000001_auth_user_roles.sql`:
|
||||
|
||||
```sql
|
||||
-- Tabla de roles
|
||||
CREATE TABLE public.user_roles (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
app_role text NOT NULL CHECK (app_role IN ('platform_admin','org_admin')),
|
||||
org_id uuid REFERENCES public.organizations(id)
|
||||
);
|
||||
|
||||
-- RLS: solo el propio usuario puede ver su rol (y platform_admin ve todos)
|
||||
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||
```
|
||||
|
||||
Implementar el hook de custom claims según el mecanismo disponible confirmado en 4.1.
|
||||
El JWT resultante debe incluir:
|
||||
```json
|
||||
{ "app_role": "platform_admin", "org_id": "10000000-0000-0000-0000-000000000002" }
|
||||
```
|
||||
|
||||
### 4.3 Script de setup — primer usuario admin
|
||||
Script operativo (NO migración) en `scripts/setup_first_admin.sql`:
|
||||
- Crea el usuario en auth.users vía INSERT directo o Supabase admin API
|
||||
- Inserta en user_roles con app_role='platform_admin' y org_id de InQuality
|
||||
- NO hardcodear credenciales — usar variables `:email` y `:password` con instrucciones
|
||||
de cómo ejecutarlo:
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -d postgres \
|
||||
-v email='admin@ejemplo.com' -v password='CAMBIAR' \
|
||||
< scripts/setup_first_admin.sql
|
||||
```
|
||||
- Documentar en el script cómo cambiar la contraseña después del primer login
|
||||
|
||||
### 4.4 Middleware Next.js
|
||||
`middleware.ts` en la raíz del proyecto:
|
||||
- Protege `/admin/*` y `/dashboard/*`
|
||||
- Si no hay sesión válida → redirect a `/login`
|
||||
- Si hay sesión → deja pasar
|
||||
- `/`, `/encuesta/*`, `/login` — sin restricción
|
||||
|
||||
### 4.5 Página de login
|
||||
`app/login/page.tsx` — Client Component:
|
||||
- Formulario email/password
|
||||
- Llama `supabase.auth.signInWithPassword()`
|
||||
- En éxito: redirect a `/admin/responses`
|
||||
- En error: mensaje claro ("Email o contraseña incorrectos"), nunca trace crudo
|
||||
- Sin registro público
|
||||
- Usar clases Tailwind existentes — sin nuevo sistema de diseño
|
||||
|
||||
### 4.6 Actualizar /admin/responses
|
||||
- Reemplazar `getSupabaseAdmin()` (service_role) por cliente de sesión del usuario
|
||||
- RLS de Postgres filtra según app_role y org_id del JWT automáticamente
|
||||
- Agregar botón "Cerrar sesión" (`supabase.auth.signOut()` → redirect a `/login`)
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — listar todos los archivos tocados
|
||||
- [ ] Diagnóstico de mecanismo de custom claims reportado (4.1) antes de implementar
|
||||
- [ ] Migración `20260627000001` aplicada en VPS — verificar con:
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -d postgres -c "\d user_roles"
|
||||
```
|
||||
- [ ] Script `scripts/setup_first_admin.sql` existe y está documentado
|
||||
- [ ] Hook JWT verificado — el token incluye `app_role` (cómo verificarlo: reportar el método)
|
||||
- [ ] `/login` funciona — email/password → sesión activa → redirect a /admin/responses
|
||||
- [ ] `/admin/responses` sin sesión → redirect a `/login`
|
||||
- [ ] `/admin/responses` con sesión → carga datos correctamente
|
||||
- [ ] Formulario público sin cambios (verificar que /encuesta/* sigue funcionando)
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] Commit + push a Gitea
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Diagnóstico previo (4.1):
|
||||
- Mecanismo de custom claims disponible: [cuál]
|
||||
- Alternativas descartadas: [por qué]
|
||||
|
||||
Verificado:
|
||||
- Migración user_roles aplicada: ✓ — output de \d user_roles
|
||||
- Hook JWT: app_role presente en token ✓/✗ — método de verificación usado
|
||||
- /login funciona: ✓/✗
|
||||
- /admin sin sesión → /login: ✓/✗
|
||||
- /admin con sesión → datos: ✓/✗
|
||||
- Formulario público sin cambios: ✓/✗
|
||||
- Build: ✓/✗
|
||||
- git log --name-only -1: [lista completa de archivos]
|
||||
|
||||
Asumido / decisiones:
|
||||
- [mecanismo de hook y razonamiento]
|
||||
|
||||
Hallazgos:
|
||||
- [lo que aparezca]
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar el formulario público ni la anon key
|
||||
- ❌ No hardcodear credenciales en ningún archivo commiteado
|
||||
- ❌ No implementar registro público de usuarios
|
||||
- ❌ No usar service_role en rutas que ya tienen sesión de usuario
|
||||
- ❌ No avanzar a vista admin mejorada — eso es la etapa siguiente
|
||||
- ❌ No hacer deploy — solo commit y push a Gitea
|
||||
- ❌ No implementar el hook sin reportar primero el diagnóstico de 4.1
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(auth): Supabase Auth email/password + middleware + login [auth]
|
||||
|
||||
Protege /admin/* con Supabase Auth. Tabla user_roles con custom claims
|
||||
(app_role, org_id) en JWT. Middleware Next.js redirige a /login sin sesión.
|
||||
Página /login con email/password. /admin/responses migrado de service_role
|
||||
a sesión de usuario autenticado.
|
||||
```
|
||||
62
sprints/sprint-4/ETAPA_DASHBOARD_CLARO.md
Normal file
62
sprints/sprint-4/ETAPA_DASHBOARD_CLARO.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# ETAPA — Dashboard tema claro (marca Re)
|
||||
|
||||
## 1. Objetivo
|
||||
Convertir el dashboard de tema oscuro a tema claro usando la paleta oficial de marca Re.
|
||||
Un solo archivo modificado: `app/globals.css`.
|
||||
|
||||
## 2. Decisiones ya tomadas — no consultar
|
||||
- Fondo página: #E7E6E5 (gris claro Re)
|
||||
- Cards/widgets: #FFFFFF
|
||||
- Texto principal: #494F4F (carbón Re)
|
||||
- Acento teal, colores de alerta inline y barras de pct: SIN CAMBIO
|
||||
- Texto de tabs activos sobre teal (.db-survey-link-active): #FFFFFF
|
||||
|
||||
## 3. Tarea
|
||||
En `app/globals.css`, sección DASHBOARD (línea 710 en adelante),
|
||||
aplicar los siguientes reemplazos SOLO en esa sección:
|
||||
|
||||
FONDOS:
|
||||
- background: #0F1923 → background: #E7E6E5 (.db-page, línea 716)
|
||||
- background: #1A2535 → background: #FFFFFF (.db-widget, línea 741)
|
||||
- background: #14202e → background: #F0EFEE (.db-sidebar, línea 887)
|
||||
- background: #101a30 → background: #F0EFEE (.db-lens-intro, .db-placeholder)
|
||||
- background: #2a3547 → background: rgba(73,79,79,0.10) (.db-bar-track)
|
||||
- background: #1e2d42 → background: #FFFFFF (.db-filter-select)
|
||||
- background: #1e2d42 → background: rgba(73,79,79,0.08) (.db-skeleton-block)
|
||||
|
||||
TEXTO:
|
||||
- color: #E7E6E5 → color: #494F4F (en TODAS las clases .db-* de esa sección)
|
||||
- color: #9ca3af → color: #6B7878
|
||||
- color: #7A8FA0 → color: #8A9A9A
|
||||
- color: #7d8da5 → color: #8A9A9A
|
||||
- color: #9fb0cc → color: #8A9A9A
|
||||
- color: #6b7280 → color: #9CA8A8
|
||||
- color: #0F1923 → color: #FFFFFF (solo .db-survey-link-active)
|
||||
- color: #fff → color: #494F4F (solo .db-page-title, línea 721)
|
||||
|
||||
NO TOCAR:
|
||||
- color: var(--color-re-teal) — sin cambio en ninguna clase
|
||||
- color: #ef4444, #f0b429, #f06b6b, #f06b6b — sin cambio
|
||||
- Todo lo que esté fuera de la sección DASHBOARD (línea 710 en adelante)
|
||||
- Cualquier archivo .tsx
|
||||
|
||||
## 4. DoD (checklist antes de reportar)
|
||||
- [ ] Solo `app/globals.css` modificado
|
||||
- [ ] `git diff --name-only HEAD` lista únicamente `app/globals.css`
|
||||
- [ ] Ningún valor de acento (teal, rojos, ámbar) fue tocado
|
||||
- [ ] Ninguna clase fuera de la sección DASHBOARD fue modificada
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
|
||||
## 5. Qué reportar
|
||||
- Cantidad de líneas modificadas
|
||||
- Lista de clases tocadas con valor anterior → valor nuevo
|
||||
- Output de `git diff --name-only HEAD`
|
||||
- Output de `npm run build` (últimas 5 líneas)
|
||||
- Hallazgo visual: ¿`.db-gap-brecha` (#f0b429 ámbar) tiene contraste visible
|
||||
sobre el nuevo fondo #F0EFEE? Verificar si el valor en pantalla sería legible.
|
||||
|
||||
## 6. Qué NO hacer
|
||||
- No tocar variables :root
|
||||
- No tocar archivos .tsx
|
||||
- No modificar colores de acento ni alertas
|
||||
- No hacer deploy — solo commit y push a Gitea
|
||||
100
sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md
Normal file
100
sprints/sprint-4/ETAPA_WIDGETS_MIRADA_EMPRESA_PROMPT.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# ETAPA — Widgets de Mirada Empresa (frontend del dashboard v3)
|
||||
|
||||
## 1. Objetivo
|
||||
Construir el frontend del dashboard de Mirada Empresa: la estructura de tres miradas
|
||||
(Empresa funcional, País/Humana como "próximamente") y los widgets que consumen las RPCs
|
||||
`rpc_me_*` ya implementadas y verificadas contra datos reales. El cliente ve las métricas.
|
||||
|
||||
## 2. Contexto previo
|
||||
- Las RPCs de Mirada Empresa están hechas y VERIFICADAS contra 50 respuestas reales
|
||||
(migración `20260625000002_dashboard_mirada_empresa.sql`): E1-E10 + estrella "Talento en riesgo".
|
||||
- Contrato de cada RPC (shapes de retorno): `docs/dashboard/SPEC_MIRADA_EMPRESA.md`.
|
||||
- Aspecto objetivo: `docs/dashboard/dashboard_tres_miradas_mockup.html`.
|
||||
- Patrón de widget existente a REUTILIZAR (no reinventar):
|
||||
- `components/dashboard/widget-shell.tsx` — estados loading/ok/suppressed/error.
|
||||
- hooks `useEffect` + `queries.X(surveyId, filters)` (lib/dashboard-queries.ts).
|
||||
- `isSuppressed()` type guard (lib/dashboard-types.ts).
|
||||
- Gráficos en SVG/CSS a mano (db-bar-*, db-donut-*, db-kpi-*) — SIN librería de charts.
|
||||
- Tema oscuro db-*, acento único teal `#43BBC8` (--color-re-teal).
|
||||
- Tres archivos de plomería a extender (mapeos mecánicos): `lib/dashboard-types.ts` (tipos
|
||||
de retorno), `lib/dashboard-queries.ts` (entradas queries), `app/api/dashboard/[widget]/route.ts`
|
||||
(RPC_MAP con los `rpc_me_*`).
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar de nuevo
|
||||
- **Opción A:** reemplazar el dashboard actual por la estructura de tres miradas. Los widgets
|
||||
v2 rotos (que consumen RPCs v2 inexistentes en v3) se RETIRAN. País y Humana = placeholder
|
||||
"Disponible próximamente".
|
||||
- Reutilizar WidgetShell, el patrón de hooks, isSuppressed, y los gráficos SVG/CSS a mano.
|
||||
- NO traer librería de charts nueva — replicar el patrón existente.
|
||||
- Mantener el tema db-* y el teal como acento.
|
||||
- NO tocar las RPCs (están verificadas). Solo el frontend + la plomería de tipos/queries/route.
|
||||
- Mirada Empresa NO muestra datos sensibles (ya garantizado en las RPCs; el frontend solo refleja lo que viene).
|
||||
|
||||
## 4. Tareas
|
||||
### 4.1 Plomería (mecánica)
|
||||
- `lib/dashboard-types.ts`: tipos de retorno de cada `rpc_me_*` según los shapes de la SPEC.
|
||||
- `lib/dashboard-queries.ts`: una entrada por métrica (E1-E10 + estrella).
|
||||
- `app/api/dashboard/[widget]/route.ts`: agregar los `rpc_me_*` al RPC_MAP.
|
||||
|
||||
### 4.2 Estructura de tres miradas
|
||||
- Tabs/secciones Empresa | País | Humana (como el mockup).
|
||||
- Empresa: funcional. País/Humana: placeholder "Disponible próximamente" (no widgets vacíos).
|
||||
- El selector de survey/p_all y los filtros del sidebar siguen funcionando como hoy.
|
||||
|
||||
### 4.3 Widgets de Mirada Empresa
|
||||
Implementar los widgets del NÚCLEO primero (los que el cliente necesita):
|
||||
- **Talento en riesgo** (estrella) — KPI destacado, métrica compuesta.
|
||||
- **E1 Prevalencia de cuidadores** — KPI.
|
||||
- **E6 Punto ciego organizacional** — el widget de contraste (carga real vs apoyo percibido),
|
||||
dos barras enfrentadas como en el mockup.
|
||||
- **E2/E3/E4 Señales de impacto** — barras (riesgo, ausentismo, presentismo).
|
||||
- **E7 Apoyos demandados** — barras horizontales (igual patrón que apoyos-bar viejo).
|
||||
Después, si el alcance lo permite, los [SEC]: E5, E8, E9 (segmentación), E10.
|
||||
|
||||
### 4.4 Manejo de estados
|
||||
- Cada widget usa WidgetShell. Estado `suppressed` (k<5) muestra el mensaje estándar 🔒.
|
||||
- Las métricas que segmentan (E7, E9) suprimen filas individuales con n<5 (las RPCs ya lo hacen;
|
||||
el widget solo no muestra lo que no viene).
|
||||
|
||||
## 5. Entregables (DoD)
|
||||
- [ ] Estructura de tres miradas; Empresa funcional, País/Humana placeholder.
|
||||
- [ ] Widgets del núcleo consumiendo las RPCs reales vía la plomería.
|
||||
- [ ] Widgets v2 rotos retirados.
|
||||
- [ ] WidgetShell y patrón existente reutilizados (no librería nueva, no reinvención).
|
||||
- [ ] Estados loading/ok/suppressed manejados.
|
||||
- [ ] Build sin errores.
|
||||
- [ ] **Validación visual del director** contra el mockup + datos reales sembrados.
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Estructura 3 miradas ✓ — Empresa funcional, País/Humana placeholder
|
||||
- Widgets implementados: [lista con qué RPC consume cada uno]
|
||||
- Plomería: tipos + queries + RPC_MAP extendidos ✓
|
||||
- Widgets v2 retirados: [lista]
|
||||
- WidgetShell reutilizado ✓ / sin librería de charts nueva ✓
|
||||
- Build: [resultado]
|
||||
- Descripción visual: cómo se ve cada widget núcleo con datos
|
||||
Asumido / decisiones de diseño:
|
||||
- [lo que haya]
|
||||
```
|
||||
Reporte con datos concretos (C.4). Para validación visual necesito ver el dashboard con datos:
|
||||
indicá cómo levantarlo (el seed_prueba_50.sql siembra los datos).
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar las RPCs (verificadas).
|
||||
- ❌ No traer librería de charts — SVG/CSS a mano como el resto.
|
||||
- ❌ No mostrar datos sensibles (3.x, 4.2, 4.3).
|
||||
- ❌ No implementar widgets de País/Humana (placeholder solo).
|
||||
- ❌ No aplicar a producción sin validación visual.
|
||||
- ❌ No tocar v2.1.0-stable, el motor, el seed del formulario.
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(dashboard): widgets de Mirada Empresa + estructura tres miradas [dashboard]
|
||||
|
||||
Reemplaza el dashboard v2 (widgets rotos contra v3) por la estructura de tres
|
||||
miradas. Mirada Empresa funcional: talento en riesgo, prevalencia, punto ciego,
|
||||
señales de impacto, apoyos demandados. País/Humana como placeholder. Reutiliza
|
||||
WidgetShell y el patrón SVG/CSS existente. Consume las rpc_me_* verificadas.
|
||||
```
|
||||
154
sprints/sprint-5/ETAPA_ADMIN_PROMPT.md
Normal file
154
sprints/sprint-5/ETAPA_ADMIN_PROMPT.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# ETAPA ADMIN — Vista de respuestas mejorada + vista detallada
|
||||
|
||||
## 1. Objetivo
|
||||
Mejorar `/admin/responses` para que sea una interfaz de administración real,
|
||||
y crear `/admin/responses/[id]` como vista detallada de una respuesta individual.
|
||||
Todo detrás del middleware de auth existente.
|
||||
|
||||
## 2. Contexto
|
||||
- Estado actual: `app/admin/responses/page.tsx` (Server Component, 249 líneas) +
|
||||
`app/admin/responses/responses-table.tsx` (Client Component, 98 líneas).
|
||||
- Auth: ya implementada — getSupabaseSession() con anon key + cookies, respeta RLS.
|
||||
- Exclusión de sensibles: `is_sensitive=false` y `type != 'text_long'` en query de
|
||||
questions — NO tocar, mantener exactamente igual.
|
||||
- SURVEY_OPTIONS hardcodeadas con UUIDs literales — reemplazar por query dinámica.
|
||||
- LIMIT 500 sin paginación real — reemplazar por paginación con page size de 50.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Paginación: page size 50, parámetro `?page=N` en la URL (Server Component,
|
||||
recarga completa — no client-side infinite scroll).
|
||||
- Filtro de survey: `<select>` que carga surveys desde la DB (solo las que el
|
||||
usuario puede ver según RLS), no hardcodeado.
|
||||
- Vista detallada: ruta `/admin/responses/[id]` — Server Component, muestra todas
|
||||
las respuestas no sensibles de esa submission, agrupadas por sección.
|
||||
- Diseño: usar clases Tailwind o clases `.admin-*` existentes — sin nuevo sistema.
|
||||
- Sin librería de tablas externa.
|
||||
- El expand/collapse por submission en la tabla se conserva.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Vista lista mejorada — `app/admin/responses/page.tsx`
|
||||
|
||||
**Filtro de survey dinámico:**
|
||||
Reemplazar SURVEY_OPTIONS hardcodeadas por query a `surveys` table:
|
||||
```ts
|
||||
const { data: surveys } = await supabase
|
||||
.from('surveys')
|
||||
.select('id, title')
|
||||
.order('title')
|
||||
```
|
||||
Renderizar como `<select>` con onChange que navega a `?survey_id=<id>`.
|
||||
Opción "Todos los surveys" con value=''.
|
||||
|
||||
**Paginación:**
|
||||
- Leer `searchParams.page` (default 1).
|
||||
- Query de responses: `.range((page-1)*50, page*50-1)` en vez de `.limit(500)`.
|
||||
- Mostrar: "Página N de M · X respuestas totales".
|
||||
- Links Anterior / Siguiente que modifican `?page=N` en la URL.
|
||||
- Si hay menos de 50 resultados, no mostrar paginación.
|
||||
|
||||
**Columna de acciones:**
|
||||
Agregar columna "Ver" en la fila cabecera de cada submission (isFirstInGroup=true)
|
||||
con link a `/admin/responses/[response_id]`.
|
||||
|
||||
**Header informativo:**
|
||||
Mostrar claramente: total de submissions, total de answers, survey seleccionado.
|
||||
|
||||
### 4.2 Vista detallada — `app/admin/responses/[id]/page.tsx`
|
||||
|
||||
Server Component. Ruta: `/admin/responses/[id]` donde `id` = `response_id` (uuid).
|
||||
|
||||
**Query principal:**
|
||||
```ts
|
||||
// 1. Metadata de la respuesta
|
||||
const { data: response } = await supabase
|
||||
.from('responses')
|
||||
.select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
|
||||
// 2. Preguntas permitidas (mismo filtro que la lista)
|
||||
const { data: questions } = await supabase
|
||||
.from('questions')
|
||||
.select('id, code, prompt, type, position, section_number, section_title')
|
||||
.eq('is_sensitive', false)
|
||||
.neq('type', 'text_long')
|
||||
.eq('survey_id', response.survey_id)
|
||||
.order('position')
|
||||
|
||||
// 3. Answers de esta respuesta
|
||||
const { data: answers } = await supabase
|
||||
.from('answers')
|
||||
.select('question_id, value, instance_id')
|
||||
.eq('response_id', id)
|
||||
.in('question_id', questions.map(q => q.id))
|
||||
```
|
||||
|
||||
**Layout:**
|
||||
- Header: fecha, zona, sector, edad — datos de qi_* de la respuesta.
|
||||
- Link "← Volver a la lista" que preserva el `?survey_id` y `?page` anteriores
|
||||
(pasar como searchParam o usar `referrer`).
|
||||
- Respuestas agrupadas por sección (section_number + section_title).
|
||||
- Dentro de cada sección: tabla código | pregunta | valor.
|
||||
- Instancias (instance_id != 'default'): mostrar agrupadas bajo "Familiar N"
|
||||
con badge visual.
|
||||
- Valores nulos: badge "sin respuesta" (igual que la lista).
|
||||
- Si `id` no existe o RLS bloquea: mostrar "Respuesta no encontrada" —
|
||||
no crashear.
|
||||
|
||||
### 4.3 Tabla de respuestas — `app/admin/responses/responses-table.tsx`
|
||||
Agregar la columna "Ver" en las filas isFirstInGroup con `<Link>` a
|
||||
`/admin/responses/[submissionId]`. Pasar `surveyId` y `page` como props
|
||||
para construir el link de "← Volver" en la vista detallada.
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — listar todos los archivos tocados
|
||||
- [ ] Filtro de survey carga desde DB, no hardcodeado
|
||||
- [ ] Paginación funciona: page=1 muestra primeros 50, page=2 los siguientes
|
||||
- [ ] Link "Ver" en cada submission lleva a `/admin/responses/[id]`
|
||||
- [ ] Vista detallada muestra respuestas agrupadas por sección
|
||||
- [ ] Instancias agrupadas bajo "Familiar N"
|
||||
- [ ] RLS respetado: misma exclusión de sensibles que la lista
|
||||
- [ ] "Respuesta no encontrada" si el id no existe
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] `git log --name-only -1`
|
||||
- [ ] Commit + push a Gitea
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- Filtro survey: ✓ — query a tabla surveys, no hardcodeado
|
||||
- Paginación: ✓ — page size 50, range correcto
|
||||
- Link "Ver" → /admin/responses/[id]: ✓
|
||||
- Vista detallada: ✓ — agrupada por sección, instancias bajo "Familiar N"
|
||||
- RLS/exclusión sensibles: ✓ — mismo filtro que antes
|
||||
- "No encontrada" si id inválido: ✓
|
||||
- Build: ✓
|
||||
- git log --name-only -1: [lista completa]
|
||||
- Commit: [hash]
|
||||
|
||||
Decisiones de diseño tomadas:
|
||||
- [lo que haya]
|
||||
|
||||
Hallazgos:
|
||||
- [lo que aparezca, especialmente sobre questions.section_number/section_title
|
||||
— si esas columnas no existen, reportarlo antes de inventar una solución]
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar el filtro `is_sensitive=false` ni `type != 'text_long'`
|
||||
- ❌ No usar service_role — solo getSupabaseSession() con RLS
|
||||
- ❌ No agregar librerías de tablas o UI externas
|
||||
- ❌ No implementar búsqueda por texto (fuera de alcance)
|
||||
- ❌ No hacer deploy — solo commit y push a Gitea
|
||||
- ❌ Si `section_number` o `section_title` no existen en questions,
|
||||
reportarlo y NO inventar columnas — esperar instrucción
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(admin): vista respuestas mejorada + vista detallada [admin]
|
||||
|
||||
/admin/responses: filtro survey dinámico, paginación 50/página,
|
||||
link a vista detallada. /admin/responses/[id]: respuestas por sección,
|
||||
instancias bajo "Familiar N". RLS y exclusión de sensibles intactos.
|
||||
```
|
||||
124
sprints/sprint-5/ETAPA_AUDIT_LOG_PROMPT.md
Normal file
124
sprints/sprint-5/ETAPA_AUDIT_LOG_PROMPT.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# ETAPA AUDIT LOG — Registro de auditoría append-only (Sprint 5, SEC-002)
|
||||
|
||||
## 1. Objetivo
|
||||
Implementar un registro de auditoría append-only en Postgres que loggee eventos
|
||||
de configuración crítica: cambios en `user_roles` (altas/bajas/modificaciones de
|
||||
acceso) y cambios de `status` en `surveys` (activación/desactivación de encuestas).
|
||||
Opcionalmente, si la implementación es simple y sin fricción, agregar también un
|
||||
trigger DDL para loggear migraciones aplicadas.
|
||||
|
||||
## 2. Contexto
|
||||
- Migración de referencia para patrones: `20260627000001_auth_user_roles.sql`
|
||||
(patrón SECURITY DEFINER + GRANT puntual a reutilizar).
|
||||
- Helpers RLS existentes a reusar: `public.current_app_role()` y
|
||||
`public.current_org_id()` — no reinventar.
|
||||
- Gap conocido y aceptado (SEC-003): esto NO protege contra TRUNCATE/DROP ejecutado
|
||||
por superusuario directo en el VPS. Documentar en comentario de la migración, no resolver.
|
||||
- Acceso a datos sensibles: NO loggear en esta etapa — hoy no existe código que
|
||||
lea columnas `is_sensitive=true`. Deferido a cuando ese código exista.
|
||||
- Naming de migración: después de `20260627000001` — usar timestamp del día de
|
||||
ejecución, formato `YYYYMMDDHHMMSS`.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- Tabla nueva: `public.audit_log`, append-only real.
|
||||
- Ningún rol (`anon`, `authenticated`, `service_role` vía API) tiene INSERT/UPDATE/DELETE
|
||||
directo en `audit_log`. Solo escriben funciones SECURITY DEFINER de los triggers.
|
||||
- Segunda barrera: trigger `BEFORE UPDATE OR DELETE ON audit_log` que aborta con
|
||||
excepción — defensa en profundidad.
|
||||
- Lectura de audit_log: RLS, solo `platform_admin` via `current_app_role()`.
|
||||
- Patrón de función trigger: SECURITY DEFINER, propiedad de `postgres`,
|
||||
GRANT EXECUTE solo al rol que lo necesita (no a PUBLIC).
|
||||
- Columnas de audit_log:
|
||||
```sql
|
||||
id bigserial PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_role text,
|
||||
actor_user_id uuid,
|
||||
action text NOT NULL,
|
||||
target_table text NOT NULL,
|
||||
target_id text,
|
||||
detail jsonb
|
||||
```
|
||||
- Triggers obligatorios (Prioridad 1):
|
||||
- AFTER INSERT/UPDATE/DELETE en `user_roles`
|
||||
- AFTER UPDATE en `surveys` cuando cambia `status`
|
||||
- Trigger DDL (Prioridad 2 — opcional): solo implementar si es simple y sin fricción.
|
||||
Si requiere extensión nueva o configuración de infraestructura, NO incluir y reportarlo.
|
||||
|
||||
## 4. Tareas
|
||||
|
||||
### 4.1 Diagnóstico previo — DDL trigger
|
||||
Antes de implementar, verificar si el DDL trigger (`ddl_command_end`) está disponible
|
||||
sin extensiones adicionales en esta versión de Postgres self-hosted:
|
||||
```bash
|
||||
docker exec -i supabase-db psql -U postgres -d postgres -c "
|
||||
SELECT event_object_schema, trigger_name
|
||||
FROM information_schema.triggers
|
||||
WHERE trigger_name LIKE '%ddl%' LIMIT 5;"
|
||||
```
|
||||
Reportar si es viable sin fricción. Si requiere configuración extra, documentarlo
|
||||
y omitirlo de esta etapa.
|
||||
|
||||
### 4.2 Migración principal
|
||||
Archivo: `supabase/migrations/YYYYMMDDHHMMSS_audit_log.sql`
|
||||
|
||||
Contenido en orden:
|
||||
1. Crear tabla `audit_log` con columnas definidas arriba.
|
||||
2. RLS en `audit_log`: solo `platform_admin` puede SELECT.
|
||||
3. Trigger `BEFORE UPDATE OR DELETE ON audit_log` → función que lanza excepción
|
||||
(append-only enforcement).
|
||||
4. Función `log_user_roles_change()` SECURITY DEFINER → INSERT en audit_log.
|
||||
5. Trigger AFTER INSERT/UPDATE/DELETE en `user_roles` → llama a la función.
|
||||
6. Función `log_survey_status_change()` SECURITY DEFINER → INSERT en audit_log
|
||||
solo cuando `NEW.status IS DISTINCT FROM OLD.status`.
|
||||
7. Trigger AFTER UPDATE en `surveys` → llama a la función.
|
||||
8. Si DDL trigger es viable (4.1): función + trigger DDL.
|
||||
9. Comentario al final documentando el gap SEC-003.
|
||||
|
||||
### 4.3 Verificación en VPS
|
||||
NO aplicar la migración — solo generar el archivo y reportar el SQL completo para
|
||||
revisión del director. El director aplica manualmente después de aprobar.
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — solo el archivo de migración
|
||||
- [ ] SQL completo de la migración en el reporte (no resumido — el director
|
||||
necesita leerlo antes de aplicar)
|
||||
- [ ] Diagnóstico DDL trigger reportado (4.1)
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] Commit + push a Gitea
|
||||
- [ ] NO aplicado en VPS — esperar aprobación del director
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
Diagnóstico DDL trigger (4.1):
|
||||
- ¿Viable sin extensiones? ✓/✗
|
||||
- Si no: razón y alternativa
|
||||
|
||||
SQL completo de la migración:
|
||||
[pegar el SQL íntegro — no resumido]
|
||||
|
||||
Verificado:
|
||||
- Build: ✓/✗
|
||||
- git log --name-only -1: [lista]
|
||||
- Commit: [hash]
|
||||
|
||||
NO aplicado en VPS — pendiente aprobación del director.
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No aplicar la migración en el VPS sin aprobación explícita del director
|
||||
- ❌ No implementar logging de accesos a datos sensibles (deferido)
|
||||
- ❌ No agregar extensiones nuevas a Postgres
|
||||
- ❌ No tocar el formulario público, las RPCs de dashboard, ni el middleware de auth
|
||||
- ❌ No usar GRANT EXECUTE a PUBLIC — solo al rol específico que necesita cada función
|
||||
- ❌ No omitir el comentario del gap SEC-003 en la migración
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
feat(compliance): audit_log append-only — user_roles y surveys.status [SEC-002]
|
||||
|
||||
Tabla audit_log con RLS (solo platform_admin), trigger BEFORE UPDATE/DELETE
|
||||
que aborta (append-only enforcement), triggers AFTER en user_roles y
|
||||
surveys.status. Patrón SECURITY DEFINER reutilizado de auth_user_roles.
|
||||
Gap SEC-003 (TRUNCATE por superusuario) documentado y aceptado.
|
||||
```
|
||||
123
sprints/sprint-5/ETAPA_CALIDAD_PROMPT.md
Normal file
123
sprints/sprint-5/ETAPA_CALIDAD_PROMPT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# ETAPA CALIDAD — Contraste, mobile, conectividad, touch (Sprint 5)
|
||||
|
||||
## 1. Objetivo
|
||||
Resolver los 4 problemas de calidad identificados en la auditoría del 27 jun 2026,
|
||||
en orden de impacto. Un solo commit al terminar todo.
|
||||
|
||||
## 2. Contexto
|
||||
Diagnóstico base: auditoría de solidez/usabilidad (27 jun 2026, sin modificaciones).
|
||||
Todos los problemas tienen archivo y línea exacta identificados.
|
||||
|
||||
## 3. DECISIONES YA TOMADAS — no consultar
|
||||
- **Contraste teal:** Opción B — `#43BBC8` queda SOLO para decoración (fondos,
|
||||
bordes, barras, elementos gráficos). Cuando se usa como COLOR DE TEXTO, reemplazar
|
||||
por `#494F4F` (carbón de marca). No crear variante oscura del teal.
|
||||
- **Mobile bottombar:** flex-wrap en el contenedor, no width fijo en los botones.
|
||||
- **Submit sin try/catch:** agregar manejo de error de red que muestre el estado
|
||||
'error' propio del wizard, no la pantalla cruda de React.
|
||||
- **Autocomplete touch:** agregar `onTouchEnd` como fallback junto al `onMouseDown`
|
||||
existente — no reemplazarlo.
|
||||
- Sin librerías nuevas en ningún fix.
|
||||
|
||||
## 4. Tareas en orden
|
||||
|
||||
### P1 — Contraste teal como texto (CRÍTICO — sistemático)
|
||||
En `app/globals.css`, reemplazar `color: var(--color-re-teal)` por
|
||||
`color: #494F4F` en estas clases específicas:
|
||||
- `.btn-secondary` (botón "Anterior") — línea ~380
|
||||
- `.wizard-section-number` ("Sección X de Y") — línea ~569
|
||||
- `.summary-code` — línea ~431
|
||||
- `.barrio-autocomplete-item--active` (texto del ítem activo) — línea ~319
|
||||
- `.consent-required-badge` — línea ~490
|
||||
|
||||
NO tocar usos de `var(--color-re-teal)` como `background-color`, `border-color`,
|
||||
`fill`, o en las clases `.db-*` del dashboard — esos son usos decorativos correctos.
|
||||
|
||||
Verificar después del cambio que no quedó ningún `color: var(--color-re-teal)` en
|
||||
clases del wizard (grep en globals.css sección wizard):
|
||||
```bash
|
||||
grep -n "color: var(--color-re-teal)" app/globals.css
|
||||
```
|
||||
Los únicos resultados aceptables son en sección `.db-*` del dashboard.
|
||||
|
||||
### P2 — Submit sin try/catch (red se cae al enviar)
|
||||
En `components/wizard-form.tsx`, función `handleSubmit` (líneas ~212-226):
|
||||
Envolver el `await submitSurvey(...)` en try/catch. Si el catch recibe un error
|
||||
de red (fetch failed, network error), setear el estado del wizard a 'error' con
|
||||
mensaje accionable: "No pudimos enviar tu respuesta. Verificá tu conexión e
|
||||
intentá de nuevo." El botón de reintento ya existe en el estado 'error'
|
||||
(wizard-form.tsx:136-152) — solo hay que llegar a ese estado correctamente.
|
||||
No crear `app/error.tsx` — el estado de error propio del wizard es suficiente.
|
||||
|
||||
### P3 — Botones bottombar en mobile ≤480px
|
||||
En `app/globals.css`, sección `.wizard-bottombar` (~línea 653):
|
||||
Agregar `flex-wrap: wrap` al contenedor `.wizard-bottombar`.
|
||||
En el breakpoint `@media (max-width: 480px)`:
|
||||
- `.wizard-bottombar` → `flex-direction: column-reverse` (Siguiente arriba, Anterior abajo)
|
||||
- `.btn` dentro del bottombar → `width: 100%`
|
||||
- El span hint entre los botones → `order: 3` para que quede al final
|
||||
`column-reverse` porque el botón primario (Siguiente/Enviar) debe estar arriba
|
||||
en mobile — el pulgar llega más fácil al centro que a la base.
|
||||
|
||||
### P4 — Autocomplete touch frágil
|
||||
En `components/widgets/barrio-autocomplete-field.tsx` y
|
||||
`components/widgets/ciudad-select-field.tsx`:
|
||||
En el handler de selección de ítem del dropdown, agregar `onTouchEnd` junto
|
||||
al `onMouseDown` existente (no reemplazarlo). El `onTouchEnd` debe llamar
|
||||
`e.preventDefault()` antes de ejecutar la selección para evitar el blur
|
||||
sintético que cierra la lista antes de que se procese la selección.
|
||||
|
||||
## 5. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — listar todos los archivos tocados
|
||||
- [ ] grep de verificación P1:
|
||||
```bash
|
||||
grep -n "color: var(--color-re-teal)" app/globals.css
|
||||
```
|
||||
Solo resultados en sección `.db-*`
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] P2 verificado: en `handleSubmit`, el catch existe y setea estado 'error'
|
||||
con mensaje accionable (pegar el bloque try/catch completo en el reporte)
|
||||
- [ ] P3 verificado: describir el CSS resultante del bottombar en mobile
|
||||
- [ ] P4 verificado: describir el handler resultante de selección en autocomplete
|
||||
- [ ] Commit: `fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch`
|
||||
- [ ] Push a Gitea
|
||||
|
||||
## 6. Qué reportar
|
||||
```
|
||||
P1 — Contraste:
|
||||
- Clases modificadas: [lista con línea antes → después]
|
||||
- grep verificación: [output]
|
||||
|
||||
P2 — Submit try/catch:
|
||||
- Bloque handleSubmit completo después del fix: [código]
|
||||
|
||||
P3 — Bottombar mobile:
|
||||
- CSS resultante de .wizard-bottombar y el breakpoint: [código]
|
||||
|
||||
P4 — Autocomplete touch:
|
||||
- Handler de selección resultante (onMouseDown + onTouchEnd): [código]
|
||||
|
||||
Verificado:
|
||||
- Build: ✓/✗
|
||||
- git log --name-only -1: [lista]
|
||||
- Commit: [hash]
|
||||
```
|
||||
|
||||
## 7. Qué NO hacer
|
||||
- ❌ No tocar `var(--color-re-teal)` en usos decorativos (background, border, fill)
|
||||
- ❌ No tocar clases `.db-*` del dashboard
|
||||
- ❌ No crear `app/error.tsx`
|
||||
- ❌ No reemplazar `onMouseDown` — agregar `onTouchEnd` junto a él
|
||||
- ❌ No agregar librerías nuevas
|
||||
- ❌ No hacer deploy — solo commit y push a Gitea
|
||||
- ❌ No mezclar con los fixes rápidos de la etapa anterior
|
||||
|
||||
## 8. Versionado
|
||||
```
|
||||
fix(quality): contraste teal, submit red, bottombar mobile, autocomplete touch
|
||||
|
||||
P1: color de texto teal → #494F4F en 5 clases del wizard (WCAG AA).
|
||||
P2: try/catch en handleSubmit — error de red muestra estado error del wizard.
|
||||
P3: flex-wrap + column-reverse en bottombar ≤480px.
|
||||
P4: onTouchEnd fallback en autocomplete barrio y ciudad.
|
||||
```
|
||||
89
sprints/sprint-5/ETAPA_E2E_PROMPT.md
Normal file
89
sprints/sprint-5/ETAPA_E2E_PROMPT.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# ETAPA E2E — Tests Playwright: verificación deploy Sprint 5
|
||||
|
||||
## Antes de tocar código, leé:
|
||||
1. `CLAUDE.md`
|
||||
2. `docs/estado/ESTADO_PROYECTO_26JUN2026.md` — estado general del proyecto
|
||||
|
||||
Confirmame el plan antes de implementar.
|
||||
|
||||
## Credenciales de test (usuario dedicado — nunca las de Marcos)
|
||||
- URL: `https://economia-cuidado.inqualityhq.com`
|
||||
- Email: `test-e2e@inquality.com.py`
|
||||
- Password: `TestE2E2026!#Cuidado`
|
||||
- Rol: `platform_admin`, org: `InQuality`
|
||||
|
||||
Estas credenciales van en `.env.test` en la raíz (agregar a `.gitignore` si no está).
|
||||
**Nunca hardcodeadas en los specs.**
|
||||
|
||||
## Verificar Playwright antes de instalar nada
|
||||
|
||||
```bash
|
||||
npx playwright --version 2>/dev/null && echo "instalado" || echo "no instalado"
|
||||
ls ~/.cache/ms-playwright 2>/dev/null && echo "browsers presentes" || echo "browsers ausentes"
|
||||
```
|
||||
|
||||
Reportar resultado antes de continuar. Si ya está instalado con browsers, agregar solo
|
||||
`@playwright/test` como devDependency sin correr `npx playwright install`.
|
||||
Si no está: `npx playwright install --with-deps chromium` (solo Chromium).
|
||||
|
||||
## Los 5 tests a implementar
|
||||
|
||||
**Archivo:** `tests/e2e/sprint5-verificacion.spec.ts`
|
||||
|
||||
### Test 1 — Login y redirect
|
||||
- Abrir `/admin/responses` sin sesión
|
||||
- Verificar redirect a `/login`
|
||||
- El DOM de `/login` contiene el campo `email`, el campo `password`, y el botón de toggle
|
||||
de contraseña (buscar `aria-label` que contenga "contraseña")
|
||||
|
||||
### Test 2 — Login funcional
|
||||
- Hacer login con las credenciales de test en `/login`
|
||||
- Verificar redirect a `/admin/responses`
|
||||
- El DOM contiene `<select>` para filtro de survey (no links hardcodeados)
|
||||
- El DOM contiene al menos una fila de respuesta (hay 50 sembradas)
|
||||
|
||||
### Test 3 — Vista detallada de respuesta
|
||||
- Desde `/admin/responses`, hacer click en el primer link "Ver" disponible
|
||||
- Verificar que la URL cambia a `/admin/responses/[uuid]`
|
||||
- El DOM contiene al menos una sección (buscar texto que indique agrupación por sección)
|
||||
- No hay error 404 ni texto "Respuesta no encontrada"
|
||||
|
||||
### Test 4 — Dashboard con datos
|
||||
- Navegar a `/dashboard` (o la ruta del dashboard — verificar cuál es)
|
||||
- Verificar que al menos un widget muestra un porcentaje (buscar `%` en el DOM)
|
||||
- No aparece "Datos insuficientes" en todos los widgets (puede aparecer en algunos
|
||||
segmentos con k<5, pero no en todos)
|
||||
|
||||
### Test 5 — Formulario público accesible sin auth
|
||||
- Navegar a `/` o `/encuesta` (verificar ruta correcta del formulario)
|
||||
- Verificar que NO redirige a `/login` (el formulario es público)
|
||||
- El DOM contiene el wizard o la pantalla de consentimiento
|
||||
|
||||
## Decisiones ya tomadas
|
||||
- Playwright en local apuntando a producción, no en CI (backlog)
|
||||
- `playwright.config.ts` en raíz, `testDir: 'tests/e2e'`
|
||||
- `.env.test` para credenciales, en `.gitignore`
|
||||
- Si un test falla, reportar el error exacto y consultarme — no intentar workarounds
|
||||
|
||||
## Archivos autorizados
|
||||
`playwright.config.ts`, `tests/e2e/sprint5-verificacion.spec.ts`, `.env.test`
|
||||
(no se commitea), `package.json` (devDependency), `package-lock.json`.
|
||||
Cualquier otro en `git diff --name-only HEAD` → revertir y reportar.
|
||||
|
||||
## DoD antes de reportar
|
||||
|
||||
```bash
|
||||
npx playwright test tests/e2e/sprint5-verificacion.spec.ts --reporter=list
|
||||
git log --name-only -1
|
||||
git diff --name-only HEAD # solo archivos autorizados, sin .env.test
|
||||
grep -r "TestE2E2026" . # debe dar vacío — contraseña no commiteada
|
||||
```
|
||||
|
||||
## Qué reportar
|
||||
- Resultado de checks de Playwright previos (versión, browsers)
|
||||
- Resultado de cada test: pass/fail con mensaje exacto si falla
|
||||
- Ruta real del dashboard y del formulario público (las que encontró en el código)
|
||||
- `git log --name-only -1` y `git diff --name-only HEAD`
|
||||
- Hallazgos no esperados
|
||||
|
||||
**No hacer deploy ni modificar nada en producción. No continuar sin mi OK si algo falla.**
|
||||
47
sprints/sprint-5/ETAPA_FIXES_RAPIDOS_PROMPT.md
Normal file
47
sprints/sprint-5/ETAPA_FIXES_RAPIDOS_PROMPT.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# ETAPA — Fixes rápidos: toggle password + card Talento en riesgo
|
||||
|
||||
## 1. Objetivo
|
||||
Dos fixes visuales independientes y quirúrgicos. Scope estricto.
|
||||
|
||||
## 2. DECISIONES YA TOMADAS — no consultar
|
||||
- Toggle de contraseña: solo en el campo password del login, sin librería nueva.
|
||||
- Card Talento en riesgo: eliminar el gradiente, quedar igual que los demás cards
|
||||
(background blanco, sin distinción especial de fondo).
|
||||
|
||||
## 3. Tareas
|
||||
|
||||
### Fix A — Toggle "ver contraseña" en /login
|
||||
En `app/login/page.tsx`:
|
||||
- Agregar estado `showPassword` (useState).
|
||||
- El input password alterna entre `type="password"` y `type="text"`.
|
||||
- Botón inline dentro del campo (position absolute o flex) con ícono SVG
|
||||
simple de ojo (inline, sin librería). Accesible: `aria-label="Mostrar contraseña"` /
|
||||
`aria-label="Ocultar contraseña"` según estado. `type="button"` para no submitear.
|
||||
- Estilo consistente con el formulario existente.
|
||||
|
||||
### Fix B — Card Talento en riesgo sin gradiente
|
||||
En `app/globals.css`, clase `.db-widget-star`:
|
||||
- Eliminar la propiedad `background` (el gradiente actual).
|
||||
- El card hereda `background: #FFFFFF` de `.db-widget` — mismo que los demás.
|
||||
- NO tocar `border-color: var(--color-re-teal)` — mantener el borde distintivo.
|
||||
- NO tocar `.db-widget-star .db-widget-title` — mantener el título en teal.
|
||||
|
||||
## 4. DoD antes de reportar
|
||||
- [ ] `git log --name-only -1` — solo `app/login/page.tsx` y `app/globals.css`
|
||||
- [ ] Toggle password funciona: click muestra/oculta, aria-label cambia
|
||||
- [ ] Card Talento en riesgo: fondo blanco igual que los demás cards
|
||||
- [ ] Build sin errores: `npm run build`
|
||||
- [ ] Commit: `fix(ui): toggle password en login + fondo blanco card talento`
|
||||
- [ ] Push a Gitea
|
||||
|
||||
## 5. Qué reportar
|
||||
- `git log --name-only -1`
|
||||
- Línea exacta eliminada en `.db-widget-star`
|
||||
- Build: ✓/✗
|
||||
|
||||
## 6. Qué NO hacer
|
||||
- ❌ No tocar otros campos del formulario de login
|
||||
- ❌ No tocar otros widgets del dashboard
|
||||
- ❌ No agregar librerías de iconos
|
||||
- ❌ No modificar el borde teal ni el título teal del card estrella
|
||||
- ❌ No hacer deploy — solo commit y push
|
||||
106
sprints/sprint-5/INSUMOS_AUDIT_LOG.md
Normal file
106
sprints/sprint-5/INSUMOS_AUDIT_LOG.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# INSUMOS — Etapa Audit Log append-only (Sprint compliance, SEC-002)
|
||||
|
||||
> Material de contexto para que Claude (Projects, arquitecto) redacte el ETAPA_PROMPT.md
|
||||
> de esta etapa. No es el prompt en sí — es lo que el arquitecto necesita para escribirlo
|
||||
> bien, sin tener que releer todo el repo.
|
||||
|
||||
---
|
||||
|
||||
## 1. Por qué esta etapa, y por qué este alcance
|
||||
|
||||
`BACKLOG.md` (SEC-002) y `COMPLIANCE.md` §5.1 piden, dentro del sprint de compliance,
|
||||
un "registro de auditoría append-only (log inmutable de operaciones sensibles)". El
|
||||
director priorizó esta pieza primero, antes de hashing de integridad, trazabilidad de
|
||||
cambios o cifrado en reposo (esas quedan para etapas futuras del mismo sprint).
|
||||
|
||||
COMPLIANCE.md §5.1 pide loggear 3 cosas: accesos a datos sensibles, cambios de
|
||||
configuración, y ejecución de migraciones. Evalué las 3 y propongo **acotar el alcance
|
||||
de esta etapa a 2 de las 3**:
|
||||
|
||||
1. **Migraciones (DDL)** — factible con un event trigger nativo de Postgres
|
||||
(`ddl_command_end`), sin tocar el flujo de deploy.
|
||||
2. **Cambios de configuración** — triggers `AFTER INSERT/UPDATE/DELETE` en
|
||||
`user_roles` (altas/bajas/cambios de `app_role`/`org_id`) y en `surveys` cuando
|
||||
cambia `status` (activación/desactivación de encuestas).
|
||||
3. **Acceso a datos sensibles — propongo DEFERIR explícitamente.** Verificado en
|
||||
COMPLIANCE.md §3.2: hoy ningún camino de código (ni `/admin/responses` ni las RPCs
|
||||
`rpc_me_*`) lee columnas `is_sensitive=true`. Instrumentar "acceso" sin que exista
|
||||
un acceso real sería simular cobertura. Cuando se construya el primer camino que sí
|
||||
lea sensibles, ESE PR debe agregar su propio log de acceso junto con el código que
|
||||
lo necesita — no antes.
|
||||
|
||||
El arquitecto puede confirmar o ajustar este recorte; lo presento como punto de partida,
|
||||
no como cerrado (esto todavía no se aprobó con el director, se está armando el prompt).
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisiones de diseño que ya tienen forma (para que el prompt las fije)
|
||||
|
||||
- **Tabla nueva:** `public.audit_log` — append-only real:
|
||||
- Ningún rol (`anon`, `authenticated`, ni `service_role` vía API) tiene
|
||||
`INSERT`/`UPDATE`/`DELETE` directo. Solo escriben funciones `SECURITY DEFINER`
|
||||
de los triggers, propiedad de `postgres` (mismo patrón que
|
||||
`custom_access_token_hook`, ver `supabase/migrations/20260627000001_auth_user_roles.sql`).
|
||||
- Trigger `BEFORE UPDATE OR DELETE ON audit_log` que aborta con excepción — segunda
|
||||
barrera, defensa en profundidad (no a prueba de un operador con `psql -U postgres`
|
||||
en el VPS — ver gap conocido abajo).
|
||||
- Lectura: RLS, solo `platform_admin` (reusar `current_app_role()`, ver
|
||||
`supabase/migrations/20260531000003_rls_policies.sql:17`).
|
||||
- **Columnas sugeridas (a validar por el arquitecto):** `id bigserial pk`,
|
||||
`occurred_at timestamptz default now()`, `actor_role text`, `actor_user_id uuid`,
|
||||
`action text`, `target_table text`, `target_id text`, `detail jsonb`.
|
||||
- **Gap conocido a documentar, no a resolver en esta etapa:** esto no protege contra
|
||||
`TRUNCATE`/`DROP` ejecutado por un superusuario directo en el VPS — mismo gap ya
|
||||
registrado en `SEC-003` (BACKLOG.md). Append-only a nivel de aplicación/RLS, no a
|
||||
prueba de acceso root a la base.
|
||||
|
||||
---
|
||||
|
||||
## 3. Convenciones del repo que el prompt debe respetar
|
||||
|
||||
- **Migraciones:** `supabase/migrations/`, naming `YYYYMMDDHHMMSS_descripcion.sql`.
|
||||
Última migración aplicada: `20260627000001_auth_user_roles.sql`. La próxima debe
|
||||
fecharse después de esa.
|
||||
- **Patrón SECURITY DEFINER + GRANT EXECUTE puntual:** ver
|
||||
`custom_access_token_hook` en `20260627000001_auth_user_roles.sql` — función
|
||||
`SECURITY DEFINER STABLE`, `GRANT EXECUTE ... TO supabase_auth_admin` (rol
|
||||
específico, no `PUBLIC`). Mismo patrón a replicar para las funciones de trigger
|
||||
de `audit_log`.
|
||||
- **Helpers RLS existentes a reusar, no reinventar:** `public.current_app_role()`
|
||||
y `public.current_org_id()` (`20260531000003_rls_policies.sql:17-30`).
|
||||
- **Verificación obligatoria antes de aplicar en VPS:** mostrar el SQL final completo
|
||||
y esperar OK explícito del director antes de correrlo en producción (ya es práctica
|
||||
establecida en este proyecto — ver incidente VPS del 27 jun, recuperación documentada
|
||||
en `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` §2.1).
|
||||
- **Formato esperado del archivo de etapa:** seguir la estructura de
|
||||
`sprints/sprint-4/ETAPA_AUTH_PROMPT.md` o `ETAPA_4G_PROMPT.md` — Objetivo,
|
||||
Decisiones ya tomadas (no consultar), Tareas, DoD, Qué reportar (datos concretos,
|
||||
no "funcionó"), Qué NO hacer, Versionado (mensaje de commit sugerido con tag
|
||||
`[compliance]` o similar).
|
||||
|
||||
---
|
||||
|
||||
## 4. Qué NO debería pedir el prompt (fuera de alcance de esta etapa)
|
||||
|
||||
- Hashing de integridad SHA-256 (siguiente pieza del mismo sprint, etapa separada).
|
||||
- Cifrado en reposo (es trabajo de VPS/infraestructura, no de migración SQL).
|
||||
- Trazabilidad histórica completa de cambios de datos (triggers de historial / WAL) —
|
||||
etapa separada también.
|
||||
- Logging de acceso a datos sensibles (ver §1, punto 3 — deferido a cuando exista
|
||||
código que lea sensibles).
|
||||
- pgaudit o cualquier extensión nueva — no está en el Supabase self-hosted actual,
|
||||
agregar una extensión es Nivel 3 aparte (afecta infraestructura/dependencias).
|
||||
|
||||
---
|
||||
|
||||
## 5. Referencias
|
||||
|
||||
- `COMPLIANCE.md` §5 (roadmap auditabilidad), §5.2 (por qué no blockchain), §3.2
|
||||
(qué es sensible y cómo se protege hoy).
|
||||
- `BACKLOG.md` → SEC-002, SEC-003 (gap de TRUNCATE), SEC-004 (bypass REST, contexto
|
||||
de otro riesgo residual ya aceptado, mismo espíritu de "documentar en vez de
|
||||
sobre-construir").
|
||||
- `TECH_DEBT.md` → TECH-DEBT-008 (resuelto 27 jun, ejemplo de cómo se cierra un ítem).
|
||||
- `docs/estado/ESTADO_PROYECTO_JUNIO_2026.md` — estado general post-release v3.0.0
|
||||
+ incidente VPS, para que el arquitecto tenga el panorama completo antes de escribir
|
||||
el prompt.
|
||||
136
supabase/migrations/20260624000001_submit_rpc.sql
Normal file
136
supabase/migrations/20260624000001_submit_rpc.sql
Normal file
@@ -0,0 +1,136 @@
|
||||
-- ============================================================
|
||||
-- Sprint 4 (v3.0.0) — Etapa 4G: submit transaccional + cierre de WITH CHECK(true)
|
||||
--
|
||||
-- PROBLEMA QUE CIERRA: answers_insert/responses_insert/consent_records_insert
|
||||
-- permitían INSERT directo a anon con casi ninguna restricción real
|
||||
-- (answers_insert: WITH CHECK(true) literal) — el cliente era la única
|
||||
-- fuente de verdad sobre qué se guardaba. Closes BRIEF decisión 15.
|
||||
--
|
||||
-- DISEÑO — dos capas, cada una validando lo que le corresponde:
|
||||
-- 1) lib/form-engine.ts + lib/submit-validation.ts, en el Server Action
|
||||
-- (Next.js, TypeScript): valida COHERENCIA DE FLUJO — visibilidad,
|
||||
-- instancias legítimas, opciones válidas, requeridas. Esto NO puede
|
||||
-- vivir en SQL sin reimplementar el motor (prohibido explícitamente).
|
||||
-- 2) Esta RPC (Postgres, SECURITY DEFINER): valida ESTRUCTURA — el
|
||||
-- question_id pertenece a esta encuesta, la encuesta está viva, el
|
||||
-- consentimiento llegó — y persiste todo de forma ATÓMICA (una
|
||||
-- llamada a función = una transacción; cualquier RAISE EXCEPTION
|
||||
-- revierte TODO, sin huérfanos).
|
||||
--
|
||||
-- POR QUÉ SECURITY DEFINER: se revoca el INSERT directo de anon sobre las
|
||||
-- 3 tablas (paso 1 abajo). Sin SECURITY DEFINER, esta función correría
|
||||
-- con los privilegios de quien la llama (anon) y esas mismas policies
|
||||
-- recién revocadas la bloquearían a ELLA también. SECURITY DEFINER la
|
||||
-- hace correr con los privilegios de quien la creó (el owner de la
|
||||
-- migración, no anon) — por eso puede insertar después de que anon ya
|
||||
-- no puede hacerlo directo. Mismo patrón que las RPCs de dashboard
|
||||
-- existentes (rpc_dashboard_kpis, etc.), aplicado por primera vez a
|
||||
-- escritura en vez de lectura.
|
||||
--
|
||||
-- LOS GRANTS DE TABLA (migration 006: GRANT INSERT ON answers TO anon...)
|
||||
-- NO se tocan acá — siguen el "diseño de capas" que esa migración ya
|
||||
-- documenta: Capa 1 (GRANT) da la capacidad de intentar el INSERT,
|
||||
-- Capa 2 (RLS, esta migración) lo deniega por default al no quedar
|
||||
-- ninguna policy permisiva. anon puede seguir "intentando" un INSERT
|
||||
-- directo — RLS lo rechaza igual, con RLS habilitado (migration 002) y
|
||||
-- cero policies de INSERT restantes en estas 3 tablas.
|
||||
--
|
||||
-- RIESGO RESIDUAL DOCUMENTADO (no resuelto en esta etapa, ver reporte):
|
||||
-- cualquiera que llame a esta RPC directamente vía la REST API de
|
||||
-- Supabase (no a través de nuestro Server Action) puede mandar un
|
||||
-- payload que pase las validaciones ESTRUCTURALES de acá pero sea
|
||||
-- incoherente de flujo — porque el motor de flujo es TypeScript y no
|
||||
-- corre dentro de Postgres. Es un problema de calidad de datos de
|
||||
-- investigación, no de privacidad/k-anonimato (el dato sigue siendo
|
||||
-- anónimo, is_sensitive, sujeto al umbral k en agg_segment). Cerrarlo
|
||||
-- del todo excede el scope de 4G — candidato a BACKLOG.md si se decide
|
||||
-- perseguirlo (ej. firma del payload por el Server Action).
|
||||
-- ============================================================
|
||||
|
||||
-- ── 1. Revoca el INSERT directo — el único camino pasa a ser la RPC ───────
|
||||
DROP POLICY IF EXISTS "consent_records_insert" ON public.consent_records;
|
||||
DROP POLICY IF EXISTS "responses_insert" ON public.responses;
|
||||
DROP POLICY IF EXISTS "answers_insert" ON public.answers;
|
||||
|
||||
-- ── 2. RPC de submit ────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.rpc_submit_response(
|
||||
p_survey_id uuid,
|
||||
p_ip_hash text,
|
||||
p_qi_age_bucket text,
|
||||
p_qi_sector text,
|
||||
p_qi_zone text,
|
||||
p_consent_operativo boolean,
|
||||
p_consent_macro_n1 boolean,
|
||||
p_answers jsonb -- [{"question_id":"...","instance_id":"familiar_1","value":...}, ...]
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_org_id uuid;
|
||||
v_cv_id uuid;
|
||||
v_consent_id uuid := gen_random_uuid();
|
||||
v_response_id uuid := gen_random_uuid();
|
||||
v_bad_refs int;
|
||||
BEGIN
|
||||
-- Defensa en profundidad: el Server Action ya confirmó esto antes de
|
||||
-- llamar, pero esta función no debe confiar en el caller.
|
||||
SELECT org_id, consent_version_id INTO v_org_id, v_cv_id
|
||||
FROM public.surveys
|
||||
WHERE id = p_survey_id AND status = 'live';
|
||||
|
||||
IF v_org_id IS NULL THEN
|
||||
RAISE EXCEPTION 'survey_not_found_or_not_live' USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
IF NOT p_consent_operativo THEN
|
||||
RAISE EXCEPTION 'consent_operativo_required' USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
-- Chequeo ESTRUCTURAL (no de flujo): cada question_id recibido existe
|
||||
-- y pertenece a ESTA encuesta; cada instance_id viene no-vacío.
|
||||
-- Visibilidad/instancia-legítima/opciones-válidas ya se validó en TS.
|
||||
SELECT count(*) INTO v_bad_refs
|
||||
FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb)
|
||||
LEFT JOIN public.questions q ON q.id = r.question_id AND q.survey_id = p_survey_id
|
||||
WHERE q.id IS NULL OR r.instance_id IS NULL OR r.instance_id = '';
|
||||
|
||||
IF v_bad_refs > 0 THEN
|
||||
RAISE EXCEPTION 'invalid_answer_reference' USING ERRCODE = 'check_violation';
|
||||
END IF;
|
||||
|
||||
-- consent_record (inmutable, ADR-003)
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes, granted_at)
|
||||
VALUES (
|
||||
v_consent_id, v_cv_id,
|
||||
jsonb_build_object('operativo', p_consent_operativo, 'macro_n1', p_consent_macro_n1),
|
||||
now()
|
||||
);
|
||||
|
||||
-- response (respondent_id SIEMPRE NULL — encuesta anónima, regla #2 CLAUDE.md)
|
||||
INSERT INTO public.responses (
|
||||
id, survey_id, company_id, respondent_id, ip_hash,
|
||||
consent_record_id, qi_age_bucket, qi_sector, qi_zone
|
||||
)
|
||||
VALUES (
|
||||
v_response_id, p_survey_id, v_org_id, NULL, p_ip_hash,
|
||||
v_consent_id, p_qi_age_bucket, p_qi_sector, p_qi_zone
|
||||
);
|
||||
|
||||
-- answers — un row por entrada validada, con su instance_id real
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
SELECT v_response_id, r.question_id, r.instance_id, r.value
|
||||
FROM jsonb_to_recordset(p_answers) AS r(question_id uuid, instance_id text, value jsonb);
|
||||
|
||||
RETURN jsonb_build_object('response_id', v_response_id);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.rpc_submit_response(
|
||||
uuid, text, text, text, text, boolean, boolean, jsonb
|
||||
) FROM PUBLIC;
|
||||
GRANT EXECUTE ON FUNCTION public.rpc_submit_response(
|
||||
uuid, text, text, text, text, boolean, boolean, jsonb
|
||||
) TO anon, authenticated;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Sprint 4 (v3.0.0) — Fix puntual: _dash_responses filtraba género/modalidad
|
||||
-- contra códigos v2 ('A6'/'A7') que no existen en el esquema v3. El filtro
|
||||
-- devolvía 0 filas SIN error — silencioso, indistinguible de "no hay nadie
|
||||
-- con ese filtro". Es una trampa: alguien podría leer "ningún hombre es
|
||||
-- cuidador" cuando en realidad el filtro nunca encontró la pregunta.
|
||||
--
|
||||
-- Mapeo: A6 (género, v2) → 2.5 (género, v3) · A7 (modalidad, v2) → 2.7 (modalidad, v3).
|
||||
--
|
||||
-- Alcance: SOLO este helper compartido. Las 9 RPCs viejas de v2 (migración
|
||||
-- 20260605140001_012_dashboard_rpc_v2.sql) NO se tocan — sus propias
|
||||
-- comparaciones de código (1.1, 1.6, 1.13, 4.3, 6.1, 6.2, 6.5, etc.) siguen
|
||||
-- sin migrar (Etapa 4H, pendiente de confirmación aparte, no se resuelve
|
||||
-- acá). Este fix no las rompe: ninguna de esas 9 RPCs compara 'A6'/'A7'
|
||||
-- directamente, solo le pasan arrays de filtro (p_genero/p_modalidad) a esta
|
||||
-- función — que ahora los evalúa contra preguntas que SÍ existen en v3.
|
||||
CREATE OR REPLACE FUNCTION public._dash_responses(
|
||||
p_survey_id uuid,
|
||||
p_depto text DEFAULT NULL,
|
||||
p_genero text[] DEFAULT NULL,
|
||||
p_etario text[] DEFAULT NULL,
|
||||
p_modalidad text[] DEFAULT NULL
|
||||
)
|
||||
RETURNS TABLE(response_id uuid)
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT r.id
|
||||
FROM public.responses r
|
||||
WHERE (p_survey_id = '00000000-0000-0000-0000-000000000000'
|
||||
OR r.survey_id = p_survey_id)
|
||||
AND (p_depto IS NULL OR r.qi_zone = p_depto)
|
||||
AND (p_genero IS NULL OR cardinality(p_genero) = 0 OR EXISTS (
|
||||
SELECT 1 FROM public.answers a
|
||||
JOIN public.questions q ON q.id = a.question_id
|
||||
WHERE a.response_id = r.id AND q.code = '2.5'
|
||||
AND a.value #>> '{}' = ANY(p_genero)
|
||||
))
|
||||
AND (p_etario IS NULL OR cardinality(p_etario) = 0
|
||||
OR r.qi_age_bucket = ANY(p_etario))
|
||||
AND (p_modalidad IS NULL OR cardinality(p_modalidad) = 0 OR EXISTS (
|
||||
SELECT 1 FROM public.answers a
|
||||
JOIN public.questions q ON q.id = a.question_id
|
||||
WHERE a.response_id = r.id AND q.code = '2.7'
|
||||
AND a.value #>> '{}' = ANY(p_modalidad)
|
||||
));
|
||||
$$;
|
||||
716
supabase/migrations/20260625000002_dashboard_mirada_empresa.sql
Normal file
716
supabase/migrations/20260625000002_dashboard_mirada_empresa.sql
Normal file
@@ -0,0 +1,716 @@
|
||||
-- ============================================================
|
||||
-- Sprint 4 (v3.0.0) — Dashboard: RPCs de la Mirada Empresa
|
||||
-- Fuente: docs/dashboard/SPEC_MIRADA_EMPRESA.md (contrato validado)
|
||||
--
|
||||
-- Patrón: idéntico al de la migración 012 (rpc_dashboard_kpis, etc.) —
|
||||
-- SECURITY DEFINER, filtro survey_id + flag p_all delegado a
|
||||
-- _dash_responses (sin tocar), k≥5 en dos niveles, shape {status, n, ...}.
|
||||
-- No se modifican las 9 RPCs viejas de v2 (quedan como están).
|
||||
--
|
||||
-- Mapeo métrica de la spec → función:
|
||||
-- E1 prevalencia de cuidadores → rpc_me_prevalencia_cuidadores
|
||||
-- E2 riesgo de rotación → rpc_me_riesgo_rotacion
|
||||
-- E3 ausentismo → rpc_me_ausentismo
|
||||
-- E4 presentismo → rpc_me_presentismo
|
||||
-- E6 punto ciego organizacional → rpc_me_punto_ciego
|
||||
-- E7 demanda de apoyos → rpc_me_apoyos_demandados
|
||||
-- ★ talento en riesgo → rpc_me_talento_en_riesgo
|
||||
-- E5 carrera congelada → rpc_me_carrera_congelada
|
||||
-- E8 intensidad de carga → rpc_me_intensidad_carga
|
||||
-- E9 segmentación nivel/modalidad → rpc_me_segmentacion
|
||||
-- E10 disposición a red de apoyo → rpc_me_disposicion_red
|
||||
-- ============================================================
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- HELPER — CUIDADOR_ACTIVO (único punto de la definición, ver spec)
|
||||
-- "Hijo menor sin discapacidad" SÍ cuenta como cuidador (criterio UNFPA,
|
||||
-- validado por Maria, jun 2026) — ensancha la población respecto a v2.
|
||||
-- Toda métrica que use "cuidador" como población llama a este helper;
|
||||
-- si la definición cambia, se cambia ACÁ y nada más.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public._dash_es_cuidador(p_response_id uuid)
|
||||
RETURNS boolean
|
||||
LANGUAGE sql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
-- CASE (no AND) a propósito: Postgres no garantiza orden de evaluación en
|
||||
-- AND/OR, solo en CASE — esto asegura que el operador jsonb "-" (solo
|
||||
-- válido sobre arrays) nunca se ejecute sobre un valor que no lo sea.
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.answers a
|
||||
JOIN public.questions q ON q.id = a.question_id
|
||||
WHERE a.response_id = p_response_id
|
||||
AND q.code = '4.1'
|
||||
AND CASE WHEN jsonb_typeof(a.value) = 'array'
|
||||
THEN jsonb_array_length(
|
||||
a.value - 'Ninguna de las anteriores / No tengo vinculación de cuidado'
|
||||
) > 0
|
||||
ELSE false
|
||||
END
|
||||
)
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] E1 — Prevalencia de cuidadores en la plantilla
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_prevalencia_cuidadores(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_total bigint;
|
||||
v_cuid bigint;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT count(*) INTO v_total
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
|
||||
|
||||
IF v_total < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_cuid
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_total,
|
||||
'pct_cuidadores', ROUND(v_cuid::numeric / v_total * 100, 1)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] E2 — Riesgo de rotación por cuidado
|
||||
-- Denominador: cuidadores que RESPONDIERON 7.6 — no "todos los cuidadores",
|
||||
-- porque 7.6 vive en Sección 7, oculta para soloNino (solo "Hijo menor sin
|
||||
-- discapacidad") y para "Ninguna". Si el denominador fuera "todos los
|
||||
-- cuidadores" sin este matiz, se diluiría el % con gente a la que nunca se
|
||||
-- le mostró la pregunta — no es lo mismo "no está en riesgo" que "no se le
|
||||
-- preguntó". El JOIN a la pregunta concreta resuelve esto solo.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_riesgo_rotacion(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n_cuid bigint;
|
||||
v_algo bigint;
|
||||
v_muy bigint;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Algo probable'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Muy probable')
|
||||
INTO v_n_cuid, v_algo, v_muy
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.6'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n_cuid < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_cuidadores',v_n_cuid);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n_cuidadores', v_n_cuid,
|
||||
'pct_riesgo', ROUND((v_algo + v_muy)::numeric / v_n_cuid * 100, 1),
|
||||
'desglose', jsonb_build_object(
|
||||
'algo', ROUND(v_algo::numeric / v_n_cuid * 100, 1),
|
||||
'muy', ROUND(v_muy::numeric / v_n_cuid * 100, 1)
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] E3 — Ausentismo atribuible al cuidado
|
||||
-- Mismo matiz de denominador que E2 (7.3 vive en Sección 7, oculta para
|
||||
-- soloNino/Ninguna) — denominador = cuidadores que respondieron 7.3.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_ausentismo(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_nunca bigint;
|
||||
v_pocas bigint;
|
||||
v_varias bigint;
|
||||
v_frec bigint;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Nunca'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Pocas veces (1–3 al año)'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces (4–10 al año)'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Con frecuencia (más de 10 al año)')
|
||||
INTO v_n, v_nunca, v_pocas, v_varias, v_frec
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.3'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_n,
|
||||
'pct_ausentismo_recurrente', ROUND((v_varias + v_frec)::numeric / v_n * 100, 1),
|
||||
'desglose', jsonb_build_object(
|
||||
'nunca', ROUND(v_nunca::numeric / v_n * 100, 1),
|
||||
'pocas', ROUND(v_pocas::numeric / v_n * 100, 1),
|
||||
'varias', ROUND(v_varias::numeric / v_n * 100, 1),
|
||||
'frecuente', ROUND(v_frec::numeric / v_n * 100, 1)
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] E4 — Presentismo (productividad reducida)
|
||||
-- Mismo matiz de denominador (7.2, Sección 7) que E2/E3.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_presentismo(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_nunca bigint;
|
||||
v_algunas bigint;
|
||||
v_varias bigint;
|
||||
v_casi bigint;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Nunca o casi nunca'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Algunas veces al mes'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Varias veces por semana'),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Casi todos los días')
|
||||
INTO v_n, v_nunca, v_algunas, v_varias, v_casi
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.2'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_n,
|
||||
'pct_presentismo', ROUND((v_varias + v_casi)::numeric / v_n * 100, 1),
|
||||
'desglose', jsonb_build_object(
|
||||
'nunca_o_casi_nunca', ROUND(v_nunca::numeric / v_n * 100, 1),
|
||||
'algunas_al_mes', ROUND(v_algunas::numeric / v_n * 100, 1),
|
||||
'varias_por_semana', ROUND(v_varias::numeric / v_n * 100, 1),
|
||||
'casi_todos_los_dias',ROUND(v_casi::numeric / v_n * 100, 1)
|
||||
)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] E6 — Punto ciego organizacional
|
||||
-- carga_real_pct: sobre TODA la plantilla encuestada (= E1).
|
||||
-- apoyo_percibido_pct: sobre quienes respondieron 9.1 (oculta solo para
|
||||
-- "Ninguna" — soloNino SÍ la ve, a diferencia de 7.x). Son denominadores
|
||||
-- distintos a propósito (carga real de TODOS vs. percepción de quienes
|
||||
-- tienen vínculo) — es justamente la brecha que la métrica busca mostrar.
|
||||
-- Cada población se suprime de forma independiente si n<5.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_punto_ciego(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_total bigint;
|
||||
v_cuid bigint;
|
||||
v_n_apoyo bigint;
|
||||
v_apoyo_si bigint;
|
||||
v_carga_pct numeric;
|
||||
v_apoyo_pct numeric;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT count(*) INTO v_total
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
|
||||
|
||||
IF v_total < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
|
||||
END IF;
|
||||
|
||||
SELECT count(*) INTO v_cuid
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
v_carga_pct := ROUND(v_cuid::numeric / v_total * 100, 1);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Sí, me siento acompañado/a')
|
||||
INTO v_n_apoyo, v_apoyo_si
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '9.1';
|
||||
|
||||
IF v_n_apoyo >= 5 THEN
|
||||
v_apoyo_pct := ROUND(v_apoyo_si::numeric / v_n_apoyo * 100, 1);
|
||||
ELSE
|
||||
v_apoyo_pct := NULL;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_total,
|
||||
'carga_real_pct', v_carga_pct,
|
||||
'apoyo_percibido_pct', v_apoyo_pct,
|
||||
'apoyo_percibido_suppressed', (v_n_apoyo < 5),
|
||||
'brecha_pp', CASE WHEN v_apoyo_pct IS NOT NULL THEN ROUND(v_carga_pct - v_apoyo_pct, 1) ELSE NULL END
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] E7 — Demanda de apoyos priorizada
|
||||
-- Denominador = TODA la plantilla encuestada (igual molde que
|
||||
-- rpc_apoyos_demandados de v2: divide por v_total, no por quienes
|
||||
-- respondieron 9.2). 9.2 ya está oculta para Ninguna/soloNino, así que en
|
||||
-- la práctica solo cuidadores "completos" la responden — coherente con
|
||||
-- cómo se comportaba 6.2 en v2.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_apoyos_demandados(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_total bigint;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT count(*) INTO v_total
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
|
||||
|
||||
IF v_total < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'apoyo', opt,
|
||||
'n', cnt,
|
||||
'pct', ROUND(cnt::numeric / v_total * 100, 1)
|
||||
) AS row
|
||||
FROM (
|
||||
SELECT jsonb_array_elements_text(a.value) AS opt,
|
||||
count(DISTINCT dr.response_id) AS cnt
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id
|
||||
WHERE q.code = '9.2' AND jsonb_typeof(a.value) = 'array'
|
||||
GROUP BY 1
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
ORDER BY cnt DESC
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object('status','ok','n',v_total,'apoyos', COALESCE(v_rows,'[]'::jsonb));
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [NÚCLEO] ★ Métrica estrella — "Talento en riesgo"
|
||||
-- Umbral PARAMETRIZABLE (p_umbral_senales, default 2 de 3) — cambiarlo no
|
||||
-- requiere tocar el cuerpo de la función. ADVERTENCIA: esto resuelve
|
||||
-- "cambiar el número de corte"; NO resuelve "pasar a un esquema ponderado"
|
||||
-- (eso sería una fórmula distinta — pesos por señal, no un conteo — y
|
||||
-- necesitaría otra firma/cuerpo de función, no solo este parámetro).
|
||||
--
|
||||
-- Población: cuidadores que respondieron 7.6 (ancla la Sección 7 visible;
|
||||
-- 7.3/7.5 se traen con LEFT JOIN porque comparten la misma regla de
|
||||
-- ocultamiento que 7.6 — en la práctica siempre van juntas, pero LEFT JOIN
|
||||
-- es defensivo ante una respuesta parcial real).
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_talento_en_riesgo(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb,
|
||||
p_umbral_senales int DEFAULT 2
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_riesgo bigint;
|
||||
v_avg numeric;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
WITH base AS (
|
||||
SELECT
|
||||
dr.response_id,
|
||||
CASE WHEN s76.value #>> '{}' IN ('Algo probable','Muy probable') THEN 1 ELSE 0 END
|
||||
AS sig_riesgo,
|
||||
CASE WHEN s73.value #>> '{}' IN ('Varias veces (4–10 al año)','Con frecuencia (más de 10 al año)') THEN 1 ELSE 0 END
|
||||
AS sig_ausentismo,
|
||||
CASE WHEN jsonb_typeof(s75.value) = 'array' THEN
|
||||
CASE WHEN jsonb_array_length(s75.value - 'Ninguna de las anteriores') > 0
|
||||
THEN 1 ELSE 0 END
|
||||
ELSE 0
|
||||
END AS sig_carrera
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers s76 ON s76.response_id = dr.response_id
|
||||
JOIN public.questions q76 ON q76.id = s76.question_id AND q76.code = '7.6'
|
||||
LEFT JOIN public.questions q73 ON q73.code = '7.3' AND q73.survey_id = q76.survey_id
|
||||
LEFT JOIN public.answers s73 ON s73.response_id = dr.response_id AND s73.question_id = q73.id
|
||||
LEFT JOIN public.questions q75 ON q75.code = '7.5' AND q75.survey_id = q76.survey_id
|
||||
LEFT JOIN public.answers s75 ON s75.response_id = dr.response_id AND s75.question_id = q75.id
|
||||
WHERE public._dash_es_cuidador(dr.response_id)
|
||||
)
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE (sig_riesgo + sig_ausentismo + sig_carrera) >= p_umbral_senales),
|
||||
avg(sig_riesgo + sig_ausentismo + sig_carrera)
|
||||
INTO v_n, v_riesgo, v_avg
|
||||
FROM base;
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_cuidadores',v_n);
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n_cuidadores', v_n,
|
||||
'umbral_senales', p_umbral_senales,
|
||||
'pct_talento_riesgo', ROUND(v_riesgo::numeric / v_n * 100, 1),
|
||||
'criterios_promedio', ROUND(v_avg, 2)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [SEC] E5 — Carrera congelada
|
||||
-- Mismo matiz de denominador (7.5, Sección 7) que E2/E3/E4.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_cong bigint;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (
|
||||
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
|
||||
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
|
||||
ELSE false
|
||||
END
|
||||
)
|
||||
INTO v_n, v_cong
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
|
||||
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row
|
||||
FROM (
|
||||
SELECT jsonb_array_elements_text(a.value) AS opt,
|
||||
count(DISTINCT dr.response_id) AS cnt
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
|
||||
WHERE public._dash_es_cuidador(dr.response_id)
|
||||
AND jsonb_typeof(a.value) = 'array'
|
||||
AND a.value #>> '{}' != 'Ninguna de las anteriores'
|
||||
GROUP BY 1
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
ORDER BY cnt DESC
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_n,
|
||||
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
|
||||
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [SEC] E8 — Intensidad de carga
|
||||
-- Población: cuidadores que respondieron 6.1 (ancla Sección 6, misma regla
|
||||
-- de ocultamiento que 6.2/6.4 — comparten r_hide_ning_sn).
|
||||
-- Son rangos categóricos (no números) — se reporta distribución por
|
||||
-- combinación, no un promedio, tal como pide la spec.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_intensidad_carga(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_guardia bigint;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT count(*) INTO v_n
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '6.1'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(row) INTO v_rows FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'dias_semana', dias_bin,
|
||||
'horas_dia', horas_bin,
|
||||
'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END,
|
||||
'suppressed', (cnt < 5)
|
||||
) AS row
|
||||
FROM (
|
||||
SELECT
|
||||
ad.value #>> '{}' AS dias_bin,
|
||||
ah.value #>> '{}' AS horas_bin,
|
||||
count(DISTINCT dr.response_id) AS cnt
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers ad ON ad.response_id = dr.response_id
|
||||
JOIN public.questions qd ON qd.id = ad.question_id AND qd.code = '6.1'
|
||||
JOIN public.answers ah ON ah.response_id = dr.response_id
|
||||
JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '6.2'
|
||||
WHERE public._dash_es_cuidador(dr.response_id)
|
||||
GROUP BY 1, 2
|
||||
) sub
|
||||
ORDER BY dias_bin, horas_bin
|
||||
) t;
|
||||
|
||||
SELECT count(*) INTO v_guardia
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '6.4'
|
||||
WHERE public._dash_es_cuidador(dr.response_id)
|
||||
AND a.value #>> '{}' = 'De forma permanente / sin horario';
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_n,
|
||||
'distribucion_intensidad', COALESCE(v_rows, '[]'::jsonb),
|
||||
'pct_guardia_permanente', ROUND(v_guardia::numeric / v_n * 100, 1)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [SEC] E9 — Segmentación por nivel y modalidad
|
||||
-- pct_cuidadores cruzado con 2.6 (nivel organizacional) y 2.7 (modalidad).
|
||||
-- k≥5 por fila — segmento con n<5 se omite (no se devuelve null disfrazado
|
||||
-- de 0%, se OMITE la fila, igual criterio que rpc_prevalencia_por_depto).
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_segmentacion(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_total bigint;
|
||||
v_por_nivel jsonb;
|
||||
v_por_modal jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT count(*) INTO v_total
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
|
||||
|
||||
IF v_total < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
|
||||
END IF;
|
||||
|
||||
-- Nivel organizacional (2.6) ya se promueve a responses.qi_sector en el
|
||||
-- submit (Etapa 4G) — se lee directo de ahí, mismo patrón que
|
||||
-- rpc_prevalencia_por_depto (v2) lee qi_zone. NO se lee de answers/2.6:
|
||||
-- esa fue la causa del bug original (la RPC buscaba la respuesta cruda
|
||||
-- en vez de la columna ya denormalizada).
|
||||
SELECT jsonb_agg(row) INTO v_por_nivel FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'nivel', nivel_val,
|
||||
'n', cnt,
|
||||
'pct', ROUND(cnt_cuid::numeric / cnt * 100, 1)
|
||||
) AS row
|
||||
FROM (
|
||||
SELECT
|
||||
r.qi_sector AS nivel_val,
|
||||
count(DISTINCT dr.response_id) AS cnt,
|
||||
count(DISTINCT dr.response_id) FILTER (WHERE public._dash_es_cuidador(dr.response_id)) AS cnt_cuid
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.responses r ON r.id = dr.response_id
|
||||
WHERE r.qi_sector IS NOT NULL
|
||||
GROUP BY 1
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
ORDER BY nivel_val
|
||||
) t;
|
||||
|
||||
-- Modalidad (2.7) NO tiene columna qi_modalidad — decisión consciente
|
||||
-- para no reabrir rpc_submit_response, ya verificada en 4G. Se lee por
|
||||
-- JOIN a answers/questions en tiempo de consulta. Ver TECH-DEBT-010.
|
||||
SELECT jsonb_agg(row) INTO v_por_modal FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'modalidad', modal_val,
|
||||
'n', cnt,
|
||||
'pct', ROUND(cnt_cuid::numeric / cnt * 100, 1)
|
||||
) AS row
|
||||
FROM (
|
||||
SELECT
|
||||
a.value #>> '{}' AS modal_val,
|
||||
count(DISTINCT dr.response_id) AS cnt,
|
||||
count(DISTINCT dr.response_id) FILTER (WHERE public._dash_es_cuidador(dr.response_id)) AS cnt_cuid
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '2.7'
|
||||
GROUP BY 1
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
ORDER BY modal_val
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_total,
|
||||
'por_nivel', COALESCE(v_por_nivel, '[]'::jsonb),
|
||||
'por_modalidad', COALESCE(v_por_modal, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
-- [SEC] E10 — Disposición a red de apoyo interna
|
||||
-- 9.3 (oculta para Ninguna Y soloNino) y 9.4 (oculta solo para Ninguna)
|
||||
-- tienen reglas de ocultamiento DISTINTAS → poblaciones distintas →
|
||||
-- cada porcentaje se calcula y se suprime de forma independiente,
|
||||
-- mismo criterio que E6.
|
||||
-- ════════════════════════════════════════════════════════════
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_disposicion_red(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n_grupo bigint;
|
||||
v_grupo_si bigint;
|
||||
v_n_vol bigint;
|
||||
v_vol_si bigint;
|
||||
v_pct_grupo numeric;
|
||||
v_pct_vol numeric;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' IN ('Sí, definitivamente','Tal vez, si hay más personas en la misma situación'))
|
||||
INTO v_n_grupo, v_grupo_si
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '9.3';
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (WHERE a.value #>> '{}' = 'Sí')
|
||||
INTO v_n_vol, v_vol_si
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '9.4';
|
||||
|
||||
IF v_n_grupo < 5 AND v_n_vol < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n_grupo_apoyo',v_n_grupo,'n_voluntariado',v_n_vol);
|
||||
END IF;
|
||||
|
||||
v_pct_grupo := CASE WHEN v_n_grupo >= 5 THEN ROUND(v_grupo_si::numeric / v_n_grupo * 100, 1) ELSE NULL END;
|
||||
v_pct_vol := CASE WHEN v_n_vol >= 5 THEN ROUND(v_vol_si::numeric / v_n_vol * 100, 1) ELSE NULL END;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n_grupo_apoyo', v_n_grupo,
|
||||
'pct_grupo_apoyo', v_pct_grupo,
|
||||
'n_voluntariado', v_n_vol,
|
||||
'pct_voluntariado', v_pct_vol
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
110
supabase/migrations/20260626000001_fix_rpc_me_carrera_apoyos.sql
Normal file
110
supabase/migrations/20260626000001_fix_rpc_me_carrera_apoyos.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- Fix puntual: column "cnt" does not exist en jsonb_agg(row ORDER BY cnt DESC).
|
||||
-- El alias cnt vive en el subquery interno; el nivel externo del jsonb_agg solo
|
||||
-- ve `row`. Solución (Opción B): exponer cnt en el SELECT externo para que
|
||||
-- jsonb_agg(row ORDER BY cnt DESC) pueda referenciarlo en ese nivel.
|
||||
-- Solo se tocan estas dos funciones; las otras 9 rpc_me_* no se modifican.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_apoyos_demandados(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_total bigint;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT count(*) INTO v_total
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad);
|
||||
|
||||
IF v_total < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_total);
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
|
||||
SELECT jsonb_build_object(
|
||||
'apoyo', opt,
|
||||
'n', cnt,
|
||||
'pct', ROUND(cnt::numeric / v_total * 100, 1)
|
||||
) AS row,
|
||||
cnt
|
||||
FROM (
|
||||
SELECT jsonb_array_elements_text(a.value) AS opt,
|
||||
count(DISTINCT dr.response_id) AS cnt
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id
|
||||
WHERE q.code = '9.2' AND jsonb_typeof(a.value) = 'array'
|
||||
GROUP BY 1
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object('status','ok','n',v_total,'apoyos', COALESCE(v_rows,'[]'::jsonb));
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_cong bigint;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (
|
||||
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
|
||||
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
|
||||
ELSE false
|
||||
END
|
||||
)
|
||||
INTO v_n, v_cong
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
|
||||
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row,
|
||||
cnt
|
||||
FROM (
|
||||
SELECT jsonb_array_elements_text(a.value) AS opt,
|
||||
count(DISTINCT dr.response_id) AS cnt
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
|
||||
WHERE public._dash_es_cuidador(dr.response_id)
|
||||
AND jsonb_typeof(a.value) = 'array'
|
||||
AND a.value #>> '{}' != 'Ninguna de las anteriores'
|
||||
GROUP BY 1
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_n,
|
||||
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
|
||||
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,73 @@
|
||||
-- BUG-001 — Fix filtro "Ninguna de las anteriores" en rpc_me_carrera_congelada
|
||||
--
|
||||
-- PROBLEMA: la migración anterior (20260626000001) dejó en el subquery interno:
|
||||
-- AND a.value #>> '{}' != 'Ninguna de las anteriores'
|
||||
-- El operador `#>> '{}'` aplicado sobre un ARRAY jsonb no extrae elementos;
|
||||
-- convierte todo el array a su representación textual (ej. '["opcion","Ninguna..."]'),
|
||||
-- por lo que la comparación nunca descarta el elemento deseado.
|
||||
--
|
||||
-- SOLUCIÓN: mover el filtro al WHERE del subquery expandido, donde `opt` ya existe
|
||||
-- como elemento individual resultado de jsonb_array_elements_text.
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.rpc_me_carrera_congelada(
|
||||
p_survey_id uuid,
|
||||
p_filters jsonb DEFAULT '{}'::jsonb
|
||||
)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_f record;
|
||||
v_n bigint;
|
||||
v_cong bigint;
|
||||
v_rows jsonb;
|
||||
BEGIN
|
||||
SELECT * INTO v_f FROM public._dash_parse_filters(p_filters);
|
||||
|
||||
SELECT
|
||||
count(*),
|
||||
count(*) FILTER (
|
||||
WHERE CASE WHEN jsonb_typeof(a.value) = 'array'
|
||||
THEN jsonb_array_length(a.value - 'Ninguna de las anteriores') > 0
|
||||
ELSE false
|
||||
END
|
||||
)
|
||||
INTO v_n, v_cong
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
|
||||
WHERE public._dash_es_cuidador(dr.response_id);
|
||||
|
||||
IF v_n < 5 THEN
|
||||
RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n);
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM (
|
||||
SELECT jsonb_build_object('opcion', opt, 'n', cnt, 'pct', ROUND(cnt::numeric / v_n * 100, 1)) AS row,
|
||||
cnt
|
||||
FROM (
|
||||
SELECT opt, count(DISTINCT response_id) AS cnt
|
||||
FROM (
|
||||
SELECT jsonb_array_elements_text(a.value) AS opt,
|
||||
dr.response_id
|
||||
FROM public._dash_responses(p_survey_id, v_f.f_depto, v_f.f_genero, v_f.f_etario, v_f.f_modalidad) dr
|
||||
JOIN public.answers a ON a.response_id = dr.response_id
|
||||
JOIN public.questions q ON q.id = a.question_id AND q.code = '7.5'
|
||||
WHERE public._dash_es_cuidador(dr.response_id)
|
||||
AND jsonb_typeof(a.value) = 'array'
|
||||
) expanded
|
||||
WHERE opt != 'Ninguna de las anteriores'
|
||||
GROUP BY opt
|
||||
) sub
|
||||
WHERE cnt >= 5
|
||||
) t;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'status', 'ok',
|
||||
'n', v_n,
|
||||
'pct_carrera_congelada', ROUND(v_cong::numeric / v_n * 100, 1),
|
||||
'top_opciones', COALESCE(v_rows, '[]'::jsonb)
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
129
supabase/migrations/20260627000001_auth_user_roles.sql
Normal file
129
supabase/migrations/20260627000001_auth_user_roles.sql
Normal file
@@ -0,0 +1,129 @@
|
||||
-- ============================================================
|
||||
-- Migration 004 / ETAPA_AUTH 4.2
|
||||
-- Tabla user_roles + hook de custom claims para JWT
|
||||
--
|
||||
-- Después de aplicar esta migración, el director debe:
|
||||
-- 1. Agregar en Dokploy (servicio supabase-auth):
|
||||
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_ENABLED=true
|
||||
-- GOTRUE_HOOKS_CUSTOM_ACCESS_TOKEN_URI=pg-functions://postgres/public/custom_access_token_hook
|
||||
-- 2. Reiniciar el servicio supabase-auth (NO la app Next.js).
|
||||
-- ============================================================
|
||||
|
||||
-- ── 1. Tabla user_roles ──────────────────────────────────────
|
||||
CREATE TABLE public.user_roles (
|
||||
user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
app_role text NOT NULL CHECK (app_role IN ('platform_admin', 'org_admin')),
|
||||
org_id uuid REFERENCES public.organizations(id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.user_roles ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- El usuario ve su propio rol; platform_admin ve todos
|
||||
CREATE POLICY "user_roles_select_own"
|
||||
ON public.user_roles
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
OR public.current_app_role() = 'platform_admin'
|
||||
);
|
||||
|
||||
-- ── 2. Extender policies existentes para incluir platform_admin ──
|
||||
|
||||
-- organizations
|
||||
DROP POLICY IF EXISTS "org_select_own" ON public.organizations;
|
||||
CREATE POLICY "org_select_own"
|
||||
ON public.organizations
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.current_app_role() IN ('analyst', 'platform_admin')
|
||||
OR (
|
||||
public.current_app_role() = 'org_admin'
|
||||
AND id = public.current_org_id()
|
||||
)
|
||||
);
|
||||
|
||||
-- surveys (no tocar surveys_select_live_anon — sigue igual)
|
||||
DROP POLICY IF EXISTS "surveys_select_auth" ON public.surveys;
|
||||
CREATE POLICY "surveys_select_auth"
|
||||
ON public.surveys
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.current_app_role() IN ('analyst', 'platform_admin')
|
||||
OR (
|
||||
public.current_app_role() = 'org_admin'
|
||||
AND org_id = public.current_org_id()
|
||||
)
|
||||
);
|
||||
|
||||
-- questions (no tocar questions_select_live — sigue igual)
|
||||
DROP POLICY IF EXISTS "questions_select_auth" ON public.questions;
|
||||
CREATE POLICY "questions_select_auth"
|
||||
ON public.questions
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (
|
||||
public.current_app_role() IN ('analyst', 'platform_admin')
|
||||
OR (
|
||||
public.current_app_role() = 'org_admin'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.surveys s
|
||||
WHERE s.id = survey_id
|
||||
AND s.org_id = public.current_org_id()
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- ── 3. Nuevas policies SELECT para platform_admin (responses/answers no tenían) ──
|
||||
CREATE POLICY "responses_select_platform_admin"
|
||||
ON public.responses
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
|
||||
|
||||
CREATE POLICY "answers_select_platform_admin"
|
||||
ON public.answers
|
||||
FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.current_app_role() IN ('analyst', 'platform_admin'));
|
||||
|
||||
-- ── 4. Hook de custom claims ─────────────────────────────────
|
||||
-- Inyecta app_role y org_id en el JWT en cada emisión de token.
|
||||
-- SECURITY DEFINER + search_path garantiza que bypasea RLS para leer user_roles.
|
||||
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event jsonb)
|
||||
RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
STABLE
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
claims jsonb;
|
||||
v_role text;
|
||||
v_org uuid;
|
||||
BEGIN
|
||||
claims := event -> 'claims';
|
||||
|
||||
SELECT app_role, org_id
|
||||
INTO v_role, v_org
|
||||
FROM public.user_roles
|
||||
WHERE user_id = (event ->> 'user_id')::uuid;
|
||||
|
||||
IF v_role IS NOT NULL THEN
|
||||
claims := jsonb_set(claims, '{app_role}', to_jsonb(v_role));
|
||||
IF v_org IS NOT NULL THEN
|
||||
claims := jsonb_set(claims, '{org_id}', to_jsonb(v_org::text));
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN jsonb_set(event, '{claims}', claims);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- GoTrue necesita EXECUTE para llamar la función y SELECT para que el SECURITY DEFINER
|
||||
-- pueda leer user_roles internamente (aunque SECURITY DEFINER corre como postgres,
|
||||
-- el grant explicito documenta la intención y protege contra cambios de ownership).
|
||||
GRANT EXECUTE ON FUNCTION public.custom_access_token_hook(jsonb) TO supabase_auth_admin;
|
||||
GRANT SELECT ON public.user_roles TO supabase_auth_admin;
|
||||
190
supabase/migrations/20260627000002_audit_log.sql
Normal file
190
supabase/migrations/20260627000002_audit_log.sql
Normal file
@@ -0,0 +1,190 @@
|
||||
-- ============================================================
|
||||
-- Migration: audit_log append-only — SEC-002 (Sprint 5, compliance)
|
||||
--
|
||||
-- Loggea cambios de configuración crítica:
|
||||
-- - user_roles: altas/bajas/cambios de app_role u org_id (quién es admin)
|
||||
-- - surveys.status: activación/desactivación de encuestas
|
||||
-- - DDL en schema public: toda migración aplicada (CREATE/ALTER/DROP),
|
||||
-- vía event trigger ddl_command_end. Confirmado viable en este Postgres
|
||||
-- self-hosted sin extensiones adicionales: el rol `postgres` tiene
|
||||
-- rolsuper=false pero pudo crear el event trigger sin error
|
||||
-- (diagnóstico ETAPA_AUDIT_LOG_PROMPT.md §4.1, 2026-06-27).
|
||||
--
|
||||
-- NO loggea acceso a datos sensibles (is_sensitive=true): hoy ningún
|
||||
-- camino de código (ni /admin/responses ni las RPCs rpc_me_*) lee esas
|
||||
-- columnas (COMPLIANCE.md §3.2). Deferido a cuando ese código exista —
|
||||
-- ese mismo cambio debe agregar su propio log de acceso, no esta migración.
|
||||
--
|
||||
-- GAP CONOCIDO (SEC-003, aceptado, NO resuelto aquí): un operador con
|
||||
-- acceso directo a `psql -U postgres` en el VPS puede TRUNCATE/DROP esta
|
||||
-- tabla, o saltear los triggers con `SET session_replication_role =
|
||||
-- replica` / `ALTER TABLE ... DISABLE TRIGGER ALL`. El append-only de
|
||||
-- abajo cierra la escritura vía API (anon/authenticated/PostgREST) y
|
||||
-- bloquea UPDATE/DELETE para cualquier caller normal (incluido
|
||||
-- service_role, que bypasa RLS pero no bypasa triggers) — no protege
|
||||
-- contra un operador con shell en la base.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE public.audit_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
occurred_at timestamptz NOT NULL DEFAULT now(),
|
||||
actor_role text,
|
||||
actor_user_id uuid,
|
||||
action text NOT NULL,
|
||||
target_table text NOT NULL,
|
||||
target_id text,
|
||||
detail jsonb
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.audit_log IS
|
||||
'Append-only. INSERT solo vía funciones SECURITY DEFINER de los triggers de esta migración. UPDATE/DELETE bloqueados por trigger audit_log_block_mutation. Gap conocido: no protege contra TRUNCATE/DROP por superusuario directo en el VPS (SEC-003).';
|
||||
|
||||
COMMENT ON COLUMN public.audit_log.actor_role IS
|
||||
'current_app_role() del que disparó el cambio (user_roles/surveys), o session_user para eventos DDL.';
|
||||
|
||||
ALTER TABLE public.audit_log ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Solo platform_admin puede leer. No hay policies de INSERT/UPDATE/DELETE
|
||||
-- para ningún rol vía API — el único camino de escritura son las
|
||||
-- funciones SECURITY DEFINER (corren como propietario de la tabla,
|
||||
-- `postgres`, que por default de Postgres bypasa RLS sobre objetos
|
||||
-- propios sin necesitar FORCE ROW LEVEL SECURITY).
|
||||
CREATE POLICY "audit_log_select_platform_admin"
|
||||
ON public.audit_log FOR SELECT
|
||||
TO authenticated
|
||||
USING (public.current_app_role() = 'platform_admin');
|
||||
|
||||
REVOKE INSERT, UPDATE, DELETE ON public.audit_log FROM PUBLIC, anon, authenticated;
|
||||
GRANT SELECT ON public.audit_log TO authenticated;
|
||||
|
||||
-- ============================================================
|
||||
-- Barrera 2: append-only enforcement a nivel de trigger.
|
||||
-- Se aplica a CUALQUIER caller (incluido service_role) salvo que alguien
|
||||
-- deshabilite triggers con privilegios de superusuario real en el VPS
|
||||
-- (gap documentado arriba, SEC-003).
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.audit_log_block_mutation()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'audit_log es append-only: % no permitido', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.audit_log_block_mutation() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER audit_log_no_update
|
||||
BEFORE UPDATE ON public.audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
|
||||
|
||||
CREATE TRIGGER audit_log_no_delete
|
||||
BEFORE DELETE ON public.audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_log_block_mutation();
|
||||
|
||||
-- ============================================================
|
||||
-- Trigger 1: cambios en user_roles (altas/bajas/cambios de acceso)
|
||||
-- Función disparada automáticamente por el trigger — no requiere GRANT
|
||||
-- EXECUTE a ningún rol (el mecanismo de triggers no pasa por una llamada
|
||||
-- SQL directa del caller).
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.log_user_roles_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
|
||||
VALUES (
|
||||
public.current_app_role(),
|
||||
auth.uid(),
|
||||
'user_roles_' || lower(TG_OP),
|
||||
'user_roles',
|
||||
COALESCE(NEW.user_id, OLD.user_id)::text,
|
||||
jsonb_build_object(
|
||||
'old', CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) ELSE NULL END,
|
||||
'new', CASE WHEN TG_OP IN ('UPDATE','INSERT') THEN to_jsonb(NEW) ELSE NULL END
|
||||
)
|
||||
);
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.log_user_roles_change() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER user_roles_audit
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.user_roles
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_user_roles_change();
|
||||
|
||||
-- ============================================================
|
||||
-- Trigger 2: cambios de status en surveys (activación/desactivación)
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.log_survey_status_change()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
|
||||
VALUES (
|
||||
public.current_app_role(),
|
||||
auth.uid(),
|
||||
'survey_status_change',
|
||||
'surveys',
|
||||
NEW.id::text,
|
||||
jsonb_build_object('old_status', OLD.status, 'new_status', NEW.status, 'title', NEW.title)
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.log_survey_status_change() FROM PUBLIC;
|
||||
|
||||
CREATE TRIGGER surveys_status_audit
|
||||
AFTER UPDATE ON public.surveys
|
||||
FOR EACH ROW EXECUTE FUNCTION public.log_survey_status_change();
|
||||
|
||||
-- ============================================================
|
||||
-- Trigger 3 (Prioridad 2, confirmado viable — ver §4.1 del prompt de etapa):
|
||||
-- DDL en schema public → loggea toda migración aplicada.
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION public.log_ddl_command()
|
||||
RETURNS event_trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
obj record;
|
||||
BEGIN
|
||||
FOR obj IN SELECT * FROM pg_event_trigger_ddl_commands() LOOP
|
||||
IF obj.schema_name = 'public' OR obj.schema_name IS NULL THEN
|
||||
INSERT INTO public.audit_log (actor_role, actor_user_id, action, target_table, target_id, detail)
|
||||
VALUES (
|
||||
session_user,
|
||||
NULL,
|
||||
'ddl_' || obj.command_tag,
|
||||
COALESCE(obj.object_identity, obj.schema_name, 'unknown'),
|
||||
obj.objid::text,
|
||||
jsonb_build_object('command_tag', obj.command_tag, 'object_type', obj.object_type)
|
||||
);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
REVOKE ALL ON FUNCTION public.log_ddl_command() FROM PUBLIC;
|
||||
|
||||
CREATE EVENT TRIGGER audit_ddl_public
|
||||
ON ddl_command_end
|
||||
EXECUTE FUNCTION public.log_ddl_command();
|
||||
|
||||
-- ============================================================
|
||||
-- Fin de migración. NO aplicar en el VPS sin aprobación explícita del
|
||||
-- director — ver ETAPA_AUDIT_LOG_PROMPT.md §4.3.
|
||||
-- ============================================================
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Activar required en todas las preguntas del formulario de cuidadores
|
||||
-- excepto barrio (2.3) y secciones (type = 'section')
|
||||
UPDATE public.questions
|
||||
SET required = true
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
|
||||
AND code != '2.3'
|
||||
AND type != 'section';
|
||||
|
||||
-- Verificar: debe mostrar todas las preguntas con required=true excepto 2.3
|
||||
SELECT code, type, required
|
||||
FROM public.questions
|
||||
WHERE survey_id = '30000000-0000-0000-0000-000000000001'
|
||||
AND type != 'section'
|
||||
ORDER BY position;
|
||||
327
supabase/tests/02_dashboard_mirada_empresa.test.sql
Normal file
327
supabase/tests/02_dashboard_mirada_empresa.test.sql
Normal file
@@ -0,0 +1,327 @@
|
||||
-- ============================================================
|
||||
-- Tests pgTAP: Dashboard v3 — RPCs de la Mirada Empresa
|
||||
--
|
||||
-- Ejecutar con: supabase test db
|
||||
-- Requiere: Docker + supabase start + extensión pgtap
|
||||
--
|
||||
-- Cubre:
|
||||
-- 1. Existencia de las 11 RPCs + el helper _dash_es_cuidador (firma correcta)
|
||||
-- 2. _dash_es_cuidador: las 4 combinaciones de la definición UNFPA
|
||||
-- (solo Ninguna / Ninguna+otro / solo "hijo sin discapacidad" / sin responder)
|
||||
-- 3. k≥5 en dos niveles: n=4 → suppressed, n=5 → ok con pct correcto (E1)
|
||||
-- 4. Fix de _dash_responses: el filtro de género ya encuentra '2.5' (no '"A6"')
|
||||
-- 5. Umbral parametrizable de la métrica estrella: mismo dataset, dos
|
||||
-- resultados distintos según p_umbral_senales (prueba que el parámetro
|
||||
-- realmente cambia el comportamiento, no es decorativo)
|
||||
-- 6. E9 (rpc_me_segmentacion) — bug real encontrado en VPS: por_nivel leía
|
||||
-- answers/2.6 en vez de responses.qi_sector. Prueba que ahora lee
|
||||
-- qi_sector, que por_modalidad sigue leyendo por JOIN a 2.7, y que el
|
||||
-- k≥5 por segmento sigue omitiendo filas chicas.
|
||||
--
|
||||
-- NO escrito por mí pero sí ejecutable por el director: no hay Docker en el
|
||||
-- entorno donde se generó este archivo — no se corrió contra Postgres real.
|
||||
-- ============================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
SELECT plan(28);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 1: las funciones existen con la firma esperada
|
||||
-- ============================================================
|
||||
SELECT has_function('public', '_dash_es_cuidador', ARRAY['uuid'],
|
||||
'1.1 _dash_es_cuidador(uuid) existe');
|
||||
SELECT has_function('public', 'rpc_me_prevalencia_cuidadores', ARRAY['uuid','jsonb'],
|
||||
'1.2 rpc_me_prevalencia_cuidadores existe');
|
||||
SELECT has_function('public', 'rpc_me_riesgo_rotacion', ARRAY['uuid','jsonb'],
|
||||
'1.3 rpc_me_riesgo_rotacion existe');
|
||||
SELECT has_function('public', 'rpc_me_ausentismo', ARRAY['uuid','jsonb'],
|
||||
'1.4 rpc_me_ausentismo existe');
|
||||
SELECT has_function('public', 'rpc_me_presentismo', ARRAY['uuid','jsonb'],
|
||||
'1.5 rpc_me_presentismo existe');
|
||||
SELECT has_function('public', 'rpc_me_punto_ciego', ARRAY['uuid','jsonb'],
|
||||
'1.6 rpc_me_punto_ciego existe');
|
||||
SELECT has_function('public', 'rpc_me_apoyos_demandados', ARRAY['uuid','jsonb'],
|
||||
'1.7 rpc_me_apoyos_demandados existe');
|
||||
SELECT has_function('public', 'rpc_me_talento_en_riesgo', ARRAY['uuid','jsonb','int4'],
|
||||
'1.8 rpc_me_talento_en_riesgo(uuid,jsonb,int) existe — confirma el parámetro de umbral');
|
||||
SELECT has_function('public', 'rpc_me_carrera_congelada', ARRAY['uuid','jsonb'],
|
||||
'1.9 rpc_me_carrera_congelada existe');
|
||||
SELECT has_function('public', 'rpc_me_intensidad_carga', ARRAY['uuid','jsonb'],
|
||||
'1.10 rpc_me_intensidad_carga existe');
|
||||
SELECT has_function('public', 'rpc_me_segmentacion', ARRAY['uuid','jsonb'],
|
||||
'1.11 rpc_me_segmentacion existe');
|
||||
SELECT has_function('public', 'rpc_me_disposicion_red', ARRAY['uuid','jsonb'],
|
||||
'1.12 rpc_me_disposicion_red existe');
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 2: fixture — 1 org, 1 survey v3, preguntas mínimas necesarias
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_org_id uuid := '10000000-1111-0000-0000-000000000001';
|
||||
v_cv_id uuid := '20000000-1111-0000-0000-000000000001';
|
||||
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
|
||||
BEGIN
|
||||
INSERT INTO public.organizations (id, name) VALUES (v_org_id, '_test_me_org');
|
||||
INSERT INTO public.consent_versions (id, version, body, scopes)
|
||||
VALUES (v_cv_id, '_test-cv-me', 'body', '["operativo"]'::jsonb);
|
||||
INSERT INTO public.surveys (id, org_id, title, is_anonymous, data_sensitivity, security_class, consent_version_id)
|
||||
VALUES (v_survey_id, v_org_id, '_test_me_survey', true, 'sensible', 'confidencial', v_cv_id);
|
||||
|
||||
INSERT INTO public.questions (id, survey_id, code, type, prompt, position) VALUES
|
||||
('40000000-1111-0000-0000-000000000041', v_survey_id, '4.1', 'multiple_choice', 'vínculos', 41),
|
||||
('40000000-1111-0000-0000-000000000072', v_survey_id, '7.2', 'single_choice', 'presentismo', 72),
|
||||
('40000000-1111-0000-0000-000000000073', v_survey_id, '7.3', 'single_choice', 'ausentismo', 73),
|
||||
('40000000-1111-0000-0000-000000000075', v_survey_id, '7.5', 'multiple_choice', 'carrera', 75),
|
||||
('40000000-1111-0000-0000-000000000076', v_survey_id, '7.6', 'single_choice', 'riesgo rotación', 76),
|
||||
('40000000-1111-0000-0000-000000000091', v_survey_id, '9.1', 'single_choice', 'conciencia', 91),
|
||||
('40000000-1111-0000-0000-000000000025', v_survey_id, '2.5', 'single_choice', 'género', 25);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 3: _dash_es_cuidador — las 4 combinaciones de la definición UNFPA
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
|
||||
v_q41 uuid := '40000000-1111-0000-0000-000000000041';
|
||||
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
|
||||
v_r_ninguna uuid := '60000000-1111-0000-0000-000000000001'; -- solo "Ninguna..."
|
||||
v_r_mixto uuid := '60000000-1111-0000-0000-000000000002'; -- "Ninguna..." + otra opción
|
||||
v_r_solonin uuid := '60000000-1111-0000-0000-000000000003'; -- solo "Hijo menor sin discapacidad"
|
||||
v_r_sinresp uuid := '60000000-1111-0000-0000-000000000004'; -- no respondió 4.1
|
||||
BEGIN
|
||||
INSERT INTO public.consent_records (id, consent_version_id, granted_scopes)
|
||||
VALUES (v_cr_id, '20000000-1111-0000-0000-000000000001', '{"operativo":true}'::jsonb);
|
||||
|
||||
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id) VALUES
|
||||
(v_r_ninguna, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r_mixto, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r_solonin, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r_sinresp, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
|
||||
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value) VALUES
|
||||
(v_r_ninguna, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado"]'::jsonb),
|
||||
(v_r_mixto, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado","Otro / Especificar"]'::jsonb),
|
||||
(v_r_solonin, v_q41, 'default', '["Hijo menor sin discapacidad"]'::jsonb);
|
||||
-- v_r_sinresp: sin fila en answers para 4.1 (a propósito)
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000001'), false,
|
||||
'3.1 solo "Ninguna..." → NO es cuidador');
|
||||
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000002'), true,
|
||||
'3.2 "Ninguna..." + otra opción → SÍ es cuidador (la otra opción cuenta)');
|
||||
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000003'), true,
|
||||
'3.3 solo "Hijo menor sin discapacidad" → SÍ es cuidador (criterio UNFPA)');
|
||||
SELECT is(public._dash_es_cuidador('60000000-1111-0000-0000-000000000004'), false,
|
||||
'3.4 sin respuesta a 4.1 → NO es cuidador');
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 4: k≥5 en rpc_me_prevalencia_cuidadores (E1)
|
||||
-- 4 cuidadores → suppressed; al agregar 1 más (5) → ok, pct correcto
|
||||
-- ============================================================
|
||||
SELECT is(
|
||||
(public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'status'),
|
||||
'suppressed',
|
||||
'4.1 con 4 responses (n<5) → status=suppressed'
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
|
||||
v_q41 uuid := '40000000-1111-0000-0000-000000000041';
|
||||
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
|
||||
v_r5 uuid := '60000000-1111-0000-0000-000000000005'; -- 5ta response, NO cuidador
|
||||
BEGIN
|
||||
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id)
|
||||
VALUES (v_r5, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
VALUES (v_r5, v_q41, 'default', '["Ninguna de las anteriores / No tengo vinculación de cuidado"]'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Ahora hay 5 responses: 3 cuidadores (mixto, solonin) + ... v_r_sinresp NO es
|
||||
-- cuidador (sin 4.1), v_r_ninguna NO es cuidador, v_r5 NO es cuidador.
|
||||
-- Cuidadores reales = {v_r_mixto, v_r_solonin} = 2 de 5 → 40.0%
|
||||
SELECT is(
|
||||
(public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'status'),
|
||||
'ok',
|
||||
'4.2 con 5 responses (n>=5) → status=ok'
|
||||
);
|
||||
SELECT is(
|
||||
((public.rpc_me_prevalencia_cuidadores('30000000-1111-0000-0000-000000000001'::uuid)->>'pct_cuidadores')::numeric),
|
||||
40.0,
|
||||
'4.3 pct_cuidadores = 2/5 = 40.0%'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 5: fix de _dash_responses — filtro de género contra 2.5 (no A6)
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
|
||||
v_q25 uuid := '40000000-1111-0000-0000-000000000025';
|
||||
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
|
||||
v_rg uuid := '60000000-1111-0000-0000-000000000006';
|
||||
BEGIN
|
||||
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id)
|
||||
VALUES (v_rg, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
VALUES (v_rg, v_q25, 'default', '"Femenino"'::jsonb);
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public._dash_responses(
|
||||
'30000000-1111-0000-0000-000000000001'::uuid, NULL, ARRAY['Femenino'], NULL, NULL
|
||||
) WHERE response_id = '60000000-1111-0000-0000-000000000006'),
|
||||
1,
|
||||
'5.1 filtro p_genero=[Femenino] encuentra la response con 2.5=Femenino (post-fix)'
|
||||
);
|
||||
SELECT is(
|
||||
(SELECT count(*)::int FROM public._dash_responses(
|
||||
'30000000-1111-0000-0000-000000000001'::uuid, NULL, ARRAY['Masculino'], NULL, NULL
|
||||
) WHERE response_id = '60000000-1111-0000-0000-000000000006'),
|
||||
0,
|
||||
'5.2 filtro p_genero=[Masculino] NO encuentra esa response (control negativo)'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 6: umbral parametrizable — métrica estrella
|
||||
-- 1 cuidador con exactamente 1 de 3 señales (riesgo de rotación, vía 7.6)
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
|
||||
v_q41 uuid := '40000000-1111-0000-0000-000000000041';
|
||||
v_q76 uuid := '40000000-1111-0000-0000-000000000076';
|
||||
v_q73 uuid := '40000000-1111-0000-0000-000000000073';
|
||||
v_q75 uuid := '40000000-1111-0000-0000-000000000075';
|
||||
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
|
||||
v_r1 uuid := '60000000-2222-0000-0000-000000000001';
|
||||
v_r2 uuid := '60000000-2222-0000-0000-000000000002';
|
||||
v_r3 uuid := '60000000-2222-0000-0000-000000000003';
|
||||
v_r4 uuid := '60000000-2222-0000-0000-000000000004';
|
||||
v_r5 uuid := '60000000-2222-0000-0000-000000000005';
|
||||
BEGIN
|
||||
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id)
|
||||
VALUES (v_r1, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r2, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r3, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r4, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id),
|
||||
(v_r5, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id);
|
||||
|
||||
-- Las 5 son cuidadoras (4.1 con un vínculo real) y respondieron 7.6
|
||||
-- (ancla de la población), pero NINGUNA cumple 2+ señales — cada una
|
||||
-- cumple EXACTAMENTE 1 (riesgo de rotación), 0 en ausentismo/carrera.
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
SELECT r, v_q41, 'default', '["Otro / Especificar"]'::jsonb
|
||||
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
|
||||
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
SELECT r, v_q76, 'default', '"Muy probable"'::jsonb
|
||||
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
|
||||
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
SELECT r, v_q73, 'default', '"Nunca"'::jsonb
|
||||
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
|
||||
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
SELECT r, v_q75, 'default', '["Ninguna de las anteriores"]'::jsonb
|
||||
FROM unnest(ARRAY[v_r1,v_r2,v_r3,v_r4,v_r5]) AS r;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Con el umbral default (2 de 3): nadie llega → 0%
|
||||
SELECT is(
|
||||
((public.rpc_me_talento_en_riesgo('30000000-1111-0000-0000-000000000001'::uuid)->>'pct_talento_riesgo')::numeric),
|
||||
0.0,
|
||||
'6.1 umbral default (2 de 3): 0% — nadie acumula 2 señales'
|
||||
);
|
||||
-- Bajando el umbral a 1 de 3: las 5 cumplen (todas tienen la señal de riesgo) → 100%
|
||||
SELECT is(
|
||||
((public.rpc_me_talento_en_riesgo(
|
||||
'30000000-1111-0000-0000-000000000001'::uuid, '{}'::jsonb, 1
|
||||
)->>'pct_talento_riesgo')::numeric),
|
||||
100.0,
|
||||
'6.2 umbral=1 (parámetro explícito): 100% — el parámetro SÍ cambia el resultado'
|
||||
);
|
||||
SELECT is(
|
||||
((public.rpc_me_talento_en_riesgo('30000000-1111-0000-0000-000000000001'::uuid)->>'criterios_promedio')::numeric),
|
||||
1.00,
|
||||
'6.3 criterios_promedio = 1.00 (cada cuidadora cumple exactamente 1 de 3 señales)'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- SECCIÓN 7: E9 (rpc_me_segmentacion) — por_nivel via qi_sector,
|
||||
-- por_modalidad via JOIN a 2.7, k≥5 por segmento
|
||||
-- ============================================================
|
||||
DO $$
|
||||
DECLARE
|
||||
v_survey_id uuid := '30000000-1111-0000-0000-000000000001';
|
||||
v_q27 uuid := '40000000-1111-0000-0000-000000000027';
|
||||
v_cr_id uuid := '50000000-1111-0000-0000-000000000001';
|
||||
v_r uuid;
|
||||
i int;
|
||||
BEGIN
|
||||
INSERT INTO public.questions (id, survey_id, code, type, prompt, position)
|
||||
VALUES (v_q27, v_survey_id, '2.7', 'single_choice', 'modalidad', 27);
|
||||
|
||||
-- 6 responses "Operativo"/"Presencial" (n=6 >= 5 → visible)
|
||||
FOR i IN 1..6 LOOP
|
||||
v_r := ('60000000-3333-0000-0000-' || lpad(i::text, 12, '0'))::uuid;
|
||||
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id, qi_sector)
|
||||
VALUES (v_r, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id, 'Operativo');
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
VALUES (v_r, v_q27, 'default', '"Presencial"'::jsonb);
|
||||
END LOOP;
|
||||
|
||||
-- 3 responses "Gerencial"/"Remoto (home office)" (n=3 < 5 → omitido)
|
||||
FOR i IN 1..3 LOOP
|
||||
v_r := ('60000000-4444-0000-0000-' || lpad(i::text, 12, '0'))::uuid;
|
||||
INSERT INTO public.responses (id, survey_id, company_id, consent_record_id, qi_sector)
|
||||
VALUES (v_r, v_survey_id, '10000000-1111-0000-0000-000000000001', v_cr_id, 'Gerencial');
|
||||
INSERT INTO public.answers (response_id, question_id, instance_id, value)
|
||||
VALUES (v_r, v_q27, 'default', '"Remoto (home office)"'::jsonb);
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
SELECT is(
|
||||
jsonb_path_query_first(
|
||||
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
|
||||
'$.por_nivel[*] ? (@.nivel == "Operativo")'
|
||||
)->>'n',
|
||||
'6',
|
||||
'7.1 por_nivel lee qi_sector — "Operativo" aparece con n=6 (post-fix)'
|
||||
);
|
||||
SELECT is(
|
||||
jsonb_path_query_first(
|
||||
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
|
||||
'$.por_nivel[*] ? (@.nivel == "Gerencial")'
|
||||
),
|
||||
NULL::jsonb,
|
||||
'7.2 "Gerencial" (n=3 < 5) está omitido de por_nivel — k-anonimato por segmento'
|
||||
);
|
||||
SELECT is(
|
||||
jsonb_path_query_first(
|
||||
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
|
||||
'$.por_modalidad[*] ? (@.modalidad == "Presencial")'
|
||||
)->>'n',
|
||||
'6',
|
||||
'7.3 por_modalidad lee 2.7 por JOIN a answers — "Presencial" aparece con n=6'
|
||||
);
|
||||
SELECT is(
|
||||
jsonb_path_query_first(
|
||||
public.rpc_me_segmentacion('30000000-1111-0000-0000-000000000001'::uuid),
|
||||
'$.por_modalidad[*] ? (@.modalidad == "Remoto (home office)")'
|
||||
),
|
||||
NULL::jsonb,
|
||||
'7.4 "Remoto (home office)" (n=3 < 5) está omitido de por_modalidad'
|
||||
);
|
||||
|
||||
SELECT * FROM finish();
|
||||
ROLLBACK;
|
||||
70
tests/e2e/sprint5-verificacion.spec.ts
Normal file
70
tests/e2e/sprint5-verificacion.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { test, expect, type Page, type BrowserContext } from '@playwright/test'
|
||||
|
||||
// Credenciales SIEMPRE desde .env.test (playwright.config.ts las carga) — nunca acá.
|
||||
const EMAIL = process.env.E2E_EMAIL!
|
||||
const PASSWORD = process.env.E2E_PASSWORD!
|
||||
|
||||
// Survey Demo — la única con datos sembrados (51 respuestas, ver
|
||||
// docs/estado/ESTADO_PROYECTO_26JUN2026.md §4). Necesaria como ?s= en el
|
||||
// formulario público: con 3 surveys en `live`, `/` sin parámetro es ambiguo
|
||||
// (app/page.tsx usa .maybeSingle() sobre status='live').
|
||||
const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001'
|
||||
|
||||
test.describe.serial('Sprint 5 — verificación post-deploy', () => {
|
||||
let context: BrowserContext
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await context.close()
|
||||
})
|
||||
|
||||
test('1. /admin/responses sin sesión redirige a /login', async () => {
|
||||
await page.goto('/admin/responses')
|
||||
await expect(page).toHaveURL(/\/login/)
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toBeVisible()
|
||||
await expect(page.getByRole('textbox', { name: 'Contraseña' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /contraseña/i })).toBeVisible()
|
||||
})
|
||||
|
||||
test('2. login funcional redirige a /admin/responses con filtro y datos', async () => {
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(EMAIL)
|
||||
await page.getByRole('textbox', { name: 'Contraseña' }).fill(PASSWORD)
|
||||
await page.getByRole('button', { name: 'Ingresar' }).click()
|
||||
await expect(page).toHaveURL(/\/admin\/responses/)
|
||||
await expect(page.locator('select.admin-survey-select')).toBeVisible()
|
||||
await expect(page.locator('table.admin-table tbody tr').first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('3. vista detallada de una respuesta', async () => {
|
||||
await page.locator('a.admin-action-link', { hasText: 'Ver' }).first().click()
|
||||
await expect(page).toHaveURL(/\/admin\/responses\/[0-9a-f-]{36}/)
|
||||
await expect(page.locator('.admin-detail-section').first()).toBeVisible()
|
||||
await expect(page.getByText('Respuesta no encontrada')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('4. dashboard muestra datos reales (no todo suprimido por k<5)', async () => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.locator('.db-widget').first()).toBeVisible()
|
||||
await expect(page.locator('body')).toContainText('%')
|
||||
const totalWidgets = await page.locator('.db-widget').count()
|
||||
const suppressed = await page.locator('.db-suppressed').count()
|
||||
expect(totalWidgets).toBeGreaterThan(0)
|
||||
expect(suppressed).toBeLessThan(totalWidgets)
|
||||
})
|
||||
|
||||
test('5. formulario público accesible sin auth', async ({ browser }) => {
|
||||
const anonContext = await browser.newContext()
|
||||
const anonPage = await anonContext.newPage()
|
||||
await anonPage.goto(`/?s=${DEMO_SURVEY_ID}`)
|
||||
await expect(anonPage).not.toHaveURL(/\/login/)
|
||||
await expect(
|
||||
anonPage.locator('.consent-container, .wizard-shell').first()
|
||||
).toBeVisible()
|
||||
await anonContext.close()
|
||||
})
|
||||
})
|
||||
211
tests/e2e/sprint6-usabilidad.spec.ts
Normal file
211
tests/e2e/sprint6-usabilidad.spec.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { test, expect, type BrowserContext, type Page } from '@playwright/test'
|
||||
|
||||
const DEMO_SURVEY_ID = '30000000-0000-0000-0000-000000000001'
|
||||
const SURVEY_URL = `/?s=${DEMO_SURVEY_ID}`
|
||||
|
||||
// Devuelve el código de la pregunta visible (ej: '2.3'), o null si estamos en una sección.
|
||||
async function currentCode(page: Page): Promise<string | null> {
|
||||
const el = page.locator('.wizard-prompt-code')
|
||||
if (await el.count() === 0) return null
|
||||
return (await el.textContent())?.trim() ?? null
|
||||
}
|
||||
|
||||
// Navega hacia adelante hasta que la pregunta con `targetCode` sea visible.
|
||||
// Maneja preguntas required (Q1.1) y screens de sección intermedias.
|
||||
async function navigateUntil(page: Page, targetCode: string, maxSteps = 30): Promise<void> {
|
||||
for (let i = 0; i < maxSteps; i++) {
|
||||
if (await currentCode(page) === targetCode) return
|
||||
// Si hay error de validación (required sin respuesta), responder primero
|
||||
if (await page.locator('.field-error').count() > 0) {
|
||||
await page.locator('.option-label').first().click()
|
||||
await page.waitForTimeout(150)
|
||||
continue
|
||||
}
|
||||
await page.getByRole('button', { name: 'Siguiente' }).click()
|
||||
// Esperar a que React aplique el state update y re-renderice
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
const code = await currentCode(page)
|
||||
if (code !== targetCode) throw new Error(`navigateUntil: llegué a ${code}, esperaba ${targetCode}`)
|
||||
}
|
||||
|
||||
// Acepta el consent y espera a que el wizard esté visible.
|
||||
async function acceptConsent(page: Page): Promise<void> {
|
||||
await page.check('#consent-operativo')
|
||||
await page.getByRole('button', { name: 'Comenzar la encuesta' }).click()
|
||||
await page.locator('.wizard-shell').waitFor()
|
||||
}
|
||||
|
||||
test.describe.serial('Sprint 6 — usabilidad y cache geo', () => {
|
||||
let context: BrowserContext
|
||||
let page: Page
|
||||
const geoRequests: string[] = []
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
context = await browser.newContext()
|
||||
page = await context.newPage()
|
||||
// Interceptar requests geo antes de navegar (para Test 1)
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('geo_paraguay_full.json')) geoRequests.push(req.url())
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await context.close()
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Test 1 — Cache geo: un solo fetch para barrio y ciudad
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test('1. cache geo: un solo fetch para barrio y ciudad', async () => {
|
||||
await page.goto(SURVEY_URL)
|
||||
await acceptConsent(page)
|
||||
|
||||
// Navegar hasta Q2.2 (CiudadSelectField monta → geo request se dispara aquí)
|
||||
await navigateUntil(page, '2.2')
|
||||
expect(await currentCode(page)).toBe('2.2')
|
||||
|
||||
// Avanzar a Q2.3 (BarrioAutocompleteField monta → debe usar cache, no nueva request)
|
||||
await page.getByRole('button', { name: 'Siguiente' }).click()
|
||||
await page.waitForTimeout(200)
|
||||
expect(await currentCode(page)).toBe('2.3')
|
||||
|
||||
// Solo debe haber habido 1 request al JSON de geo (Q2.2 lo cargó, Q2.3 usó el cache)
|
||||
expect(geoRequests.length).toBe(1)
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Test 2 — Estado loading del campo geo
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test('2. estado loading del campo geo', async () => {
|
||||
// Estamos en Q2.3 (del test anterior). Volver a Q2.2 para observar el estado del campo.
|
||||
await page.getByRole('button', { name: 'Anterior' }).click()
|
||||
await page.waitForTimeout(200)
|
||||
expect(await currentCode(page)).toBe('2.2')
|
||||
|
||||
// CiudadSelectField: input con clase .text-short-input
|
||||
const input = page.locator('input.text-short-input').first()
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
const isDisabled = await input.isDisabled()
|
||||
if (isDisabled) {
|
||||
// Estado loading capturado — verificar placeholder correcto
|
||||
const placeholder = await input.getAttribute('placeholder')
|
||||
expect(placeholder).toBe('Cargando opciones...')
|
||||
}
|
||||
// En cualquier caso, el campo debe estar (o volverse) habilitado
|
||||
await expect(input).toBeEnabled({ timeout: 5000 })
|
||||
|
||||
// Volver a Q2.3 para el siguiente test
|
||||
await page.getByRole('button', { name: 'Siguiente' }).click()
|
||||
await page.waitForTimeout(200)
|
||||
expect(await currentCode(page)).toBe('2.3')
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Test 3 — Texto libre funciona en campo barrio
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test('3. texto libre funciona en campo barrio', async () => {
|
||||
// Estamos en Q2.3 (BarrioAutocompleteField)
|
||||
expect(await currentCode(page)).toBe('2.3')
|
||||
|
||||
const barrioCampo = page.locator('input.text-short-input')
|
||||
await expect(barrioCampo).toBeVisible()
|
||||
await expect(barrioCampo).toBeEnabled()
|
||||
|
||||
// Escribir un barrio que no existe en el JSON (texto libre)
|
||||
await barrioCampo.fill('Barrio Inexistente XYZ')
|
||||
await expect(barrioCampo).toHaveValue('Barrio Inexistente XYZ')
|
||||
|
||||
// Avanzar — campo es opcional, texto libre debe aceptarse sin error
|
||||
await page.getByRole('button', { name: 'Siguiente' }).click()
|
||||
await page.waitForTimeout(200)
|
||||
await expect(page.locator('.field-error')).toHaveCount(0)
|
||||
await expect(page.locator('.wizard-shell')).toBeVisible()
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Test 4 — Draft persiste tras recarga
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test('4. draft persiste tras recarga', async () => {
|
||||
// Volver a Q2.3 para sobreescribir el draft con un valor reconocible
|
||||
await page.getByRole('button', { name: 'Anterior' }).click()
|
||||
await page.waitForTimeout(200)
|
||||
expect(await currentCode(page)).toBe('2.3')
|
||||
|
||||
const barrioCampo = page.locator('input.text-short-input')
|
||||
await barrioCampo.fill('Barrio Test Draft')
|
||||
await expect(barrioCampo).toHaveValue('Barrio Test Draft')
|
||||
|
||||
// Avanzar — handleNext guarda el draft inmediatamente (no debounce)
|
||||
await page.getByRole('button', { name: 'Siguiente' }).click()
|
||||
await page.waitForTimeout(300)
|
||||
|
||||
// Verificar que el draft fue guardado antes de recargar
|
||||
const draftRaw = await page.evaluate(
|
||||
(id) => localStorage.getItem(`survey_draft_${id}`),
|
||||
DEMO_SURVEY_ID,
|
||||
)
|
||||
expect(draftRaw, 'draft debe existir en localStorage antes del reload').not.toBeNull()
|
||||
expect(draftRaw, 'draft debe contener el valor del barrio').toContain('Barrio Test Draft')
|
||||
|
||||
// Recargar
|
||||
await page.reload()
|
||||
|
||||
// Verificar que el draft sobrevive el reload
|
||||
const draftAfterReload = await page.evaluate(
|
||||
(id) => localStorage.getItem(`survey_draft_${id}`),
|
||||
DEMO_SURVEY_ID,
|
||||
)
|
||||
expect(draftAfterReload, 'draft debe persistir después del reload').not.toBeNull()
|
||||
|
||||
await acceptConsent(page)
|
||||
|
||||
// El indicador de restauración es el valor de Q2.3 — navegamos hasta ahí
|
||||
// (navigateUntil funciona desde cualquier pantalla de inicio, incluyendo S1)
|
||||
await navigateUntil(page, '2.3')
|
||||
await expect(page.locator('input.text-short-input')).toHaveValue('Barrio Test Draft')
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// Test 5 — Submit con error de red muestra estado error del wizard
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
test('5. submit con error de red muestra estado error del wizard', async ({ browser }) => {
|
||||
const ctx5 = await browser.newContext()
|
||||
const page5 = await ctx5.newPage()
|
||||
|
||||
// Pre-rellenar Q1.1 (única pregunta required) para no bloquearnos al avanzar
|
||||
await page5.goto(SURVEY_URL)
|
||||
await page5.evaluate((id) => {
|
||||
localStorage.setItem(`survey_draft_${id}`, JSON.stringify({ '1.1:default': 'true' }))
|
||||
}, DEMO_SURVEY_ID)
|
||||
|
||||
await acceptConsent(page5)
|
||||
|
||||
// Navegar hasta el end screen: todas las preguntas restantes son opcionales
|
||||
let iters = 0
|
||||
while (
|
||||
(await page5.locator('button', { hasText: 'Enviar encuesta' }).count()) === 0 &&
|
||||
iters < 70
|
||||
) {
|
||||
await page5.getByRole('button', { name: 'Siguiente' }).click()
|
||||
await page5.waitForTimeout(100)
|
||||
iters++
|
||||
}
|
||||
|
||||
await expect(page5.getByRole('button', { name: 'Enviar encuesta' })).toBeVisible()
|
||||
|
||||
// Poner el contexto offline para simular error de red en el submit
|
||||
await page5.context().setOffline(true)
|
||||
|
||||
await page5.getByRole('button', { name: 'Enviar encuesta' }).click()
|
||||
|
||||
// El wizard debe mostrar su propio estado de error (no un React error boundary)
|
||||
await expect(page5.locator('.error-container')).toBeVisible({ timeout: 15000 })
|
||||
await expect(page5.getByText(/Application error/i)).toHaveCount(0)
|
||||
await expect(page5.getByRole('button', { name: /Volver e intentar/i })).toBeVisible()
|
||||
|
||||
await page5.context().setOffline(false)
|
||||
await ctx5.close()
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
|
||||
Reference in New Issue
Block a user