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; }
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
DASHBOARD — tema oscuro premium
|
||||
Fondo: #0F1923 · Widgets: #1A2535 · Teal: #43BBC8
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
.db-page {
|
||||
display: flex; min-height: 100vh;
|
||||
background: #0F1923; color: #E7E6E5;
|
||||
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||
}
|
||||
.db-main { flex: 1; padding: 24px; overflow-x: hidden; }
|
||||
.db-page-header { margin-bottom: 20px; }
|
||||
.db-page-title { font-size: 1.375rem; font-weight: 700; color: #fff; margin: 0 0 4px; }
|
||||
.db-page-subtitle { font-size: 0.8125rem; color: #9ca3af; margin: 0; }
|
||||
.db-active-filter { color: var(--color-re-teal); font-weight: 600; }
|
||||
.db-section { margin-bottom: 16px; }
|
||||
.db-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Widget shell ── */
|
||||
.db-widget {
|
||||
background: #1A2535;
|
||||
border: 1px solid rgba(67,187,200,0.2);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.db-widget:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
|
||||
.db-widget-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 12px 16px 8px;
|
||||
}
|
||||
.db-widget-title { font-size: 0.875rem; font-weight: 600; color: #E7E6E5; margin: 0; }
|
||||
.db-widget-n { font-size: 0.75rem; color: #9ca3af; }
|
||||
.db-widget-body { padding: 0 16px 16px; }
|
||||
|
||||
/* ── Suppressed / loading states ── */
|
||||
.db-suppressed {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: 100px; text-align: center; color: #9ca3af; gap: 6px;
|
||||
}
|
||||
.db-suppressed-icon { font-size: 1.5rem; }
|
||||
.db-suppressed-hint { font-size: 0.75rem; color: #6b7280; }
|
||||
.db-suppressed-card {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 4px; color: #6b7280;
|
||||
}
|
||||
.db-skeleton-block { background: #1e2d42; border-radius: 6px; animation: pulse 1.4s ease-in-out infinite; }
|
||||
.db-error { color: #ef4444; font-size: 0.875rem; padding: 12px; }
|
||||
.db-loading-page { padding: 24px; background: #0F1923; min-height: 100vh; }
|
||||
|
||||
/* ── KPI row ── */
|
||||
.db-kpi-row {
|
||||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
.db-kpi-card {
|
||||
background: #1A2535; border: 1px solid rgba(67,187,200,0.2);
|
||||
border-radius: 10px; padding: 16px; text-align: center;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
transition: box-shadow 0.3s;
|
||||
}
|
||||
.db-kpi-card:hover { box-shadow: 0 0 20px rgba(67,187,200,0.18); }
|
||||
.db-kpi-value {
|
||||
font-size: 2rem; font-weight: 700; color: var(--color-re-teal);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.db-kpi-label { font-size: 0.8125rem; color: #E7E6E5; }
|
||||
.db-kpi-sub { font-size: 0.75rem; color: #9ca3af; }
|
||||
|
||||
/* ── Mapa tile ── */
|
||||
.db-map-wrapper { position: relative; }
|
||||
.db-map-tooltip {
|
||||
position: absolute; top: -28px; left: 50%; transform: translateX(-50%);
|
||||
background: #0F1923; color: #E7E6E5; font-size: 0.75rem;
|
||||
padding: 4px 10px; border-radius: 6px; border: 1px solid rgba(67,187,200,0.3);
|
||||
white-space: nowrap; z-index: 10; pointer-events: none;
|
||||
}
|
||||
.db-tile-grid {
|
||||
display: grid; grid-template-columns: repeat(5, 1fr); gap: 3px;
|
||||
grid-template-rows: repeat(6, 1fr); min-height: 220px;
|
||||
}
|
||||
.db-tile {
|
||||
border: none; border-radius: 4px; cursor: pointer; padding: 4px 2px;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
transition: filter 0.15s, outline 0.15s;
|
||||
min-height: 36px;
|
||||
}
|
||||
.db-tile:hover { filter: brightness(1.2); }
|
||||
.db-tile:focus-visible { outline: 2px solid var(--color-re-teal); outline-offset: 1px; }
|
||||
.db-tile-selected { outline: 2px solid #fff; }
|
||||
.db-tile-suppressed { background-image: repeating-linear-gradient(
|
||||
45deg, transparent, transparent 4px, rgba(255,255,255,0.06) 4px, rgba(255,255,255,0.06) 8px
|
||||
) !important; }
|
||||
.db-tile-name { font-size: 0.5rem; color: #fff; text-align: center; line-height: 1.2; font-weight: 600; }
|
||||
.db-tile-pct { font-size: 0.6rem; color: rgba(255,255,255,0.85); font-weight: 700; }
|
||||
|
||||
/* ── Donut ── */
|
||||
.db-donut-wrapper { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.db-donut-svg { width: 130px; height: 130px; flex-shrink: 0; }
|
||||
.db-donut-legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.db-legend-item { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; }
|
||||
.db-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.db-legend-val { margin-left: auto; font-weight: 600; color: var(--color-re-teal); }
|
||||
|
||||
/* ── Gauge ── */
|
||||
.db-gauge-wrapper { text-align: center; }
|
||||
.db-gauge-svg { width: 100%; max-width: 160px; }
|
||||
.db-gauge-zona { font-size: 0.9rem; font-weight: 700; margin: 4px 0 2px; }
|
||||
.db-gauge-hint { font-size: 0.7rem; color: #6b7280; margin: 0; }
|
||||
|
||||
/* ── Bar chart ── */
|
||||
.db-bar-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
||||
.db-bar-item { display: flex; align-items: center; gap: 8px; }
|
||||
.db-bar-label { font-size: 0.75rem; color: #E7E6E5; width: 180px; flex-shrink: 0; line-height: 1.3; }
|
||||
.db-bar-track { flex: 1; background: #2a3547; border-radius: 4px; height: 14px; overflow: hidden; }
|
||||
.db-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease; }
|
||||
.db-bar-pct { font-size: 0.75rem; color: var(--color-re-teal); width: 40px; text-align: right; font-weight: 600; }
|
||||
|
||||
/* ── Heatmap / hexbin shared ── */
|
||||
.db-heatmap-scroll { overflow-x: auto; }
|
||||
.db-heatmap-grid { display: grid; gap: 2px; }
|
||||
.db-heatmap-header {
|
||||
font-size: 0.625rem; color: #9ca3af; text-align: center; padding: 2px;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.db-heatmap-rowlabel { font-size: 0.625rem; color: #9ca3af; padding: 2px 4px; line-height: 1.2; }
|
||||
.db-heatmap-cell {
|
||||
border-radius: 3px; min-height: 32px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 0.7rem; color: rgba(255,255,255,0.8); transition: filter 0.15s;
|
||||
}
|
||||
.db-heatmap-cell:hover { filter: brightness(1.2); }
|
||||
.db-hexbin-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 8px; }
|
||||
|
||||
/* ── Pirámide ── */
|
||||
.db-piramide { display: flex; flex-direction: column; gap: 6px; }
|
||||
.db-piramide-legend { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.db-piramide-row { display: flex; align-items: center; gap: 6px; }
|
||||
.db-piramide-label { font-size: 0.7rem; color: #9ca3af; width: 90px; flex-shrink: 0; text-align: right; }
|
||||
.db-piramide-bars { flex: 1; display: flex; gap: 2px; }
|
||||
.db-piramide-bar-wrap { display: flex; flex-direction: column; }
|
||||
.db-piramide-bar { height: 20px; border-radius: 3px; min-width: 4px; transition: width 0.4s; }
|
||||
.db-piramide-n { font-size: 0.7rem; color: #9ca3af; width: 24px; text-align: right; }
|
||||
|
||||
/* ── Tabla riesgo ── */
|
||||
.db-table-wrapper { overflow-x: auto; }
|
||||
.db-table-note { font-size: 0.75rem; color: #9ca3af; margin-bottom: 10px; }
|
||||
.db-table { width: 100%; border-collapse: collapse; font-size: 0.8125rem; }
|
||||
.db-table th { color: #9ca3af; font-weight: 600; text-align: left; padding: 8px 12px; border-bottom: 1px solid #2a3547; }
|
||||
.db-table td { padding: 10px 12px; border-bottom: 1px solid #1e2d42; color: #E7E6E5; }
|
||||
.db-table tr:last-child td { border-bottom: none; }
|
||||
.db-risk-badge { display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 700; color: #fff; }
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.db-sidebar {
|
||||
width: 220px; flex-shrink: 0; background: #14202e;
|
||||
border-right: 1px solid rgba(67,187,200,0.15);
|
||||
padding: 12px; transition: width 0.3s ease;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.db-sidebar-collapsed { width: 52px; }
|
||||
.db-sidebar-toggle {
|
||||
background: none; border: 1px solid rgba(67,187,200,0.3);
|
||||
color: var(--color-re-teal); border-radius: 6px; padding: 6px;
|
||||
cursor: pointer; font-size: 0.75rem; white-space: nowrap;
|
||||
}
|
||||
.db-sidebar-content { display: flex; flex-direction: column; gap: 16px; overflow-y: auto; }
|
||||
.db-filter-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.db-filter-label { font-size: 0.75rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.db-filter-select {
|
||||
background: #1e2d42; border: 1px solid #2a3547; color: #E7E6E5;
|
||||
border-radius: 6px; padding: 6px 8px; font-size: 0.8125rem; width: 100%;
|
||||
}
|
||||
.db-filter-check { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: #E7E6E5; cursor: pointer; }
|
||||
.db-filter-check input { accent-color: var(--color-re-teal); width: 14px; height: 14px; }
|
||||
.db-filter-reset {
|
||||
background: rgba(67,187,200,0.1); border: 1px solid rgba(67,187,200,0.3);
|
||||
color: var(--color-re-teal); border-radius: 6px; padding: 8px;
|
||||
cursor: pointer; font-size: 0.8125rem; font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.db-filter-reset:hover { background: rgba(67,187,200,0.2); }
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 768px) {
|
||||
.db-kpi-row { grid-template-columns: repeat(2,1fr); }
|
||||
.db-sidebar { width: 100%; border-right: none; border-bottom: 1px solid rgba(67,187,200,0.15); }
|
||||
.db-page { flex-direction: column; }
|
||||
}
|
||||
|
||||
/* ── Reduced motion ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||
|
||||
Reference in New Issue
Block a user