Etapa completada (local) — widgets de Mirada Empresa

This commit is contained in:
markosbenitez
2026-06-26 11:29:16 -03:00
parent df9b168577
commit 56443a739f
24 changed files with 905 additions and 808 deletions

View File

@@ -4,18 +4,25 @@ import { queryToFilters, filtersToRpc } from '@/lib/dashboard-utils'
// Route Handler server-side: llama a las RPC con el cliente Supabase.
// El service_role nunca llega al browser — vive solo en este servidor.
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen 3.4/7.1 internamente.
// Las RPC son SECURITY DEFINER → aplican k-anonimato y excluyen datos
// sensibles internamente.
//
// Las 9 RPCs v2 (rpc_dashboard_kpis, etc.) se retiraron de este mapa junto
// con sus widgets — referencian códigos de pregunta que no existen en v3
// (Etapa 4H, migración de códigos, queda pendiente aparte). Mirada Empresa
// usa las rpc_me_* nuevas, ya verificadas contra datos reales.
const RPC_MAP: Record<string, string> = {
kpis: 'rpc_dashboard_kpis',
prevalencia: 'rpc_prevalencia_por_depto',
heatmap: 'rpc_heatmap_intensidad',
distribucion: 'rpc_perfil_poblacion',
apoyos: 'rpc_apoyos_demandados',
score: 'rpc_score_organizacional',
piramide: 'rpc_piramide_demografica',
intensidad: 'rpc_intensidad_vs_bienestar',
saturacion: 'rpc_saturacion_cuidador',
'me-prevalencia': 'rpc_me_prevalencia_cuidadores',
'me-riesgo-rotacion': 'rpc_me_riesgo_rotacion',
'me-ausentismo': 'rpc_me_ausentismo',
'me-presentismo': 'rpc_me_presentismo',
'me-punto-ciego': 'rpc_me_punto_ciego',
'me-apoyos': 'rpc_me_apoyos_demandados',
'me-talento-riesgo': 'rpc_me_talento_en_riesgo',
'me-carrera-congelada': 'rpc_me_carrera_congelada',
'me-intensidad-carga': 'rpc_me_intensidad_carga',
'me-segmentacion': 'rpc_me_segmentacion',
'me-disposicion-red': 'rpc_me_disposicion_red',
}
function getSupabaseServer() {
@@ -45,10 +52,18 @@ export async function GET(
const filters = queryToFilters(sp)
const supabase = getSupabaseServer()
const { data, error } = await supabase.rpc(rpcName, {
const rpcParams: Record<string, unknown> = {
p_survey_id: surveyId,
p_filters: filtersToRpc(filters),
})
p_filters: filtersToRpc(filters),
}
// Único caso con parámetro extra: el umbral parametrizable de la estrella.
if (widget === 'me-talento-riesgo') {
const umbral = sp.get('umbral')
if (umbral) rpcParams.p_umbral_senales = parseInt(umbral, 10)
}
const { data, error } = await supabase.rpc(rpcName, rpcParams)
if (error) {
console.error(`[dashboard/${widget}] RPC error:`, error.message)

View File

@@ -1,16 +1,14 @@
'use client'
import { useState } from 'react'
import { useSearchParams } from 'next/navigation'
import { KpiCards } from '@/components/dashboard/kpi-cards'
import { ParaguayMap } from '@/components/dashboard/paraguay-map'
import { DonutCuidados } from '@/components/dashboard/donut-cuidados'
import { GaugeOrganizacional } from '@/components/dashboard/gauge-organizacional'
import { HeatmapWidget } from '@/components/dashboard/heatmap-widget'
import { PiramideDemografica } from '@/components/dashboard/piramide-demografica'
import { ApoyosBar } from '@/components/dashboard/apoyos-bar'
import { IntensidadProductividad } from '@/components/dashboard/intensidad-productividad'
import { TablaSaturacion } from '@/components/dashboard/tabla-saturacion'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import { FilterSidebar } from '@/components/dashboard/filter-sidebar'
import { TalentoRiesgoCard } from '@/components/dashboard/talento-riesgo-card'
import { PrevalenciaCuidadoresCard } from '@/components/dashboard/prevalencia-cuidadores-card'
import { PuntoCiegoCard } from '@/components/dashboard/punto-ciego-card'
import { SenalesImpactoBar } from '@/components/dashboard/senales-impacto-bar'
import { ApoyosBarME } from '@/components/dashboard/apoyos-bar-me'
import { SegmentacionBar } from '@/components/dashboard/segmentacion-bar'
import { DisposicionRedCard } from '@/components/dashboard/disposicion-red-card'
import type { DashboardFilters } from '@/lib/dashboard-types'
// El survey_id se pasa como query param: /dashboard?survey_id=UUID
@@ -22,15 +20,30 @@ const SURVEY_OPTIONS = [
{ label: 'Demo', id: '30000000-0000-0000-0000-000000000001' },
]
type Lens = 'empresa' | 'pais' | 'humana'
const LENSES: { id: Lens; label: string; sub: string }[] = [
{ id: 'empresa', label: 'Mirada Empresa', sub: 'rendimiento · rotación · clima' },
{ id: 'pais', label: 'Mirada País', sub: 'desarrollo · vulnerabilidad · política' },
{ id: 'humana', label: 'Mirada Humana', sub: 'bienestar · derechos · desarrollo' },
]
function PlaceholderLens({ title, hint }: { title: string; hint: string }) {
return (
<div className="db-placeholder" role="status">
<span className="db-placeholder-icon" aria-hidden="true">🔒</span>
<p className="db-placeholder-title">{title} Disponible próximamente</p>
<p className="db-placeholder-hint">{hint}</p>
</div>
)
}
export default function DashboardPage() {
const searchParams = useSearchParams()
const surveyId = searchParams.get('survey_id') ?? '30000000-0000-0000-0000-000000000001'
const [filters, setFilters] = useState<DashboardFilters>({})
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
function handleDeptoClick(depto: string) {
setFilters(f => ({ ...f, depto: depto || undefined }))
}
const [lens, setLens] = useState<Lens>('empresa')
return (
<div className="db-page">
@@ -38,7 +51,7 @@ export default function DashboardPage() {
filters={filters}
onChange={setFilters}
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(c => !c)}
onToggle={() => setSidebarCollapsed((c) => !c)}
/>
<div className="db-main">
@@ -66,34 +79,70 @@ export default function DashboardPage() {
</nav>
</header>
{/* Fila 1: KPIs (span 12) */}
<section className="db-section" aria-label="Indicadores clave">
<KpiCards surveyId={surveyId} filters={filters} />
</section>
<nav className="db-tabs" aria-label="Mirada del dashboard">
{LENSES.map((l) => (
<button
key={l.id}
type="button"
className={`db-tab${lens === l.id ? ' db-tab-active' : ''}`}
onClick={() => setLens(l.id)}
aria-current={lens === l.id ? 'page' : undefined}
>
<span>{l.label}</span>
<span className="db-tab-lens">{l.sub}</span>
</button>
))}
</nav>
{/* Fila 2: Mapa (6) + Donut (3) + Gauge (3) */}
<section className="db-grid db-section" aria-label="Distribución geográfica y tipos">
<ParaguayMap surveyId={surveyId} filters={filters} onDeptoClick={handleDeptoClick} />
<DonutCuidados surveyId={surveyId} filters={filters} />
<GaugeOrganizacional surveyId={surveyId} filters={filters} />
</section>
{lens === 'empresa' && (
<>
<div className="db-lens-intro">
<strong>Mirada Empresa.</strong> Traduce el cuidado a lenguaje de negocio: cuánto cuesta el
punto ciego y qué talento está en riesgo.
</div>
{/* Fila 3: Heatmap (6) + Pirámide (6) */}
<section className="db-grid db-section" aria-label="Intensidad y demografía">
<HeatmapWidget surveyId={surveyId} filters={filters} />
<PiramideDemografica surveyId={surveyId} filters={filters} />
</section>
{/* Fila 1: núcleo — estrella + prevalencia + punto ciego */}
<section className="db-grid db-section" aria-label="Indicadores núcleo">
<TalentoRiesgoCard surveyId={surveyId} filters={filters} />
<PrevalenciaCuidadoresCard surveyId={surveyId} filters={filters} />
<PuntoCiegoCard surveyId={surveyId} filters={filters} />
</section>
{/* Fila 4: Apoyos demandados (6) + Hexbin intensidad/bienestar (6) */}
<section className="db-grid db-section" aria-label="Apoyos e impacto">
<ApoyosBar surveyId={surveyId} filters={filters} />
<IntensidadProductividad surveyId={surveyId} filters={filters} />
</section>
{/* Fila 2: señales de impacto (6) + apoyos demandados (6) */}
<section className="db-grid db-section" aria-label="Señales de impacto y apoyos">
<SenalesImpactoBar surveyId={surveyId} filters={filters} />
<ApoyosBarME surveyId={surveyId} filters={filters} />
</section>
{/* Fila 5: Saturación del cuidador (12) */}
<section className="db-grid db-section" aria-label="Saturación del cuidador">
<TablaSaturacion surveyId={surveyId} filters={filters} />
</section>
{/* Fila 3: segmentación (12, SEC) */}
<section className="db-grid db-section" aria-label="Segmentación de la carga">
<SegmentacionBar surveyId={surveyId} filters={filters} />
</section>
{/* Fila 4: disposición a red de apoyo (4, SEC) */}
<section className="db-grid db-section" aria-label="Disposición a participar">
<DisposicionRedCard surveyId={surveyId} filters={filters} />
</section>
<p className="db-table-note" style={{ textAlign: 'right' }}>
k5 aplicado · segmentos con n&lt;5 suprimidos
</p>
</>
)}
{lens === 'pais' && (
<PlaceholderLens
title="Mirada País"
hint="Insumo para el estudio país y el Sistema Nacional de Cuidados (SINACUP) — desarrollo, vulnerabilidad, impacto económico. En construcción."
/>
)}
{lens === 'humana' && (
<PlaceholderLens
title="Mirada Humana"
hint="Bienestar de quien cuida y de quien es cuidado — agotamiento, soledad del cuidado, disposición solidaria. En construcción."
/>
)}
</div>
</div>
)

View File

@@ -912,6 +912,56 @@ body {
}
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
/* ── Tres miradas: tabs ── */
.db-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid rgba(67,187,200,0.15); }
.db-tab {
padding: 10px 18px; font-size: 0.8125rem; font-weight: 600; color: #9ca3af;
cursor: pointer; border: none; background: none; border-bottom: 2px solid transparent;
display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
}
.db-tab:hover { color: #E7E6E5; }
.db-tab-active { color: var(--color-re-teal); border-bottom-color: var(--color-re-teal); }
.db-tab-lens { font-size: 0.6875rem; color: #5e6f8c; font-weight: 400; }
/* ── Tres miradas: intro de cada lente ── */
.db-lens-intro {
background: #101a30; border-left: 3px solid var(--color-re-teal);
border-radius: 0 8px 8px 0; padding: 10px 14px; margin-bottom: 16px;
font-size: 0.8125rem; color: #9fb0cc; line-height: 1.5;
}
/* ── Card destacada (estrella) ── */
.db-widget-star {
border-color: var(--color-re-teal);
background: linear-gradient(135deg, #0e1b2e, #10243000);
}
.db-widget-star .db-widget-title { color: var(--color-re-teal); }
/* ── Big stat (KPI de una sola cifra, dentro de WidgetShell) ── */
.db-big-stat { font-size: 2.375rem; font-weight: 700; color: var(--color-re-teal); line-height: 1; }
.db-big-stat-warn { color: #f0b429; }
.db-big-stat-danger { color: #f06b6b; }
.db-big-stat-sub { font-size: 0.75rem; color: #7d8da5; margin-top: 6px; line-height: 1.4; }
/* ── Punto ciego: contraste de dos barras ── */
.db-gap-viz { display: flex; align-items: flex-end; gap: 24px; justify-content: center; padding: 8px 0 4px; }
.db-gap-col { text-align: center; }
.db-gap-bar { width: 56px; border-radius: 6px 6px 0 0; margin: 0 auto; transition: height 0.4s; }
.db-gap-val { font-size: 0.8125rem; font-weight: 700; margin-bottom: 4px; }
.db-gap-lab { font-size: 0.6875rem; color: #9fb0cc; margin-top: 6px; }
.db-gap-brecha { text-align: center; font-size: 0.75rem; color: #f0b429; margin-top: 10px; }
/* ── Placeholder (Mirada País / Humana, "próximamente") ── */
.db-placeholder {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; min-height: 220px; text-align: center; color: #7d8da5;
background: #101a30; border: 1px dashed rgba(67,187,200,0.25); border-radius: 12px;
padding: 32px 24px;
}
.db-placeholder-icon { font-size: 1.75rem; }
.db-placeholder-title { font-size: 1rem; font-weight: 600; color: #E7E6E5; }
.db-placeholder-hint { font-size: 0.8125rem; max-width: 420px; line-height: 1.5; }
/* ── Admin: control de respuestas (Etapa 3V) ── */
.admin-page { max-width: 1100px; margin: 0 auto; padding: 24px; }
.admin-header {