Con marca y dashboards
This commit is contained in:
292
sprints/sprint-2/DASHBOARD.md
Normal file
292
sprints/sprint-2/DASHBOARD.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# PROMPT: Dashboard "Economía del Cuidado" — Análisis Empresarial (v2)
|
||||
|
||||
> **Cambio clave respecto a v1:** toda la agregación y los filtros de privacidad
|
||||
> corren **server-side** (RPC de Postgres). El cliente NUNCA consulta `answers` crudo.
|
||||
> El k-anonimato (k≥5) y la exclusión de campos sensibles son **estructurales**, no
|
||||
> dependen de recordar un filtro en cada query.
|
||||
|
||||
---
|
||||
|
||||
## Contexto de negocio
|
||||
|
||||
Dashboard analítico de la plataforma "Datos País sobre el Cuidado" (Paraguay). Es el producto de valor para las empresas participantes: entender el impacto de las responsabilidades de cuidado en su fuerza laboral y tomar decisiones basadas en evidencia.
|
||||
|
||||
**Audiencia:** directores de RRHH, sostenibilidad, gerencia general de empresas paraguayas.
|
||||
|
||||
---
|
||||
|
||||
## Stack técnico
|
||||
|
||||
- **Frontend:** Next.js 15 (App Router), TypeScript, Tailwind CSS
|
||||
- **Base de datos:** Supabase (PostgreSQL) — misma instancia que el formulario
|
||||
- **Gráficos:** Recharts (ya instalado)
|
||||
- **Mapa:** SVG inline de Paraguay (NO librería externa — ver Widget 1). Esto evita
|
||||
incompatibilidades de peer-deps con React 19 y no agrega dependencias (alineado con
|
||||
la política de gobernanza del método).
|
||||
- **NO agregar nuevas dependencias npm.**
|
||||
- **Paleta RE:** `#43BBC8` (teal), `#494F4F` (oscuro), `#F8AD13` (naranja), `#EE761A` (naranja intenso), `#E7E6E5` (gris claro), blanco
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ REGLAS DE PRIVACIDAD — NO NEGOCIABLES (leer primero)
|
||||
|
||||
Estas reglas son del proyecto (CLAUDE.md) y tienen prioridad sobre cualquier widget.
|
||||
Si un widget no puede cumplirlas, NO se construye ese widget — se reporta el conflicto.
|
||||
|
||||
1. **k-anonimato k≥5 por construcción.** Toda celda, barra, punto, segmento o
|
||||
departamento que represente menos de 5 respondentes se SUPRIME (se muestra como
|
||||
"Datos insuficientes" o se agrega a un grupo mayor). No es post-filtro: las funciones
|
||||
RPC ya devuelven datos con la supresión aplicada. El cliente nunca ve un N<5.
|
||||
2. **`3.4` (salario) jamás sale de la base.** Protegido por RLS server-side que niega
|
||||
`question_code='3.4'` al rol usado por el dashboard. El dashboard NO usa este dato
|
||||
en ningún cálculo ni widget. No alcanza con filtrarlo en el cliente.
|
||||
3. **`7.1` (texto libre) jamás llega al dashboard.** Mismo tratamiento que `3.4`:
|
||||
excluido en la capa de datos, no en el cliente.
|
||||
4. **El cliente NUNCA consulta la tabla `answers` directamente.** Todo pasa por
|
||||
funciones RPC que devuelven agregados ya suprimidos. Si el browser pudiera pedir
|
||||
filas crudas, la privacidad sería inspeccionable/evitable desde la consola.
|
||||
|
||||
---
|
||||
|
||||
## Arquitectura de datos (server-side)
|
||||
|
||||
### Capa 1 — Funciones RPC en Postgres (Supabase)
|
||||
Crear funciones SQL (`SECURITY DEFINER` con search_path fijado) que:
|
||||
- Reciben `survey_id` + filtros opcionales (departamento, género, etario, contrato)
|
||||
- Calculan el agregado de cada widget
|
||||
- **Aplican supresión k≥5 ANTES de devolver**
|
||||
- Nunca tocan `3.4` ni `7.1`
|
||||
- Devuelven JSON tipado
|
||||
|
||||
Las funciones van en una migración nueva: `migrations/0XX_dashboard_rpc.sql`.
|
||||
Listar las funciones a crear (una por widget o agrupadas por afinidad):
|
||||
```
|
||||
rpc_dashboard_kpis(survey_id, filters) → KPIs + N total
|
||||
rpc_prevalencia_por_depto(survey_id, filters) → mapa (con supresión por depto)
|
||||
rpc_heatmap_intensidad(survey_id, filters) → celdas con N≥5
|
||||
rpc_distribucion_cuidado(survey_id, filters) → donut
|
||||
rpc_politicas_gap(survey_id, filters) → barras (demandadas vs existentes)
|
||||
rpc_score_organizacional(survey_id, filters) → gauge
|
||||
rpc_piramide_demografica(survey_id, filters) → pirámide (celdas con N≥5)
|
||||
rpc_riesgo_retencion(survey_id, filters) → tabla (segmentos con N≥5)
|
||||
rpc_intensidad_vs_productividad(survey_id, filters) → reemplazo del scatter (ver Widget 8)
|
||||
```
|
||||
|
||||
### Capa 2 — Route Handlers (Next.js)
|
||||
`app/api/dashboard/[widget]/route.ts` — llaman a las RPC con el cliente server-side
|
||||
de Supabase (service_role NUNCA expuesto al browser; vive solo en el servidor).
|
||||
Validan que `survey_id` exista y esté `live`.
|
||||
|
||||
### Capa 3 — Cliente
|
||||
Los componentes hacen `fetch('/api/dashboard/...')`. Reciben datos ya agregados y
|
||||
ya suprimidos. No tienen acceso a Supabase directo.
|
||||
|
||||
### Casteo de tipos
|
||||
`answer_value` es TEXT. Las RPC deben castear explícitamente y manejar no-numéricos:
|
||||
```sql
|
||||
-- ejemplo dentro de una RPC
|
||||
AVG(NULLIF(regexp_replace(answer_value, '[^0-9.]', '', 'g'), '')::numeric)
|
||||
FILTER (WHERE answer_value ~ '^[0-9.]+$')
|
||||
```
|
||||
Valores no parseables o nulos se excluyen del cálculo, no se cuentan como 0.
|
||||
|
||||
---
|
||||
|
||||
## Esquema de base de datos (referencia)
|
||||
|
||||
```sql
|
||||
surveys (id UUID PK, title TEXT, status TEXT, brand_config JSONB, created_at TIMESTAMP)
|
||||
responses (id UUID PK, survey_id UUID FK, ip_hash TEXT, created_at TIMESTAMP)
|
||||
answers (id UUID PK, response_id UUID FK, question_code TEXT, answer_value TEXT, created_at TIMESTAMP)
|
||||
consent_records (id UUID PK, response_id UUID, operational_consent BOOL, macro_consent BOOL, created_at TIMESTAMP)
|
||||
```
|
||||
|
||||
### Códigos de preguntas (instrumento de 44)
|
||||
- **A1** Departamento (18 deptos) · **A2** Ciudad · **A3** Barrio (texto) · **A4** Género · **A5** Rango etario · **A6** Educativo · **A7** Contrato
|
||||
- **1.1** Discapacidad propia (→1.2-1.5) · **1.6** Cuida familiar (→1.7-1.12) · **1.13** Cuida adulto mayor (→1.14-1.16)
|
||||
- **2.1-2.5** Intensidad (horas, tipo, red de apoyo)
|
||||
- **3.1** Ausencias días/mes · **3.2** Tardanzas · **3.3** Reducción horas · **3.4** Salario 🔒 · **3.5** Gasto en cuidados
|
||||
- **4.1-4.3** Sueño, tiempo libre, salud
|
||||
- **5.1** Productividad (1-5) · **5.2** Consideró dejar el trabajo (Sí/No)
|
||||
- **6.1** Empresa conoce (Sí/No/Parcial) · **6.2** Políticas que conoce (multi, max 3) · **6.3** Satisfacción (1-5) · **6.4** Políticas que necesita (multi) · **6.5** NPS (0-10)
|
||||
- **7.1** Texto libre 🔒
|
||||
|
||||
---
|
||||
|
||||
## Ruta y estructura
|
||||
|
||||
`/dashboard?survey_id=UUID`
|
||||
|
||||
- Header `SurveyHeader` existente (marca RE)
|
||||
- Sidebar izquierdo colapsable — filtros globales
|
||||
- Grid principal de widgets
|
||||
- Footer `SurveyFooter` existente
|
||||
|
||||
### Filtros globales (sidebar)
|
||||
Multi-select Departamento · checkbox Género · slider dual Rango etario · checkbox Contrato · botón Resetear.
|
||||
Los filtros se pasan como query params a los Route Handlers; las RPC los aplican server-side.
|
||||
**Importante:** al combinar filtros, el N puede caer bajo 5 → el widget muestra "Datos insuficientes para esta combinación de filtros" en lugar de datos.
|
||||
|
||||
---
|
||||
|
||||
## WIDGETS
|
||||
|
||||
### Estado de N insuficiente
|
||||
TODO widget tiene 3 estados: `loading` (skeleton) · `ok` (datos) · `suppressed`
|
||||
("Datos insuficientes — se necesitan al menos 5 respuestas en este corte"). El estado
|
||||
`suppressed` lo determina la RPC, no el cliente.
|
||||
|
||||
### 🗺️ WIDGET 1 — Mapa de Paraguay (OBLIGATORIO)
|
||||
**Qué muestra:** prevalencia de cuidadores por departamento (% con Sí en `1.6` o `1.13`).
|
||||
**Implementación SVG inline:**
|
||||
- Crear `public/geo/paraguay-departamentos.svg` o un componente con los paths de los
|
||||
18 departamentos. Fuente del contorno: GeoJSON público de límites administrativos de
|
||||
Paraguay (nivel ADM1), simplificado. Si no se consigue un SVG fiel, usar una grilla
|
||||
esquemática de 18 celdas etiquetadas con los departamentos (cuadrícula tipo "tile map")
|
||||
— es aceptable como fallback y mantiene la interactividad.
|
||||
- Color por departamento: escala `#E7E6E5` (baja) → `#43BBC8` (alta).
|
||||
- **Departamentos con N<5 se pintan en gris neutro con patrón rayado y tooltip "Datos
|
||||
insuficientes"** — NO muestran porcentaje.
|
||||
- Hover: tooltip con nombre + % + N (solo si N≥5).
|
||||
- Click: filtra el dashboard a ese departamento.
|
||||
|
||||
### 📊 WIDGET 2 — KPI Cards
|
||||
1. **% Cuidadores activos** — `1.1=Sí` OR `1.6=Sí` OR `1.13=Sí`
|
||||
2. **Prom. días ausencia/mes** — media de `3.1` (casteo numérico)
|
||||
3. **Score bienestar** — media de `5.1` (1-5 → 0-100%)
|
||||
4. **NPS Cuidador** — de `6.5`. **Solo se muestra si N≥10**; si no, "N insuficiente para NPS".
|
||||
Card: número grande teal, label, animación de conteo al cargar.
|
||||
|
||||
### 🔥 WIDGET 3 — Heatmap intensidad vs. ausentismo
|
||||
X = horas semanales (`2.1`, en bins) · Y = días ausencia (`3.1`, en bins).
|
||||
Celdas coloreadas por densidad. **Celdas con N<5 se muestran vacías/neutras**, no con
|
||||
su conteo real. Insight: correlación carga↔ausentismo.
|
||||
|
||||
### 🍩 WIDGET 4 — Donut: tipo de cuidado
|
||||
% discapacidad propia (`1.1`) / cuidador familiar (`1.6`) / cuidador adulto mayor (`1.13`).
|
||||
Solapamiento calculado (categorías no excluyentes). Agregado simple, sin riesgo de k
|
||||
salvo que el total del survey sea <5 (entonces todo el widget es `suppressed`).
|
||||
|
||||
### 📈 WIDGET 5 — Barras: gap de políticas
|
||||
Top 8 de `6.4` (necesitadas) vs `6.2` (existentes). Teal = necesidad, naranja = ya existe.
|
||||
Insight: brecha demanda/oferta de políticas.
|
||||
|
||||
### 🎯 WIDGET 6 — Gauge: madurez organizacional
|
||||
Compuesto: `6.1` (Sí=1/Parcial=0.5/No=0) + `6.3` normalizado + `6.5` normalizado.
|
||||
Score 0-100. Zonas: 0-33 rojo "Baja madurez" / 34-66 naranja "En desarrollo" / 67-100 teal "Consciente".
|
||||
|
||||
### 👥 WIDGET 7 — Pirámide demográfica
|
||||
Rango etario (`A5`) × género (`A4`). Mujeres izquierda (`#EE761A`), hombres derecha
|
||||
(`#43BBC8`), no binario (`#F8AD13`). **Cada celda (etario×género) con N<5 se colapsa**
|
||||
al rango contiguo o se omite con nota al pie.
|
||||
|
||||
### 💸 WIDGET 8 — Intensidad de cuidado vs. productividad (REEMPLAZA al scatter)
|
||||
> El scatter original graficaba un punto por persona = dato individual, reidentificable.
|
||||
> Se reemplaza por un **hexbin / binned heatmap agregado**:
|
||||
- X = horas de cuidado (`2.1`, bins) · Y = productividad (`5.1`, 1-5)
|
||||
- Color de cada bin = cantidad de respondentes (solo bins con N≥5)
|
||||
- Línea de tendencia calculada sobre los bins, no sobre individuos
|
||||
- Insight idéntico al scatter (relación carga↔productividad) sin exponer personas
|
||||
- **NO usa `3.5` (gasto) como en v1**, porque cruzar gasto+productividad+género+horas en
|
||||
un punto individual era el vector de reidentificación más fuerte.
|
||||
|
||||
### 📋 WIDGET 9 — Tabla de riesgo de retención
|
||||
Segmentos: Alto (`5.2=Sí` AND `6.3≤2`) / Medio (`5.2=Sí` OR `6.3≤2`) / Bajo (resto).
|
||||
Columnas: Segmento · N · % del total · Política más demandada del segmento.
|
||||
> **Cambio vs v1:** se ELIMINA la columna "Depto. más frecuente". Cruzar segmento de
|
||||
> riesgo × departamento producía celdas de N=1-2 reidentificables en el piloto.
|
||||
> **Cada segmento con N<5 se muestra como "—" (suprimido)**, no con su conteo.
|
||||
|
||||
---
|
||||
|
||||
## Diseño visual — "deslumbrante"
|
||||
- Fondo `#0F1923` (oscuro premium) · widgets `#1A2535` con borde teal 20% opacity
|
||||
- Texto principal blanco, secundario `#E7E6E5`
|
||||
- CSS Grid 12 columnas, responsive
|
||||
- KPI con counter animation (requestAnimationFrame), `tabular-nums`
|
||||
- Hover en widget: `box-shadow: 0 0 20px #43BBC830`
|
||||
- Transición de filtro 300ms ease
|
||||
|
||||
**Layout (12 cols):**
|
||||
```
|
||||
Fila 1: [KPI×4] cada uno 3 cols
|
||||
Fila 2: [MAPA 6 cols][DONUT 3 cols][GAUGE 3 cols]
|
||||
Fila 3: [HEATMAP 6 cols][PIRÁMIDE 6 cols]
|
||||
Fila 4: [BARRAS 6 cols][INTENSIDAD/PRODUCTIVIDAD 6 cols]
|
||||
Fila 5: [TABLA RIESGO 12 cols]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos a crear
|
||||
|
||||
```
|
||||
migrations/
|
||||
0XX_dashboard_rpc.sql — funciones RPC con supresión k≥5
|
||||
0XX_rls_deny_sensitive.sql — RLS que niega 3.4 y 7.1 al rol del dashboard
|
||||
|
||||
app/dashboard/
|
||||
page.tsx
|
||||
loading.tsx
|
||||
app/api/dashboard/
|
||||
[widget]/route.ts — o un route.ts por widget
|
||||
|
||||
components/dashboard/
|
||||
kpi-cards.tsx · paraguay-map.tsx · heatmap-widget.tsx · donut-cuidados.tsx
|
||||
politicas-bar.tsx · gauge-organizacional.tsx · piramide-demografica.tsx
|
||||
intensidad-productividad.tsx · tabla-riesgo.tsx · filter-sidebar.tsx · widget-shell.tsx
|
||||
|
||||
lib/
|
||||
dashboard-queries.ts — fetch a los Route Handlers (NO Supabase directo)
|
||||
dashboard-types.ts
|
||||
dashboard-utils.ts — NPS, score organizacional, regresión sobre bins
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entregables (DoD)
|
||||
|
||||
- [ ] Migración de RPC aplicada; cada función aplica supresión k≥5 internamente
|
||||
- [ ] Migración RLS que niega `3.4` y `7.1` aplicada y verificada
|
||||
- [ ] El cliente NO importa el cliente de Supabase para datos — solo hace fetch a `/api/dashboard`
|
||||
- [ ] `/dashboard?survey_id=UUID` carga con datos reales
|
||||
- [ ] Los 9 widgets renderizan en uno de los 3 estados (loading/ok/suppressed)
|
||||
- [ ] Mapa de Paraguay interactivo (hover + click filtra)
|
||||
- [ ] Filtros globales afectan todos los widgets via query params server-side
|
||||
- [ ] Widget 8 es agregado (hexbin), NO scatter individual
|
||||
- [ ] Tabla de riesgo SIN columna de departamento
|
||||
- [ ] Test manual: con un survey de N=3, todos los widgets muestran "suppressed"
|
||||
- [ ] `grep -r "3.4" app/ components/ lib/` no encuentra el código usándolo como dato
|
||||
- [ ] Build sin errores (`next build`)
|
||||
- [ ] `git diff --name-only HEAD` — solo archivos del scope
|
||||
|
||||
## Qué NO hacer
|
||||
- ❌ Cliente consultando `answers` directo (todo via RPC/Route Handler)
|
||||
- ❌ Filtros de privacidad en el cliente como única protección
|
||||
- ❌ Exponer `3.4` o `7.1` en cualquier capa
|
||||
- ❌ Widgets que grafiquen individuos (scatter de personas, tablas con cruces de N<5)
|
||||
- ❌ service_role en código de cliente o en `NEXT_PUBLIC_*`
|
||||
- ❌ Agregar dependencias npm
|
||||
- ❌ Tocar el formulario, `app/actions/` de submit, o RLS existente de escritura
|
||||
|
||||
## Qué reportar
|
||||
```
|
||||
Verificado:
|
||||
- migración RPC aplicada: [funciones creadas]
|
||||
- migración RLS aplicada: 3.4 y 7.1 negados — confirmado con query de prueba
|
||||
- cliente sin acceso directo a answers: confirmado (grep)
|
||||
- build exitoso
|
||||
- archivos creados: [lista]
|
||||
- widgets en estado ok / suppressed con datos actuales: [lista]
|
||||
- prueba k-anonimato (survey N<5 → suppressed): [resultado]
|
||||
- mapa interactivo: [descripción]
|
||||
|
||||
Asumido:
|
||||
- validación visual pendiente del director
|
||||
|
||||
Decisiones proactivas (Nivel 2):
|
||||
- [cambios fuera de scope exacto]
|
||||
|
||||
Conflictos de scope (si git diff mostró algo no autorizado):
|
||||
- [archivo + qué se hizo]
|
||||
```
|
||||
Reference in New Issue
Block a user