From a17e810ea87df396b9653513a3bd0be41ee9c961 Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Fri, 5 Jun 2026 07:18:10 -0300 Subject: [PATCH] Con marca y dashboards --- app/api/dashboard/[widget]/route.ts | 59 ++ app/dashboard/loading.tsx | 15 + app/dashboard/page.tsx | 76 +++ app/globals.css | 192 ++++++ assets/.DS_Store | Bin 14340 -> 14340 bytes components/dashboard/donut-cuidados.tsx | 81 +++ components/dashboard/filter-sidebar.tsx | 86 +++ components/dashboard/gauge-organizacional.tsx | 61 ++ components/dashboard/heatmap-widget.tsx | 70 ++ .../dashboard/intensidad-productividad.tsx | 75 +++ components/dashboard/kpi-cards.tsx | 62 ++ components/dashboard/paraguay-map.tsx | 111 ++++ components/dashboard/piramide-demografica.tsx | 80 +++ components/dashboard/politicas-bar.tsx | 42 ++ components/dashboard/tabla-riesgo.tsx | 65 ++ components/dashboard/widget-shell.tsx | 41 ++ lib/dashboard-queries.ts | 24 + lib/dashboard-types.ts | 57 ++ lib/dashboard-utils.ts | 50 ++ sprints/sprint-2/DASHBOARD.md | 292 ++++++++ .../20260605093948_dashboard_rpc.sql | 624 ++++++++++++++++++ .../20260605093949_rls_deny_sensitive.sql | 19 + 22 files changed, 2182 insertions(+) create mode 100644 app/api/dashboard/[widget]/route.ts create mode 100644 app/dashboard/loading.tsx create mode 100644 app/dashboard/page.tsx create mode 100644 components/dashboard/donut-cuidados.tsx create mode 100644 components/dashboard/filter-sidebar.tsx create mode 100644 components/dashboard/gauge-organizacional.tsx create mode 100644 components/dashboard/heatmap-widget.tsx create mode 100644 components/dashboard/intensidad-productividad.tsx create mode 100644 components/dashboard/kpi-cards.tsx create mode 100644 components/dashboard/paraguay-map.tsx create mode 100644 components/dashboard/piramide-demografica.tsx create mode 100644 components/dashboard/politicas-bar.tsx create mode 100644 components/dashboard/tabla-riesgo.tsx create mode 100644 components/dashboard/widget-shell.tsx create mode 100644 lib/dashboard-queries.ts create mode 100644 lib/dashboard-types.ts create mode 100644 lib/dashboard-utils.ts create mode 100644 sprints/sprint-2/DASHBOARD.md create mode 100644 supabase/migrations/20260605093948_dashboard_rpc.sql create mode 100644 supabase/migrations/20260605093949_rls_deny_sensitive.sql diff --git a/app/api/dashboard/[widget]/route.ts b/app/api/dashboard/[widget]/route.ts new file mode 100644 index 0000000..cae15f0 --- /dev/null +++ b/app/api/dashboard/[widget]/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server' +import { createClient } from '@supabase/supabase-js' +import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils' + +// Route Handler server-side: llama a las RPC con el cliente Supabase. +// El service_role nunca llega al browser — vive solo en este servidor. +// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen 3.4/7.1 internamente. + +const RPC_MAP: Record = { + kpis: 'rpc_dashboard_kpis', + prevalencia: 'rpc_prevalencia_por_depto', + heatmap: 'rpc_heatmap_intensidad', + distribucion: 'rpc_distribucion_cuidado', + politicas: 'rpc_politicas_gap', + score: 'rpc_score_organizacional', + piramide: 'rpc_piramide_demografica', + intensidad: 'rpc_intensidad_vs_productividad', + riesgo: 'rpc_riesgo_retencion', +} + +function getSupabaseServer() { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL! + // Usar anon key — las RPC son SECURITY DEFINER y aplican sus propias reglas + const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + return createClient(url, key) +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ widget: string }> } +) { + const { widget } = await params + const rpcName = RPC_MAP[widget] + + if (!rpcName) { + return NextResponse.json({ error: 'Widget desconocido' }, { status: 404 }) + } + + const sp = request.nextUrl.searchParams + const surveyId = sp.get('survey_id') + if (!surveyId) { + return NextResponse.json({ error: 'survey_id requerido' }, { status: 400 }) + } + + const filters = queryToFilters(sp) + const supabase = getSupabaseServer() + + const { data, error } = await supabase.rpc(rpcName, { + p_survey_id: surveyId, + p_filters: filtersToRpc(filters), + }) + + if (error) { + console.error(`[dashboard/${widget}] RPC error:`, error.message) + return NextResponse.json({ error: error.message }, { status: 500 }) + } + + return NextResponse.json(data) +} diff --git a/app/dashboard/loading.tsx b/app/dashboard/loading.tsx new file mode 100644 index 0000000..0024013 --- /dev/null +++ b/app/dashboard/loading.tsx @@ -0,0 +1,15 @@ +export default function DashboardLoading() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} + {Array.from({ length: 6 }).map((_, i) => ( +
+ ))} +
+
+ ) +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..996a6c4 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,76 @@ +'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 { PoliticasBar } from '@/components/dashboard/politicas-bar' +import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad' +import { TablaRiesgo } from '@/components/dashboard/tabla-riesgo' +import { FilterSidebar } from '@/components/dashboard/filter-sidebar' +import type { DashboardFilters } from '@/lib/dashboard-types' + +// El survey_id se pasa como query param: /dashboard?survey_id=UUID +export default function DashboardPage() { + const searchParams = useSearchParams() + const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001' + const [filters, setFilters] = useState({}) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + + function handleDeptoClick(depto: string) { + setFilters(f => ({ ...f, depto: depto || undefined })) + } + + return ( +
+ setSidebarCollapsed(c => !c)} + /> + +
+
+

Economía del Cuidado — Paraguay

+

+ Datos agregados · k-anonimato k≥5 · Encuesta Mapeo de Cuidadores 2026 + {filters.depto && · Filtro: {filters.depto}} +

+
+ + {/* Fila 1: KPIs (span 12) */} +
+ +
+ + {/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */} +
+ + + +
+ + {/* Fila 3: Heatmap (6) + Pirámide (6) */} +
+ + +
+ + {/* Fila 4: Barras políticas (6) + Hexbin intensidad/bienestar (6) */} +
+ + +
+ + {/* Fila 5: Tabla riesgo (12) */} +
+ +
+
+
+ ) +} diff --git a/app/globals.css b/app/globals.css index 58fe01d..7ffc0f9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -543,6 +543,198 @@ body { } .survey-footer-logo { height: 32px; width: auto; display: block; } +/* ══════════════════════════════════════════════════════════════ + DASHBOARD — tema oscuro premium + Fondo: #0F1923 · Widgets: #1A2535 · Teal: #43BBC8 + ══════════════════════════════════════════════════════════════ */ +.db-page { + display: flex; min-height: 100vh; + background: #0F1923; color: #E7E6E5; + 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-active-filter { color: var(--color-re-teal); font-weight: 600; } +.db-section { margin-bottom: 16px; } +.db-grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 12px; +} + +/* ── Widget shell ── */ +.db-widget { + background: #1A2535; + border: 1px solid rgba(67,187,200,0.2); + border-radius: 10px; + overflow: hidden; + transition: box-shadow 0.3s ease; +} +.db-widget:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); } +.db-widget-header { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 16px 8px; +} +.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #E7E6E5; margin: 0; } +.db-widget-n { font-size: 0.75rem; color: #9ca3af; } +.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; +} +.db-suppressed-icon { font-size: 1.5rem; } +.db-suppressed-hint { font-size: 0.75rem; color: #6b7280; } +.db-suppressed-card { + display: flex; flex-direction: column; align-items: center; justify-content: center; + gap: 4px; color: #6b7280; +} +.db-skeleton-block { background: #1e2d42; 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; } + +/* ── KPI row ── */ +.db-kpi-row { + display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; + padding: 0; +} +.db-kpi-card { + background: #1A2535; 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); } +.db-kpi-value { + font-size: 2rem; font-weight: 700; color: var(--color-re-teal); + font-variant-numeric: tabular-nums; +} +.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; } +.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; } + +/* ── 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; + padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3); + white-space: nowrap; z-index: 10; pointer-events: none; +} +.db-tile-grid { + display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px; + grid-template-rows: repeat(6, 1fr); min-height: 220px; +} +.db-tile { + border: none; border-radius: 4px; cursor: pointer; padding: 4px 2px; + display: flex; flex-direction: column; align-items: center; justify-content: center; + transition: filter 0.15s, outline 0.15s; + min-height: 36px; +} +.db-tile:hover { filter: brightness(1.2); } +.db-tile:focus-visible { outline: 2px solid var(--color-re-teal); outline-offset: 1px; } +.db-tile-selected { outline: 2px solid #fff; } +.db-tile-suppressed { background-image: repeating-linear-gradient( + 45deg, transparent, transparent 4px, rgba(255,255,255,0.06) 4px, rgba(255,255,255,0.06) 8px +) !important; } +.db-tile-name { font-size: 0.5rem; color: #fff; text-align: center; line-height: 1.2; font-weight: 600; } +.db-tile-pct { font-size: 0.6rem; color: rgba(255,255,255,0.85); font-weight: 700; } + +/* ── Donut ── */ +.db-donut-wrapper { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } +.db-donut-svg { width: 130px; height: 130px; flex-shrink: 0; } +.db-donut-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.db-legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; } +.db-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } +.db-legend-val { margin-left: auto; font-weight: 600; color: var(--color-re-teal); } + +/* ── Gauge ── */ +.db-gauge-wrapper { text-align: center; } +.db-gauge-svg { width: 100%; max-width: 160px; } +.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; } +.db-gauge-hint { font-size: 0.7rem; color: #6b7280; 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-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; } +.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; } + +/* ── Heatmap / hexbin shared ── */ +.db-heatmap-scroll { overflow-x: auto; } +.db-heatmap-grid { display: grid; gap: 2px; } +.db-heatmap-header { + font-size: 0.625rem; color: #9ca3af; 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-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; } + +/* ── 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-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; } + +/* ── Tabla riesgo ── */ +.db-table-wrapper { overflow-x: auto; } +.db-table-note { font-size: 0.75rem; color: #9ca3af; 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 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; + border-right: 1px solid rgba(67,187,200,0.15); + padding: 12px; transition: width 0.3s ease; + display: flex; flex-direction: column; gap: 8px; +} +.db-sidebar-collapsed { width: 52px; } +.db-sidebar-toggle { + background: none; border: 1px solid rgba(67,187,200,0.3); + color: var(--color-re-teal); border-radius: 6px; padding: 6px; + cursor: pointer; font-size: 0.75rem; white-space: nowrap; +} +.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; } +.db-filter-group { display: flex; flex-direction: column; gap: 4px; } +.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; } +.db-filter-select { + background: #1e2d42; border: 1px solid #2a3547; color: #E7E6E5; + 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 input { accent-color: var(--color-re-teal); width: 14px; height: 14px; } +.db-filter-reset { + background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3); + color: var(--color-re-teal); border-radius: 6px; padding: 8px; + cursor: pointer; font-size: 0.8125rem; font-weight: 600; + transition: background 0.15s; +} +.db-filter-reset:hover { background: rgba(67,187,200,0.2); } + +/* ── Responsive ── */ +@media (max-width: 768px) { + .db-kpi-row { grid-template-columns: repeat(2,1fr); } + .db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); } + .db-page { flex-direction: column; } +} + /* ── Reduced motion ── */ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } diff --git a/assets/.DS_Store b/assets/.DS_Store index 33ac60d5a5696e56731590c2c54ea32cd01286a0..11c17e292eb6b80beca1010b31eaebff04dac537 100644 GIT binary patch delta 62 zcmV-E0Kxx+aD;HM))oP{lP(}}3mAKQGB-CiEFd#AldB*b0g97w7aswOvyc~i2(t3m_Z_lR+30v!D>?0<$0+<^;0`F#QKd2@eqf diff --git a/components/dashboard/donut-cuidados.tsx b/components/dashboard/donut-cuidados.tsx new file mode 100644 index 0000000..170d471 --- /dev/null +++ b/components/dashboard/donut-cuidados.tsx @@ -0,0 +1,81 @@ +'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(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 ( + + {data && ( +
+ +
    + {SEGMENTS.map(s => ( +
  • + + {s.label} + + {(data[s.key as keyof DistribucionData] as number ?? 0)}% + +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/components/dashboard/filter-sidebar.tsx b/components/dashboard/filter-sidebar.tsx new file mode 100644 index 0000000..6b52a47 --- /dev/null +++ b/components/dashboard/filter-sidebar.tsx @@ -0,0 +1,86 @@ +'use client' +import { DEPARTMENTS } from '@/lib/paraguay-geo' +import { ETARIO_ORDER } from '@/lib/dashboard-utils' +import type { DashboardFilters } from '@/lib/dashboard-types' + +const GENEROS = ['Femenino','Masculino','No binario/Otro','Prefiero no decir'] +const MODALIDADES = ['Presencial','Remoto (home office)','Híbrido'] + +interface Props { + filters: DashboardFilters + onChange: (f: DashboardFilters) => void + collapsed: boolean + onToggle: () => void +} + +export function FilterSidebar({ filters, onChange, collapsed, onToggle }: Props) { + function toggleArray(key: keyof DashboardFilters, val: string) { + const arr = (filters[key] as string[] | undefined) ?? [] + const next = arr.includes(val) ? arr.filter(v => v !== val) : [...arr, val] + onChange({ ...filters, [key]: next.length ? next : undefined }) + } + + function reset() { onChange({}) } + + return ( + + ) +} diff --git a/components/dashboard/gauge-organizacional.tsx b/components/dashboard/gauge-organizacional.tsx new file mode 100644 index 0000000..ba5434e --- /dev/null +++ b/components/dashboard/gauge-organizacional.tsx @@ -0,0 +1,61 @@ +'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(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 ( + + {data && ( +
+ + {/* Fondo semicírculo */} + + {/* Arco coloreado proporcional al score */} + + {/* Aguja */} + + + {/* Score */} + {score} + / 100 + +

+ {ZONA_LABEL[data.zona]} +

+

+ 0–33 baja · 34–66 desarrollo · 67–100 consciente +

+
+ )} +
+ ) +} diff --git a/components/dashboard/heatmap-widget.tsx b/components/dashboard/heatmap-widget.tsx new file mode 100644 index 0000000..af054b8 --- /dev/null +++ b/components/dashboard/heatmap-widget.tsx @@ -0,0 +1,70 @@ +'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(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 ( + + {data && ( +
+
+ {/* Header row */} +
+ {SHORT_AUSENCIAS.map(h => ( +
{h}
+ ))} + {/* Data rows */} + {HORAS_ORDER.map(horas => ( + <> +
{horas}
+ {AUSENCIAS_ORDER.map(aus => { + const cell = cellMap.get(`${horas}|${aus}`) + const n = cell?.n ?? null + const sup2 = cell?.suppressed ?? (!cell) + return ( +
+ {!sup2 && n !== null && {n}} +
+ ) + })} + + ))} +
+
+ )} + + ) +} diff --git a/components/dashboard/intensidad-productividad.tsx b/components/dashboard/intensidad-productividad.tsx new file mode 100644 index 0000000..27253ed --- /dev/null +++ b/components/dashboard/intensidad-productividad.tsx @@ -0,0 +1,75 @@ +'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(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 ( + + {data && ( +
+

+ Cada celda = respondentes agregados. Celdas sin color: N < 5 (suprimido). +

+
+
+
+ {BIENESTAR_BINS.map(b => ( +
Bienestar {b}
+ ))} + {HORAS_ORDER.map(horas => ( + <> +
{horas}
+ {BIENESTAR_BINS.map(bien => { + const cell = cellMap.get(`${horas}|${bien}`) + const n = cell?.n ?? null + const suppressed = cell?.suppressed ?? true + return ( +
+ {!suppressed && n !== null && {n}} +
+ ) + })} + + ))} +
+
+
+ )} + + ) +} diff --git a/components/dashboard/kpi-cards.tsx b/components/dashboard/kpi-cards.tsx new file mode 100644 index 0000000..dc3ae6d --- /dev/null +++ b/components/dashboard/kpi-cards.tsx @@ -0,0 +1,62 @@ +'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' + +interface KpiCardProps { label: string; value: string; sub?: string } + +function KpiCard({ label, value, sub }: KpiCardProps) { + return ( +
+ {value} + {label} + {sub && {sub}} +
+ ) +} + +interface Props { surveyId: string; filters: DashboardFilters } + +export function KpiCards({ surveyId, filters }: Props) { + const [data, setData] = useState(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

Error al cargar KPIs

+ if (!data) { + return ( +
+ {[1,2,3,4].map(i =>
)} +
+ ) + } + if (isSuppressed(data)) { + return ( +
+ {[1,2,3,4].map(i => ( +
+ 🔒 + n < 5 +
+ ))} +
+ ) + } + + const d = data as KpiData + return ( +
+ + + + +
+ ) +} diff --git a/components/dashboard/paraguay-map.tsx b/components/dashboard/paraguay-map.tsx new file mode 100644 index 0000000..24203ab --- /dev/null +++ b/components/dashboard/paraguay-map.tsx @@ -0,0 +1,111 @@ +'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(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 ( + +
+ {tooltip && ( +
+ {tooltip.name} + {tooltip.pct !== null + ? <> · {tooltip.pct}% · n={tooltip.n} + : <> · Datos insuficientes} +
+ )} +
+ {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 ( + + ) + })} +
+
+
+ ) +} diff --git a/components/dashboard/piramide-demografica.tsx b/components/dashboard/piramide-demografica.tsx new file mode 100644 index 0000000..379c9dc --- /dev/null +++ b/components/dashboard/piramide-demografica.tsx @@ -0,0 +1,80 @@ +'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 = { + '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(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 ( + + {data && ( +
+
+ {generos.map(g => ( + + + {g} + + ))} +
+ {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 ( +
+ {etario} +
+ {row.map(({ gen, n, suppressed }) => ( +
+
+
+ ))} +
+ + {row.every(r => r.suppressed) ? '🔒' : row.reduce((s, r) => s + (r.n ?? 0), 0)} + +
+ ) + })} +
+ )} + + ) +} diff --git a/components/dashboard/politicas-bar.tsx b/components/dashboard/politicas-bar.tsx new file mode 100644 index 0000000..9dfa974 --- /dev/null +++ b/components/dashboard/politicas-bar.tsx @@ -0,0 +1,42 @@ +'use client' +import { useEffect, useState } from 'react' +import { isSuppressed, type PoliticasData } from '@/lib/dashboard-types' +import { queries } from '@/lib/dashboard-queries' +import type { DashboardFilters } from '@/lib/dashboard-types' +import { WidgetShell } from './widget-shell' + +export function PoliticasBar({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) { + const [data, setData] = useState(null) + const [sup, setSup] = useState(false) + + useEffect(() => { + setData(null); setSup(false) + queries.politicas(surveyId, filters) + .then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PoliticasData) }) + .catch(() => {}) + }, [surveyId, JSON.stringify(filters)]) + + const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok' + + return ( + + {data && ( +
    + {data.data.length === 0 && ( +
  • No hay datos suficientes por política (n<5 en todas).
  • + )} + {data.data.map(item => ( +
  • + {item.politica} +
  • + ))} +
+ )} +
+ ) +} diff --git a/components/dashboard/tabla-riesgo.tsx b/components/dashboard/tabla-riesgo.tsx new file mode 100644 index 0000000..51c1233 --- /dev/null +++ b/components/dashboard/tabla-riesgo.tsx @@ -0,0 +1,65 @@ +'use client' +import { useEffect, useState } from 'react' +import { isSuppressed, type RiesgoData } from '@/lib/dashboard-types' +import { queries } from '@/lib/dashboard-queries' +import type { DashboardFilters } from '@/lib/dashboard-types' +import { WidgetShell } from './widget-shell' + +const RIESGO_COLOR: Record = { + Alto: '#ef4444', + Medio: '#F8AD13', + Bajo: '#43BBC8', +} + +export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) { + const [data, setData] = useState(null) + const [sup, setSup] = useState(false) + + useEffect(() => { + setData(null); setSup(false) + queries.riesgo(surveyId, filters) + .then(d => { if (isSuppressed(d)) setSup(true); else setData(d as RiesgoData) }) + .catch(() => {}) + }, [surveyId, JSON.stringify(filters)]) + + const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok' + + return ( + + {data && ( +
+

+ Alto: ausencias frecuentes + empresa no comprende. Medio: una condición. Bajo: ninguna. + La columna de departamento fue eliminada para evitar cruces reidentificables. +

+ + + + + + + + + + + {data.segmentos.map(seg => ( + + + + + + + ))} + +
SegmentoN% del totalPolítica más demandada
+ + {seg.segmento} + + {seg.suppressed ? '—' : seg.n}{seg.suppressed ? '—' : `${seg.pct}%`}{seg.suppressed ? Datos insuficientes (n<5) + : seg.politica ?? '—'}
+
+ )} +
+ ) +} diff --git a/components/dashboard/widget-shell.tsx b/components/dashboard/widget-shell.tsx new file mode 100644 index 0000000..66d24ef --- /dev/null +++ b/components/dashboard/widget-shell.tsx @@ -0,0 +1,41 @@ +'use client' +import type { WidgetStatus } from '@/lib/dashboard-types' + +interface WidgetShellProps { + title: string + status: WidgetStatus + n?: number + children: React.ReactNode + colSpan?: number +} + +export function WidgetShell({ title, status, n, children, colSpan = 6 }: WidgetShellProps) { + return ( +
+
+

{title}

+ {n !== undefined && status === 'ok' && ( + n = {n.toLocaleString('es-PY')} + )} +
+
+ {status === 'loading' && ( +
+ )} + {status === 'suppressed' && ( +
+ 🔒 +

Datos insuficientes

+

Se necesitan al menos 5 respuestas en este corte.

+
+ )} + {status === 'error' && ( +
+

Error al cargar los datos.

+
+ )} + {status === 'ok' && children} +
+
+ ) +} diff --git a/lib/dashboard-queries.ts b/lib/dashboard-queries.ts new file mode 100644 index 0000000..b88cc0f --- /dev/null +++ b/lib/dashboard-queries.ts @@ -0,0 +1,24 @@ +// El cliente NUNCA consulta Supabase directamente. +// Toda la comunicación va a través de los Route Handlers /api/dashboard/[widget]. + +import type { DashboardFilters } from './dashboard-types' +import { filtersToQuery } from './dashboard-utils' + +async function fetchWidget(widget: string, surveyId: string, filters: DashboardFilters): Promise { + const qs = filtersToQuery(surveyId, filters) + const res = await fetch(`/api/dashboard/${widget}?${qs}`, { cache: 'no-store' }) + if (!res.ok) throw new Error(`Widget ${widget}: ${res.status}`) + return res.json() +} + +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), + politicas: (sid: string, f: DashboardFilters) => fetchWidget('politicas', 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), + riesgo: (sid: string, f: DashboardFilters) => fetchWidget('riesgo', sid, f), +} diff --git a/lib/dashboard-types.ts b/lib/dashboard-types.ts new file mode 100644 index 0000000..c22dae0 --- /dev/null +++ b/lib/dashboard-types.ts @@ -0,0 +1,57 @@ +// Filtros globales del dashboard (sidebar) +export interface DashboardFilters { + depto?: string + genero?: string[] + etario?: string[] + modalidad?: string[] +} + +// 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 + 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 PoliticaItem { politica: string; n: number; pct: number } +export interface PoliticasData { status: 'ok'; n_total: number; data: PoliticaItem[] } + +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 RiesgoSegmento { segmento: string; n: number | null; pct: number | null; politica: string | null; suppressed: boolean } +export interface RiesgoData { status: 'ok'; n_total: number; segmentos: RiesgoSegmento[] } + +export type SuppressedPayload = { status: 'suppressed'; min_n: number; n: number } + +export type RpcResult = T | SuppressedPayload + +export function isSuppressed(r: unknown): r is SuppressedPayload { + return typeof r === 'object' && r !== null && (r as Record)['status'] === 'suppressed' +} diff --git a/lib/dashboard-utils.ts b/lib/dashboard-utils.ts new file mode 100644 index 0000000..e8f5120 --- /dev/null +++ b/lib/dashboard-utils.ts @@ -0,0 +1,50 @@ +import type { DashboardFilters } from './dashboard-types' + +/** Convierte filtros a query string para las API routes */ +export function filtersToQuery(surveyId: string, filters: DashboardFilters): string { + const p = new URLSearchParams({ survey_id: surveyId }) + if (filters.depto) p.set('depto', filters.depto) + if (filters.genero?.length) p.set('genero', filters.genero.join(',')) + if (filters.etario?.length) p.set('etario', filters.etario.join(',')) + if (filters.modalidad?.length) p.set('modalidad', filters.modalidad.join(',')) + return p.toString() +} + +/** Parsea query string a objeto DashboardFilters */ +export function queryToFilters(searchParams: URLSearchParams): DashboardFilters { + const f: DashboardFilters = {} + if (searchParams.has('depto')) f.depto = searchParams.get('depto')! + const g = searchParams.get('genero') + if (g) f.genero = g.split(',').filter(Boolean) + const e = searchParams.get('etario') + if (e) f.etario = e.split(',').filter(Boolean) + const m = searchParams.get('modalidad') + if (m) f.modalidad = m.split(',').filter(Boolean) + return f +} + +/** Normaliza filtros a JSONB-compatible object para las RPC */ +export function filtersToRpc(filters: DashboardFilters): Record { + return { + depto: filters.depto ?? null, + genero: filters.genero ?? [], + etario: filters.etario ?? [], + modalidad: filters.modalidad ?? [], + } +} + +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>( + 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) + }) +} diff --git a/sprints/sprint-2/DASHBOARD.md b/sprints/sprint-2/DASHBOARD.md new file mode 100644 index 0000000..52e4c1f --- /dev/null +++ b/sprints/sprint-2/DASHBOARD.md @@ -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] +``` \ No newline at end of file diff --git a/supabase/migrations/20260605093948_dashboard_rpc.sql b/supabase/migrations/20260605093948_dashboard_rpc.sql new file mode 100644 index 0000000..11c04b7 --- /dev/null +++ b/supabase/migrations/20260605093948_dashboard_rpc.sql @@ -0,0 +1,624 @@ +-- ============================================================ +-- Migration 009: Dashboard RPC functions +-- Todas son SECURITY DEFINER + search_path fijado. +-- Aplican k-anonimato k>=5 ANTES de devolver. +-- NUNCA tocan question codes 3.4 (salario) ni 7.1 (texto libre). +-- El cliente NUNCA accede a `answers` directamente — solo via estas funciones. +-- ============================================================ + +-- ── Helper: responses filtradas por cohort ──────────────────── +-- Devuelve IDs de respuestas que cumplen los filtros opcionales. +-- Filtros pasados como text arrays o NULL (sin filtro). +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 r.survey_id = p_survey_id + -- Departamento: A1 + AND (p_depto IS NULL 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 = 'A1' + AND a.value #>> '{}' = p_depto + )) + -- Género: A6 + 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 = 'A6' + AND a.value #>> '{}' = ANY(p_genero) + )) + -- Rango etario: qi_age_bucket en responses + AND (p_etario IS NULL OR cardinality(p_etario) = 0 + OR r.qi_age_bucket = ANY(p_etario)) + -- Modalidad: A7 + 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 = 'A7' + AND a.value #>> '{}' = ANY(p_modalidad) + )); +$$; + +-- ── Helper: parse filters JSONB → arrays ────────────────────── +-- Convierte el objeto de filtros recibido del cliente en arrays tipados. +-- Devuelve NULL para filtros no presentes o vacíos. +CREATE OR REPLACE FUNCTION public._dash_parse_filters(p_filters jsonb) +RETURNS TABLE( + f_depto text, + f_genero text[], + f_etario text[], + f_modalidad text[] +) +LANGUAGE sql IMMUTABLE SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + NULLIF(p_filters->>'depto',''), + ARRAY(SELECT jsonb_array_elements_text(p_filters->'genero'))::text[], + ARRAY(SELECT jsonb_array_elements_text(p_filters->'etario'))::text[], + ARRAY(SELECT jsonb_array_elements_text(p_filters->'modalidad'))::text[]; +$$; + +-- ── KPIs ───────────────────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.rpc_dashboard_kpis( + 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_cuidadores bigint; + v_bienestar_avg numeric; + v_bienestar_n bigint; + v_ausencias_pct numeric; +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); + + IF v_n < 5 THEN + RETURN jsonb_build_object('status','suppressed','min_n',5,'n',v_n); + END IF; + + -- % cuidadores: algún trigger = 'Sí' + SELECT count(DISTINCT dr.response_id) INTO v_cuidadores + 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 EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id + AND q.code IN ('1.1','1.6','1.13') + AND a.value #>> '{}' = 'Sí' + ); + + -- Score bienestar: media de 4.3 (rating 1-5), normalizado a 0-100 + SELECT avg((a.value #>> '{}')::numeric), count(*) + INTO v_bienestar_avg, v_bienestar_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 + WHERE q.code = '4.3' + AND a.value #>> '{}' ~ '^[0-9]+$'; + + -- % ausencias frecuentes: 5.2 = frecuentes (Varias veces o Con frecuencia) + SELECT count(DISTINCT dr.response_id) INTO v_ausencias_pct + 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 = '5.2' + AND a.value #>> '{}' IN ('Varias veces (4-10 veces al año)','Con frecuencia (más de 10 veces al año)'); + + RETURN jsonb_build_object( + 'status', 'ok', + 'n', v_n, + 'pct_cuidadores', ROUND(v_cuidadores::numeric / v_n * 100, 1), + 'score_bienestar', CASE WHEN v_bienestar_n > 0 + THEN ROUND((v_bienestar_avg - 1) / 4 * 100, 1) + ELSE NULL END, + 'pct_ausencias', ROUND(v_ausencias_pct::numeric / v_n * 100, 1) + ); +END; +$$; + +-- ── Prevalencia por departamento ────────────────────────────── +CREATE OR REPLACE FUNCTION public.rpc_prevalencia_por_depto( + 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) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'depto', depto_val, + 'n', CASE WHEN n_depto >= 5 THEN n_depto ELSE NULL END, + 'pct_cuidadores',CASE WHEN n_depto >= 5 + THEN ROUND(n_cuid::numeric / n_depto * 100, 1) + ELSE NULL END, + 'suppressed', (n_depto < 5) + ) AS row + FROM ( + SELECT + a.value #>> '{}' AS depto_val, + count(DISTINCT dr.response_id) AS n_depto, + count(DISTINCT dr.response_id) FILTER ( + WHERE EXISTS ( + SELECT 1 FROM public.answers a2 + JOIN public.questions q2 ON q2.id = a2.question_id + WHERE a2.response_id = dr.response_id + AND q2.code IN ('1.1','1.6','1.13') + AND a2.value #>> '{}' = 'Sí' + ) + ) AS n_cuid + FROM public._dash_responses(p_survey_id, NULL, 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 = 'A1' + GROUP BY a.value #>> '{}' + ) sub + ORDER BY depto_val + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Heatmap: horas de cuidado × ausencias ──────────────────── +CREATE OR REPLACE FUNCTION public.rpc_heatmap_intensidad( + 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) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'horas', horas_bin, + 'ausencias', aus_bin, + 'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END, + 'suppressed', (cnt < 5) + ) AS row + FROM ( + SELECT + ah.value #>> '{}' AS horas_bin, + aa.value #>> '{}' AS aus_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 ah ON ah.response_id = dr.response_id + JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '2.2' + JOIN public.answers aa ON aa.response_id = dr.response_id + JOIN public.questions qa ON qa.id = aa.question_id AND qa.code = '5.2' + GROUP BY 1, 2 + ) sub + ORDER BY horas_bin, aus_bin + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Distribución tipo de cuidado (donut) ───────────────────── +CREATE OR REPLACE FUNCTION public.rpc_distribucion_cuidado( + 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_disc bigint; + v_familiar bigint; + v_mayor bigint; + v_ninguno 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(DISTINCT dr.response_id) INTO v_disc + 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 = '1.1' AND a.value #>> '{}' = 'Sí'; + + SELECT count(DISTINCT dr.response_id) INTO v_familiar + 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 = '1.6' AND a.value #>> '{}' = 'Sí'; + + SELECT count(DISTINCT dr.response_id) INTO v_mayor + 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 = '1.13' AND a.value #>> '{}' = 'Sí'; + + SELECT count(DISTINCT dr.response_id) INTO v_ninguno + 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 NOT EXISTS ( + SELECT 1 FROM public.answers a + JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id + AND q.code IN ('1.1','1.6','1.13') + AND a.value #>> '{}' = 'Sí' + ); + + RETURN jsonb_build_object( + 'status', 'ok', + 'n', v_total, + 'disc_propia', ROUND(v_disc::numeric / v_total * 100, 1), + 'familiar', ROUND(v_familiar::numeric / v_total * 100, 1), + 'adulto_mayor', ROUND(v_mayor::numeric / v_total * 100, 1), + 'ninguno', ROUND(v_ninguno::numeric / v_total * 100, 1) + ); +END; +$$; + +-- ── Gap de políticas (barras) ───────────────────────────────── +CREATE OR REPLACE FUNCTION public.rpc_politicas_gap( + 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; + + -- 6.2 = apoyos que ayudarían (necesitadas), 6.2 = ya existentes (same question, max_select=3) + -- Nota: el instrumento no tiene una pregunta separada "políticas existentes". + -- Widget 5 muestra TOP políticas demandadas (6.2) para priorización. + SELECT jsonb_agg(row ORDER BY cnt DESC) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'politica', 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 = '6.2' + AND jsonb_typeof(a.value) = 'array' + GROUP BY 1 + ) sub + WHERE cnt >= 5 + ORDER BY cnt DESC + LIMIT 8 + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Score organizacional (gauge) ────────────────────────────── +CREATE OR REPLACE FUNCTION public.rpc_score_organizacional( + 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_score_6_1 numeric; + v_score_4_3 numeric; + v_score_final numeric; + v_zona text; +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; + + -- 6.1: Sí=100, Parcialmente=50, No=0 + SELECT AVG(CASE + WHEN a.value #>> '{}' = 'Sí, me siento acompañado/a' THEN 100.0 + WHEN a.value #>> '{}' = 'Parcialmente, hay cierta comprensión' THEN 50.0 + ELSE 0.0 + END) INTO v_score_6_1 + 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 = '6.1'; + + -- 4.3: rating 1-5 → 0-100 + SELECT AVG((a.value #>> '{}')::numeric - 1) / 4 * 100 INTO v_score_4_3 + 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 = '4.3' AND a.value #>> '{}' ~ '^[0-9]+$'; + + v_score_final := ROUND(COALESCE(v_score_6_1, 0) * 0.6 + COALESCE(v_score_4_3, 0) * 0.4, 1); + + v_zona := CASE + WHEN v_score_final < 34 THEN 'baja' + WHEN v_score_final < 67 THEN 'desarrollo' + ELSE 'consciente' + END; + + RETURN jsonb_build_object('status','ok','n',v_total,'score',v_score_final,'zona',v_zona); +END; +$$; + +-- ── Pirámide demográfica ────────────────────────────────────── +CREATE OR REPLACE FUNCTION public.rpc_piramide_demografica( + 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) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'etario', etario_val, + 'genero', genero_val, + 'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END, + 'suppressed', (cnt < 5) + ) AS row + FROM ( + SELECT + ae.value #>> '{}' AS etario_val, + ag.value #>> '{}' AS genero_val, + 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 ae ON ae.response_id = dr.response_id + JOIN public.questions qe ON qe.id = ae.question_id AND qe.code = 'A5' + JOIN public.answers ag ON ag.response_id = dr.response_id + JOIN public.questions qg ON qg.id = ag.question_id AND qg.code = 'A6' + GROUP BY 1, 2 + ) sub + ORDER BY etario_val, genero_val + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Intensidad de cuidado vs. bienestar (hexbin) ───────────── +CREATE OR REPLACE FUNCTION public.rpc_intensidad_vs_productividad( + 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) INTO v_rows FROM ( + SELECT jsonb_build_object( + 'horas', horas_bin, + 'bienestar', bien_bin, + 'n', CASE WHEN cnt >= 5 THEN cnt ELSE NULL END, + 'suppressed', (cnt < 5) + ) AS row + FROM ( + SELECT + ah.value #>> '{}' AS horas_bin, + ab.value #>> '{}' AS bien_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 ah ON ah.response_id = dr.response_id + JOIN public.questions qh ON qh.id = ah.question_id AND qh.code = '2.2' + JOIN public.answers ab ON ab.response_id = dr.response_id + JOIN public.questions qb ON qb.id = ab.question_id AND qb.code = '4.3' + WHERE ab.value #>> '{}' ~ '^[0-9]+$' + GROUP BY 1, 2 + ) sub + ORDER BY horas_bin, bien_bin + ) t; + + RETURN jsonb_build_object('status','ok','n_total',v_total,'data', COALESCE(v_rows,'[]'::jsonb)); +END; +$$; + +-- ── Tabla de riesgo de retención ────────────────────────────── +-- Sin columna de departamento (vector de reidentificación en N pequeños). +CREATE OR REPLACE FUNCTION public.rpc_riesgo_retencion( + 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_n_alto bigint; + v_n_medio bigint; + v_n_bajo bigint; + v_pol_alto text; + v_pol_medio text; + v_pol_bajo text; +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; + + -- Alto: ausencias frecuentes AND empresa no comprende + SELECT count(DISTINCT dr.response_id) INTO v_n_alto + 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 EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id AND q.code = '5.2' + AND a.value #>> '{}' IN ('Varias veces (4-10 veces al año)','Con frecuencia (más de 10 veces al año)') + ) + AND EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id AND q.code = '6.1' + AND a.value #>> '{}' IN ('No, es algo bastante invisible','No, nunca se ha hablado del tema') + ); + + -- Medio: exactamente una de las dos condiciones (XOR en SQL estándar) + SELECT count(DISTINCT dr.response_id) INTO v_n_medio + 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 ( + EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id AND q.code = '5.2' + AND a.value #>> '{}' IN ('Varias veces (4-10 veces al año)','Con frecuencia (más de 10 veces al año)') + ) + AND NOT EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id AND q.code = '6.1' + AND a.value #>> '{}' IN ('No, es algo bastante invisible','No, nunca se ha hablado del tema') + ) + ) OR ( + NOT EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id AND q.code = '5.2' + AND a.value #>> '{}' IN ('Varias veces (4-10 veces al año)','Con frecuencia (más de 10 veces al año)') + ) + AND EXISTS ( + SELECT 1 FROM public.answers a JOIN public.questions q ON q.id = a.question_id + WHERE a.response_id = dr.response_id AND q.code = '6.1' + AND a.value #>> '{}' IN ('No, es algo bastante invisible','No, nunca se ha hablado del tema') + ) + ); + + v_n_bajo := v_total - v_n_alto - v_n_medio; + + -- Política más demandada por segmento (solo si N>=5) + IF v_n_alto >= 5 THEN + SELECT jsonb_array_elements_text(a.value) INTO v_pol_alto + 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 = '6.2' AND jsonb_typeof(a.value) = 'array' + AND EXISTS ( + SELECT 1 FROM public.answers a2 JOIN public.questions q2 ON q2.id = a2.question_id + WHERE a2.response_id = dr.response_id AND q2.code = '5.2' + AND a2.value #>> '{}' IN ('Varias veces (4-10 veces al año)','Con frecuencia (más de 10 veces al año)') + ) + AND EXISTS ( + SELECT 1 FROM public.answers a3 JOIN public.questions q3 ON q3.id = a3.question_id + WHERE a3.response_id = dr.response_id AND q3.code = '6.1' + AND a3.value #>> '{}' IN ('No, es algo bastante invisible','No, nunca se ha hablado del tema') + ) + GROUP BY jsonb_array_elements_text(a.value) + ORDER BY count(*) DESC LIMIT 1; + END IF; + + RETURN jsonb_build_object( + 'status', 'ok', + 'n_total', v_total, + 'segmentos', jsonb_build_array( + jsonb_build_object( + 'segmento', 'Alto', + 'n', CASE WHEN v_n_alto >= 5 THEN v_n_alto ELSE NULL END, + 'pct', CASE WHEN v_n_alto >= 5 THEN ROUND(v_n_alto::numeric/v_total*100,1) ELSE NULL END, + 'politica', CASE WHEN v_n_alto >= 5 THEN v_pol_alto ELSE NULL END, + 'suppressed', (v_n_alto < 5) + ), + jsonb_build_object( + 'segmento', 'Medio', + 'n', CASE WHEN v_n_medio >= 5 THEN v_n_medio ELSE NULL END, + 'pct', CASE WHEN v_n_medio >= 5 THEN ROUND(v_n_medio::numeric/v_total*100,1) ELSE NULL END, + 'politica', v_pol_medio, + 'suppressed', (v_n_medio < 5) + ), + jsonb_build_object( + 'segmento', 'Bajo', + 'n', CASE WHEN v_n_bajo >= 5 THEN v_n_bajo ELSE NULL END, + 'pct', CASE WHEN v_n_bajo >= 5 THEN ROUND(v_n_bajo::numeric/v_total*100,1) ELSE NULL END, + 'politica', v_pol_bajo, + 'suppressed', (v_n_bajo < 5) + ) + ) + ); +END; +$$; diff --git a/supabase/migrations/20260605093949_rls_deny_sensitive.sql b/supabase/migrations/20260605093949_rls_deny_sensitive.sql new file mode 100644 index 0000000..37ccad5 --- /dev/null +++ b/supabase/migrations/20260605093949_rls_deny_sensitive.sql @@ -0,0 +1,19 @@ +-- ============================================================ +-- Migration 010: RLS deny sensitive questions +-- Niega SELECT sobre answers donde el código de pregunta es 3.4 (salario) +-- o 7.1 (texto libre) a todos los roles no-superuser. +-- Defensa en profundidad: las RPC ya excluyen estos códigos, pero esta +-- política los bloquea también en consultas directas. +-- Los service_role y postgres (superuser) no están afectados (bypasan RLS). +-- ============================================================ +CREATE POLICY "deny_sensitive_answers" + ON public.answers + AS RESTRICTIVE + FOR SELECT + TO anon, authenticated + USING ( + question_id NOT IN ( + SELECT q.id FROM public.questions q + WHERE q.code IN ('3.4', '7.1') + ) + );