Con marca y dashboards

This commit is contained in:
markosbenitez
2026-06-05 07:18:10 -03:00
parent b813622e30
commit a17e810ea8
22 changed files with 2182 additions and 0 deletions

View File

@@ -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<string, string> = {
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)
}

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

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

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

@@ -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<DashboardFilters>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
function handleDeptoClick(depto: string) {
setFilters(f => ({ ...f, depto: depto || undefined }))
}
return (
<div className="db-page">
<FilterSidebar
filters={filters}
onChange={setFilters}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(c => !c)}
/>
<div className="db-main">
<header className="db-page-header">
<h1 className="db-page-title">Economía del Cuidado Paraguay</h1>
<p className="db-page-subtitle">
Datos agregados · k-anonimato k5 · Encuesta Mapeo de Cuidadores 2026
{filters.depto && <span className="db-active-filter"> · Filtro: {filters.depto}</span>}
</p>
</header>
{/* Fila 1: KPIs (span 12) */}
<section className="db-section" aria-label="Indicadores clave">
<KpiCards surveyId={surveyId} filters={filters} />
</section>
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */}
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos">
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} />
<DonutCuidados surveyId={surveyId} filters={filters} />
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
</section>
{/* Fila 3: Heatmap (6) + Pirámide (6) */}
<section className="db-grid db-section" aria-label="Intensidad y demografía">
<HeatmapWidget surveyId={surveyId} filters={filters} />
<PiramideDemografica surveyId={surveyId} filters={filters} />
</section>
{/* Fila 4: Barras políticas (6) + Hexbin intensidad/bienestar (6) */}
<section className="db-grid db-section" aria-label="Políticas e impacto">
<PoliticasBar surveyId={surveyId} filters={filters} />
<IntensidadProductividad surveyId={surveyId} filters={filters} />
</section>
{/* Fila 5: Tabla riesgo (12) */}
<section className="db-grid db-section" aria-label="Riesgo de retención">
<TablaRiesgo surveyId={surveyId} filters={filters} />
</section>
</div>
</div>
)
}

View File

@@ -543,6 +543,198 @@ body {
} }
.survey-footer-logo { height: 32px; width: auto; display: block; } .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 ── */ /* ── Reduced motion ── */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }

BIN
assets/.DS_Store vendored

Binary file not shown.

View File

@@ -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<DistribucionData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.distribucion(surveyId, filters)
.then(d => {
if (isSuppressed(d)) setSup(true)
else setData(d as DistribucionData)
}).catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
// SVG donut
const R = 60; const cx = 75; const cy = 75; const stroke = 22
let offset = 0
const circumference = 2 * Math.PI * R
const arcs = data ? SEGMENTS.map(s => {
const pct = (data[s.key as keyof DistribucionData] as number) ?? 0
const dash = (pct / 100) * circumference
const gap = circumference - dash
const arc = { ...s, dash, gap, offset: circumference * (1 - offset / 100) }
offset += pct
return arc
}) : []
return (
<WidgetShell title="🍩 Tipo de cuidado" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-donut-wrapper">
<svg viewBox="0 0 150 150" className="db-donut-svg" aria-hidden="true">
{arcs.map(arc => (
<circle key={arc.key}
cx={cx} cy={cy} r={R}
fill="none"
stroke={arc.color}
strokeWidth={stroke}
strokeDasharray={`${arc.dash} ${arc.gap}`}
strokeDashoffset={arc.offset}
transform="rotate(-90, 75, 75)"
/>
))}
<text x={cx} y={cy-6} textAnchor="middle" fill="white" fontSize="14" fontWeight="bold">
{data.disc_propia + data.familiar + data.adulto_mayor > 0
? `${Math.round(data.disc_propia + data.familiar + data.adulto_mayor)}%`
: '0%'}
</text>
<text x={cx} y={cy+12} textAnchor="middle" fill="#9ca3af" fontSize="8">cuidadores</text>
</svg>
<ul className="db-donut-legend" aria-label="Leyenda del donut">
{SEGMENTS.map(s => (
<li key={s.key} className="db-legend-item">
<span className="db-legend-dot" style={{ background: s.color }} />
<span>{s.label}</span>
<span className="db-legend-val">
{(data[s.key as keyof DistribucionData] as number ?? 0)}%
</span>
</li>
))}
</ul>
</div>
)}
</WidgetShell>
)
}

View File

@@ -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 (
<aside className={`db-sidebar${collapsed ? ' db-sidebar-collapsed' : ''}`}
aria-label="Filtros del dashboard">
<button className="db-sidebar-toggle" onClick={onToggle}
aria-expanded={!collapsed} aria-controls="db-sidebar-content">
{collapsed ? '▶ Filtros' : '◀ Cerrar'}
</button>
{!collapsed && (
<div id="db-sidebar-content" className="db-sidebar-content">
<div className="db-filter-group">
<label className="db-filter-label" htmlFor="filter-depto">Departamento</label>
<select id="filter-depto" className="db-filter-select"
value={filters.depto ?? ''}
onChange={e => onChange({ ...filters, depto: e.target.value || undefined })}>
<option value="">Todos</option>
{DEPARTMENTS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
<div className="db-filter-group">
<span className="db-filter-label">Género</span>
{GENEROS.map(g => (
<label key={g} className="db-filter-check">
<input type="checkbox"
checked={(filters.genero ?? []).includes(g)}
onChange={() => toggleArray('genero', g)} />
{g}
</label>
))}
</div>
<div className="db-filter-group">
<span className="db-filter-label">Rango etario</span>
{ETARIO_ORDER.filter(e => e !== 'Prefiero no decir').map(e => (
<label key={e} className="db-filter-check">
<input type="checkbox"
checked={(filters.etario ?? []).includes(e)}
onChange={() => toggleArray('etario', e)} />
{e}
</label>
))}
</div>
<div className="db-filter-group">
<span className="db-filter-label">Modalidad</span>
{MODALIDADES.map(m => (
<label key={m} className="db-filter-check">
<input type="checkbox"
checked={(filters.modalidad ?? []).includes(m)}
onChange={() => toggleArray('modalidad', m)} />
{m}
</label>
))}
</div>
<button className="db-filter-reset" onClick={reset}>Resetear filtros</button>
</div>
)}
</aside>
)
}

View File

@@ -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<ScoreData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.score(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as ScoreData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
// SVG semicircle gauge
const score = data?.score ?? 0
const angle = (score / 100) * 180 // 0° = left, 180° = right
const rad = (angle - 90) * Math.PI / 180
const r = 60; const cx = 75; const cy = 80
const needleX = cx + r * Math.cos(rad)
const needleY = cy + r * Math.sin(rad)
const color = data ? ZONA_COLOR[data.zona] : '#43BBC8'
return (
<WidgetShell title="🎯 Madurez organizacional" status={status} n={data?.n} colSpan={3}>
{data && (
<div className="db-gauge-wrapper">
<svg viewBox="0 0 150 100" className="db-gauge-svg" aria-label={`Score: ${score}/100`}>
{/* Fondo semicírculo */}
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none" stroke="#2a3547" strokeWidth="18" strokeLinecap="round" />
{/* Arco coloreado proporcional al score */}
<path d="M 15 80 A 60 60 0 0 1 135 80" fill="none"
stroke={color} strokeWidth="18" strokeLinecap="round"
strokeDasharray={`${(score/100)*188.5} 188.5`} />
{/* Aguja */}
<line x1={cx} y1={cy} x2={needleX} y2={needleY} stroke="white" strokeWidth="2" strokeLinecap="round" />
<circle cx={cx} cy={cy} r={4} fill="white" />
{/* Score */}
<text x={cx} y={cy-10} textAnchor="middle" fill="white" fontSize="18" fontWeight="bold">{score}</text>
<text x={cx} y={cy+6} textAnchor="middle" fill="#9ca3af" fontSize="7">/ 100</text>
</svg>
<p className="db-gauge-zona" style={{ color }}>
{ZONA_LABEL[data.zona]}
</p>
<p className="db-gauge-hint">
033 baja · 3466 desarrollo · 67100 consciente
</p>
</div>
)}
</WidgetShell>
)
}

View File

@@ -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<HeatmapData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.heatmap(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as HeatmapData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
function cellColor(n: number | null): string {
if (n === null) return '#1e2d42'
const max = Math.max(...(data?.data.map(c => c.n ?? 0) ?? [1]), 1)
const t = n / max
return `rgba(67, 187, 200, ${0.15 + t * 0.85})`
}
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.ausencias}`, c]))
return (
<WidgetShell title="🔥 Horas de cuidado × Ausencias laborales" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-heatmap-scroll" role="table" aria-label="Heatmap de intensidad">
<div className="db-heatmap-grid"
style={{ gridTemplateColumns: `120px repeat(${AUSENCIAS_ORDER.length}, 1fr)` }}>
{/* Header row */}
<div role="columnheader" />
{SHORT_AUSENCIAS.map(h => (
<div key={h} className="db-heatmap-header" role="columnheader">{h}</div>
))}
{/* Data rows */}
{HORAS_ORDER.map(horas => (
<>
<div key={`row-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
{AUSENCIAS_ORDER.map(aus => {
const cell = cellMap.get(`${horas}|${aus}`)
const n = cell?.n ?? null
const sup2 = cell?.suppressed ?? (!cell)
return (
<div key={`${horas}|${aus}`}
className="db-heatmap-cell"
style={{ background: cellColor(n) }}
role="cell"
aria-label={`${horas} / ${aus}: ${sup2 ? 'suprimido' : `n=${n}`}`}
title={sup2 ? 'Datos insuficientes' : `n = ${n}`}>
{!sup2 && n !== null && <span>{n}</span>}
</div>
)
})}
</>
))}
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -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<IntensidadData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.intensidad(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as IntensidadData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
const cellMap = new Map((data?.data ?? []).map(c => [`${c.horas}|${c.bienestar}`, c]))
function cellColor(n: number | null): string {
if (n === null) return '#1e2d42'
const t = n / maxN
return `rgba(67, 187, 200, ${0.1 + t * 0.9})`
}
return (
<WidgetShell title="💸 Intensidad de cuidado vs. Bienestar (bins N≥5)" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-hexbin-wrapper">
<p className="db-hexbin-note">
Cada celda = respondentes agregados. Celdas sin color: N &lt; 5 (suprimido).
</p>
<div className="db-heatmap-scroll">
<div className="db-heatmap-grid"
style={{ gridTemplateColumns: `120px repeat(${BIENESTAR_BINS.length}, 1fr)` }}>
<div role="columnheader" />
{BIENESTAR_BINS.map(b => (
<div key={b} className="db-heatmap-header" role="columnheader">Bienestar {b}</div>
))}
{HORAS_ORDER.map(horas => (
<>
<div key={`r-${horas}`} className="db-heatmap-rowlabel" role="rowheader">{horas}</div>
{BIENESTAR_BINS.map(bien => {
const cell = cellMap.get(`${horas}|${bien}`)
const n = cell?.n ?? null
const suppressed = cell?.suppressed ?? true
return (
<div key={`${horas}|${bien}`}
className="db-heatmap-cell"
style={{ background: cellColor(n) }}
role="cell"
title={suppressed ? 'Datos insuficientes' : `n = ${n}`}
aria-label={suppressed ? 'suprimido' : `n=${n}`}>
{!suppressed && n !== null && <span>{n}</span>}
</div>
)
})}
</>
))}
</div>
</div>
</div>
)}
</WidgetShell>
)
}

View File

@@ -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 (
<div className="db-kpi-card">
<span className="db-kpi-value" aria-live="polite">{value}</span>
<span className="db-kpi-label">{label}</span>
{sub && <span className="db-kpi-sub">{sub}</span>}
</div>
)
}
interface Props { surveyId: string; filters: DashboardFilters }
export function KpiCards({ surveyId, filters }: Props) {
const [data, setData] = useState<KpiData | SuppressedPayload | null>(null)
const [error, setError] = useState(false)
useEffect(() => {
setData(null); setError(false)
queries.kpis(surveyId, filters)
.then(d => setData(d as KpiData | SuppressedPayload))
.catch(() => setError(true))
}, [surveyId, JSON.stringify(filters)])
if (error) return <p className="db-error">Error al cargar KPIs</p>
if (!data) {
return (
<div className="db-kpi-row">
{[1,2,3,4].map(i => <div key={i} className="db-kpi-card db-skeleton-block" style={{height:96}} />)}
</div>
)
}
if (isSuppressed(data)) {
return (
<div className="db-kpi-row">
{[1,2,3,4].map(i => (
<div key={i} className="db-kpi-card db-suppressed-card">
<span className="db-suppressed-icon">🔒</span>
<span className="db-kpi-sub">n &lt; 5</span>
</div>
))}
</div>
)
}
const d = data as KpiData
return (
<div className="db-kpi-row">
<KpiCard label="Cuidadores activos" value={`${d.pct_cuidadores}%`} sub={`n = ${d.n}`} />
<KpiCard label="Score bienestar (0-100)" value={d.score_bienestar !== null ? `${d.score_bienestar}` : '—'} />
<KpiCard label="Ausencias frecuentes" value={`${d.pct_ausencias}%`} />
<KpiCard label="Total respuestas" value={d.n.toLocaleString('es-PY')} sub="en este corte" />
</div>
)
}

View File

@@ -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<PrevalenciaData | SuppressedPayload | null>(null)
const [tooltip, setTooltip] = useState<{ name: string; pct: number | null; n: number | null } | null>(null)
useEffect(() => {
setData(null)
queries.prevalencia(surveyId, filters)
.then(d => setData(d as PrevalenciaData | SuppressedPayload))
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const deptoMap = new Map(
(!data || isSuppressed(data)) ? [] :
(data as PrevalenciaData).data.map(d => [d.depto, d])
)
const status = !data ? 'loading' : isSuppressed(data) ? 'suppressed' : 'ok'
const n = !isSuppressed(data) && data ? (data as PrevalenciaData).n_total : undefined
return (
<WidgetShell title="🗺️ Prevalencia de cuidadores por departamento" status={status} n={n} colSpan={6}>
<div className="db-map-wrapper" role="img" aria-label="Mapa de Paraguay con prevalencia de cuidadores por departamento">
{tooltip && (
<div className="db-map-tooltip" aria-live="polite">
<strong>{tooltip.name}</strong>
{tooltip.pct !== null
? <> · {tooltip.pct}% · n={tooltip.n}</>
: <> · Datos insuficientes</>}
</div>
)}
<div className="db-tile-grid">
{TILES.map(tile => {
const d = deptoMap.get(tile.name)
const suppressed = !d || d.suppressed
const pct = d?.pct_cuidadores ?? null
const color = suppressed ? '#2a3547' : lerp((pct ?? 0)/100, '#E7E6E5', '#43BBC8')
const isSelected = filters.depto === tile.name
return (
<button
key={tile.name}
className={`db-tile${isSelected ? ' db-tile-selected' : ''}${suppressed ? ' db-tile-suppressed' : ''}`}
style={{
gridColumn: tile.col + 1,
gridRow: tile.row + 1,
background: color,
}}
onClick={() => onDeptoClick(isSelected ? '' : tile.name)}
onMouseEnter={() => setTooltip({ name: tile.name, pct, n: d?.n ?? null })}
onMouseLeave={() => setTooltip(null)}
title={suppressed ? `${tile.name} — Datos insuficientes` : `${tile.name}${pct}% (n=${d?.n})`}
aria-label={suppressed
? `${tile.name}: datos insuficientes`
: `${tile.name}: ${pct}% cuidadores, n=${d?.n}`}
aria-pressed={isSelected}
>
<span className="db-tile-name">{tile.name.replace(' (Capital)','')}</span>
{!suppressed && pct !== null && (
<span className="db-tile-pct">{pct}%</span>
)}
</button>
)
})}
</div>
</div>
</WidgetShell>
)
}

View File

@@ -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<string, string> = {
'Femenino': '#EE761A',
'Masculino': '#43BBC8',
'No binario/Otro': '#F8AD13',
'Prefiero no decir':'#6b7280',
}
export function PiramideDemografica({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<PiramideData | null>(null)
const [sup, setSup] = useState(false)
useEffect(() => {
setData(null); setSup(false)
queries.piramide(surveyId, filters)
.then(d => { if (isSuppressed(d)) setSup(true); else setData(d as PiramideData) })
.catch(() => {})
}, [surveyId, JSON.stringify(filters)])
const status = !data && !sup ? 'loading' : sup ? 'suppressed' : 'ok'
const maxN = data ? Math.max(...data.data.map(c => c.n ?? 0), 1) : 1
const generos = ['Femenino','Masculino','No binario/Otro']
return (
<WidgetShell title="👥 Pirámide demográfica" status={status} n={data?.n_total} colSpan={6}>
{data && (
<div className="db-piramide">
<div className="db-piramide-legend" aria-label="Leyenda">
{generos.map(g => (
<span key={g} className="db-legend-item">
<span className="db-legend-dot" style={{ background: GENERO_COLOR[g] }} />
{g}
</span>
))}
</div>
{ETARIO_ORDER.filter(e => e !== 'Prefiero no decir').map(etario => {
const row = generos.map(gen => {
const cell = data.data.find(c => c.etario === etario && c.genero === gen)
return { gen, n: cell?.n ?? null, suppressed: cell?.suppressed ?? true }
})
return (
<div key={etario} className="db-piramide-row" role="row" aria-label={etario}>
<span className="db-piramide-label">{etario}</span>
<div className="db-piramide-bars">
{row.map(({ gen, n, suppressed }) => (
<div key={gen} className="db-piramide-bar-wrap"
style={{ flex: `0 0 ${Math.max(10, (n ?? 0) / maxN * 100)}%` }}>
<div className="db-piramide-bar"
style={{
width: suppressed ? '100%' : `${(n ?? 0) / maxN * 100}%`,
background: suppressed ? '#2a3547' : GENERO_COLOR[gen],
opacity: suppressed ? 0.4 : 1,
}}
title={suppressed ? 'Datos insuficientes' : `${gen}: n=${n}`}
role="cell"
aria-label={suppressed ? `${gen}: suprimido` : `${gen}: ${n}`}
/>
</div>
))}
</div>
<span className="db-piramide-n">
{row.every(r => r.suppressed) ? '🔒' : row.reduce((s, r) => s + (r.n ?? 0), 0)}
</span>
</div>
)
})}
</div>
)}
</WidgetShell>
)
}

View File

@@ -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<PoliticasData | null>(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 (
<WidgetShell title="📈 Políticas más demandadas" status={status} n={data?.n_total} colSpan={6}>
{data && (
<ul className="db-bar-list" aria-label="Políticas demandadas">
{data.data.length === 0 && (
<li className="db-suppressed-hint">No hay datos suficientes por política (n&lt;5 en todas).</li>
)}
{data.data.map(item => (
<li key={item.politica} className="db-bar-item">
<span className="db-bar-label">{item.politica}</span>
<div className="db-bar-track" aria-hidden="true">
<div className="db-bar-fill"
style={{ width: `${item.pct}%`, background: '#43BBC8' }} />
</div>
<span className="db-bar-pct">{item.pct}%</span>
</li>
))}
</ul>
)}
</WidgetShell>
)
}

View File

@@ -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<string, string> = {
Alto: '#ef4444',
Medio: '#F8AD13',
Bajo: '#43BBC8',
}
export function TablaRiesgo({ surveyId, filters }: { surveyId: string; filters: DashboardFilters }) {
const [data, setData] = useState<RiesgoData | null>(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 (
<WidgetShell title="📋 Tabla de riesgo de retención" status={status} n={data?.n_total} colSpan={12}>
{data && (
<div className="db-table-wrapper">
<p className="db-table-note">
Alto: ausencias frecuentes + empresa no comprende. Medio: una condición. Bajo: ninguna.
La columna de departamento fue eliminada para evitar cruces reidentificables.
</p>
<table className="db-table" aria-label="Tabla de riesgo de retención">
<thead>
<tr>
<th>Segmento</th>
<th>N</th>
<th>% del total</th>
<th>Política más demandada</th>
</tr>
</thead>
<tbody>
{data.segmentos.map(seg => (
<tr key={seg.segmento}>
<td>
<span className="db-risk-badge"
style={{ background: RIESGO_COLOR[seg.segmento] ?? '#6b7280' }}>
{seg.segmento}
</span>
</td>
<td>{seg.suppressed ? '—' : seg.n}</td>
<td>{seg.suppressed ? '—' : `${seg.pct}%`}</td>
<td>{seg.suppressed ? <span className="db-suppressed-hint">Datos insuficientes (n&lt;5)</span>
: seg.politica ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</WidgetShell>
)
}

View File

@@ -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 (
<div className={`db-widget db-col-${colSpan}`} style={{ gridColumn: `span ${colSpan}` }}>
<div className="db-widget-header">
<h3 className="db-widget-title">{title}</h3>
{n !== undefined && status === 'ok' && (
<span className="db-widget-n">n = {n.toLocaleString('es-PY')}</span>
)}
</div>
<div className="db-widget-body">
{status === 'loading' && (
<div className="db-skeleton-block" aria-label="Cargando…" aria-busy="true" />
)}
{status === 'suppressed' && (
<div className="db-suppressed" role="status">
<span className="db-suppressed-icon">🔒</span>
<p>Datos insuficientes</p>
<p className="db-suppressed-hint">Se necesitan al menos 5 respuestas en este corte.</p>
</div>
)}
{status === 'error' && (
<div className="db-suppressed" role="alert">
<p>Error al cargar los datos.</p>
</div>
)}
{status === 'ok' && children}
</div>
</div>
)
}

24
lib/dashboard-queries.ts Normal file
View File

@@ -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<T>(widget: string, surveyId: string, filters: DashboardFilters): Promise<T> {
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),
}

57
lib/dashboard-types.ts Normal file
View File

@@ -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> = T | SuppressedPayload
export function isSuppressed(r: unknown): r is SuppressedPayload {
return typeof r === 'object' && r !== null && (r as Record<string, unknown>)['status'] === 'suppressed'
}

50
lib/dashboard-utils.ts Normal file
View File

@@ -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<string, unknown> {
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 (13 veces al año)','Varias veces (410 veces al año)','Con frecuencia (más de 10 veces al año)']
export const ETARIO_ORDER = ['Menos de 25 años','25 a 34 años','35 a 44 años','45 a 54 años','55 años o más','Prefiero no decir']
export function sortByOrder<T extends Record<string, unknown>>(
arr: T[],
key: keyof T,
order: string[]
): T[] {
return [...arr].sort((a, b) => {
const ia = order.indexOf(a[key] as string)
const ib = order.indexOf(b[key] as string)
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
})
}

View 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]
```

View File

@@ -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;
$$;

View File

@@ -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')
)
);