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