Rafael Lopez

ubigeo

......@@ -24,6 +24,10 @@
// Navegación por teclado
let selectedResultIndex = -1;
let expandedGroups = [];
let collapsedGroups = [];
let scrollSentinel;
let scrollObserver;
// Tracking para evitar re-animaciones
let seenHitIds = new Set();
......@@ -103,6 +107,20 @@
geografico: 'Ubicación geográfica',
acteco: 'Sectores Económicos',
sectores: 'Sectores Económicos',
otros: 'Programas y/o Proyectos',
programa_proyecto: 'Programas y/o Proyectos',
undefined: 'Programas y/o Proyectos',
};
const CLASS_EASY_LABELS = {
entidad: 'Quién',
objeto: 'En qué se gasta',
finfun: 'Para qué se gasta',
ubigeo: 'Dónde se gasta',
acteco: 'En qué sectores',
rubro: 'Con qué recursos',
organismo: 'Quién financia',
fuente: 'Origen del ingreso',
};
// Extraer código de finfun desde metadatos (formato con puntos: 1, 1.1, 1.1.3)
......@@ -132,8 +150,8 @@
$: groupedHits = (() => {
if (filteredHits.length === 0) return null;
// Búsqueda mixta: agrupar por tipo de clasificador
if (isMixedClassSearch) {
// Búsqueda mixta o global: agrupar por tipo
if (isMixedClassSearch || searchMode === 'todo') {
// Filtrar duplicados de finfun
const filtered = filteredHits.filter(hit => {
if (hit.document.class_ !== 'finfun') return true;
......@@ -160,12 +178,9 @@
});
});
// Menos hits primero (clasificadores arriba, programas abajo)
const sortedGroups = Object.values(groups);
if (sortBy === 'alpha') {
sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
} else {
sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
}
sortedGroups.sort((a, b) => a.hits.length - b.hits.length);
return sortedGroups;
}
......@@ -287,9 +302,9 @@
const CLASIFICADORES = [
{ id: 'entidad', label: '¿Quién gasta?', icon: '🏛️', tecnico: 'Clasificador institucional', desc: 'Ministerios, municipios, universidades, empresas públicas y todas las entidades del sector público que ejecutan presupuesto.' },
{ id: 'objeto', label: '¿En qué se gasta?', icon: '📦', tecnico: 'Objeto de gasto', desc: 'Categorías estandarizadas del destino del gasto: sueldos, viáticos, materiales, servicios básicos, inversiones, deuda, etc.' },
{ id: 'finfun', label: '¿Para qué se gasta?', icon: '🎯', tecnico: 'Finalidad y función', desc: 'El propósito del gasto según su función social: salud, educación, defensa, justicia, infraestructura, etc.' },
{ id: 'geografico', label: '¿Dónde se gasta?', icon: '📍', tecnico: 'Geográfico', desc: 'Distribución territorial del gasto por departamento y municipio.' },
{ id: 'sectores', label: '¿En qué sectores se gasta?', icon: '🏢', tecnico: 'Sectores económicos', desc: 'Sectores de actividad económica donde se dirige el gasto: agricultura, minería, manufactura, transporte, etc.' },
{ id: 'finfun', label: '¿Para qué se gasta?', icon: '🎯', tecnico: 'Finalidad y función', desc: 'El propósito del gasto según su función social: salud, educación, defensa, justicia, infraestructura, etc.' },
{ id: 'rubro', label: '¿Con qué recursos?', icon: '💰', tecnico: 'Rubros de ingreso', desc: 'Tipos de recursos que financian el gasto: impuestos, tasas, regalías, transferencias, donaciones, créditos, etc.' },
{ id: 'organismo', label: '¿Quién financia?', icon: '🏦', tecnico: 'Organismos financiadores', desc: 'Origen institucional del financiamiento: Tesoro General, gobiernos subnacionales, organismos internacionales, etc.' },
{ id: 'fuente', label: 'Origen del ingreso', icon: '💵', tecnico: 'Fuentes de financiamiento', desc: 'Clasificación por tipo de fuente: recursos propios, transferencias, crédito interno/externo, donaciones.' },
......@@ -369,14 +384,14 @@
if (mode === 'programas') {
return 'Buscar en programas y proyectos...';
}
if (classifiers.length === 0) {
return 'Selecciona al menos un clasificador...';
if (mode === 'clasificadores' && classifiers.length > 0) {
const nombres = classifiers.map(id => {
const clf = CLASIFICADORES.find(c => c.id === id);
return clf?.label?.toLowerCase() || id;
}).join(', ');
return `Buscar en ${nombres}...`;
}
const nombres = classifiers.map(id => {
const clf = CLASIFICADORES.find(c => c.id === id);
return clf?.label?.toLowerCase() || id;
}).join(', ');
return `Buscar en ${nombres}...`;
return 'Sueldos, Hospital La Paz, Ministerio de Salud...';
}
// Debounce
......@@ -411,9 +426,10 @@
];
const EXAMPLES_CLASIFICADORES = [
{ label: 'YPFB', classifier: 'entidad' },
{ label: 'viáticos', classifier: 'objeto' },
{ label: 'Ministerio de Salud', classifier: 'entidad' },
{ label: 'YPFB', href: '/entidad/513' },
{ label: 'Sueldos', href: '/objeto/11700' },
{ label: 'Uyuni', href: '/ubicacion/51201' },
{ label: 'Educación', href: '/finfun/9' },
];
// Función para buscar desde pills
......@@ -585,15 +601,16 @@
const params = new URLSearchParams({
q: query,
mode: 'gastos',
page: page.toString()
page: page.toString(),
per_page: '30'
});
// Mapeo de ids internos a class_ de la API
const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco' };
const CLASS_API_MAP = { geografico: 'ubigeo', sectores: 'acteco', finfun: 'finalidad' };
if (isClassSearch) {
params.set('is_class', 'true');
params.set('per_page', '250');
params.set('is_class', 'true');
params.set('class_', selectedClassifiers.map(c => CLASS_API_MAP[c] || c).join(','));
} else if (isProgramSearch) {
params.set('is_class', 'false');
......@@ -609,6 +626,8 @@
results = data;
if (page === 1) {
resetSeenHits();
expandedGroups = [];
collapsedGroups = [];
allHits = data.hits;
} else {
allHits = [...allHits, ...data.hits];
......@@ -627,6 +646,7 @@
if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
}
function handleGlobalKeydown(e) {
if (e.key === 'Escape' && (searchVal || allHits.length > 0)) {
clearSearch();
......@@ -803,8 +823,8 @@
}
$: stats = filteredHits.length > 0 ? getStats(
(isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav : filteredHits,
(isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav.length : filteredHits.length
(isFinfunSearchActive || isMixedClassSearch || searchMode === 'todo') ? flatHitsForNav : filteredHits,
results?.found || filteredHits.length
) : null;
// Texto contextual para resultados
......@@ -1035,6 +1055,9 @@
</div>
<div class="mode-card-pills">
{#each EXAMPLES_CLASIFICADORES as ex}
{#if ex.href}
<a href={ex.href} class="mode-pill">{ex.label}</a>
{:else}
<span
class="mode-pill"
on:click|stopPropagation={() => searchFromPill(ex.label, ex.classifier)}
......@@ -1044,6 +1067,7 @@
>
{ex.label}
</span>
{/if}
{/each}
</div>
<div class="mode-card-radio">
......@@ -1161,14 +1185,26 @@
{#if groupedHits}
<!-- Vista agrupada para entidades u objetos -->
{#each groupedHits as group, gi}
{@const collapsible = isMixedClassSearch && groupedHits.length > 1}
{@const isCollapsed = collapsedGroups.includes(gi)}
{@const maxVisible = isCollapsed ? 0 : group.hits.length}
<div class="result-group">
<div class="result-group-header">
<span class="result-group-name">{group.name}</span>
<button class="result-group-header" on:click={() => {
if (isCollapsed) { collapsedGroups = collapsedGroups.filter(x => x !== gi); }
else { collapsedGroups = [...collapsedGroups, gi]; }
}}>
<svg class="group-chevron" class:collapsed={isCollapsed} width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
<span class="result-group-name">
{CLASS_EASY_LABELS[group.classType] || group.name}
<span class="result-group-tecnico">{CLASS_LABELS[group.classType] || ''}</span>
</span>
<span class="result-group-count">{group.hits.length}</span>
<span class="result-group-monto">{formatMonto(group.totalMonto)}</span>
</div>
</button>
<div class="result-group-items">
{#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))}
{#each group.hits.slice(0, maxVisible) as hit, i (getHitKey(hit, gi * 1000 + i))}
{@const meta = parseMetadatos(hit.document.metadatos)}
{@const globalIdx = flatHitsForNav.indexOf(hit)}
{@const hitClass = hit.document.class_}
......@@ -1251,6 +1287,28 @@
</div>
</div>
</a>
{:else}
<!-- Programa/proyecto -->
<div class="result-card result-card-grouped"
class:result-card-selected={selectedResultIndex === globalIdx}
data-result-index={globalIdx}>
<div class="result-content">
<div class="result-text">
{#if hit.highlights?.[0]?.snippet}
{@html hit.highlights[0].snippet}
{:else}
{hit.document.texto}
{/if}
</div>
<div class="result-meta">
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
{#if meta.entidad_desc_entidad}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidad_desc_entidad}</span>
{/if}
</div>
</div>
</div>
{/if}
{/each}
</div>
......@@ -1333,7 +1391,7 @@
{#if isLoadingMore}
<span class="loading-dot"></span> Cargando...
{:else}
Cargar más ({allHits.length} de {results.found})
Cargar más resultados
{/if}
</button>
</div>
......@@ -2001,7 +2059,7 @@
/* Section Group */
.section-group{margin-top:16px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)}
.section-group-hidden{opacity:0;pointer-events:none;position:absolute;z-index:-1}
.section-label{display:block;font-family:var(--sans);font-size:10px;font-weight:500;color:#6B6860;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:8px;padding-left:4px}
.section-label{display:block;font-family:var(--sans);font-size:11px;font-weight:600;color:#D4B76A;text-transform:uppercase;letter-spacing:0.08em;margin-bottom:8px;padding-left:4px}
.section-label-goto{margin-top:12px}
/* Mode Cards */
......@@ -2129,8 +2187,15 @@
/* Result Group (for entidades) */
.result-group{background:transparent;border-radius:0;overflow:hidden;border-bottom:1px solid rgba(255,255,255,0.06);padding-bottom:8px;margin-bottom:8px}
.result-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
.result-group-header{display:flex;align-items:center;gap:12px;padding:10px 12px;background:rgba(255,255,255,0.04);border-radius:8px;margin-bottom:6px}
.result-group-name{font-family:var(--sans);font-size:14px;font-weight:600;color:#F5F0E8;flex:1}
.result-group-header{display:flex;align-items:center;gap:8px;padding:10px 12px;background:rgba(255,255,255,0.04);border-radius:8px;margin-bottom:6px;border:none;width:100%;text-align:left;font-family:var(--sans);color:inherit;cursor:pointer;transition:background 0.15s}
.result-group-header:hover{background:rgba(255,255,255,0.08)}
.group-chevron{color:#9B9890;transition:transform 0.2s;flex-shrink:0}
.group-chevron.collapsed{transform:rotate(-90deg)}
.group-show-more{display:block;width:100%;padding:8px;background:none;border:none;color:#9B9890;font-size:12px;font-family:var(--sans);cursor:pointer;transition:color 0.15s;text-align:center}
.group-show-more:hover{color:#F5F0E8}
.infinite-scroll-sentinel{display:flex;justify-content:center;padding:16px;min-height:1px}
.result-group-name{font-family:var(--sans);font-size:14px;font-weight:600;color:#F5F0E8;flex:1;display:flex;align-items:baseline;gap:6px}
.result-group-tecnico{font-size:11px;font-weight:400;color:#9B9890;opacity:0.7}
.result-group-count{font-family:var(--mono);font-size:11px;color:#A5A29A;background:rgba(255,255,255,0.08);padding:3px 10px;border-radius:4px}
.result-group-monto{font-family:var(--mono);font-size:13px;font-weight:500;color:#5AAF8A}
.result-group-items{display:flex;flex-direction:column}
......
// Proxy para evitar CORS
const API_BASE = 'http://34.171.18.2/api/proyecto/search';
const API_BASE = 'http://136.112.29.74/api/proyecto/search';
export async function GET({ url }) {
const q = url.searchParams.get('q') || '';
......
const API_BASE = 'http://136.112.29.74/api/ubigeo/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
export async function load({ fetch }) {
const res = await fetch('/api/ubigeo-clasificador');
const data = await res.json();
return { clasificador: data };
}
<script>
let { data } = $props();
let searchQuery = $state('');
let allMunicipios = data.clasificador.filter(d => d.nivel === 'municipio' && d.municipio_ubigeo);
let highlightedMuni = $state(null);
let sidebarOpen = $state(false);
function titleCase(str) {
if (!str) return '';
return str.toLowerCase().replace(/\b\w/g, c => c.toUpperCase());
}
// Agrupar por departamento (calculado una vez)
const deptoMap = {};
allMunicipios.forEach(m => {
const dept = m.desc_departamento;
if (!deptoMap[dept]) {
deptoMap[dept] = { nombre: dept, municipios: [] };
}
deptoMap[dept].municipios.push(m);
});
let departamentos = Object.values(deptoMap).sort((a, b) => a.nombre.localeCompare(b.nombre));
let selectedDepto = $state(departamentos[0] || null);
function searchGlobal(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allMunicipios
.filter(m =>
m.desc_ubigeo?.toLowerCase().includes(q) ||
m.desc_departamento?.toLowerCase().includes(q) ||
m.municipio_ubigeo?.toString().includes(q)
)
.slice(0, 20);
}
function selectDepto(depto) {
selectedDepto = depto;
searchQuery = '';
highlightedMuni = null;
sidebarOpen = false;
}
function goToSearchResult(item) {
const targetDepto = departamentos.find(d => d.nombre === item.desc_departamento);
if (targetDepto) {
selectedDepto = targetDepto;
highlightedMuni = item.municipio_ubigeo;
searchQuery = '';
sidebarOpen = false;
setTimeout(() => {
const el = document.getElementById(`muni-${item.municipio_ubigeo}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
}
}
function formatGestiones(gestiones) {
if (!gestiones) return '';
const years = gestiones.split(',').map(y => parseInt(y.trim())).sort((a, b) => a - b);
if (years.length <= 1) return years[0]?.toString() || '';
const ranges = [];
let start = years[0], end = years[0];
for (let i = 1; i < years.length; i++) {
if (years[i] === end + 1) { end = years[i]; }
else { ranges.push(start === end ? `${start}` : `${start}-${end}`); start = years[i]; end = years[i]; }
}
ranges.push(start === end ? `${start}` : `${start}-${end}`);
return ranges.join(', ');
}
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
let totalMunicipios = $derived(allMunicipios.length);
let totalDeptos = $derived(departamentos.length);
</script>
<svelte:head>
<title>Geográfico | Presupuesto Público</title>
</svelte:head>
<div class="min-h-screen" style="font-family: var(--font-sans); background-color: var(--theme-body); color: var(--theme-titulo);">
<!-- Header -->
<header class="border-b" style="border-color: var(--theme-borde);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
<nav class="mb-4" style="font-size: 0.75rem; color: var(--theme-texto);">
<div class="flex items-center gap-2">
<a href="/" class="hover:opacity-80">Inicio</a>
<span style="opacity: 0.5;">/</span>
<a href="/clasificadores" class="hover:opacity-80">Clasificadores</a>
</div>
</nav>
<div class="max-w-3xl">
<p class="text-xs uppercase tracking-widest mb-2" style="color: var(--theme-texto);">Clasificador geográfico</p>
<h1 class="text-3xl mb-3" style="font-weight: 700;">¿Dónde se gasta?</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Este clasificador organiza el gasto público por <strong style="color: var(--theme-titulo);">ubicación geográfica</strong>:
departamentos y municipios donde se ejecuta el presupuesto, independientemente de la institución que lo ejecuta.
</p>
{#if totalMunicipios > 0}
<p class="text-sm mb-5" style="color: var(--theme-texto);">
<span style="color: var(--theme-titulo); font-weight: 500;">{totalMunicipios}</span> municipios en
<span style="color: var(--theme-titulo); font-weight: 500;">{totalDeptos}</span> departamentos
</p>
{/if}
<div class="flex flex-wrap items-center gap-2 text-xs mb-6">
<span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body);">
Departamento
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="border: 1px solid var(--theme-borde); color: var(--theme-titulo);">
Municipio
</span>
</div>
</div>
</div>
</header>
<!-- Botón móvil sidebar -->
<div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
<button
onclick={() => sidebarOpen = !sidebarOpen}
class="flex items-center gap-2 text-sm"
style="color: var(--theme-texto);"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<span class="font-medium">{selectedDepto ? titleCase(selectedDepto.nombre) : 'Departamentos'}</span>
<svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
<div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
<!-- Sidebar: Departamentos -->
{#if sidebarOpen}
<div class="fixed inset-0 bg-black/30 z-40 lg:hidden" onclick={() => sidebarOpen = false}></div>
{/if}
<aside class="
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
lg:translate-x-0
fixed lg:relative
inset-y-0 left-0
w-72 lg:w-64
z-50 lg:z-auto
transition-transform duration-200 ease-in-out
lg:flex-shrink-0
shadow-xl lg:shadow-none
sidebar-left
">
<div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-6" style="background-color: var(--theme-body);">
<!-- Cerrar en móvil -->
<div class="flex justify-between items-center mb-4 lg:hidden">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Departamentos</span>
<button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Buscador -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar municipio..."
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2 focus:ring-offset-0"
style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
/>
</div>
<!-- Resultados de búsqueda -->
{#if isSearching}
<div class="mb-4">
<p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
{searchResults.length} resultados
</p>
<div class="space-y-1">
{#each searchResults as result}
<button
class="w-full text-left px-2 py-2 text-sm rounded transition-all"
style="color: var(--theme-titulo);"
onclick={() => goToSearchResult(result)}
>
<span class="text-xs block" style="color: var(--theme-texto);">{titleCase(result.desc_departamento)}</span>
<span>{titleCase(result.desc_ubigeo)}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm py-4 text-center" style="color: var(--theme-texto);">Sin resultados</p>
{/if}
</div>
</div>
{:else}
<!-- Lista de departamentos -->
<div class="space-y-0.5">
{#each departamentos as depto}
<button
class="w-full text-left px-3 py-2.5 text-sm rounded-lg transition-all flex items-center justify-between"
style="{selectedDepto?.nombre === depto.nombre
? `background-color: var(--theme-accent); color: var(--theme-body);`
: `color: var(--theme-titulo);`}"
onclick={() => selectDepto(depto)}
>
<span class="font-medium">{titleCase(depto.nombre)}</span>
<span class="text-xs" style="opacity: 0.6;">{depto.municipios.length}</span>
</button>
{/each}
</div>
{/if}
</div>
</aside>
<!-- Contenido: Municipios -->
<main class="flex-1 min-w-0 py-6 lg:pl-6 lg:border-l" style="border-color: var(--theme-borde);">
{#if selectedDepto}
<div class="mb-6">
<h2 class="text-xl font-bold mb-1" style="color: var(--theme-titulo);">{titleCase(selectedDepto.nombre)}</h2>
<p class="text-sm" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
{selectedDepto.municipios.length} municipios
</p>
</div>
<div class="space-y-2">
{#each [...selectedDepto.municipios].sort((a, b) => a.desc_ubigeo.localeCompare(b.desc_ubigeo)) as muni}
<a
href="/ubicacion/{muni.municipio_ubigeo}"
id="muni-{muni.municipio_ubigeo}"
class="block p-4 rounded-xl border transition-all hover:shadow-sm"
style="border-color: {highlightedMuni === muni.municipio_ubigeo ? 'var(--theme-accent)' : 'var(--theme-borde)'}; background-color: var(--theme-surface);"
>
<div class="flex items-center justify-between">
<div>
<span class="font-medium" style="color: var(--theme-titulo);">{titleCase(muni.desc_ubigeo)}</span>
<span class="text-xs ml-2" style="color: var(--theme-texto); opacity: 0.5;">{muni.municipio_ubigeo}</span>
</div>
<div class="text-xs" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
{muni.n_gestiones} años
</div>
</div>
<div class="text-xs mt-1" style="font-family: 'DM Mono', monospace; color: var(--theme-texto); opacity: 0.6;">
{formatGestiones(muni.gestiones)}
</div>
</a>
{/each}
</div>
{/if}
</main>
</div>
</div>
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
export async function load({ params, fetch }) {
const codigo = params.codigo;
const isDA = codigo.includes('.');
const entidadCode = isDA ? codigo.split('.')[0] : codigo;
......@@ -11,8 +11,8 @@ export async function load({ params }) {
throw error(400, 'Código de entidad inválido');
}
// Cargar metadata y resumen en paralelo
const [entidadRes, resumenRes] = await Promise.all([
// Cargar metadata, resumen y población en paralelo
const [entidadRes, resumenRes, pobRes] = await Promise.all([
supabase
.schema('ppto')
.from('clas_institucional')
......@@ -24,9 +24,31 @@ export async function load({ params }) {
.schema('ppto')
.from('entidad_resumen')
.select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
.eq('codigo', codigo)
.eq('codigo', codigo),
fetch('/poblacion.csv').then(r => r.text())
]);
// Buscar población: primero por entidad, si no suma nacional
const poblacionEntidad = {};
const poblacionNacional = {};
pobRes.split('\n').slice(1).forEach(line => {
const [, ent, gestion, pob] = line.split(',');
const g = parseInt(gestion);
const p = parseInt(pob);
if (!g || !p) return;
// Suma nacional
poblacionNacional[g] = (poblacionNacional[g] || 0) + p;
// Por entidad (código sin DA)
if (ent === entidadCode) {
poblacionEntidad[g] = (poblacionEntidad[g] || 0) + p;
}
});
// Usar población de la entidad si existe, si no la nacional
const tienePobEntidad = Object.keys(poblacionEntidad).length > 0;
const poblacionMap = tienePobEntidad ? poblacionEntidad : poblacionNacional;
if (entidadRes.error || !entidadRes.data) {
throw error(404, 'Entidad no encontrada');
}
......@@ -60,6 +82,8 @@ export async function load({ params }) {
codigoEntidadPadre: isDA ? entidadCode : null,
resumenData: resumenRes.data || [],
distribucionesData: distRes.data || [],
gestionInicial: ultimaGestion
gestionInicial: ultimaGestion,
poblacionMap,
tienePobEntidad
};
}
......
......@@ -10,6 +10,9 @@
let nombreDA = $derived(data.nombreDA);
let nombreEntidadMadre = $derived(data.nombreEntidadMadre);
let codigoEntidadPadre = $derived(data.codigoEntidadPadre);
let poblacionMap = $derived(data.poblacionMap);
let tienePobEntidad = $derived(data.tienePobEntidad);
let modoPerCapita = $state(false);
// Datos desde el loader (Supabase)
let codigoSeleccionado = $derived($page.params.codigo);
......@@ -137,22 +140,34 @@
}
// Datos derivados (ya filtrados por codigo desde el loader)
function aplicarPerCapita(rows) {
if (!modoPerCapita) return rows;
return rows.map(d => {
const pob = poblacionMap[d.gestion];
return { ...d, devengado: pob ? d.devengado / pob : 0 };
});
}
let historiaGastos = $derived(
resumenData
.filter(d => d.tipo === 'gastos')
.sort((a, b) => a.gestion - b.gestion)
aplicarPerCapita(
resumenData
.filter(d => d.tipo === 'gastos' && d.gestion >= 2016)
.sort((a, b) => a.gestion - b.gestion)
)
);
let historiaIngresos = $derived(
resumenData
.filter(d => d.tipo === 'ingresos')
.sort((a, b) => a.gestion - b.gestion)
aplicarPerCapita(
resumenData
.filter(d => d.tipo === 'ingresos' && d.gestion >= 2016)
.sort((a, b) => a.gestion - b.gestion)
)
);
let tieneIngresos = $derived(historiaIngresos.length > 0);
let gestiones = $derived(() => {
const years = [...new Set(resumenData.map(d => d.gestion))].sort();
const years = [...new Set(resumenData.map(d => d.gestion))].filter(g => g >= 2016).sort((a, b) => b - a);
return years;
});
......@@ -176,11 +191,12 @@
let distFiltradas = $derived(distribucionesData);
function prepararSegmentos(data) {
const pob = modoPerCapita ? poblacionMap[gestionSeleccionada] : null;
return data
.map(d => ({
codigo: d.hijo,
nombre: d.desc_hijo,
monto: d.devengado,
monto: pob ? d.devengado / pob : d.devengado,
padre: d.desc_padre
}))
.filter(d => d.monto > 0)
......@@ -231,6 +247,18 @@
return valor.toFixed(0);
}
function formatearPerCapita(valor) {
if (valor >= 1e6) return `${(valor / 1e6).toFixed(1)} millones`;
if (valor >= 1e4) return `${(valor / 1e3).toFixed(1)} mil`;
return Math.round(valor).toLocaleString('es-BO');
}
function fmt(valor) {
return modoPerCapita ? formatearPerCapita(valor) : formatearMonto(valor);
}
let unidadMonto = $derived(modoPerCapita ? 'Bs por persona al año' : 'de Bolivianos');
function formatearMontoCorto(valor) {
if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
......@@ -551,7 +579,13 @@
<!-- Historia temporal (todos los años) -->
<section class="seccion">
<h2 class="seccion-titulo">Historia</h2>
<div class="seccion-titulo-row">
<h2 class="seccion-titulo">Historia</h2>
<div class="percapita-toggle">
<button class="percapita-btn" class:active={!modoPerCapita} onclick={() => { modoPerCapita = false; }}>Total</button>
<button class="percapita-btn" class:active={modoPerCapita} onclick={() => { modoPerCapita = true; }}>Per cápita</button>
</div>
</div>
<div class="historia-grid" class:single={!tieneIngresos}>
{#if tieneIngresos}
<div class="historia-card" bind:this={chartRefA}>
......@@ -560,9 +594,9 @@
{#if historiaIngresos.length > 0}
<span class="historia-monto ingresos">
{#if hoveredIngresos}
{hoveredIngresos.gestion} · {formatearMonto(hoveredIngresos.devengado)} de Bolivianos
{hoveredIngresos.gestion} · {fmt(hoveredIngresos.devengado)} {unidadMonto}
{:else}
{formatearMonto(historiaIngresos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos
{fmt(historiaIngresos.reduce((s, d) => s + d.devengado, 0))} {unidadMonto}
{/if}
</span>
{/if}
......@@ -610,9 +644,9 @@
{#if historiaGastos.length > 0}
<span class="historia-monto gastos">
{#if hoveredGastos}
{hoveredGastos.gestion} · {formatearMonto(hoveredGastos.devengado)} de Bolivianos
{hoveredGastos.gestion} · {fmt(hoveredGastos.devengado)} {unidadMonto}
{:else}
{formatearMonto(historiaGastos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos
{fmt(historiaGastos.reduce((s, d) => s + d.devengado, 0))} {unidadMonto}
{/if}
</span>
{/if}
......@@ -712,7 +746,7 @@
<div class="ranking-card">
<div class="ranking-headline">
<span class="ranking-pos-big">#{r.posicion}</span>
<span class="ranking-pos-context">de <span class="ranking-total-num">{total}</span> instituciones · {r.posicion <= total / 2 ? 'entre las que más ingresan' : 'entre las que menos ingresan'}</span>
<span class="ranking-pos-context">de <span class="ranking-total-num">{total}</span> instituciones · {r.posicion <= total / 2 ? 'entre las que más ingreso reciben/generan' : 'entre las que menos ingreso reciben/generan'}</span>
</div>
<div class="ranking-bars">
{#each Array(80) as _, i}
......@@ -745,7 +779,7 @@
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<div class="clasificador-valores">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span>
<span class="clasificador-monto">{fmt(displayItem?.monto || 0)} {unidadMonto}</span>
<span class="clasificador-pct">{pct}%</span>
</div>
</div>
......@@ -775,7 +809,7 @@
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<div class="clasificador-valores">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span>
<span class="clasificador-monto">{fmt(displayItem?.monto || 0)} {unidadMonto}</span>
<span class="clasificador-pct">{pct}%</span>
</div>
</div>
......@@ -802,6 +836,12 @@
font-family: var(--font-sans);
}
.dashboard > :not(.nav-spacer) {
max-width: 1200px;
margin-left: auto;
margin-right: auto;
}
:global(html:not(.dark)) .dashboard {
background: #f5f5f7;
}
......@@ -1119,6 +1159,45 @@
margin-bottom: 2.5rem;
}
.seccion-titulo-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.seccion-titulo-row .seccion-titulo {
margin-bottom: 0;
}
.percapita-toggle {
display: flex;
gap: 2px;
background: rgba(255, 255, 255, 0.06);
border-radius: 8px;
padding: 3px;
flex-shrink: 0;
}
:global(html:not(.dark)) .percapita-toggle { background: rgba(0, 0, 0, 0.05); }
.percapita-btn {
padding: 5px 12px;
font-size: 0.75rem;
font-weight: 600;
font-family: var(--font-sans);
border: none;
border-radius: 6px;
cursor: pointer;
background: transparent;
color: var(--theme-texto);
opacity: 0.6;
transition: all 0.2s;
}
.percapita-btn:hover { opacity: 0.8; }
.percapita-btn.active { opacity: 1; background: rgba(255, 255, 255, 0.12); color: var(--theme-titulo); }
:global(html:not(.dark)) .percapita-btn.active { background: rgba(255, 255, 255, 0.8); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); }
.seccion-titulo {
font-size: 1.1rem;
font-weight: 600;
......
......@@ -1530,7 +1530,7 @@
display: grid;
grid-template-columns: 320px minmax(auto, 900px);
gap: 16px;
padding: 1rem 2rem 2rem;
padding: 3.5rem 2rem 2rem;
max-width: 1600px;
margin: 0 auto;
justify-content: center;
......
......@@ -1076,6 +1076,55 @@
<!-- Separador -->
<div class="context-separator"></div>
<!-- Explorar -->
<div class="context-section">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<div class="explore-scroll">
<a href="/objeto/{objetoCodigo}?vista=comparar" class="explore-card" onclick={(e) => { e.preventDefault(); vista = 'comparar'; }}>
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en {objetoData.desc_objeto?.toLowerCase()}?</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
</svg>
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en esta partida</span>
</a>
<a href="/ubicacion" class="explore-card explore-card-disabled">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en esta partida</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
</svg>
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesa {objetoData.desc_objeto?.toLowerCase()} respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
</svg>
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todas las partidas de gasto y navegar entre ellas</span>
</a>
</div>
</div>
<!-- Separador -->
<div class="context-separator"></div>
<!-- Contexto: Vigencia temporal -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
......@@ -1113,18 +1162,18 @@
<!-- Contexto: Definición oficial -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
<p class="context-desc">Descripción oficial {getNivelArticuloContracto(objetoData.nivel)} {objetoData.nivel} según el clasificador presupuestario.</p>
<div class="definicion-compact">
<button class="def-link" onclick={() => showDefinicionesModal = true}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
{#if objeto.variaciones.length === 1}
<span class="def-status">Sin cambios en el período</span>
Ver definición oficial
{:else}
<span class="def-status def-changed">{objeto.variaciones.length} versiones en el período</span>
Ver {objeto.variaciones.length} variaciones en la definición
{/if}
<button class="def-btn" onclick={() => showDefinicionesModal = true}>
Ver
</button>
</div>
</button>
</div>
</div>
</aside>
......@@ -1154,11 +1203,13 @@
<span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
</div>
{#if loading}
<div class="chart-loading" style="height: 280px;">
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else}
<BarChart data={gastoPerCapita} bind:hoveredYear height={280} fill={!!selectedEntity} />
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs abajo -->
......@@ -1200,6 +1251,7 @@
</div>
{/if}
</main>
</div>
<!-- Modal de definiciones (fuera del layout) -->
......@@ -1548,12 +1600,13 @@
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: 320px minmax(auto, 900px);
gap: 16px;
padding: 1rem 2rem 2rem;
max-width: 1600px;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
justify-content: center;
padding: 1.5rem 2rem 2rem;
height: calc(100vh - 4rem);
overflow: hidden;
}
/* Contexto */
......@@ -1570,13 +1623,24 @@
.context-card {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 1rem;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar {
width: 4px;
}
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
......@@ -1760,59 +1824,47 @@
opacity: 0.7;
}
/* Definición compacta */
.definicion-compact {
/* Definición - link style */
.def-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.def-status {
font-size: 0.8125rem;
color: #555555;
}
.def-status.def-changed {
color: #555555;
}
.def-btn {
font-size: 0.6875rem;
font-weight: 500;
color: #555555;
background: rgba(0, 0, 0, 0.06);
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0.25rem 0.625rem;
border-radius: 4px;
padding: 0;
cursor: pointer;
transition: background 0.2s;
transition: opacity 0.15s;
}
.def-btn:hover {
background: rgba(0, 0, 0, 0.1);
.def-link:hover {
opacity: 0.7;
}
.def-link svg {
flex-shrink: 0;
opacity: 0.8;
}
/* Main content */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
width: 100%;
align-self: start;
padding-top: 0;
padding-bottom: 0;
transition: all 0.3s ease;
}
.dashboard-main.expanded {
align-self: stretch;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
/* Header compacto */
.page-nav {
padding: 1rem 2rem;
padding: 2rem 2rem 0.5rem;
max-width: 1400px;
margin: 0 auto;
}
.chart-section {
margin-bottom: 0;
}
.back-link {
......@@ -2158,17 +2210,8 @@
}
/* Dark mode - Definición */
:global(html.dark) .def-status,
:global(html.dark) .def-btn {
color: rgba(255, 255, 255, 0.7);
}
:global(html.dark) .def-btn {
background: rgba(255, 255, 255, 0.1);
}
:global(html.dark) .def-btn:hover {
background: rgba(255, 255, 255, 0.15);
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
:global(html.dark) .tree-children {
......@@ -2488,22 +2531,18 @@
/* Chart */
.chart-section {
margin-bottom: 1rem;
margin-bottom: 0;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
transition: flex 0.3s ease;
}
.chart-protagonista.expanded {
flex: 1;
min-height: 0;
flex: 2;
overflow: hidden;
}
.chart-protagonista .chart-wrapper {
......@@ -2526,6 +2565,11 @@
padding-bottom: 1.5rem;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.chart-top-row {
display: flex;
justify-content: space-between;
......@@ -2536,59 +2580,49 @@
.main-title-pills {
display: flex;
gap: 0.5rem;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(166, 138, 62, 0.12);
color: #C9A751;
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: rgba(107, 159, 212, 0.12);
color: #6B9FD4;
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-size: 1.375rem;
font-weight: 600;
font-family: 'DM Serif Display', serif;
font-size: 1.75rem;
font-weight: 400;
color: var(--theme-titulo);
margin: 0 0 0.5rem 0;
line-height: 1.2;
flex-shrink: 0;
}
.chart-header {
margin-bottom: 0.5rem;
flex-shrink: 0;
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.75rem;
font-weight: 500;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
text-transform: uppercase;
letter-spacing: 0.08em;
opacity: 0.7;
}
.chart-period {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.75rem;
font-weight: 400;
color: var(--theme-texto);
opacity: 0.7;
text-transform: none;
letter-spacing: 0;
opacity: 0.5;
}
.chart-loading {
......@@ -2734,75 +2768,149 @@
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 1rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
text-align: center;
min-width: 0;
padding: 0.625rem 0.5rem;
background: transparent;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.kpi-value {
font-family: 'DM Mono', monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.5625rem;
color: var(--theme-texto);
opacity: 0.6;
line-height: 1.3;
}
/* Ranking Simple */
/* Explorar - scroll horizontal */
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
:global(html.dark) .kpi {
border-color: rgba(255, 255, 255, 0.1);
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
:global(html.dark) .kpi-value,
:global(html.dark) .kpi-label {
color: rgba(255, 255, 255, 0.7);
.explore-card:hover .explore-watermark {
opacity: 0.18;
color: var(--theme-accent);
}
.kpi-value {
display: block;
font-family: 'Qanelas', var(--font-sans);
font-size: 0.9375rem;
.explore-watermark {
position: absolute;
bottom: -4px;
right: -4px;
width: 44px;
height: 44px;
opacity: 0.15;
pointer-events: none;
color: var(--theme-accent, #C9A751);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
color: #666666;
margin-bottom: 0.125rem;
transition: opacity 0.2s ease;
font-variant-numeric: tabular-nums;
min-height: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.kpi-label {
font-size: 0.5625rem;
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-card-disabled {
opacity: 0.3;
pointer-events: none;
}
.tools-title {
display: block;
font-size: 0.6rem;
font-weight: 600;
color: #666666;
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1.3;
transition: opacity 0.2s ease;
min-height: auto;
letter-spacing: 0.08em;
color: var(--theme-texto);
opacity: 0.5;
margin-bottom: 0.75rem;
}
.tools-list {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 4px;
}
/* Ranking Simple */
.ranking-section {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.ranking-title {
font-size: 1.375rem;
font-size: 1.125rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.5rem 0;
line-height: 1.2;
margin: 0 0 0.25rem;
}
.ranking-desc {
font-size: 0.8125rem;
color: #666666;
margin: 0 0 1.25rem 0;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.ranking-cards {
......@@ -2815,16 +2923,17 @@
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.25rem 0;
min-width: 0;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: var(--theme-borde);
}
.ranking-position {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
color: #555555;
flex-shrink: 0;
min-width: 1.5rem;
}
.ranking-list {
......@@ -2848,37 +2957,15 @@
.ranking-name {
flex: 1;
font-size: 0.8125rem;
font-weight: 400;
color: #666666;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.75rem;
color: var(--theme-titulo);
}
.ranking-pct {
font-family: 'Qanelas', var(--font-sans);
font-size: 1rem;
font-family: 'DM Mono', monospace;
font-size: 0.8125rem;
font-weight: 600;
color: #555555;
flex-shrink: 0;
min-width: 3.5rem;
text-align: right;
}
/* Dark mode - Ranking texts */
:global(html.dark) .ranking-desc {
color: rgba(255, 255, 255, 0.6);
}
:global(html.dark) .ranking-position,
:global(html.dark) .ranking-pct {
color: rgba(255, 255, 255, 0.85);
}
:global(html.dark) .ranking-name {
color: rgba(255, 255, 255, 0.7);
color: var(--theme-titulo);
}
.see-all {
......@@ -2905,7 +2992,7 @@
gap: 0.75rem;
padding: 0.75rem 2rem 1.5rem;
width: 100%;
max-width: 1200px;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 60px);
}
......@@ -3402,12 +3489,26 @@
.dashboard-layout {
grid-template-columns: 1fr;
padding: 1rem;
height: auto;
overflow: visible;
}
.dashboard-context {
height: auto;
}
.dashboard-main {
overflow: visible;
}
.chart-protagonista {
flex: none;
}
.ranking-section {
flex: none;
}
/* Mobile sidebar toggle */
.mobile-sidebar-toggle {
display: flex;
......
import { supabase } from '$lib/supabase';
// Hardcoded a objeto 11700 (Sueldos) para testing de layout
const CODIGO = '11700';
function getGrupoCode(objeto) { return objeto.charAt(0) + '0000'; }
function getSubgrupoCode(objeto) { return objeto.substring(0, 2) + '000'; }
function getPartidaCode(objeto) { return objeto.substring(0, 3) + '00'; }
export async function load() {
const { data } = await supabase.schema('ppto').from('clas_objetos').select('*').eq('objeto', CODIGO);
const objeto = data[0];
const grupoCode = getGrupoCode(CODIGO);
const subgrupoCode = getSubgrupoCode(CODIGO);
const partidaCode = getPartidaCode(CODIGO);
const { data: padresData } = await supabase.schema('ppto').from('clas_objetos').select('*').in('objeto', [grupoCode, subgrupoCode, partidaCode]).order('objeto');
const padres = padresData || [];
// Contar entidades
const nEntidades = objeto.n_entidades || 0;
return { objeto, padres, hijos: [], nEntidades };
}
<script>
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import { supabase } from '$lib/supabase';
import ObjetoSearch from '$lib/components/objeto/ObjetoSearch.svelte';
import BarChart from '$lib/components/objeto/BarChart.svelte';
import EntitySelector from '$lib/components/objeto/EntitySelector.svelte';
import { initIndex } from '$lib/stores/searchStore';
let { data } = $props();
let objetoData = $derived(data.objeto);
let padres = $derived(data.padres);
let hijos = $derived(data.hijos);
let mounted = $state(false);
let loading = $state(true);
let hoveredYear = $state(null);
// Estado selector entidad
let entidades = $state([]);
let selectedEntity = $state(null);
let nEntidades = $derived(data.nEntidades);
// Datos
let datosAnuales = $state([]);
let datosHistorico = $state(null);
let topEntidadesPorAño = $state({});
const POBLACION = 12000000;
const objetoCodigo = '11700';
const objetoNivel = 'subpartida';
let gastoPerCapita = $derived(
datosAnuales.map(d => ({
año: d.gestion,
monto: selectedEntity ? (d.monto ?? d.devengado) : d.total,
perCapita: d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)
}))
);
let primerAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[0].año : 2005);
let ultimoAño = $derived(gastoPerCapita.length > 0 ? gastoPerCapita[gastoPerCapita.length - 1].año : 2025);
let totalHistorico = $derived(datosHistorico ? (selectedEntity ? datosHistorico.monto : datosHistorico.total) : 0);
let promedioPerCapita = $derived(datosHistorico?.per_capita || 0);
let totalPorcentaje = $derived(datosHistorico?.prop || 0);
let ranking = $derived(datosHistorico?.ranking || 0);
let nPares = $derived(datosHistorico?.n_pares || 0);
let currentData = $derived(
hoveredYear ? gastoPerCapita.find(g => g.año === hoveredYear) : null
);
let displayMonto = $derived(currentData ? currentData.monto : totalHistorico);
let displayMontoLabel = $derived(currentData ? `gastados en ${currentData.año}` : `gastados ${primerAño}-${ultimoAño}`);
let displayPerCapita = $derived(currentData ? currentData.perCapita : promedioPerCapita);
let displayPerCapitaLabel = $derived(currentData ? `por cada boliviano en ${currentData.año}` : 'por cada boliviano');
let displayPorcentaje = $derived(currentData ? (totalHistorico > 0 ? totalPorcentaje * (currentData.monto / (totalHistorico / gastoPerCapita.length)) : 0) : totalPorcentaje);
let displayPeriodo = $derived(currentData ? currentData.año : `${primerAño}-${ultimoAño}`);
let displayRanking = $derived(`${currentData ? (datosAnuales.find(d => d.gestion === hoveredYear)?.ranking || ranking) : ranking} de ${selectedEntity ? (datosHistorico?.n_entidades || nEntidades) : nPares}`);
let displayPorcentajeLabel = $derived(currentData ? `del gasto en ${currentData.año}` : 'del gasto total');
let displayRankingLabel = $derived(currentData ? `partidas más gastadas en ${currentData.año}` : 'partidas más gastadas');
// Top entidades
let topEntidadesTotal = $derived(
datosHistorico && !selectedEntity ? [
{ nombre: datosHistorico.top1_entidad, porcentaje: datosHistorico.top1_pct },
{ nombre: datosHistorico.top2_entidad, porcentaje: datosHistorico.top2_pct },
{ nombre: datosHistorico.top3_entidad, porcentaje: datosHistorico.top3_pct },
].filter(e => e.nombre) : []
);
let displayTopEntidades = $derived(
hoveredYear && topEntidadesPorAño[hoveredYear] ? topEntidadesPorAño[hoveredYear] : topEntidadesTotal
);
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
const twPerCapita = tweened(0, tw);
const twPorcentaje = tweened(0, tw);
const twTop1 = tweened(0, tw);
const twTop2 = tweened(0, tw);
const twTop3 = tweened(0, tw);
$effect(() => { twMonto.set(displayMonto); });
$effect(() => { twPerCapita.set(displayPerCapita); });
$effect(() => { twPorcentaje.set(displayPorcentaje); });
$effect(() => { if (displayTopEntidades[0]) twTop1.set(displayTopEntidades[0].porcentaje); });
$effect(() => { if (displayTopEntidades[1]) twTop2.set(displayTopEntidades[1].porcentaje); });
$effect(() => { if (displayTopEntidades[2]) twTop3.set(displayTopEntidades[2].porcentaje); });
function formatMontoLargo(n) {
if (n >= 1e9) return `${(n / 1e9).toFixed(1)} mil mill.`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} mill.`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)} mil`;
return n.toLocaleString('es-BO');
}
function formatPerCapita(n) {
return n.toLocaleString('es-BO', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
async function loadData() {
loading = true;
if (selectedEntity) {
const { data } = await supabase.schema('ppto').from('vista_objeto_entidad').select('*')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).eq('entidad', selectedEntity.entidad).order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
topEntidadesPorAño = {};
}
} else {
const { data } = await supabase.schema('ppto').from('vista_objeto_estado').select('*')
.eq('objeto', objetoCodigo).order('gestion');
if (data) {
datosHistorico = data.find(d => d.gestion === 0) || null;
datosAnuales = data.filter(d => d.gestion > 0);
topEntidadesPorAño = {};
datosAnuales.forEach(d => {
topEntidadesPorAño[d.gestion] = [
{ nombre: d.top1_entidad, porcentaje: d.top1_pct },
{ nombre: d.top2_entidad, porcentaje: d.top2_pct },
{ nombre: d.top3_entidad, porcentaje: d.top3_pct },
].filter(e => e.nombre);
});
}
}
loading = false;
}
async function loadEntidades() {
const { data } = await supabase.schema('ppto').from('entidades_por_objeto').select('entidad, entidad_desc')
.eq('objeto', objetoCodigo).eq('nivel', objetoNivel).order('entidad_desc');
if (data) entidades = data;
}
onMount(async () => {
mounted = true;
initIndex();
await Promise.all([loadData(), loadEntidades()]);
});
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<nav class="page-nav">
<a href="/clasificadores/objeto-gasto" class="back-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Clasificador
</a>
</nav>
<div class="dashboard-layout">
<!-- Sidebar -->
<aside class="dashboard-context">
<div class="context-card">
<h2 class="context-main-title">Herramientas</h2>
<!-- Buscar -->
<div class="context-section">
<h3 class="context-subtitle">Buscar</h3>
<p class="context-hint">Buscar otro grupo, subgrupo, partida o subpartida</p>
<ObjetoSearch />
</div>
<div class="context-separator"></div>
<!-- Navegación -->
{#if padres.length > 0 || hijos.length > 0}
<div class="context-section">
<h3 class="context-subtitle">Navegación</h3>
<p class="context-hint">Partidas relacionadas. Haz click para explorar.</p>
<div class="tree">
{#each padres as padre, i}
<a href="/objeto/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{padre.objeto}</span>
<span class="tree-name">{padre.desc_objeto}</span>
</a>
{/each}
<div class="tree-node tree-node-current has-tooltip" style="--indent: {padres.length}" data-tooltip="{objetoData.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{objetoData.objeto}</span>
<span class="tree-name">{objetoData.desc_objeto}</span>
</div>
{#if hijos.length > 0}
<div class="tree-children" style="--indent: {padres.length + 1}">
{#each hijos as hijo}
<a href="/objeto/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_objeto}">
<span class="tree-dot"></span>
<span class="tree-code">{hijo.objeto}</span>
<span class="tree-name">{hijo.desc_objeto}</span>
</a>
{/each}
</div>
{/if}
</div>
</div>
<div class="context-separator"></div>
{/if}
<!-- Explorar -->
<div class="context-section">
<h3 class="context-subtitle">Explorar visualizaciones</h3>
<div class="explore-scroll">
<a href="/objeto/{objetoData.objeto}?vista=comparar" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<rect x="4" y="14" width="4" height="6"/><rect x="10" y="8" width="4" height="12"/><rect x="16" y="4" width="4" height="16"/>
</svg>
<span class="explore-title">Comparar dos entidades</span>
<span class="explore-desc">¿Quién gasta más en sueldos, el Min. de Salud o Defensa?</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M3 20h18M5 17h2v3H5zM9 14h2v6H9zM13 11h2v9h-2zM17 8h2v12h-2z"/>
</svg>
<span class="explore-title">Ranking</span>
<span class="explore-desc">{nEntidades} entidades que gastan en esta partida</span>
</a>
<a href="/ubicacion" class="explore-card explore-card-disabled">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
</svg>
<span class="explore-title">Mapa</span>
<span class="explore-desc">Dónde se concentra el gasto en esta partida</span>
</a>
<a href="/clasificadores/objeto-gasto?modo=mapa" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<circle cx="12" cy="12" r="10"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10A15 15 0 0 1 12 2z"/>
</svg>
<span class="explore-title">Peso relativo</span>
<span class="explore-desc">Cuánto pesan los sueldos respecto a otros gastos</span>
</a>
<a href="/clasificadores/objeto-gasto" class="explore-card">
<svg class="explore-watermark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.75">
<path d="M4 6h16M8 10h12M12 14h8M8 18h12"/>
</svg>
<span class="explore-title">Clasificador</span>
<span class="explore-desc">Ver todas las partidas de gasto y navegar entre ellas</span>
</a>
</div>
</div>
<div class="context-separator"></div>
<!-- Comparabilidad -->
<div class="context-section">
<h3 class="context-subtitle">Comparabilidad</h3>
<div class="vigencia-compact">
<div class="vigencia-status-icon vigencia-full">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div class="vigencia-text">
<span class="vigencia-main">Comparable en todo el período</span>
<span class="vigencia-sub">2005 – 2025</span>
</div>
</div>
</div>
<div class="context-separator"></div>
<!-- Definición -->
<div class="context-section">
<h3 class="context-subtitle">Definición</h3>
<button class="def-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
Ver variaciones en la definición
</button>
</div>
</div>
</aside>
<!-- Main -->
<main class="dashboard-main">
<div class="chart-protagonista">
<!-- Header -->
<div class="chart-top-row">
<div class="main-title-pills">
<span class="pill pill-nivel">Subpartida</span>
<span class="pill pill-codigo">{objetoData.objeto}</span>
</div>
</div>
<h2 class="main-title-name">{objetoData.desc_objeto}</h2>
<div class="chart-controls">
<EntitySelector {entidades} bind:selectedEntity {nEntidades} />
</div>
<div class="chart-header">
<span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
</div>
{#if loading}
<div class="chart-loading" style="flex: 1; min-height: 100px;">
<span>Cargando...</span>
</div>
{:else}
<div class="chart-flex-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
</div>
{/if}
<!-- KPIs -->
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMontoLargo(Math.round($twMonto))}</span>
<span class="kpi-label">{displayMontoLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{formatPerCapita($twPerCapita)} Bs.</span>
<span class="kpi-label">{displayPerCapitaLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{$twPorcentaje.toLocaleString('es-BO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}%</span>
<span class="kpi-label">{displayPorcentajeLabel}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayRanking}</span>
<span class="kpi-label">{displayRankingLabel}</span>
</div>
</div>
</div>
{#if !selectedEntity && displayTopEntidades.length > 0}
<div class="ranking-section">
<h3 class="ranking-title">Concentración del gasto</h3>
<p class="ranking-desc">Porcentaje del total gastado en {objetoData.desc_objeto?.toLowerCase()} por todo el Estado {hoveredYear ? `en ${hoveredYear}` : `(${primerAño}-${ultimoAño})`}</p>
<div class="ranking-cards">
{#each displayTopEntidades as ent, i}
{@const pct = i === 0 ? $twTop1 : i === 1 ? $twTop2 : $twTop3}
<div class="ranking-card">
<span class="ranking-position">#{i + 1}</span>
<span class="ranking-name">{ent.nombre}</span>
<span class="ranking-pct">{pct.toFixed(1)}%</span>
</div>
{/each}
</div>
</div>
{/if}
</main>
</div>
</div>
<style>
/* ═══════════════════════════════════════════════════════════════
PAGE
═══════════════════════════════════════════════════════════════ */
.page {
min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s ease;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page {
background: #f5f5f7;
}
/* ═══════════════════════════════════════════════════════════════
NAV
═══════════════════════════════════════════════════════════════ */
.page-nav {
padding: 2rem 2rem 0.5rem;
max-width: 1400px;
margin: 0 auto;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.back-link:hover { color: var(--theme-accent); }
/* ═══════════════════════════════════════════════════════════════
LAYOUT
═══════════════════════════════════════════════════════════════ */
.dashboard-layout {
display: grid;
grid-template-columns: minmax(200px, 30%) 1fr;
gap: 1.5rem;
max-width: 1400px;
margin: 0 auto;
padding: 1.5rem 2rem 2rem;
height: calc(100vh - 4rem);
overflow: hidden;
}
/* ═══════════════════════════════════════════════════════════════
SIDEBAR
═══════════════════════════════════════════════════════════════ */
.dashboard-context {
display: flex;
flex-direction: column;
min-height: 0;
}
.context-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
}
.context-card::-webkit-scrollbar { width: 4px; }
.context-card::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.3);
border-radius: 2px;
}
.context-main-title {
font-size: 1.375rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
}
.context-subtitle {
font-family: 'Qanelas', var(--font-sans);
font-size: 0.875rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0;
}
.context-hint {
font-size: 0.75rem;
color: #666666;
margin: 0;
line-height: 1.4;
}
.context-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.context-separator {
border-top: 1px solid var(--theme-borde);
margin: 0.25rem 0;
}
/* ═══════════════════════════════════════════════════════════════
TREE
═══════════════════════════════════════════════════════════════ */
.tree {
font-size: 0.75rem;
line-height: 1.6;
}
.tree-node {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
margin-left: calc(var(--indent, 0) * 1rem);
border-left: 2px solid transparent;
border-radius: 0 6px 6px 0;
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
}
.tree-node:hover { color: var(--theme-accent); }
.tree-node-current {
color: var(--theme-titulo);
font-weight: 600;
border-left-color: var(--theme-accent);
}
.tree-children {
margin-left: calc(var(--indent, 0) * 1rem);
}
.tree-node-child {
padding-left: 0.75rem;
}
.tree-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
opacity: 0.4;
}
.tree-code {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
min-width: 2.5rem;
opacity: 0.6;
}
.tree-name {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 140px;
}
/* ═══════════════════════════════════════════════════════════════
EXPLORE
═══════════════════════════════════════════════════════════════ */
.explore-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.explore-scroll::-webkit-scrollbar { display: none; }
.explore-card {
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: 0.375rem 0.5rem;
width: 120px;
height: 95px;
border-radius: 8px;
background: var(--theme-borde);
text-decoration: none;
color: var(--theme-texto);
transition: all 0.15s;
flex-shrink: 0;
overflow: hidden;
}
.explore-card:hover {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.explore-card:hover .explore-watermark {
opacity: 0.18;
color: var(--theme-accent);
}
.explore-card:hover .explore-title {
color: var(--theme-accent);
}
.explore-watermark {
position: absolute;
bottom: -4px;
right: -4px;
width: 44px;
height: 44px;
opacity: 0.15;
pointer-events: none;
color: var(--theme-accent, #C9A751);
}
.explore-title {
position: relative;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.3;
color: var(--theme-titulo);
z-index: 1;
}
.explore-desc {
position: relative;
font-size: 0.625rem;
line-height: 1.35;
opacity: 0.6;
margin-top: 3px;
z-index: 1;
}
.explore-card-disabled {
opacity: 0.3;
pointer-events: none;
}
/* ═══════════════════════════════════════════════════════════════
VIGENCIA
═══════════════════════════════════════════════════════════════ */
.vigencia-compact {
display: flex;
align-items: center;
gap: 0.75rem;
}
.vigencia-status-icon {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.vigencia-status-icon.vigencia-full {
background: rgba(74, 158, 107, 0.15);
color: #4A9E6B;
}
.vigencia-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.vigencia-main {
font-size: 0.875rem;
font-weight: 500;
color: var(--theme-titulo);
}
.vigencia-sub {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
/* ═══════════════════════════════════════════════════════════════
DEFINICIÓN
═══════════════════════════════════════════════════════════════ */
.def-link {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--theme-accent, #C9A751);
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
}
.def-link:hover { opacity: 0.7; }
.def-link svg { flex-shrink: 0; opacity: 0.8; }
/* ═══════════════════════════════════════════════════════════════
MAIN
═══════════════════════════════════════════════════════════════ */
.dashboard-main {
display: flex;
flex-direction: column;
gap: 1.5rem;
min-height: 0;
overflow: hidden;
}
.chart-protagonista {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
display: flex;
flex-direction: column;
min-height: 0;
flex: 2;
overflow: hidden;
}
.ranking-section {
background: var(--theme-surface);
border-radius: 12px;
padding: 0.75rem;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ═══════════════════════════════════════════════════════════════
CHART & KPIs
═══════════════════════════════════════════════════════════════ */
.chart-top-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.main-title-pills {
display: flex;
gap: 0.375rem;
}
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-nivel {
background: rgba(201, 167, 81, 0.15);
color: var(--theme-accent);
}
.pill-codigo {
background: var(--theme-borde);
color: var(--theme-texto);
}
.main-title-name {
font-family: 'DM Serif Display', serif;
font-size: 1.75rem;
font-weight: 400;
margin: 0 0 0.5rem;
line-height: 1.2;
color: var(--theme-titulo);
}
.chart-controls {
margin-bottom: 0.5rem;
}
.chart-header {
margin-bottom: 0.25rem;
}
.chart-title {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period {
opacity: 0.5;
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
}
.chart-flex-wrapper {
flex: 1;
min-height: 0;
}
.kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.kpi-value {
font-family: 'DM Mono', monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--theme-titulo);
}
.kpi-label {
font-size: 0.5625rem;
color: var(--theme-texto);
opacity: 0.6;
line-height: 1.3;
}
/* ═══════════════════════════════════════════════════════════════
RANKING
═══════════════════════════════════════════════════════════════ */
.ranking-title {
font-size: 1.125rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.25rem;
}
.ranking-desc {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 0.75rem;
}
.ranking-cards {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ranking-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.625rem;
border-radius: 8px;
background: var(--theme-borde);
}
.ranking-position {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.ranking-name {
flex: 1;
font-size: 0.75rem;
color: var(--theme-titulo);
}
.ranking-pct {
font-family: 'DM Mono', monospace;
font-size: 0.8125rem;
font-weight: 600;
color: var(--theme-titulo);
}
/* ═══════════════════════════════════════════════════════════════
DARK MODE
═══════════════════════════════════════════════════════════════ */
:global(html.dark) .context-hint {
color: rgba(255, 255, 255, 0.4);
}
:global(html.dark) .def-link {
color: var(--theme-accent, #C9A751);
}
</style>
......@@ -18,7 +18,7 @@ export async function load({ fetch }) {
const poblacionMap = {};
pobRes.split('\n').slice(1).forEach(line => {
const [cod, gestion, pob] = line.split(',');
const [cod, , gestion, pob] = line.split(',');
if (parseInt(gestion) === 2025) {
poblacionMap[cod] = parseInt(pob);
}
......
......@@ -223,7 +223,7 @@
}
const map = {};
pobCSV.split('\n').slice(1).forEach(line => {
const [cod, g, pob] = line.split(',');
const [cod, , g, pob] = line.split(',');
if (parseInt(g) === gestion) {
map[cod] = parseInt(pob);
}
......
......@@ -19,10 +19,10 @@ export async function load({ params, fetch }) {
throw error(404, 'Ubicación no encontrada');
}
// Parsear población para este código
// Parsear población para este código (codigo_ine)
const poblacionMap = {};
pobRes.split('\n').slice(1).forEach(line => {
const [cod, gestion, pob] = line.split(',');
const [cod, , gestion, pob] = line.split(',');
if (cod === codigo) {
poblacionMap[parseInt(gestion)] = parseInt(pob);
}
......
......@@ -246,7 +246,7 @@
let tieneIngresos = $derived(historiaIngresos.length > 0);
let gestiones = $derived(() => {
const years = [...new Set(resumenData.map(d => d.gestion))].filter(g => poblacionMap[g]).sort();
const years = [...new Set(resumenData.map(d => d.gestion))].filter(g => poblacionMap[g]).sort((a, b) => b - a);
return years;
});
......
This diff could not be displayed because it is too large.