Compare commits
19 Commits
424071b97c
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcf93ea3ea | ||
|
|
3e7109a0f1 | ||
|
|
6e9ad89f7b | ||
|
|
3ec5da0e54 | ||
|
|
dbcc60ad95 | ||
|
|
474b501fff | ||
|
|
c847ef3ef5 | ||
|
|
8dea04cae3 | ||
|
|
56443a739f | ||
|
|
df9b168577 | ||
|
|
83782ab6f8 | ||
|
|
b0cf273bc8 | ||
|
|
eaf71567a7 | ||
|
|
2e6fbd7801 | ||
|
|
61676e1423 | ||
|
|
ce679ec1c3 | ||
|
|
7c8b034756 | ||
|
|
c30688810a | ||
|
|
f2666c7d3c |
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.
|
||||
|
||||
|
||||
25
TECH_DEBT.md
25
TECH_DEBT.md
@@ -85,3 +85,28 @@
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
'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 { 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,15 +20,30 @@ 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">
|
||||
@@ -38,7 +51,7 @@ export default function DashboardPage() {
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(c => !c)}
|
||||
onToggle={() => setSidebarCollapsed((c) => !c)}
|
||||
/>
|
||||
|
||||
<div className="db-main">
|
||||
@@ -66,34 +79,70 @@ 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>
|
||||
)
|
||||
|
||||
325
app/globals.css
325
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,36 @@ 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;
|
||||
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: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Textarea ── */
|
||||
.text-area {
|
||||
display: block;
|
||||
@@ -482,6 +516,165 @@ 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: var(--color-primary);
|
||||
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: bloque con altura reservada constante, mismo lugar pantalla tras pantalla. */
|
||||
.wizard-prompt-block {
|
||||
min-height: 116px;
|
||||
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;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ── */
|
||||
.loading-wrapper {
|
||||
max-width: 720px;
|
||||
@@ -546,27 +739,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 +769,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 +780,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 +805,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 +869,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 +883,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 +928,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 +943,56 @@ 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);
|
||||
background: linear-gradient(135deg, #dff4f5, #ffffff);
|
||||
}
|
||||
.db-widget-star .db-widget-title { color: var(--color-re-teal); }
|
||||
|
||||
/* ── 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; }
|
||||
|
||||
/* ── Admin: control de respuestas (Etapa 3V) ── */
|
||||
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
|
||||
.admin-header {
|
||||
|
||||
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} />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
164
components/widgets/barrio-autocomplete-field.tsx
Normal file
164
components/widgets/barrio-autocomplete-field.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
|
||||
type GeoData = Record<string, Record<string, string[]>>
|
||||
|
||||
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 [open, setOpen] = useState(false)
|
||||
const [activeIdx, setActiveIdx] = useState(-1)
|
||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/data/geo_paraguay_full.json')
|
||||
.then((r) => r.json())
|
||||
.then(setGeoData)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 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}
|
||||
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)}
|
||||
>
|
||||
{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) => {
|
||||
|
||||
168
components/widgets/ciudad-select-field.tsx
Normal file
168
components/widgets/ciudad-select-field.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
|
||||
|
||||
type GeoData = Record<string, Record<string, string[]>>
|
||||
|
||||
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 [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(() => {
|
||||
fetch('/data/geo_paraguay_full.json')
|
||||
.then((r) => r.json())
|
||||
.then(setGeoData)
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// 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}
|
||||
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)}
|
||||
>
|
||||
{ciudad}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="field-error" id={`${name}-error`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
328
components/wizard-form.tsx
Normal file
328
components/wizard-form.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
'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
|
||||
saveDraft(survey.id, state.answers)
|
||||
}, [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
|
||||
}
|
||||
setState((s) => next(s, schema))
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
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 () => {
|
||||
const result = await submitSurvey({
|
||||
surveyId: survey.id,
|
||||
answers,
|
||||
consentOperativo,
|
||||
consentMacroN1,
|
||||
})
|
||||
if (result.success) {
|
||||
clearDraft(survey.id)
|
||||
setStage('success')
|
||||
} else {
|
||||
setSubmitError(result.error)
|
||||
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>
|
||||
@@ -1,5 +1,5 @@
|
||||
# ESTADO DEL PROYECTO — Datos País sobre el Cuidado
|
||||
## Documento de continuidad — 20 junio 2026
|
||||
## Documento de continuidad — 25 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,115 @@
|
||||
---
|
||||
|
||||
## 1. Versionado y ramas
|
||||
- **Producción:** 2.1.0 (formulario lineal) — los 3 surveys en `draft`
|
||||
- **Desarrollo:** 3.0.0 (wizard + dashboard nuevo) en `main`, NO desplegado aún
|
||||
- **Preservación:** `v2.1.0-stable` + tag `v2.1.0`
|
||||
- SemVer + CHANGELOG + tags. ADRs en `docs/adr/`.
|
||||
|
||||
- **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.
|
||||
⚠️ Los 3 surveys están en `draft` a propósito (el frontend v2 desplegado no entiende seed v3).
|
||||
Se reactivan a `live` al desplegar v3 completo.
|
||||
|
||||
---
|
||||
|
||||
## 2. Sprint 4 — Wizard v3 — EN CURSO
|
||||
## 2. Sprint 4 — Wizard v3 — Formulario COMPLETO, falta release
|
||||
|
||||
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) | ⏳ pendiente |
|
||||
| 4I validación e2e + tag v3.0.0 + desplegar + reactivar surveys | ⏳ pendiente |
|
||||
|
||||
### 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).
|
||||
|
||||
---
|
||||
|
||||
## 3. Decisiones arquitectónicas clave (no reabrir)
|
||||
## 3. Dashboard v3 — DECISIÓN MAYOR: rediseño de "tres miradas"
|
||||
|
||||
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
|
||||
**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 implementadas y VERIFICADAS contra 50 respuestas reales.
|
||||
E1-E4 exactos, estrella "Talento en riesgo" con umbral parametrizable, E9 segmentación
|
||||
con k≥5. Migraciones `20260625000001` (fix helper) + `20260625000002` (11 RPCs). Commiteado.
|
||||
Widgets del frontend: PENDIENTE (etapa siguiente).
|
||||
- **Mirada País y Mirada Humana:** propuesta + mockup listos, implementación DIFERIDA a
|
||||
sprint de dashboard post-deadline.
|
||||
|
||||
### 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
|
||||
|
||||
| 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 |
|
||||
|
||||
COMPLIANCE.md documenta estado de seguridad, roadmap de auditabilidad, y la decisión
|
||||
fundamentada de NO usar blockchain.
|
||||
| TECH-DEBT-010 | E9 lee modalidad por JOIN a answers (2.7) en vez de qi_modalidad. Promover si se usa seguido | Baja |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
1. **Widgets de Mirada Empresa** — el frontend del dashboard que consume las RPCs ya verificadas.
|
||||
La spec (SPEC_MIRADA_EMPRESA.md) define shapes de retorno; el mockup define el aspecto.
|
||||
2. **4F autocomplete barrios** — subir `geo_paraguay_full.json` (8152 barrios) a public/data/.
|
||||
3. **4I release** — validación e2e, tag v3.0.0, desplegar v3, reactivar surveys a live.
|
||||
4. **Sprint de dashboard (post-deadline):** Miradas País y Humana, preguntas faltantes
|
||||
(bienestar, horas no remuneradas, escolarización), decisión qi_modalidad.
|
||||
|
||||
Orden sugerido para go-live: widgets Mirada Empresa → 4F → 4I.
|
||||
|
||||
---
|
||||
|
||||
## 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]
|
||||
```
|
||||
@@ -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
|
||||
|
||||
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 }
|
||||
}
|
||||
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
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).
|
||||
```
|
||||
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.
|
||||
```
|
||||
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;
|
||||
$$;
|
||||
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;
|
||||
@@ -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