Rafael Lopez

buscador

......@@ -228,3 +228,145 @@ body {
.font-display {
font-family: var(--font-display);
}
/* ============================================
FALLBACK LAYOUT UTILITIES
Respaldo para asegurar que flex siempre funcione
============================================ */
/* Forzar flex en contenedores principales */
.flex {
display: flex !important;
}
.flex-1 {
flex: 1 1 0% !important;
}
.flex-col {
flex-direction: column !important;
}
.flex-row {
flex-direction: row !important;
}
.flex-wrap {
flex-wrap: wrap !important;
}
.flex-shrink-0 {
flex-shrink: 0 !important;
}
.items-center {
align-items: center !important;
}
.items-start {
align-items: flex-start !important;
}
.justify-center {
justify-content: center !important;
}
.justify-between {
justify-content: space-between !important;
}
.gap-1 { gap: 0.25rem !important; }
.gap-2 { gap: 0.5rem !important; }
.gap-3 { gap: 0.75rem !important; }
.gap-4 { gap: 1rem !important; }
.gap-6 { gap: 1.5rem !important; }
/* Grid fallbacks */
.grid {
display: grid !important;
}
/* Hidden utilities */
.hidden {
display: none !important;
}
/* Responsive: mostrar en lg+ */
@media (min-width: 1024px) {
.lg\:flex {
display: flex !important;
}
.lg\:block {
display: block !important;
}
.lg\:hidden {
display: none !important;
}
.lg\:relative {
position: relative !important;
}
.lg\:translate-x-0 {
transform: translateX(0) !important;
}
}
@media (min-width: 1280px) {
.xl\:block {
display: block !important;
}
.xl\:hidden {
display: none !important;
}
}
/* ============================================
CLASIFICADORES LAYOUT - ESTILOS GLOBALES
Forzar layout correcto en todas las páginas
============================================ */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
.clasificador-layout > .sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0 !important;
width: 18rem !important;
z-index: auto !important;
box-shadow: none !important;
}
.clasificador-layout > main {
flex: 1 1 0% !important;
min-width: 0 !important;
}
/* Forzar estilos correctos después del mount (para navegación cliente) */
@media (min-width: 1024px) {
.sidebar-mounted.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0 !important;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
.clasificador-layout > .sidebar-left {
position: fixed !important;
transform: translateX(-100%) !important;
width: 20rem !important;
z-index: 50 !important;
}
.clasificador-layout > .sidebar-left.translate-x-0 {
transform: translateX(0) !important;
}
}
......
......@@ -17,7 +17,7 @@
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<body data-sveltekit-preload-data="off">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
......
......@@ -33,7 +33,12 @@
// Dimensiones del área del gráfico
let effectiveHeight = $derived(fill ? measuredHeight : height);
let chartHeight = $derived(effectiveHeight - marginTop - marginBottom);
let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50));
// Máximo del eje Y (con guard para data vacía o valores undefined)
let maxPerCapita = $derived(
data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 1) : 1
);
// Escalas D3
let xScale = $derived(
......@@ -46,7 +51,7 @@
// Para CSS bottom positioning: 0 → 0%, max → 100%
let yScale = $derived(
scaleLinear()
.domain([0, Math.max(...data.map(d => d.perCapita))])
.domain([0, maxPerCapita])
.range([0, chartHeight])
.nice()
);
......@@ -65,7 +70,8 @@
}
</script>
<div class="chart-wrapper" bind:this={wrapperEl} style="height: {fill ? '100%' : height + 'px'}">
<div class="chart-wrapper" bind:this={wrapperEl} style="height: {fill ? '100%' : height + 'px'}; min-height: {height}px">
{#if data.length > 0 && chartHeight > 0}
<!-- Contenedor principal con márgenes -->
<div class="chart-inner" style="top: {marginTop}px; bottom: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px">
......@@ -118,6 +124,11 @@
</span>
{/each}
</div>
{:else}
<div class="chart-empty">
<span>Sin datos</span>
</div>
{/if}
</div>
<style>
......@@ -210,4 +221,15 @@
.x-label.visible {
opacity: 1;
}
.chart-empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
font-size: 0.875rem;
}
</style>
......
<script>
import { goto } from '$app/navigation';
import {
query,
results,
isLoading as searchLoading,
indexLoaded,
selectedIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
import { indexLoaded } from '$lib/stores/searchStore';
import { search } from '$lib/services/search';
let {
preserveVista = false,
......@@ -19,19 +11,23 @@
let searchInputRef = $state(null);
let searchVal = $state('');
let searchFocused = $state(false);
let isSearching = $state(false);
let localResults = $state([]);
let selectedIdx = $state(-1);
let debounceTimer;
// Filtrar solo objeto_gasto
let filteredSearchResults = $derived($results.filter(r => r.tipo === 'objeto_gasto'));
function handleSearchInput(e) {
const val = e.target.value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (val.length >= 2) {
performSearch(val);
isSearching = true;
// Buscar y filtrar solo objetos de gasto
const allResults = search(val, 50);
localResults = allResults.filter(r => r.tipo === 'objeto_gasto').slice(0, 15);
isSearching = false;
} else {
clearSearch();
localResults = [];
}
}, 200);
}
......@@ -39,14 +35,18 @@
function handleSearchKeydown(e) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
navigateResults(e.key === 'ArrowDown' ? 1 : -1, filteredSearchResults.length);
} else if (e.key === 'Enter' && filteredSearchResults.length > 0) {
if (e.key === 'ArrowDown') {
selectedIdx = selectedIdx < localResults.length - 1 ? selectedIdx + 1 : 0;
} else {
selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : localResults.length - 1;
}
} else if (e.key === 'Enter' && localResults.length > 0) {
e.preventDefault();
const selected = filteredSearchResults[$selectedIndex];
const selected = localResults[selectedIdx >= 0 ? selectedIdx : 0];
if (selected) handleSearchSelect(selected);
} else if (e.key === 'Escape') {
searchVal = '';
clearSearch();
localResults = [];
searchInputRef?.blur();
}
}
......@@ -57,7 +57,7 @@
: `/objeto/${result.codigo}`;
goto(url);
searchVal = '';
clearSearch();
localResults = [];
searchFocused = false;
}
......@@ -68,7 +68,7 @@
}
function handleClear() {
clearSearch();
localResults = [];
searchVal = '';
}
</script>
......@@ -97,17 +97,17 @@
{/if}
{#if searchFocused && searchVal.length >= 2}
<div class="search-dropdown">
{#if $searchLoading}
{#if isSearching}
<div class="search-msg">Buscando...</div>
{:else if filteredSearchResults.length === 0}
{:else if localResults.length === 0}
<div class="search-msg">Sin resultados</div>
{:else}
{#each filteredSearchResults as result, i}
{#each localResults as result, i}
<button
class="search-item"
class:selected={$selectedIndex === i}
class:selected={selectedIdx === i}
onclick={() => handleSearchSelect(result)}
onmouseenter={() => selectedIndex.set(i)}
onmouseenter={() => selectedIdx = i}
>
<span class="item-code">{result.codigo}</span>
<span class="item-name">{result.nombre}</span>
......
<script>
import { onMount } from 'svelte';
let { onOpenDrawer = () => {} } = $props();
let { onOpenDrawer = () => {}, onOpenSearch = () => {} } = $props();
let isDark = $state(false);
let isMac = $state(false);
function toggleTheme() {
isDark = !isDark;
......@@ -12,6 +13,9 @@
}
onMount(() => {
// Detect Mac for keyboard shortcut display
isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
// Read actual state from document (set by app.html)
isDark = document.documentElement.classList.contains('dark');
......@@ -28,40 +32,67 @@
});
</script>
<!-- MÓVIL: Solo hamburguesa arriba a la derecha -->
<div class="fixed top-4 right-4 z-[110] md:hidden">
<!-- MÓVIL: Búsqueda + Theme + hamburguesa arriba a la derecha -->
<div class="navbar-mobile">
<button
onclick={onOpenSearch}
class="nav-btn-icon-mobile"
aria-label="Buscar"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
</button>
<button
onclick={toggleTheme}
class="nav-btn-icon-mobile"
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDark}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
</button>
<button
onclick={onOpenDrawer}
class="nav-icon p-2 rounded-full transition-all flex-shrink-0"
class="nav-btn-icon-mobile"
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
</div>
<!-- DESKTOP: Barra completa con Home + Theme + Menu -->
<div class="hidden md:flex fixed top-6 right-6 z-[110]">
<div class="flex items-center gap-1">
<!-- DESKTOP: Barra completa con Search + Theme + Menu -->
<div class="navbar-desktop">
<div class="navbar-desktop-inner">
<!-- BOTÓN HOME (4 cuadrados) -->
<div class="relative group/home">
<a
href="/"
data-sveltekit-preload-data="off"
class="nav-icon p-2 rounded-full transition-all block"
<!-- BOTÓN BÚSQUEDA GLOBAL -->
<div class="relative group/search">
<button
onclick={onOpenSearch}
class="nav-search-btn"
aria-label="Buscar"
>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<rect x="2" y="2" width="7" height="7" rx="1.5"/>
<rect x="11" y="2" width="7" height="7" rx="1.5"/>
<rect x="2" y="11" width="7" height="7" rx="1.5"/>
<rect x="11" y="11" width="7" height="7" rx="1.5"/>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
</a>
<span class="nav-search-shortcut">
<kbd>{isMac ? '\u2318' : 'Ctrl'}</kbd>
<kbd>K</kbd>
</span>
</button>
<!-- Tooltip -->
<div class="absolute right-0 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover/home:opacity-100 group-hover/home:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Página principal
<div class="absolute right-0 top-full mt-2 px-3 py-1.5 bg-gray-900/90 text-white text-xs rounded-lg whitespace-nowrap opacity-0 invisible group-hover/search:opacity-100 group-hover/search:visible transition-all duration-200 pointer-events-none backdrop-blur-sm">
Buscar
<div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
</div>
</div>
......@@ -70,17 +101,15 @@
<div class="relative group/theme">
<button
onclick={toggleTheme}
class="nav-icon p-2 rounded-full transition-all cursor-pointer"
class="nav-btn-icon"
aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
>
{#if isDark}
<!-- LUNA (modo oscuro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
</svg>
{:else}
<!-- SOL (modo claro activo) -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"/>
</svg>
{/if}
......@@ -96,10 +125,10 @@
<div class="relative group/menu">
<button
onclick={onOpenDrawer}
class="nav-icon p-2 rounded-full transition-all cursor-pointer"
class="nav-btn-icon"
aria-label="Abrir menú"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</button>
......@@ -114,6 +143,40 @@
</div>
<style>
/* Responsive navbar containers */
.navbar-mobile {
display: flex;
position: fixed;
top: 1rem;
right: 1rem;
z-index: 110;
align-items: center;
gap: 0.25rem;
}
.navbar-desktop {
display: none;
position: fixed;
top: 1.5rem;
right: 1.5rem;
z-index: 110;
}
.navbar-desktop-inner {
display: flex;
align-items: center;
gap: 0.25rem;
}
@media (min-width: 768px) {
.navbar-mobile {
display: none;
}
.navbar-desktop {
display: flex;
}
}
/* Iconos del navbar */
.nav-icon {
background-color: transparent;
......@@ -131,4 +194,114 @@
:global(html:not(.dark)) .nav-icon:hover {
color: #1A1A18;
}
/* Search button */
.nav-search-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px 7px 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: #9B9890;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-search-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(201, 167, 81, 0.4);
color: #F5F0E8;
}
.nav-search-shortcut {
display: flex;
gap: 4px;
}
.nav-search-shortcut kbd {
font-family: system-ui, -apple-system, sans-serif;
font-size: 11px;
font-weight: 500;
padding: 3px 7px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 5px;
color: #aaa;
box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.05);
line-height: 1;
}
/* Modo claro */
:global(html:not(.dark)) .nav-search-btn {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.12);
color: #666;
}
:global(html:not(.dark)) .nav-search-btn:hover {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(201, 167, 81, 0.5);
color: #1A1A18;
}
/* Icon buttons (theme, menu) */
.nav-btn-icon {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
color: #9B9890;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(201, 167, 81, 0.4);
color: #F5F0E8;
}
:global(html:not(.dark)) .nav-btn-icon {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.12);
color: #666;
}
:global(html:not(.dark)) .nav-btn-icon:hover {
background: rgba(0, 0, 0, 0.08);
border-color: rgba(201, 167, 81, 0.5);
color: #1A1A18;
}
:global(html:not(.dark)) .nav-search-shortcut kbd {
background: #f5f5f5;
border-color: #d0d0d0;
color: #666;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8);
}
/* Mobile icon buttons */
.nav-btn-icon-mobile {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: #9B9890;
cursor: pointer;
transition: all 0.2s ease;
}
:global(html:not(.dark)) .nav-btn-icon-mobile {
background: rgba(0, 0, 0, 0.04);
border-color: rgba(0, 0, 0, 0.12);
color: #666;
}
</style>
......
<script>
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import {
query,
results,
isLoading,
indexLoaded,
error,
selectedIndex,
initIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
let { open = $bindable(false) } = $props();
let searchInput = $state(null);
let searchVal = $state('');
let isMac = $state(false);
// Search filters
let searchFilters = $state({
entidad: true,
objeto_gasto: true,
rubro: true
});
// Filtered results based on active filters
let filteredResults = $derived(
$results.filter(item => searchFilters[item.tipo])
);
function toggleFilter(tipo) {
searchFilters[tipo] = !searchFilters[tipo];
}
onMount(() => {
isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
initIndex();
});
// Focus input when modal opens
$effect(() => {
if (open) {
tick().then(() => {
searchInput?.focus();
});
} else {
// Clear on close
searchVal = '';
clearSearch();
}
});
function closeModal() {
open = false;
}
function handleInput(e) {
searchVal = e.target.value;
if (searchVal.length >= 2) {
performSearch(searchVal);
} else {
clearSearch();
}
}
function handleKeydown(e) {
if (e.key === 'Escape') {
closeModal();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateResults('down', filteredResults.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
navigateResults('up', filteredResults.length);
} else if (e.key === 'Enter' && $selectedIndex >= 0) {
e.preventDefault();
goToResult(filteredResults[$selectedIndex]);
}
}
function goToResult(item) {
closeModal();
if (item.tipo === 'entidad') {
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
}
}
function handleBackdropClick(e) {
if (e.target === e.currentTarget) {
closeModal();
}
}
// Type labels and colors
const typeConfig = {
entidad: { label: 'Institución', color: '#C9A751' },
objeto_gasto: { label: 'Objeto de gasto', color: '#6B9F78' },
rubro: { label: 'Rubro', color: '#7B8EC9' }
};
</script>
{#if open}
<div class="search-modal-backdrop" onclick={handleBackdropClick} onkeydown={handleKeydown} role="dialog" aria-modal="true">
<div class="search-modal">
<!-- Header with input -->
<div class="search-modal-header">
<svg class="search-modal-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
<input
bind:this={searchInput}
type="text"
class="search-modal-input"
placeholder="Buscar instituciones, gastos, ingresos..."
value={searchVal}
oninput={handleInput}
/>
<button class="search-modal-close" onclick={closeModal}>
<kbd>Esc</kbd>
</button>
</div>
<!-- Filters -->
<div class="search-modal-filters">
<button
class="search-filter-chip"
class:filter-active={searchFilters.entidad}
onclick={() => toggleFilter('entidad')}
>
<span class="filter-dot" style="background:{typeConfig.entidad.color}"></span>
Instituciones
</button>
<button
class="search-filter-chip"
class:filter-active={searchFilters.objeto_gasto}
onclick={() => toggleFilter('objeto_gasto')}
>
<span class="filter-dot" style="background:{typeConfig.objeto_gasto.color}"></span>
Objetos de gasto
</button>
<button
class="search-filter-chip"
class:filter-active={searchFilters.rubro}
onclick={() => toggleFilter('rubro')}
>
<span class="filter-dot" style="background:{typeConfig.rubro.color}"></span>
Rubros
</button>
</div>
<!-- Results -->
<div class="search-modal-results">
{#if !$indexLoaded}
<div class="search-modal-status">Cargando índice...</div>
{:else if $isLoading}
<div class="search-modal-status">Buscando...</div>
{:else if searchVal.length < 2}
<div class="search-modal-hint">
<span class="hint-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4M12 8h.01"/>
</svg>
</span>
<span>Escribe al menos 2 caracteres para buscar</span>
</div>
{:else if filteredResults.length === 0}
<div class="search-modal-status">Sin resultados para "{searchVal}"</div>
{:else}
{#each filteredResults as item, i}
<button
class="search-result-item"
class:result-selected={$selectedIndex === i}
onclick={() => goToResult(item)}
onmouseenter={() => selectedIndex.set(i)}
>
<div class="result-main">
<span class="result-type" style="color:{typeConfig[item.tipo]?.color || '#888'}">
{typeConfig[item.tipo]?.label || item.tipo}
</span>
<span class="result-name">{item.nombre}</span>
{#if item.descripcion}
<span class="result-desc">{item.descripcion}</span>
{/if}
</div>
<span class="result-code">{item.codigo}</span>
</button>
{/each}
{/if}
</div>
<!-- Footer hint -->
<div class="search-modal-footer">
<span class="footer-hint">
<kbd>↑</kbd><kbd>↓</kbd> navegar
</span>
<span class="footer-hint">
<kbd>Enter</kbd> seleccionar
</span>
<span class="footer-hint">
<kbd>Esc</kbd> cerrar
</span>
</div>
</div>
</div>
{/if}
<style>
.search-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
animation: fadeIn 0.15s ease-out;
}
.search-modal {
width: 100%;
max-width: 580px;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 16px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
overflow: hidden;
animation: slideIn 0.2s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Header */
.search-modal-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--theme-borde);
}
.search-modal-icon {
flex-shrink: 0;
color: var(--theme-texto);
opacity: 0.5;
}
.search-modal-input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 1.0625rem;
font-family: inherit;
color: var(--theme-titulo);
caret-color: var(--color-gold);
}
.search-modal-input::placeholder {
color: var(--theme-texto);
opacity: 0.5;
}
.search-modal-close {
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.search-modal-close kbd {
font-family: inherit;
font-size: 0.6875rem;
padding: 4px 8px;
background: var(--theme-fill);
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: var(--theme-texto);
opacity: 0.7;
transition: opacity 0.15s;
}
.search-modal-close:hover kbd {
opacity: 1;
}
/* Filters */
.search-modal-filters {
display: flex;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid var(--theme-borde);
background: var(--theme-fill);
}
.search-filter-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 20px;
font-size: 0.8125rem;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
opacity: 0.5;
}
.search-filter-chip:hover {
opacity: 0.8;
}
.search-filter-chip.filter-active {
opacity: 1;
border-color: var(--theme-texto);
}
.filter-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
/* Results */
.search-modal-results {
max-height: 360px;
overflow-y: auto;
}
.search-modal-status,
.search-modal-hint {
padding: 24px 20px;
text-align: center;
color: var(--theme-texto);
font-size: 0.875rem;
}
.search-modal-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
opacity: 0.6;
}
.hint-icon {
display: flex;
opacity: 0.7;
}
.search-result-item {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 20px;
background: none;
border: none;
border-top: 1px solid var(--theme-borde);
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.search-result-item:first-child {
border-top: none;
}
.search-result-item:hover,
.search-result-item.result-selected {
background: var(--theme-fill);
}
.result-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.result-type {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.result-name {
font-size: 0.9375rem;
font-weight: 500;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-desc {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-code {
flex-shrink: 0;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.5;
padding: 4px 8px;
background: var(--theme-fill);
border-radius: 4px;
}
/* Footer */
.search-modal-footer {
display: flex;
gap: 16px;
padding: 12px 20px;
border-top: 1px solid var(--theme-borde);
background: var(--theme-fill);
}
.footer-hint {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
}
.footer-hint kbd {
font-family: inherit;
font-size: 0.625rem;
padding: 2px 5px;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 4px;
}
/* Responsive */
@media (max-width: 640px) {
.search-modal-backdrop {
padding: 0;
align-items: flex-end;
}
.search-modal {
max-width: 100%;
border-radius: 20px 20px 0 0;
max-height: 85vh;
}
.search-modal-results {
max-height: 50vh;
}
.search-modal-filters {
flex-wrap: wrap;
}
.search-modal-footer {
display: none;
}
}
</style>
......@@ -54,7 +54,7 @@ export async function initSearchIndex() {
const { data: rubros, error: errorRubros } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('rubro, desc_rubros, nivel');
.select('rubro, desc_rubro, nivel');
if (errorRubros) {
console.error('Error cargando rubros:', errorRubros);
......@@ -93,11 +93,11 @@ export async function initSearchIndex() {
const rubrosIndex = rubros.map(item => ({
tipo: 'rubro',
codigo: item.rubro,
nombre: item.desc_rubros,
nombre: item.desc_rubro,
sigla: null,
contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_rubros),
nombre_normalizado: normalizeText(item.desc_rubro),
sigla_normalizada: '',
codigo_normalizado: item.rubro?.toString() || ''
}));
......
......@@ -4,6 +4,7 @@
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/components/ui/Navbar.svelte';
import NavigationDrawer from '$lib/components/layout/NavigationDrawer.svelte';
import SearchModal from '$lib/components/ui/SearchModal.svelte';
import { openDrawer } from '$lib/stores/drawer.js';
import Spinner from '$lib/components/ui/Spinner.svelte';
......@@ -12,6 +13,21 @@
// Don't show navbar on landing page
let isLanding = $derived($page.url.pathname === '/');
// Global search modal state
let searchModalOpen = $state(false);
function openSearchModal() {
searchModalOpen = true;
}
function handleGlobalKeydown(e) {
// Cmd+K or Ctrl+K to open search
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchModalOpen = true;
}
}
// Navigation loading state
let showLoader = $state(false);
let loaderTimeout = null;
......@@ -42,12 +58,17 @@
<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>
<svelte:window onkeydown={handleGlobalKeydown} />
<!-- Navigation Drawer (siempre presente) -->
<NavigationDrawer />
<!-- Global Search Modal -->
<SearchModal bind:open={searchModalOpen} />
<!-- Floating controls bar (fixed position - no afecta el flujo) -->
{#if !isLanding}
<Navbar onOpenDrawer={openDrawer} />
<Navbar onOpenDrawer={openDrawer} onOpenSearch={openSearchModal} />
{/if}
<!-- Navigation loading overlay -->
......
<script>
import { supabase } from '$lib/supabase';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
import * as d3 from 'd3';
let loading = $state(true);
let searchQuery = $state('');
let finalidades = $state([]);
let allItems = $state([]);
let selectedFinalidad = $state(null);
let selectedItem = $state(null);
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
// Modo de visualización desde URL
let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
// ══════════════════════════════════════════════════════════════
// ESTADO PARA TREEMAP (MODO MAPA)
// ══════════════════════════════════════════════════════════════
let availableYears = $state([]);
let selectedYear = $state(null);
let entities = $state([]);
let selectedEntity = $state(null);
let entitySearchQuery = $state('');
let entityDropdownOpen = $state(false);
let yearDropdownOpen = $state(false);
let entityLimit = $state(30);
let treemapData = $state([]);
let treemapRoot = $state(null);
let treemapNodes = $state([]);
let currentTreemapNode = $state(null);
let treemapBreadcrumb = $state([]);
let treemapWidth = $state(0);
let treemapHeight = $state(500);
let treemapContainer = $state(null);
let hoveredNode = $state(null);
let mapaSidebarOpen = $state(false);
let mapaSidebarCollapsed = $state(false);
// Selector de nivel para vista aplanada
let treemapViewLevel = $state('grpfuncion'); // 'jerarquico' | 'finalidad' | 'grpfuncion' | 'funcion'
const NIVEL_OPTIONS = [
{ value: 'jerarquico', label: 'Jerarquía', desc: 'Navegar por niveles' },
{ value: 'finalidad', label: 'Finalidades', desc: '10 categorías' },
{ value: 'grpfuncion', label: 'Grupos Función', desc: '~50 categorías' },
{ value: 'funcion', label: 'Funciones', desc: '~100 categorías' }
];
// Colores por finalidad (primer dígito del código finfun)
const FINALIDAD_COLORS = {
'1': '#4E79A7', // Servicios Públicos Generales - azul
'2': '#A0CBE8', // Defensa - azul claro
'3': '#F28E2B', // Orden Público y Seguridad - naranja
'4': '#FFBE7D', // Asuntos Económicos - naranja claro
'5': '#59A14F', // Protección del Medio Ambiente - verde
'6': '#8CD17D', // Vivienda y Servicios Comunitarios - verde claro
'7': '#E15759', // Salud - rojo
'8': '#B07AA1', // Actividades Recreativas, Cultura y Religión - púrpura
'9': '#EDC948', // Educación - amarillo
'10': '#76B7B2', // Protección Social - teal
};
const FINALIDAD_NAMES = {
'1': 'Servicios Públicos Generales',
'2': 'Defensa',
'3': 'Orden Público y Seguridad',
'4': 'Asuntos Económicos',
'5': 'Protección del Medio Ambiente',
'6': 'Vivienda y Servicios Comunitarios',
'7': 'Salud',
'8': 'Cultura y Religión',
'9': 'Educación',
'10': 'Protección Social',
};
// Función para obtener color según la finalidad (primer dígito del código finfun)
function getNodeColor(finfun, opacity = 1) {
const finalidadDigit = String(finfun).charAt(0);
const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
if (opacity === 1) return baseColor;
return d3.color(baseColor).copy({opacity}).formatRgb();
}
function getTextColor(finfun) {
const finalidadDigit = String(finfun).charAt(0);
const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
const color = d3.color(baseColor);
const r = color.r / 255;
const g = color.g / 255;
const b = color.b / 255;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
}
function getTextColorSecondary(finfun) {
const finalidadDigit = String(finfun).charAt(0);
const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
const color = d3.color(baseColor);
const r = color.r / 255;
const g = color.g / 255;
const b = color.b / 255;
const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
return luminance > 0.5 ? 'rgba(0,0,0,0.78)' : 'rgba(255,255,255,0.88)';
}
// Cerrar dropdowns al hacer clic fuera
function handleClickOutside(e) {
if (entityDropdownOpen && !e.target.closest('.entity-dropdown-sidebar')) {
entityDropdownOpen = false;
}
if (yearDropdownOpen && !e.target.closest('.sidebar-year-dropdown')) {
yearDropdownOpen = false;
}
}
// Formatear números
function formatMoney(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)} mil millones`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)} millones`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)} mil`;
return `Bs ${value.toFixed(0)}`;
}
function formatMoneyCompact(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
return `Bs ${value.toFixed(0)}`;
}
const POBLACION = 12000000;
function formatPerCapita(value) {
const perCapita = value / POBLACION;
if (perCapita >= 1000) return `Bs ${(perCapita / 1000).toFixed(1)}K/hab`;
if (perCapita >= 1) return `Bs ${perCapita.toFixed(0)}/hab`;
return `Bs ${perCapita.toFixed(2)}/hab`;
}
// Cargar datos del treemap
async function loadTreemapData() {
treemapNodes = [];
try {
const entidadFiltro = selectedEntity?.entidad ?? 0;
const { data, error: dbError } = await supabase
.schema('ppto')
.from('treemap_finfun')
.select('gestion, nivel, finfun, desc_funcion, parent, devengado')
.eq('gestion', selectedYear)
.eq('entidad', entidadFiltro)
.gt('devengado', 0);
if (dbError) {
console.error('Error fetching treemap data:', dbError.message);
return;
}
treemapData = data.map(d => ({
gestion: d.gestion,
nivel: d.nivel,
finfun: d.finfun,
desc_finfun: d.desc_funcion,
parent: d.parent,
devengado: d.devengado || 0
}));
console.log('Treemap finfun data loaded:', treemapData.length, 'items');
} catch (err) {
console.error('Error loading treemap data:', err);
}
}
// Cargar entidades para el año
async function loadEntitiesForYear(year) {
if (!year) return;
try {
const { data, error: dbError } = await supabase
.schema('ppto')
.from('entidades_treemap')
.select('entidad, desc_entidad, sigla_entidad')
.eq('gestion', year)
.order('desc_entidad');
if (dbError) {
console.error('Error loading entities:', dbError.message);
return;
}
entities = data || [];
if (selectedEntity) {
const existsInYear = entities.some(e => e.entidad === selectedEntity.entidad);
if (!existsInYear) {
selectedEntity = null;
}
}
} catch (err) {
console.error('Error loading entities:', err);
}
}
// Construir jerarquía D3
function buildTreemapHierarchy() {
if (!treemapData.length) {
console.log('No treemap data to build hierarchy');
return;
}
// MODO JERÁRQUICO
const stratify = d3.stratify()
.id(d => d.finfun)
.parentId(d => d.parent);
const dataWithRoot = [
{ finfun: 'root', parent: null, desc_finfun: 'Total Presupuesto', devengado: 0, nivel: 'root' },
...treemapData.map(d => ({
...d,
parent: d.parent || 'root'
}))
];
try {
const root = stratify(dataWithRoot);
root.sum(d => d.devengado)
.sort((a, b) => b.value - a.value);
root.eachAfter(node => {
if (node.children && node.children.length > 0) {
node.value = node.children.reduce((sum, child) => sum + child.value, 0);
}
});
treemapRoot = root;
currentTreemapNode = root;
treemapBreadcrumb = [{ id: 'root', name: 'Total' }];
calculateTreemapLayout();
} catch (err) {
console.error('Error building hierarchy:', err);
}
}
// Construir treemap aplanado
function buildFlatTreemap() {
const levelItems = treemapData.filter(d => d.nivel === treemapViewLevel);
if (!levelItems.length) {
console.log('No items for level:', treemapViewLevel);
treemapNodes = [];
return;
}
let hierarchyData;
if (treemapViewLevel === 'finalidad') {
// Para finalidades: mostrar directamente sin agrupación
hierarchyData = {
id: 'root',
name: 'Total',
children: levelItems.map(item => ({
id: item.finfun,
name: item.desc_finfun,
value: item.devengado,
finalidadCode: item.finfun
}))
};
} else {
// Para grpfuncion y funcion: agrupar por finalidad (primer dígito)
const finalidadesData = treemapData.filter(d => d.nivel === 'finalidad');
const grouped = {};
levelItems.forEach(item => {
const finalidadCode = String(item.finfun).charAt(0);
if (!grouped[finalidadCode]) {
const finalidadInfo = finalidadesData.find(f => f.finfun === finalidadCode);
grouped[finalidadCode] = {
code: finalidadCode,
name: finalidadInfo?.desc_finfun || FINALIDAD_NAMES[finalidadCode] || `Finalidad ${finalidadCode}`,
items: []
};
}
grouped[finalidadCode].items.push(item);
});
hierarchyData = {
id: 'root',
name: 'Total',
children: Object.values(grouped).map(grupo => ({
id: `finalidad-${grupo.code}`,
name: grupo.name,
finalidadCode: grupo.code,
children: grupo.items.map(item => ({
id: item.finfun,
name: item.desc_finfun,
value: item.devengado,
finalidadCode: grupo.code
}))
}))
};
}
const root = d3.hierarchy(hierarchyData)
.sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
treemapRoot = root;
currentTreemapNode = root;
treemapBreadcrumb = [{ id: 'root', name: `Todos los ${NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || 'items'}` }];
calculateFlatLayout();
}
// Calcular layout aplanado
function calculateFlatLayout() {
if (!treemapRoot || !treemapRoot.children) {
treemapNodes = [];
return;
}
if (treemapWidth <= 0 || treemapHeight <= 0) {
return;
}
const nodes = [];
if (treemapViewLevel === 'finalidad') {
// Para finalidades: layout simple sin grupos anidados
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(4)
.paddingInner(3)
.round(true);
treemap(treemapRoot);
treemapRoot.children.forEach(item => {
nodes.push({
x0: item.x0,
y0: item.y0,
x1: item.x1,
y1: item.y1,
id: item.data.id,
name: item.data.name,
value: item.value,
type: 'item',
grupoCode: item.data.finalidadCode
});
});
} else {
// Para grpfuncion y funcion: grupos con items anidados
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(4)
.paddingTop(22)
.paddingInner(2)
.round(true);
treemap(treemapRoot);
treemapRoot.children.forEach(grupo => {
nodes.push({
x0: grupo.x0,
y0: grupo.y0,
x1: grupo.x1,
y1: grupo.y1,
id: grupo.data.id,
name: grupo.data.name,
value: grupo.value,
type: 'grupo',
grupoCode: grupo.data.finalidadCode
});
if (grupo.children) {
grupo.children.forEach(item => {
nodes.push({
x0: item.x0,
y0: item.y0,
x1: item.x1,
y1: item.y1,
id: item.data.id,
name: item.data.name,
value: item.value,
type: 'item',
grupoCode: grupo.data.finalidadCode
});
});
}
});
}
treemapNodes = nodes;
}
// Calcular layout jerárquico
function calculateTreemapLayout() {
if (!currentTreemapNode) {
treemapNodes = [];
return;
}
if (treemapWidth <= 0 || treemapHeight <= 0) {
return;
}
if (currentTreemapNode.children && currentTreemapNode.children.length > 0) {
const treemap = d3.treemap()
.size([treemapWidth, treemapHeight])
.paddingOuter(3)
.paddingInner(2)
.round(true);
const tempHierarchy = d3.hierarchy({
...currentTreemapNode.data,
children: currentTreemapNode.children.map(c => ({
...c.data,
value: c.value,
_originalNode: c
}))
}).sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
treemap(tempHierarchy);
treemapNodes = tempHierarchy.children.map(child => ({
x0: child.x0,
y0: child.y0,
x1: child.x1,
y1: child.y1,
id: child.data.finfun || child.data.id,
name: child.data.desc_finfun || child.data.name,
value: child.value,
data: child.data,
_originalNode: child.data._originalNode
}));
} else {
treemapNodes = [];
}
}
// Drill down/up
function drillDown(node) {
if (!node._originalNode?.children || node._originalNode.children.length === 0) return;
currentTreemapNode = node._originalNode;
treemapBreadcrumb = [...treemapBreadcrumb, {
id: node.id,
name: node.name || node.data?.desc_finfun || node.id,
nivel: node.data?.nivel
}];
calculateTreemapLayout();
}
function drillUp(targetIndex) {
if (targetIndex === treemapBreadcrumb.length - 1) return;
if (targetIndex === 0) {
currentTreemapNode = treemapRoot;
treemapBreadcrumb = [{ id: 'root', name: 'Total' }];
} else {
const targetId = treemapBreadcrumb[targetIndex].id;
let targetNode = null;
treemapRoot.each(node => {
if ((node.data.finfun || node.data.id) === targetId) {
targetNode = node;
}
});
if (targetNode) {
currentTreemapNode = targetNode;
treemapBreadcrumb = treemapBreadcrumb.slice(0, targetIndex + 1);
}
}
calculateTreemapLayout();
}
// Filtrar entidades
let filteredEntities = $derived(() => {
if (!entitySearchQuery) return entities.slice(0, entityLimit);
const q = entitySearchQuery.toLowerCase();
return entities.filter(e =>
e.desc_entidad?.toLowerCase().includes(q) ||
e.sigla_entidad?.toLowerCase().includes(q)
).slice(0, entityLimit);
});
// Variables para controlar la carga
let lastLoadedKey = $state('');
let dataReady = $state(false);
// Cargar datos cuando cambia año o entidad
$effect(() => {
const year = selectedYear;
const entityId = selectedEntity?.entidad ?? 0;
const mode = viewMode;
const loadKey = `${year}-${entityId}`;
if (mode === 'mapa' && year && loadKey !== lastLoadedKey) {
lastLoadedKey = loadKey;
dataReady = false;
// Cargar datos sin crear dependencias adicionales
untrack(() => {
loadTreemapData().then(() => {
dataReady = true;
});
loadEntitiesForYear(year);
});
}
});
// Función para cambiar el nivel (llamada desde onclick)
function changeLevel(newLevel) {
treemapViewLevel = newLevel;
mapaSidebarOpen = false;
// El efecto se encargará de reconstruir el treemap
}
// Reconstruir cuando los datos están listos
$effect(() => {
// Leer las dependencias explícitamente
const ready = dataReady;
const width = treemapWidth;
const height = treemapHeight;
const level = treemapViewLevel;
if (ready && width > 0 && height > 0) {
// Usar untrack para evitar que las funciones creen dependencias adicionales
untrack(() => {
if (level === 'jerarquico') {
buildTreemapHierarchy();
} else {
buildFlatTreemap();
}
});
}
});
// Observar tamaño del contenedor
$effect(() => {
const container = treemapContainer;
const mode = viewMode;
if (container && mode === 'mapa') {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const newWidth = entry.contentRect.width;
const newHeight = Math.max(400, entry.contentRect.height);
// Actualizar dimensiones
treemapWidth = newWidth;
treemapHeight = newHeight;
}
});
observer.observe(container);
return () => observer.disconnect();
}
});
// Función para cambiar de modo preservando otros parámetros
function setMode(mode) {
const params = new URLSearchParams($page.url.searchParams);
if (mode === 'lista') {
params.delete('modo');
} else {
params.set('modo', mode);
}
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true });
}
onMount(async () => {
// Forzar recálculo de layout para navegación cliente
requestAnimationFrame(() => {
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.order('finfun');
if (!error && data) {
allItems = data;
// Extraer finalidades únicas (nivel más agregado)
finalidades = data
.filter(item => item.nivel === 'finalidad')
.reduce((acc, item) => {
if (!acc.find(f => f.finfun === item.finfun)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => parseInt(a.finfun) - parseInt(b.finfun));
if (finalidades.length > 0) {
selectedFinalidad = finalidades[0];
}
}
// Cargar años disponibles para el treemap
const years = [];
for (let y = 2025; y >= 2006; y--) {
years.push(y);
}
availableYears = years;
selectedYear = years[0]; // Año más reciente por defecto
loading = false;
});
// Helpers para derivar jerarquía desde código finfun
// Jerarquía: Finalidad (1 dígito) → Grupo Función (2 dígitos) → Función (3+ dígitos)
function getFinalidadFromFinfun(finfun) {
return finfun.charAt(0);
}
function getGrpFuncionFromFinfun(finfun) {
return finfun.substring(0, 2);
}
function getItemsForFinalidad(finalidadCode) {
if (!finalidadCode) return { gruposFuncion: [] };
const finalidadNum = getFinalidadFromFinfun(finalidadCode);
// Obtener grupos de función únicos de esta finalidad
const grpFuncionesUnicas = allItems
.filter(item => item.nivel === 'grpfuncion' && getFinalidadFromFinfun(item.finfun) === finalidadNum)
.reduce((acc, item) => {
if (!acc.find(g => g.finfun === item.finfun)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => parseInt(a.finfun) - parseInt(b.finfun));
const gruposFuncion = grpFuncionesUnicas.map(grpFuncion => {
const grpFuncionPrefix = getGrpFuncionFromFinfun(grpFuncion.finfun);
// Obtener funciones de este grupo
const funciones = allItems
.filter(item => item.nivel === 'funcion' && getGrpFuncionFromFinfun(item.finfun) === grpFuncionPrefix)
.reduce((acc, item) => {
if (!acc.find(f => f.finfun === item.finfun)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => parseInt(a.finfun) - parseInt(b.finfun));
return { ...grpFuncion, funciones };
});
return { gruposFuncion };
}
// Búsqueda global
function searchGlobal(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allItems
.filter(item =>
item.finfun?.toString().includes(q) ||
item.desc_finfun?.toLowerCase().includes(q)
)
.reduce((acc, item) => {
if (!acc.find(i => i.finfun === item.finfun)) {
acc.push(item);
}
return acc;
}, [])
.slice(0, 20);
}
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
// Si ya es objeto, usarlo directamente
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
function getMaxYear(rangos) {
if (!rangos) return 0;
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
}
function selectFinalidad(finalidad) {
selectedFinalidad = finalidad;
selectedItem = null;
searchQuery = '';
highlightedItem = null;
sidebarOpen = false;
}
function openDetail(item) {
selectedItem = item;
}
function closeDetail() {
selectedItem = null;
}
function goToSearchResult(item) {
const finalidadNum = getFinalidadFromFinfun(item.finfun);
const targetFinalidad = finalidades.find(f => getFinalidadFromFinfun(f.finfun) === finalidadNum);
if (targetFinalidad) {
selectedFinalidad = targetFinalidad;
highlightedItem = item.finfun;
searchQuery = '';
sidebarOpen = false;
setTimeout(() => {
const prefix = item.nivel === 'funcion' ? 'fn' : item.nivel === 'subfuncion' ? 'sf' : 'ac';
const element = document.getElementById(`${prefix}-${item.finfun}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
setTimeout(() => {
highlightedItem = null;
}, 2000);
}
}
function getNivelLabel(nivel) {
const labels = { finalidad: 'Finalidad', grpfuncion: 'Grupo Función', funcion: 'Función' };
return labels[nivel] || nivel;
}
let finalidadContent = $derived(selectedFinalidad ? getItemsForFinalidad(selectedFinalidad.finfun) : { gruposFuncion: [] });
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
// Contadores
let totalItems = $derived(allItems.length);
let countByNivel = $derived({
finalidades: allItems.filter(i => i.nivel === 'finalidad').length,
gruposFuncion: allItems.filter(i => i.nivel === 'grpfuncion').length,
funciones: allItems.filter(i => i.nivel === 'funcion').length
});
</script>
<svelte:head>
<title>Finalidad y Función | Presupuesto Público</title>
<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>
<svelte:window onclick={handleClickOutside} />
<div class="min-h-screen" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo); transition: background-color 0.2s, color 0.2s;">
<!-- Header pedagógico -->
<header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
<!-- Breadcrumb: responsive -->
<nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
<!-- Móvil: solo padre -->
<a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
← Clasificadores
</a>
<!-- Desktop: ruta completa -->
<div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
<a href="/" class="transition-colors hover:opacity-80">Inicio</a>
<span style="opacity: 0.5;">/</span>
<a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
</div>
</nav>
<div class="max-w-3xl">
<p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Clasificador 03
</p>
<h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
¿Para qué se gasta?
</h1>
<p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
Organiza el gasto público según su <strong style="color: var(--theme-titulo);">propósito o finalidad</strong>:
administración general, defensa, educación, salud, protección social, servicios económicos.
Es la forma de entender hacia qué objetivos se dirige el presupuesto.
</p>
{#if totalItems > 0}
<p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
<span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> categorías:
{countByNivel.finalidades} finalidades, {countByNivel.gruposFuncion} grupos de función, {countByNivel.funciones} funciones
</p>
{/if}
<!-- Jerarquía -->
<div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
<span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
Finalidad
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
Grupo Función
</span>
<span style="color: var(--theme-texto);">→</span>
<span class="px-2.5 py-1 rounded-full" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">
Función
</span>
</div>
<!-- Versión móvil simplificada -->
<p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
Jerarquía: Finalidad → Grupo Función → Función
</p>
<!-- Toggle de modos: Lista, Mapa, Comparar + Botón filtros móvil -->
<div class="flex items-center gap-3">
<div class="header-modes">
<button
class="header-mode-btn"
class:active={viewMode === 'lista'}
onclick={() => setMode('lista')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="header-mode-btn"
class:active={viewMode === 'mapa'}
onclick={() => setMode('mapa')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Mapa
</button>
<button
class="header-mode-btn"
class:active={viewMode === 'comparar'}
onclick={() => setMode('comparar')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Comparar
</button>
</div>
<!-- Botón filtros para móvil en modo mapa -->
{#if viewMode === 'mapa'}
<button
class="lg:hidden flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
style="background-color: var(--theme-fill); color: var(--theme-titulo);"
onclick={() => mapaSidebarOpen = !mapaSidebarOpen}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Filtros
</button>
{/if}
</div>
</div>
</div>
</header>
{#if loading}
<div class="flex items-center justify-center py-20">
<p style="color: var(--theme-texto);">Cargando clasificador...</p>
</div>
{:else if viewMode === 'mapa'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO MAPA: Treemap de proporciones del gasto por finalidad -->
<!-- ═══════════════════════════════════════════════════════════ -->
<div class="mapa-layout">
<!-- Sidebar con controles -->
<aside class="mapa-sidebar" class:open={mapaSidebarOpen} class:collapsed={mapaSidebarCollapsed}>
<!-- Jalador para colapsar en desktop -->
<button
class="sidebar-puller"
onclick={() => mapaSidebarCollapsed = !mapaSidebarCollapsed}
aria-label={mapaSidebarCollapsed ? 'Mostrar panel de filtros' : 'Ocultar panel de filtros'}
>
<span class="puller-grip">
<span class="grip-dot"></span>
<span class="grip-dot"></span>
<span class="grip-dot"></span>
<span class="grip-dot"></span>
<span class="grip-dot"></span>
<span class="grip-dot"></span>
</span>
</button>
<!-- Botón cerrar en móvil -->
<button
class="lg:hidden absolute top-3 right-3 p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/5"
onclick={() => mapaSidebarOpen = false}
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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div class="sidebar-content">
<!-- Selector de Modo (Lista, Mapa, Comparar) -->
<div class="sidebar-section">
<label class="sidebar-label">Explorar</label>
<div class="sidebar-modes">
<button
class="mode-btn"
class:active={viewMode === 'lista'}
onclick={() => { setMode('lista'); mapaSidebarOpen = false; }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="mode-btn"
class:active={viewMode === 'mapa'}
onclick={() => { setMode('mapa'); mapaSidebarOpen = false; }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Mapa
</button>
<button
class="mode-btn"
class:active={viewMode === 'comparar'}
onclick={() => { setMode('comparar'); mapaSidebarOpen = false; }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Comparar
</button>
</div>
</div>
<!-- Navegación drill-down (solo en modo jerárquico con depth > 1) -->
{#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 1}
<div class="sidebar-section">
<label class="sidebar-label">Navegación</label>
<nav class="sidebar-breadcrumb">
{#each treemapBreadcrumb as crumb, i}
<button
onclick={() => drillUp(i)}
class="breadcrumb-item"
class:active={i === treemapBreadcrumb.length - 1}
>
{#if i > 0}
<svg class="breadcrumb-arrow" width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M2 1L5 4L2 7" />
</svg>
{/if}
<span class="truncate">{crumb.name}</span>
</button>
{/each}
</nav>
</div>
{/if}
<!-- Selector de Nivel de detalle -->
<div class="sidebar-section">
<label class="sidebar-label">Nivel</label>
<div class="sidebar-options">
{#each NIVEL_OPTIONS as opt}
<button
class="sidebar-option"
class:active={treemapViewLevel === opt.value}
onclick={() => changeLevel(opt.value)}
>
<span class="option-label">{opt.label}</span>
<span class="option-desc">{opt.desc}</span>
</button>
{/each}
</div>
</div>
<!-- Selector de Año -->
<div class="sidebar-section sidebar-year-dropdown">
<label class="sidebar-label">Año</label>
<div class="relative">
<button
class="sidebar-dropdown-btn"
onclick={() => yearDropdownOpen = !yearDropdownOpen}
>
<span>{selectedYear || 'Seleccionar'}</span>
<svg class="dropdown-chevron" class:open={yearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if yearDropdownOpen}
<div class="sidebar-dropdown-panel">
<div class="sidebar-dropdown-list">
{#each availableYears as year}
<button
class="sidebar-dropdown-option"
class:active={selectedYear === year}
onclick={() => { selectedYear = year; yearDropdownOpen = false; }}
>
{year}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
<!-- Selector de Entidad -->
<div class="sidebar-section entity-dropdown-sidebar">
<label class="sidebar-label">Entidad</label>
<div class="relative">
<button
class="sidebar-entity-btn"
onclick={() => entityDropdownOpen = !entityDropdownOpen}
>
<span class="truncate">{selectedEntity?.desc_entidad || 'Todo el Estado'}</span>
<svg class:open={entityDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if entityDropdownOpen}
<div class="entity-dropdown-panel">
<div class="p-2 border-b" style="border-color: var(--theme-borde);">
<input
type="text"
bind:value={entitySearchQuery}
placeholder="Buscar entidad..."
class="w-full px-3 py-2 text-sm rounded-md"
style="border: 1px solid var(--theme-borde); background: var(--theme-body); color: var(--theme-titulo);"
/>
</div>
<div class="entity-list">
<button
class="entity-option"
class:active={selectedEntity === null}
onclick={() => { selectedEntity = null; entityDropdownOpen = false; entitySearchQuery = ''; }}
>
<span style="color: var(--theme-accent);">Todo el Estado</span>
</button>
{#each filteredEntities() as entity}
<button
class="entity-option"
class:active={selectedEntity?.entidad === entity.entidad}
onclick={() => { selectedEntity = entity; entityDropdownOpen = false; entitySearchQuery = ''; }}
>
<span class="truncate">{entity.desc_entidad}</span>
{#if entity.sigla_entidad}
<span class="text-xs opacity-50">({entity.sigla_entidad})</span>
{/if}
</button>
{/each}
{#if filteredEntities().length >= entityLimit}
<button
class="entity-option"
style="color: var(--theme-accent); justify-content: center;"
onclick={() => entityLimit += 30}
>
Cargar más...
</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
</div>
</aside>
<!-- Overlay para cerrar sidebar en móvil -->
{#if mapaSidebarOpen}
<button
class="mapa-overlay lg:hidden"
onclick={() => mapaSidebarOpen = false}
aria-label="Cerrar sidebar"
></button>
{/if}
<!-- Área principal del treemap -->
<div class="mapa-main">
<!-- Contenedor del Treemap -->
<div
bind:this={treemapContainer}
class="relative rounded-xl overflow-hidden w-full treemap-container"
style="height: {treemapHeight}px;"
>
{#if treemapNodes.length > 0 && treemapWidth > 0}
<svg
width={treemapWidth}
height={treemapHeight}
class="block"
>
{#if treemapViewLevel === 'jerarquico'}
<!-- MODO JERÁRQUICO: con drill-down -->
{@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
{#each treemapNodes as node}
{@const width = node.x1 - node.x0}
{@const height = node.y1 - node.y0}
{@const hasChildren = node._originalNode?.children && node._originalNode.children.length > 0}
{@const isHovered = hoveredNode === node}
{@const textColor = getTextColor(node.id)}
{@const textColorSecondary = getTextColorSecondary(node.id)}
{@const label = node.data?.desc_finfun || node.name || node.id}
{@const pct = (node.value / totalValue) * 100}
{@const area = width * height}
{@const scale = Math.sqrt(area) / 12}
{@const pctSize = Math.min(20, Math.max(9, scale * 1.6))}
{@const nameSize = Math.min(14, Math.max(8, scale * 1.0))}
{@const valueSize = Math.min(12, Math.max(7, scale * 0.8))}
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onclick={() => hasChildren && drillDown(node)}
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: {hasChildren ? 'pointer' : 'default'};"
>
<rect
width={width}
height={height}
fill={getNodeColor(node.id, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'var(--theme-surface)'}
stroke-width={isHovered ? 2 : 1}
rx="2"
/>
{#if width > 45 && height > 30}
<foreignObject x="5" y="4" width={width - 10} height={height - 8} style="pointer-events: none;">
<div class="flex flex-col gap-0 overflow-hidden" style="font-family: var(--font-sans); pointer-events: none;">
<div class="font-semibold leading-none opacity-80" style="font-size: {pctSize}px; color: {textColor};">
{pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
</div>
{#if height > 50 && width > 55}
<div class="leading-tight mt-1 opacity-90" style="font-size: {nameSize}px; color: {textColor};">
{width > 120 ? label : label.slice(0, Math.floor(width / 7)) + (label.length > Math.floor(width / 7) ? '…' : '')}
</div>
{/if}
{#if height > 70 && width > 65}
<div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
</foreignObject>
{/if}
{#if hasChildren && width > 30 && height > 30}
{@const btnSize = Math.min(10, Math.max(6, scale * 0.6))}
<circle
cx={width - btnSize - 4}
cy={btnSize + 4}
r={btnSize}
fill={textColor.includes('255') ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.15)'}
class="transition-opacity"
style="opacity: {isHovered ? 1 : 0.5};"
/>
<text
x={width - btnSize - 4}
y={btnSize + 7}
text-anchor="middle"
fill={textColor}
font-size={btnSize * 1.2}
font-weight="bold"
>+</text>
{/if}
</g>
{/each}
{:else}
<!-- MODO APLANADO: finalidades con títulos + items -->
{@const flatTotalValue = treemapRoot?.value || 1}
{#each treemapNodes as node}
{@const width = node.x1 - node.x0}
{@const height = node.y1 - node.y0}
{@const isHovered = hoveredNode === node}
{@const finalidadId = node.grupoCode}
{@const textColor = getTextColor(finalidadId)}
{@const textColorSecondary = getTextColorSecondary(finalidadId)}
{#if node.type === 'grupo'}
<!-- Contenedor de finalidad con título -->
<g class="treemap-node" transform="translate({node.x0}, {node.y0})">
<!-- Fondo de la finalidad (sutil) -->
<rect
width={width}
height={height}
fill={getNodeColor(finalidadId, 0.08)}
stroke={getNodeColor(finalidadId, 0.3)}
stroke-width="1"
rx="3"
/>
<!-- Título de la finalidad con fondo para legibilidad -->
{#if width > 50}
{@const labelText = width > 180 ? node.name : (width > 100 ? node.name.slice(0, 18) + (node.name.length > 18 ? '…' : '') : (width > 60 ? node.name.slice(0, 10) + '…' : 'F' + node.grupoCode))}
<rect
x="3"
y="2"
width={Math.min(labelText.length * 5.8 + 16, width - 6)}
height="16"
fill="rgba(0,0,0,0.5)"
rx="3"
/>
<text
x="8"
y="13"
fill="#ffffff"
font-size="10"
font-weight="600"
style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; text-transform: uppercase; letter-spacing: 0.5px;"
>
{labelText}
</text>
{/if}
</g>
{:else}
<!-- Item individual -->
{@const pct = (node.value / flatTotalValue) * 100}
{@const area = width * height}
{@const scale = Math.sqrt(area) / 12}
{@const pctSize = Math.min(20, Math.max(9, scale * 1.5))}
{@const nameSize = Math.min(13, Math.max(8, scale * 0.95))}
{@const valueSize = Math.min(11, Math.max(7, scale * 0.75))}
<g
class="treemap-node"
transform="translate({node.x0}, {node.y0})"
onmouseenter={() => hoveredNode = node}
onmouseleave={() => hoveredNode = null}
style="cursor: pointer;"
>
<rect
width={width}
height={height}
fill={getNodeColor(finalidadId, isHovered ? 1 : 0.85)}
stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
stroke-width={isHovered ? 2 : 0}
rx="2"
/>
{#if width > 35 && height > 22}
<foreignObject x="4" y="3" width={width - 8} height={height - 6} style="pointer-events: none;">
<div class="flex flex-col gap-0 overflow-hidden" style="font-family: var(--font-sans); pointer-events: none;">
<div class="font-semibold leading-none opacity-75" style="font-size: {pctSize}px; color: {textColor};">
{pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
</div>
{#if height > 40 && width > 50}
<div class="leading-tight mt-0.5 opacity-85" style="font-size: {nameSize}px; color: {textColor};">
{width > 90 ? (node.name || node.id) : (node.name || node.id).slice(0, Math.floor(width / 6)) + ((node.name || node.id).length > Math.floor(width / 6) ? '…' : '')}
</div>
{/if}
{#if height > 55 && width > 55}
<div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
</foreignObject>
{/if}
</g>
{/if}
{/each}
{/if}
</svg>
<!-- Tooltip flotante (frosted glass) -->
{#if hoveredNode && hoveredNode.type !== 'grupo'}
{@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
<div
class="treemap-tooltip"
style="
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
"
>
{#if treemapViewLevel !== 'jerarquico'}
<!-- Vista aplanada: mostrar contexto de la finalidad -->
<div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
</div>
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total
</div>
{:else}
<!-- Vista jerárquica -->
<div class="font-medium text-sm">{hoveredNode.data?.desc_finfun || hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
</div>
{#if currentTreemapNode && currentTreemapNode.value}
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}% del gasto total
</div>
{/if}
{#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0}
<div class="text-xs opacity-60 mt-1">Clic para explorar</div>
{/if}
{/if}
</div>
{/if}
{:else}
<!-- Estado vacío / cargando -->
<div class="absolute inset-0 flex flex-col items-center justify-center p-8">
<svg class="treemap-loader" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" rx="1" width="10" height="10"><animate id="spinner_c7A9" begin="0;spinner_23zP.end" attributeName="x" dur="0.2s" values="1;13" fill="freeze"/><animate id="spinner_Acnw" begin="spinner_ZmWi.end" attributeName="y" dur="0.2s" values="1;13" fill="freeze"/><animate id="spinner_iIcm" begin="spinner_zfQN.end" attributeName="x" dur="0.2s" values="13;1" fill="freeze"/><animate id="spinner_WX4U" begin="spinner_rRAc.end" attributeName="y" dur="0.2s" values="13;1" fill="freeze"/></rect><rect x="1" y="13" rx="1" width="10" height="10"><animate id="spinner_YLx7" begin="spinner_c7A9.end" attributeName="y" dur="0.2s" values="13;1" fill="freeze"/><animate id="spinner_vwnJ" begin="spinner_Acnw.end" attributeName="x" dur="0.2s" values="1;13" fill="freeze"/><animate id="spinner_KQuy" begin="spinner_iIcm.end" attributeName="y" dur="0.2s" values="1;13" fill="freeze"/><animate id="spinner_arKy" begin="spinner_WX4U.end" attributeName="x" dur="0.2s" values="13;1" fill="freeze"/></rect><rect x="13" y="13" rx="1" width="10" height="10"><animate id="spinner_ZmWi" begin="spinner_YLx7.end" attributeName="x" dur="0.2s" values="13;1" fill="freeze"/><animate id="spinner_zfQN" begin="spinner_vwnJ.end" attributeName="y" dur="0.2s" values="13;1" fill="freeze"/><animate id="spinner_rRAc" begin="spinner_KQuy.end" attributeName="x" dur="0.2s" values="1;13" fill="freeze"/><animate id="spinner_23zP" begin="spinner_arKy.end" attributeName="y" dur="0.2s" values="1;13" fill="freeze"/></rect></svg>
<p class="text-sm mt-4" style="color: var(--theme-texto); opacity: 0.6;">Cargando visualización...</p>
</div>
{/if}
</div>
</div>
</div>
{:else if viewMode === 'comparar'}
<!-- MODO COMPARAR -->
<div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-12">
<div class="text-center py-20" style="color: var(--theme-texto);">
<svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<p class="text-lg font-medium mb-2" style="color: var(--theme-titulo);">Vista Comparar</p>
<p class="text-sm">Próximamente: comparación lado a lado entre años y entidades</p>
</div>
</div>
{:else}
<!-- MODO LISTA -->
<!-- Botón móvil para abrir 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">{selectedFinalidad ? selectedFinalidad.desc_finfun : 'Seleccionar finalidad'}</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 izquierda: Finalidades -->
{#if sidebarOpen}
<div
transition:fade={{ duration: 150 }}
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">
<!-- 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);">Finalidades</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..."
class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
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);">{getNivelLabel(result.nivel)}</span>
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.finfun}</span>
<span class="ml-1">{result.desc_finfun}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
{/if}
</div>
</div>
{:else}
<!-- Lista de finalidades -->
<nav>
<p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Finalidades</p>
<ul class="space-y-1">
{#each finalidades as finalidad}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
style="{selectedFinalidad?.finfun === finalidad.finfun
? `background-color: color-mix(in srgb, var(--theme-accent) 15%, transparent); color: var(--theme-titulo); font-weight: 500; border-left: 2px solid var(--theme-accent);`
: `color: var(--theme-texto);`}"
onclick={() => selectFinalidad(finalidad)}
>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">{finalidad.finfun}</span>
{finalidad.desc_finfun}
</button>
</li>
{/each}
</ul>
</nav>
{/if}
</div>
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
<div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
{#if selectedFinalidad}
{#key selectedFinalidad.finfun}
<div in:fade={{ duration: 200, delay: 50 }}>
<!-- Título de la finalidad -->
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">{selectedFinalidad.finfun}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
{selectedFinalidad.desc_finfun}
<a
href="/finfun/{selectedFinalidad.finfun}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver detalle"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h2>
{#if selectedFinalidad.descripciones}
{@const finalidadDescs = parseDescripciones(selectedFinalidad.descripciones)}
{#if finalidadDescs.length > 0}
<p class="leading-relaxed" style="color: var(--theme-texto);">{finalidadDescs[0].descripcion}</p>
{#if selectedFinalidad.n_variaciones > 1}
<button
class="text-sm text-orange-500 hover:text-orange-700 mt-2 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(selectedFinalidad)}
>
Ver {selectedFinalidad.n_variaciones} variaciones históricas
</button>
{/if}
{/if}
{/if}
<!-- Vigencia temporal -->
{#if selectedFinalidad.gestiones}
<p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
Vigente: {selectedFinalidad.gestiones}
</p>
{/if}
</div>
<!-- Grupos de Función -->
<div class="space-y-12">
{#each finalidadContent.gruposFuncion as grpFuncion}
{@const grpFuncionDescs = parseDescripciones(grpFuncion.descripciones)}
<section
id="gf-{grpFuncion.finfun}"
class="scroll-mt-4 {highlightedItem === grpFuncion.finfun ? 'highlighted' : ''}"
>
<div class="flex items-start gap-2 sm:gap-4 mb-3">
<span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{grpFuncion.finfun}</span>
<div class="flex-1">
<h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{grpFuncion.desc_finfun}</span>
{#if grpFuncion.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(grpFuncion)}
title="Ver variaciones de descripción"
>
{grpFuncion.n_variaciones} var.
</button>
{/if}
<a
href="/finfun/{grpFuncion.finfun}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h3>
{#if grpFuncionDescs.length > 0}
<p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{grpFuncionDescs[0].descripcion}</p>
{/if}
</div>
</div>
<!-- Funciones -->
{#if grpFuncion.funciones?.length > 0}
<div class="ml-4 sm:ml-8 lg:ml-16 space-y-5 border-l pl-4 sm:pl-6 lg:pl-8" style="border-color: var(--theme-borde);">
{#each grpFuncion.funciones as funcion}
{@const funcionDescs = parseDescripciones(funcion.descripciones)}
<div
id="fn-{funcion.finfun}"
class="scroll-mt-4 {highlightedItem === funcion.finfun ? 'highlighted-md' : ''}"
>
<div class="flex items-start gap-2 sm:gap-3">
<span class="font-mono text-xs pt-0.5" style="color: var(--theme-texto);">{funcion.finfun}</span>
<div class="flex-1">
<h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{funcion.desc_finfun}</span>
{#if funcion.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(funcion)}
title="Ver variaciones de descripción"
>
{funcion.n_variaciones} var.
</button>
{/if}
<a
href="/finfun/{funcion.finfun}"
class="transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="Ver página de detalle"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h4>
{#if funcionDescs.length > 0}
<p class="text-xs mt-1 leading-relaxed" style="color: var(--theme-texto);">{funcionDescs[0].descripcion}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</section>
{/each}
</div>
</div>
{/key}
{/if}
</div>
</main>
<!-- Sidebar derecha: En esta página -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
<p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
{#if selectedFinalidad && !isSearching}
<nav class="space-y-2">
{#each finalidadContent.gruposFuncion as grpFuncion}
<div>
<a
href="#gf-{grpFuncion.finfun}"
class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="{grpFuncion.desc_finfun}"
>
{grpFuncion.desc_finfun}
</a>
{#if grpFuncion.funciones?.length > 0}
<div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);">
{#each grpFuncion.funciones.slice(0, 5) as funcion}
<a
href="#fn-{funcion.finfun}"
class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto); opacity: 0.7;"
title="{funcion.desc_finfun}"
>
{funcion.desc_finfun}
</a>
{/each}
{#if grpFuncion.funciones.length > 5}
<span class="text-xs" style="color: var(--theme-texto); opacity: 0.5;">+{grpFuncion.funciones.length - 5} más</span>
{/if}
</div>
{/if}
</div>
{/each}
</nav>
{/if}
</div>
</aside>
</div>
{/if}
</div>
<!-- Modal de detalle -->
{#if selectedItem}
<div
transition:fade={{ duration: 150 }}
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
onclick={closeDetail}
>
<div
transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
style="background-color: var(--theme-surface);"
onclick={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
<div>
<p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">{getNivelLabel(selectedItem.nivel)}</p>
<h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
<span class="font-mono" style="color: var(--theme-texto);">{selectedItem.finfun}</span>
<span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
{selectedItem.desc_finfun}
</h3>
</div>
<button
onclick={closeDetail}
class="text-2xl leading-none"
style="color: var(--theme-texto);"
>
&times;
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
{#if selectedItem.n_variaciones > 1}
Descripciones ({selectedItem.n_variaciones} variaciones)
{:else}
Descripción
{/if}
</h4>
<div class="space-y-4">
{#each parseDescripciones(selectedItem.descripciones) as desc, i}
<div class="border-l-2 pl-4 py-3 rounded-r" style="{i === 0 ? `border-color: var(--theme-accent); background-color: color-mix(in srgb, var(--theme-accent) 10%, transparent);` : `border-color: var(--theme-borde);`}">
<p class="text-sm mb-2" style="color: var(--theme-texto);">
{#if i === 0 && selectedItem.n_variaciones > 1}
<span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
<span class="mx-1" style="opacity: 0.5;">·</span>
{/if}
<span class="font-mono">{desc.rangos}</span>
</p>
<p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
</div>
{/each}
</div>
<!-- Vigencia temporal -->
{#if selectedItem.gestiones}
<div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
<p class="text-xs font-mono" style="color: var(--theme-texto);">
<span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
</p>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
/* Header mode selector */
.header-modes {
display: inline-flex;
gap: 0.25rem;
background: var(--theme-fill);
padding: 0.25rem;
border-radius: 0.5rem;
}
.header-mode-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-texto);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.header-mode-btn:hover {
background: var(--theme-surface);
color: var(--theme-titulo);
}
.header-mode-btn.active {
background: var(--theme-surface);
color: var(--theme-titulo);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
:global(.light) .header-modes,
:global(html:not(.dark)) .header-modes {
background: #f0f0f0;
}
:global(.light) .header-mode-btn.active,
:global(html:not(.dark)) .header-mode-btn.active {
background: #ffffff;
box-shadow: 0 1px 3px rgba(28,28,26,0.1);
}
/* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
.sidebar-left {
background-color: var(--theme-surface);
}
@media (min-width: 1024px) {
.sidebar-left {
background-color: transparent;
}
}
/* Highlight pulse animation para resultados de búsqueda */
@keyframes highlight-pulse {
0%, 100% {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent);
}
50% {
box-shadow: 0 0 0 4px color-mix(in srgb, var(--theme-accent) 50%, transparent);
}
}
.highlighted {
animation: highlight-pulse 0.8s ease-in-out 2;
border-radius: 0.5rem;
}
.highlighted-sm {
animation: highlight-pulse 0.8s ease-in-out 2;
border-radius: 0.25rem;
padding: 0.25rem;
margin-left: -0.25rem;
}
.highlighted-md {
animation: highlight-pulse 0.8s ease-in-out 2;
border-radius: 0.5rem;
padding: 0.5rem;
margin-left: -0.5rem;
}
/* Layout principal - fallback nativo */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
/* Sidebar - fallback para responsive */
.sidebar-left {
position: fixed;
transform: translateX(-100%);
}
@media (min-width: 1024px) {
.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0;
width: 16rem;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
}
/* ═══════════════════════════════════════════════════════════ */
/* MAPA LAYOUT: Sidebar + Treemap */
/* ═══════════════════════════════════════════════════════════ */
/* Treemap loader */
.treemap-loader {
width: 32px;
height: 32px;
fill: var(--theme-texto);
opacity: 0.4;
}
:global(html.dark) .treemap-loader {
fill: var(--theme-texto);
opacity: 0.5;
}
.mapa-layout {
display: flex;
height: calc(100vh - 90px);
max-height: calc(100vh - 90px);
position: relative;
overflow: hidden;
}
.mapa-sidebar {
width: 220px;
flex-shrink: 0;
background-color: var(--theme-surface);
border-right: 1px solid var(--theme-borde);
padding: 1rem;
overflow-y: auto;
position: relative;
transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
}
/* Desktop: sidebar colapsable */
@media (min-width: 1024px) {
.mapa-sidebar.collapsed {
width: 0;
padding: 0;
overflow: hidden;
border-right: none;
}
.mapa-sidebar.collapsed .sidebar-content {
opacity: 0;
pointer-events: none;
}
}
/* Jalador estilo drawer */
.sidebar-puller {
display: none;
position: absolute;
top: 50%;
right: -12px;
transform: translateY(-50%);
width: 24px;
height: 48px;
border: 1px solid var(--theme-borde);
border-left: none;
background: var(--theme-fill);
border-radius: 0 8px 8px 0;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.12);
cursor: ew-resize;
z-index: 10;
transition: background-color 0.15s ease, box-shadow 0.15s ease;
align-items: center;
justify-content: center;
}
.sidebar-puller:hover {
background: var(--theme-borde);
box-shadow: 3px 0 12px rgba(0, 0, 0, 0.18);
}
.sidebar-puller:active {
cursor: grabbing;
}
/* Grip dots pattern */
.puller-grip {
display: grid;
grid-template-columns: repeat(2, 4px);
grid-template-rows: repeat(3, 4px);
gap: 3px;
}
.grip-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--theme-texto);
opacity: 0.4;
transition: opacity 0.15s ease;
}
.sidebar-puller:hover .grip-dot {
opacity: 0.7;
}
@media (min-width: 1024px) {
.sidebar-puller {
display: flex;
}
/* Cuando está colapsado, el puller queda visible al borde izquierdo */
.mapa-sidebar.collapsed .sidebar-puller {
position: fixed;
left: 0;
right: auto;
}
}
/* Móvil: sidebar como drawer */
@media (max-width: 1023px) {
.mapa-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
z-index: 100;
transform: translateX(-100%);
transition: transform 0.3s ease;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
}
.mapa-sidebar.open {
transform: translateX(0);
}
}
.mapa-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 99;
}
.mapa-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0.5rem;
overflow: hidden;
}
.treemap-container {
flex: 1;
min-height: 400px;
}
/* Sidebar sections */
.sidebar-content {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.sidebar-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sidebar-label {
font-family: var(--font-sans);
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
}
/* Vista options */
.sidebar-options {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sidebar-option {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
transition: background-color 0.15s ease;
text-align: left;
}
.sidebar-option:hover {
background-color: var(--theme-fill);
}
.sidebar-option.active {
background-color: var(--theme-borde);
}
.sidebar-option .option-label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-titulo);
}
.sidebar-option .option-desc {
font-size: 0.6875rem;
color: var(--theme-texto);
margin-top: 0.125rem;
}
/* Mode buttons (Lista, Mapa, Comparar) */
.sidebar-modes {
display: flex;
gap: 0.25rem;
background: var(--theme-fill);
padding: 0.25rem;
border-radius: 0.5rem;
}
.mode-btn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.25rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--theme-texto);
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.mode-btn:hover {
background: var(--theme-surface);
color: var(--theme-titulo);
}
.mode-btn.active {
background: var(--theme-surface);
color: var(--theme-titulo);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Sidebar dropdown buttons (año y entidad) */
.sidebar-dropdown-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: none;
background: var(--theme-fill);
color: var(--theme-titulo);
font-size: 0.8125rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.sidebar-dropdown-btn:hover {
background: var(--theme-borde);
}
.dropdown-chevron {
color: var(--theme-texto);
transition: transform 0.2s ease;
}
.dropdown-chevron.open {
transform: rotate(180deg);
}
.sidebar-dropdown-panel {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 50;
overflow: hidden;
}
.sidebar-dropdown-list {
max-height: 200px;
overflow-y: auto;
}
.sidebar-dropdown-option {
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
text-align: left;
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--theme-titulo);
cursor: pointer;
transition: background-color 0.15s ease;
}
.sidebar-dropdown-option:hover {
background: var(--theme-fill);
}
.sidebar-dropdown-option.active {
background: var(--theme-fill);
font-weight: 500;
}
/* Sidebar breadcrumb navigation */
.sidebar-breadcrumb {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--theme-texto);
font-size: 0.75rem;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
}
.breadcrumb-item:hover {
background: var(--theme-fill);
color: var(--theme-titulo);
}
.breadcrumb-item.active {
background: color-mix(in srgb, var(--theme-accent) 15%, transparent);
color: var(--theme-titulo);
font-weight: 500;
}
.breadcrumb-arrow {
color: var(--theme-texto);
opacity: 0.5;
flex-shrink: 0;
}
/* Entity dropdown in sidebar */
.sidebar-entity-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
border: none;
background: var(--theme-fill);
color: var(--theme-titulo);
font-size: 0.8125rem;
cursor: pointer;
transition: background-color 0.15s ease;
}
.sidebar-entity-btn:hover {
background: var(--theme-borde);
}
.sidebar-entity-btn svg {
color: var(--theme-texto);
transition: transform 0.2s ease;
}
.entity-dropdown-panel {
position: fixed;
bottom: auto;
top: 150px;
right: 280px;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 0.5rem;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
z-index: 9999;
max-height: 400px;
overflow: hidden;
width: 380px;
}
.entity-list {
max-height: 280px;
overflow-y: auto;
}
.entity-option {
width: 100%;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
text-align: left;
font-size: 0.75rem;
color: var(--theme-titulo);
cursor: pointer;
transition: background-color 0.15s ease;
}
.entity-option:hover {
background: var(--theme-fill);
}
.entity-option.active {
background: color-mix(in srgb, var(--theme-accent) 15%, transparent);
}
/* Tooltip frosted glass */
.treemap-tooltip {
position: absolute;
pointer-events: none;
padding: 0.75rem 1rem;
border-radius: 12px;
z-index: 10;
max-width: 280px;
background: rgba(255, 255, 255, 0.75);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
color: #1a1a1a;
font-family: var(--font-sans);
}
:global(html.dark) .treemap-tooltip {
background: rgba(30, 30, 30, 0.75);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
color: #f5f5f5;
}
/* Transiciones suaves para el treemap */
:global(.treemap-node) {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
:global(.treemap-node rect) {
transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
fill 0.3s ease;
}
:global(.treemap-fade-enter) {
opacity: 0;
transform: scale(0.95);
}
/* Tema claro para sidebar mapa */
:global(.light) .mapa-sidebar,
:global(html:not(.dark)) .mapa-sidebar {
background: #ffffff;
border-right-color: rgba(28,28,26,0.08);
}
:global(.light) .sidebar-puller,
:global(html:not(.dark)) .sidebar-puller {
background: #f5f5f5;
border-color: rgba(28,28,26,0.12);
box-shadow: 2px 0 8px rgba(28,28,26,0.1);
}
:global(.light) .sidebar-puller:hover,
:global(html:not(.dark)) .sidebar-puller:hover {
background: #e8e8e8;
}
:global(.light) .grip-dot,
:global(html:not(.dark)) .grip-dot {
background-color: rgba(28,28,26,0.5);
}
:global(.light) .sidebar-puller:hover .grip-dot,
:global(html:not(.dark)) .sidebar-puller:hover .grip-dot {
background-color: rgba(28,28,26,0.7);
}
:global(.light) .sidebar-modes,
:global(html:not(.dark)) .sidebar-modes {
background: #f0f0f0;
}
:global(.light) .mode-btn.active,
:global(html:not(.dark)) .mode-btn.active {
background: #ffffff;
box-shadow: 0 1px 3px rgba(28,28,26,0.1);
}
:global(.light) .sidebar-entity-btn,
:global(html:not(.dark)) .sidebar-entity-btn,
:global(.light) .sidebar-dropdown-btn,
:global(html:not(.dark)) .sidebar-dropdown-btn {
background: #f0f0f0;
}
:global(.light) .sidebar-entity-btn:hover,
:global(html:not(.dark)) .sidebar-entity-btn:hover,
:global(.light) .sidebar-dropdown-btn:hover,
:global(html:not(.dark)) .sidebar-dropdown-btn:hover {
background: #e5e5e5;
}
:global(.light) .sidebar-dropdown-panel,
:global(html:not(.dark)) .sidebar-dropdown-panel {
background: #ffffff;
border-color: rgba(28,28,26,0.1);
box-shadow: 0 8px 24px rgba(28,28,26,0.12);
}
:global(.light) .sidebar-dropdown-option:hover,
:global(html:not(.dark)) .sidebar-dropdown-option:hover,
:global(.light) .sidebar-dropdown-option.active,
:global(html:not(.dark)) .sidebar-dropdown-option.active {
background: #f5f5f5;
}
:global(.light) .entity-dropdown-panel,
:global(html:not(.dark)) .entity-dropdown-panel {
background: #ffffff;
border-color: rgba(28,28,26,0.1);
box-shadow: 0 8px 24px rgba(28,28,26,0.12);
}
</style>
......@@ -28,6 +28,11 @@
}
onMount(async () => {
// Forzar recálculo de layout para navegación cliente
requestAnimationFrame(() => {
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_institucional')
......@@ -307,7 +312,7 @@
</button>
</div>
<div class="max-w-screen-xl mx-auto flex px-4">
<div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
<!-- Sidebar izquierda: Áreas -->
<!-- En móvil: overlay, en desktop: sidebar fijo -->
{#if sidebarOpen}
......@@ -611,4 +616,33 @@
background-color: transparent;
}
}
/* Layout principal - fallback nativo */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
/* Sidebar - fallback para responsive */
.sidebar-left {
position: fixed;
transform: translateX(-100%);
}
@media (min-width: 1024px) {
.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0;
width: 16rem;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
}
</style>
......
......@@ -15,6 +15,7 @@
let selectedItem = $state(null);
let highlightedItem = $state(null);
let sidebarOpen = $state(false);
let layoutMounted = $state(false); // Para forzar estilos correctos después del mount
// Modo de visualización desde URL
let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
......@@ -95,10 +96,10 @@
let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida'
let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias)
const NIVEL_OPTIONS = [
{ value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegación drill-down' },
{ value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' },
{ value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' },
{ value: 'partida', label: 'Partidas', desc: '~100 categorías' },
{ value: 'subpartida', label: 'Subpartidas', desc: 'Máximo detalle' }
{ value: 'subpartida', label: 'Subpartidas', desc: '~300 categorías' }
];
// Colores por grupo (primer dígito del código)
......@@ -161,16 +162,24 @@
return luminance > 0.5 ? 'rgba(0,0,0,0.78)' : 'rgba(255,255,255,0.88)';
}
// Formatear números en bolivianos
// Formatear números en bolivianos con texto descriptivo
function formatMoney(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)} mil millones`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)} millones`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)} mil`;
return `Bs ${value.toFixed(0)}`;
}
// Formatear números compacto para etiquetas dentro del treemap
function formatMoneyCompact(value) {
if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
return `Bs ${value.toFixed(0)}`;
}
// Formatear per cápita (población ~10M)
const POBLACION = 10000000;
// Formatear per cápita (población ~12M)
const POBLACION = 12000000;
function formatPerCapita(value) {
const perCapita = value / POBLACION;
if (perCapita >= 1000) return `Bs ${(perCapita / 1000).toFixed(1)}K/hab`;
......@@ -711,6 +720,13 @@
});
onMount(async () => {
// Forzar recálculo de layout para navegación cliente
// Esto evita problemas de renderizado cuando se navega desde otra página
requestAnimationFrame(() => {
document.body.offsetHeight; // Force reflow
layoutMounted = true;
});
// Intentar usar cache primero
let cacheValue;
const unsubscribe = clasificadorCache.subscribe(value => { cacheValue = value; });
......@@ -1229,13 +1245,26 @@
}
}
// Helpers para derivar jerarquía desde código objeto
function getGrupoFromObjeto(objeto) {
return objeto.charAt(0);
}
function getSubgrupoFromObjeto(objeto) {
return objeto.substring(0, 2);
}
function getPartidaFromObjeto(objeto) {
return objeto.substring(0, 3);
}
function getItemsForGrupo(grupoCode) {
if (!grupoCode) return { subgrupos: [] };
const grupoNum = grupoCode.substring(0, 1);
const subgruposUnicos = allItems
.filter(item => item.nivel === 'subgrupo' && item.grupo == grupoNum)
.filter(item => item.nivel === 'subgrupo' && getGrupoFromObjeto(item.objeto) === grupoNum)
.reduce((acc, item) => {
if (!acc.find(s => s.objeto === item.objeto)) {
acc.push(item);
......@@ -1245,9 +1274,11 @@
.sort((a, b) => a.objeto.localeCompare(b.objeto));
const subgrupos = subgruposUnicos.map(sg => {
const sgPrefix = getSubgrupoFromObjeto(sg.objeto); // ej: "11"
// Obtener partidas existentes
const partidasUnicas = allItems
.filter(item => item.nivel === 'partida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo)
.filter(item => item.nivel === 'partida' && getSubgrupoFromObjeto(item.objeto) === sgPrefix)
.reduce((acc, item) => {
if (!acc.find(p => p.objeto === item.objeto)) {
acc.push(item);
......@@ -1258,7 +1289,7 @@
// Obtener todas las subpartidas del subgrupo
const todasSubpartidas = allItems
.filter(item => item.nivel === 'subpartida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo)
.filter(item => item.nivel === 'subpartida' && getSubgrupoFromObjeto(item.objeto) === sgPrefix)
.reduce((acc, item) => {
if (!acc.find(sp => sp.objeto === item.objeto)) {
acc.push(item);
......@@ -1267,41 +1298,39 @@
}, [])
.sort((a, b) => a.objeto.localeCompare(b.objeto));
// Set de números de partida que existen
const partidasExistentes = new Set(partidasUnicas.map(p => p.partida));
// Set de prefijos de partida que existen (ej: "111", "112")
const partidasExistentes = new Set(partidasUnicas.map(p => getPartidaFromObjeto(p.objeto)));
// Encontrar subpartidas huérfanas (cuya partida no existe)
const subpartidasHuerfanas = todasSubpartidas.filter(sp => !partidasExistentes.has(sp.partida));
const subpartidasHuerfanas = todasSubpartidas.filter(sp => !partidasExistentes.has(getPartidaFromObjeto(sp.objeto)));
// Agrupar huérfanas por número de partida para crear partidas sintéticas
// Agrupar huérfanas por prefijo de partida para crear partidas sintéticas
const huerfanasPorPartida = {};
subpartidasHuerfanas.forEach(sp => {
if (!huerfanasPorPartida[sp.partida]) {
huerfanasPorPartida[sp.partida] = [];
const partidaPrefix = getPartidaFromObjeto(sp.objeto);
if (!huerfanasPorPartida[partidaPrefix]) {
huerfanasPorPartida[partidaPrefix] = [];
}
huerfanasPorPartida[sp.partida].push(sp);
huerfanasPorPartida[partidaPrefix].push(sp);
});
// Crear partidas sintéticas para las huérfanas
const partidasSinteticas = Object.entries(huerfanasPorPartida).map(([partidaNum, subpartidas]) => {
// Generar código de partida (ej: grupo=3, subgrupo=4, partida=1 -> 34100)
const codigoPartida = `${grupoNum}${sg.subgrupo}${partidaNum}00`;
const partidasSinteticas = Object.entries(huerfanasPorPartida).map(([partidaPrefix, subpartidas]) => {
const codigoPartida = `${partidaPrefix}00`;
return {
objeto: codigoPartida,
desc_objeto: subpartidas[0]?.desc_objeto?.split(',')[0] || `Partida ${codigoPartida}`,
nivel: 'partida',
grupo: parseInt(grupoNum),
subgrupo: sg.subgrupo,
partida: parseInt(partidaNum),
_sintetica: true, // Marcar como sintética
_sintetica: true,
subpartidas
};
});
// Asignar subpartidas a partidas existentes
const partidas = partidasUnicas.map(p => {
const pPrefix = getPartidaFromObjeto(p.objeto);
const subpartidas = todasSubpartidas
.filter(item => item.partida == p.partida)
.filter(item => getPartidaFromObjeto(item.objeto) === pPrefix)
.sort((a, b) => a.objeto.localeCompare(b.objeto));
return { ...p, subpartidas };
......@@ -1354,49 +1383,19 @@
function getMaxYear(rangos) {
if (!rangos) return 0;
// Extraer todos los números de 4 dígitos (años) del string
// rangos es un string como "2005, 2007-2020, 2022-2026"
// Extraer todos los números de 4 dígitos
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
}
/**
* Converts a string with years into compact ranges
* Example: "2006, 2007, 2008, 2009, 2015, 2016" → "2006-2009, 2015-2016"
* Single years: "2006, 2008, 2010" → "2006, 2008, 2010"
* rangos ya viene formateado como string "2005, 2007-2020, 2022-2026"
* Solo lo retornamos tal cual
*/
function formatYearsAsRanges(rangosStr) {
if (!rangosStr) return '';
// Extract all 4-digit years
const yearsMatch = rangosStr.match(/\d{4}/g);
if (!yearsMatch || yearsMatch.length === 0) return rangosStr;
// Convert to numbers and sort
const years = [...new Set(yearsMatch.map(y => parseInt(y)))].sort((a, b) => a - b);
if (years.length === 1) return String(years[0]);
// Group consecutive years into ranges
const ranges = [];
let rangeStart = years[0];
let rangeEnd = years[0];
for (let i = 1; i < years.length; i++) {
if (years[i] === rangeEnd + 1) {
// Consecutive year, extend range
rangeEnd = years[i];
} else {
// Gap found, save current range and start new one
ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
rangeStart = years[i];
rangeEnd = years[i];
}
}
// Add the last range
ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
return ranges.join(', ');
function formatYearsAsRanges(rangos) {
return rangos || '';
}
function selectGrupo(grupo) {
......@@ -1423,7 +1422,7 @@
}
// Para otros niveles, encontrar y seleccionar el grupo padre
const grupoNum = item.grupo;
const grupoNum = getGrupoFromObjeto(item.objeto);
const targetGrupo = grupos.find(g => g.objeto === grupoNum + '0000');
if (targetGrupo) {
......@@ -1495,14 +1494,12 @@
</nav>
<div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
{#if viewMode !== 'comparar'}
<p class="text-sm uppercase tracking-widest mb-2 font-semibold" style="font-family: var(--font-sans); color: var(--theme-accent);">
Clasificador de Objeto del Gasto
</p>
{/if}
<div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}">
<h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
{viewMode === 'comparar' ? 'Comparar gasto' : '¿En qué se gasta?'}
¿En qué se gasta?
</h1>
{#if viewMode === 'mapa'}
<button
......@@ -1591,6 +1588,15 @@
</div>
{/if}
</div>
<!-- Leyenda de unidades -->
<span class="hidden sm:inline-flex items-center gap-1.5 text-xs ml-3 pl-3 border-l font-medium" style="color: var(--theme-texto); opacity: 0.7; border-color: var(--theme-borde);">
<span>MM = Mil Millones</span>
<span style="opacity: 0.5;">·</span>
<span>M = Millones</span>
<span style="opacity: 0.5;">·</span>
<span>% sobre gasto total</span>
</span>
</div>
{/if}
......@@ -1690,7 +1696,7 @@
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs font-bold" style="background-color: var(--theme-fill); color: var(--theme-titulo); border: 1px solid var(--theme-borde);">+</span>
para desglose.
{:else}
Colores agrupan por categoría.
MM = Mil Millones · M = Millones · % sobre gasto total
{/if}
</p>
{:else}
......@@ -1707,6 +1713,7 @@
<Spinner size={48} color="var(--theme-texto)" />
</div>
{:else}
{#key layoutMounted}
{#if viewMode === 'lista'}
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MODO LISTA: Navegación jerárquica de categorías de gasto -->
......@@ -1729,13 +1736,14 @@
</button>
</div>
<div class="max-w-screen-xl mx-auto flex px-4 lg:px-6">
<div class="clasificador-layout max-w-screen-xl mx-auto flex px-4 lg:px-6" style="display: flex !important; flex-direction: row;">
<!-- Sidebar izquierda: Grupos -->
<!-- En móvil: overlay, en desktop: sidebar fijo -->
{#if sidebarOpen}
<div class="fixed inset-0 bg-black/30 z-40 lg:hidden" onclick={() => sidebarOpen = false}></div>
{/if}
<aside class="
<aside
class="
{sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
lg:translate-x-0
fixed lg:relative
......@@ -1746,7 +1754,9 @@
lg:flex-shrink-0
shadow-xl lg:shadow-none
sidebar-left
">
{layoutMounted ? 'sidebar-mounted' : ''}
"
>
<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">
<!-- Cerrar en móvil -->
<div class="flex justify-between items-center mb-4 lg:hidden">
......@@ -2261,7 +2271,10 @@
{/if}
{#if height > 70 && width > 65}
<div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoney(node.value)}
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
......@@ -2372,7 +2385,10 @@
{/if}
{#if height > 55 && width > 55}
<div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoney(node.value)}
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
......@@ -2390,34 +2406,30 @@
<div
class="treemap-tooltip"
style="
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 220)}px;
left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px;
top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
"
>
{#if treemapViewLevel !== 'jerarquico'}
<!-- Vista aplanada: mostrar contexto del grupo -->
<div class="text-xs opacity-60 mb-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{hoveredNode.id}
</div>
<div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)} · {((hoveredNode.value / totalValue) * 100).toFixed(1)}%
<div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
</div>
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatPerCapita(hoveredNode.value)}
{((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total
</div>
{:else}
<!-- Vista jerárquica -->
<div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div>
<div class="text-xs opacity-80 mt-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)}
{#if currentTreemapNode && currentTreemapNode.value}
· {((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}%
{/if}
<div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
</div>
{#if currentTreemapNode && currentTreemapNode.value}
<div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
{formatPerCapita(hoveredNode.value)}
{((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}% del gasto total
</div>
{/if}
{#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0}
<div class="text-xs opacity-60 mt-1">Clic para explorar</div>
{/if}
......@@ -2578,32 +2590,8 @@
<!-- Panel A -->
<div class="compare-panel">
<div class="compare-header">
<span class="compare-badge" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">A</span>
<div class="inline-dropdown compare-year-dropdown">
<button
class="inline-dropdown-btn year-btn"
onclick={() => { compareAYearDropdownOpen = !compareAYearDropdownOpen; compareBYearDropdownOpen = false; compareADropdownOpen = false; }}
>
<span>{compareA.year}</span>
<svg class="inline-chevron" class:open={compareAYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if compareAYearDropdownOpen}
<div class="inline-dropdown-panel year-panel">
{#each availableYears as year}
<button
class="inline-dropdown-option"
class:active={compareA.year === year}
onclick={() => { compareA.year = year; compareAYearDropdownOpen = false; }}
>
{year}
</button>
{/each}
</div>
{/if}
</div>
<div class="inline-dropdown compare-a-dropdown flex-1">
<div class="flex items-center gap-2">
<div class="inline-dropdown compare-a-dropdown">
<button
class="inline-dropdown-btn entity-btn"
onclick={() => { compareADropdownOpen = !compareADropdownOpen; compareAYearDropdownOpen = false; }}
......@@ -2630,6 +2618,31 @@
</div>
{/if}
</div>
<div class="inline-dropdown compare-year-dropdown">
<button
class="inline-dropdown-btn year-btn"
onclick={() => { compareAYearDropdownOpen = !compareAYearDropdownOpen; compareBYearDropdownOpen = false; compareADropdownOpen = false; }}
>
<span>{compareA.year}</span>
<svg class="inline-chevron" class:open={compareAYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if compareAYearDropdownOpen}
<div class="inline-dropdown-panel year-panel">
{#each availableYears as year}
<button
class="inline-dropdown-option"
class:active={compareA.year === year}
onclick={() => { compareA.year = year; compareAYearDropdownOpen = false; }}
>
{year}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<!-- Treemap A -->
<div
......@@ -2701,7 +2714,10 @@
{/if}
{#if height > 55 && width > 55}
<div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoney(node.value)}
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
......@@ -2736,32 +2752,8 @@
<!-- Panel B -->
<div class="compare-panel">
<div class="compare-header">
<span class="compare-badge" style="background-color: color-mix(in srgb, #8b5cf6 20%, transparent); color: #8b5cf6;">B</span>
<div class="inline-dropdown compare-year-dropdown">
<button
class="inline-dropdown-btn year-btn"
onclick={() => { compareBYearDropdownOpen = !compareBYearDropdownOpen; compareAYearDropdownOpen = false; compareBDropdownOpen = false; }}
>
<span>{compareB.year}</span>
<svg class="inline-chevron" class:open={compareBYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if compareBYearDropdownOpen}
<div class="inline-dropdown-panel year-panel">
{#each availableYears as year}
<button
class="inline-dropdown-option"
class:active={compareB.year === year}
onclick={() => { compareB.year = year; compareBYearDropdownOpen = false; }}
>
{year}
</button>
{/each}
</div>
{/if}
</div>
<div class="inline-dropdown compare-b-dropdown flex-1">
<div class="flex items-center gap-2">
<div class="inline-dropdown compare-b-dropdown">
<button
class="inline-dropdown-btn entity-btn"
onclick={() => { compareBDropdownOpen = !compareBDropdownOpen; compareBYearDropdownOpen = false; }}
......@@ -2788,6 +2780,31 @@
</div>
{/if}
</div>
<div class="inline-dropdown compare-year-dropdown">
<button
class="inline-dropdown-btn year-btn"
onclick={() => { compareBYearDropdownOpen = !compareBYearDropdownOpen; compareAYearDropdownOpen = false; compareBDropdownOpen = false; }}
>
<span>{compareB.year}</span>
<svg class="inline-chevron" class:open={compareBYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if compareBYearDropdownOpen}
<div class="inline-dropdown-panel year-panel">
{#each availableYears as year}
<button
class="inline-dropdown-option"
class:active={compareB.year === year}
onclick={() => { compareB.year = year; compareBYearDropdownOpen = false; }}
>
{year}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<!-- Treemap B -->
<div
......@@ -2855,7 +2872,10 @@
{/if}
{#if height > 55 && width > 55}
<div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatMoney(node.value)}
{formatMoneyCompact(node.value)}
</div>
<div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
{formatPerCapita(node.value)}
</div>
{/if}
</div>
......@@ -2887,6 +2907,7 @@
</div>
</div>
{/if}
{/key}
{/if}
</div>
......@@ -2929,22 +2950,22 @@
<!-- Barra A -->
<div class="compare-bar-row">
<div class="compare-bar-header">
<span class="compare-bar-entity">{entityNameA}</span>
<span class="compare-bar-entity">{entityNameA} <span class="opacity-60">· {compareA.year}</span></span>
<span class="compare-bar-value">{formatMoney(valueA)}</span>
</div>
<div class="compare-bar-track">
<div class="compare-bar-fill a" style="width: {barA}%;"></div>
<div class="compare-bar-track" style="background-color: color-mix(in srgb, var(--theme-borde) 30%, transparent);">
<div class="compare-bar-fill" style="width: {barA}%; background-color: var(--theme-accent);"></div>
</div>
<span class="compare-bar-pct">{pctA >= 1 ? pctA.toFixed(1) : pctA.toFixed(2)}%</span>
</div>
<!-- Barra B -->
<div class="compare-bar-row">
<div class="compare-bar-header">
<span class="compare-bar-entity">{entityNameB}</span>
<span class="compare-bar-entity">{entityNameB} <span class="opacity-60">· {compareB.year}</span></span>
<span class="compare-bar-value">{formatMoney(valueB)}</span>
</div>
<div class="compare-bar-track">
<div class="compare-bar-fill b" style="width: {barB}%;"></div>
<div class="compare-bar-track" style="background-color: color-mix(in srgb, var(--theme-borde) 30%, transparent);">
<div class="compare-bar-fill" style="width: {barB}%; background-color: var(--theme-accent); opacity: 0.7;"></div>
</div>
<span class="compare-bar-pct">{pctB >= 1 ? pctB.toFixed(1) : pctB.toFixed(2)}%</span>
</div>
......@@ -4336,4 +4357,33 @@
text-overflow: ellipsis;
white-space: nowrap;
}
/* Layout principal - fallback nativo */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
/* Sidebar - fallback para responsive */
.sidebar-left {
position: fixed;
transform: translateX(-100%);
}
@media (min-width: 1024px) {
.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0;
width: 18rem;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
}
</style>
......
......@@ -14,6 +14,11 @@
let sidebarOpen = $state(false);
onMount(async () => {
// Forzar recálculo de layout para navegación cliente
requestAnimationFrame(() => {
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_rubros')
......@@ -32,7 +37,7 @@
}
return acc;
}, [])
.sort((a, b) => a.rubro - b.rubro);
.sort((a, b) => a.rubro.localeCompare(b.rubro));
if (tipos.length > 0) {
selectedTipo = tipos[0];
......@@ -42,70 +47,103 @@
loading = false;
});
function getItemsForTipo(tipoCode) {
if (!tipoCode) return { clases: [] };
// Helpers para derivar jerarquía desde código rubro
function getTipoFromRubro(rubro) {
return rubro.charAt(0);
}
const tipoNum = Math.floor(tipoCode / 1000);
function getClaseFromRubro(rubro) {
return rubro.substring(0, 2);
}
// Obtener todos los items de este tipo
const itemsDelTipo = allItems.filter(item => item.tipo === tipoNum && item.nivel !== 'tipo');
function getCuentaFromRubro(rubro) {
return rubro.substring(0, 3);
}
// Encontrar clases únicas desde los valores de la columna 'clase'
const clasesUnicas = [...new Set(itemsDelTipo.map(i => i.clase).filter(c => c != null))].sort((a, b) => a - b);
function getItemsForTipo(tipoCode) {
if (!tipoCode) return { clases: [] };
const clases = clasesUnicas.map(claseNum => {
// Buscar si existe una entrada de nivel 'clase' para esta clase
const claseEntry = allItems.find(item => item.nivel === 'clase' && item.tipo === tipoNum && item.clase === claseNum);
const tipoNum = getTipoFromRubro(tipoCode);
// Items de esta clase
const itemsDeClase = itemsDelTipo.filter(item => item.clase === claseNum);
// Obtener clases únicas de este tipo
const clasesUnicas = allItems
.filter(item => item.nivel === 'clase' && getTipoFromRubro(item.rubro) === tipoNum)
.reduce((acc, item) => {
if (!acc.find(c => c.rubro === item.rubro)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => a.rubro.localeCompare(b.rubro));
// Encontrar cuentas únicas
const cuentasUnicas = [...new Set(itemsDeClase.map(i => i.cuenta).filter(c => c != null))].sort((a, b) => a - b);
const clases = clasesUnicas.map(clase => {
const clasePrefix = getClaseFromRubro(clase.rubro);
const cuentas = cuentasUnicas.map(cuentaNum => {
// Buscar si existe una entrada de nivel 'cuenta' para esta cuenta
const cuentaEntry = allItems.find(item => item.nivel === 'cuenta' && item.tipo === tipoNum && item.clase === claseNum && item.cuenta === cuentaNum);
// Obtener cuentas de esta clase
const cuentasUnicas = allItems
.filter(item => item.nivel === 'cuenta' && getClaseFromRubro(item.rubro) === clasePrefix)
.reduce((acc, item) => {
if (!acc.find(c => c.rubro === item.rubro)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => a.rubro.localeCompare(b.rubro));
// Sub_cuentas de esta cuenta
const subcuentas = itemsDeClase
.filter(item => item.nivel === 'sub_cuenta' && item.cuenta === cuentaNum)
// Obtener todas las subcuentas de esta clase
const todasSubcuentas = allItems
.filter(item => item.nivel === 'sub_cuenta' && getClaseFromRubro(item.rubro) === clasePrefix)
.reduce((acc, item) => {
if (!acc.find(s => s.rubro === item.rubro)) acc.push(item);
if (!acc.find(sc => sc.rubro === item.rubro)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => a.rubro - b.rubro);
.sort((a, b) => a.rubro.localeCompare(b.rubro));
// Set de prefijos de cuenta que existen
const cuentasExistentes = new Set(cuentasUnicas.map(c => getCuentaFromRubro(c.rubro)));
// Si existe entrada de cuenta, usarla; si no, crear una virtual
const cuentaData = cuentaEntry || {
rubro: tipoNum * 1000 + claseNum * 100 + cuentaNum * 10,
// Encontrar subcuentas huérfanas (cuya cuenta no existe)
const subcuentasHuerfanas = todasSubcuentas.filter(sc => !cuentasExistentes.has(getCuentaFromRubro(sc.rubro)));
// Agrupar huérfanas por prefijo de cuenta para crear cuentas sintéticas
const huerfanasPorCuenta = {};
subcuentasHuerfanas.forEach(sc => {
const cuentaPrefix = getCuentaFromRubro(sc.rubro);
if (!huerfanasPorCuenta[cuentaPrefix]) {
huerfanasPorCuenta[cuentaPrefix] = [];
}
huerfanasPorCuenta[cuentaPrefix].push(sc);
});
// Crear cuentas sintéticas para las huérfanas
const cuentasSinteticas = Object.entries(huerfanasPorCuenta).map(([cuentaPrefix, subcuentas]) => {
const codigoCuenta = `${cuentaPrefix}0`;
return {
rubro: codigoCuenta,
desc_rubro: subcuentas[0]?.desc_rubro?.split(',')[0] || `Cuenta ${codigoCuenta}`,
nivel: 'cuenta',
tipo: tipoNum,
clase: claseNum,
cuenta: cuentaNum,
desc_cuenta: subcuentas[0]?.desc_cuenta || `Cuenta ${cuentaNum}`,
desc_tipo: subcuentas[0]?.desc_tipo,
desc_clase: subcuentas[0]?.desc_clase,
n_variaciones: null,
descripciones: null
_sintetica: true,
subcuentas
};
});
// Asignar subcuentas a cuentas existentes
const cuentas = cuentasUnicas.map(cuenta => {
const cuentaPrefix = getCuentaFromRubro(cuenta.rubro);
const subcuentas = todasSubcuentas
.filter(item => getCuentaFromRubro(item.rubro) === cuentaPrefix)
.sort((a, b) => a.rubro.localeCompare(b.rubro));
return { ...cuentaData, subcuentas };
return { ...cuenta, subcuentas };
});
// Si existe entrada de clase, usarla; si no, crear una virtual
const claseData = claseEntry || {
rubro: tipoNum * 1000 + claseNum * 100,
nivel: 'clase',
tipo: tipoNum,
clase: claseNum,
desc_clase: itemsDeClase[0]?.desc_clase || `Clase ${claseNum}`,
desc_tipo: itemsDeClase[0]?.desc_tipo,
n_variaciones: null,
descripciones: null
};
// Combinar cuentas existentes con sintéticas y ordenar
const todasCuentas = [...cuentas, ...cuentasSinteticas]
.sort((a, b) => a.rubro.localeCompare(b.rubro));
return { ...claseData, cuentas };
return { ...clase, cuentas: todasCuentas };
});
return { clases };
......@@ -119,8 +157,7 @@
return allItems
.filter(item =>
item.rubro?.toString().includes(q) ||
item.desc_rubros?.toLowerCase().includes(q) ||
item.descripciones?.toLowerCase().includes(q)
item.desc_rubro?.toLowerCase().includes(q)
)
.reduce((acc, item) => {
if (!acc.find(i => i.rubro === item.rubro)) {
......@@ -134,7 +171,10 @@
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = JSON.parse(descripcionesStr);
// Si ya es objeto, usarlo directamente
const parsed = typeof descripcionesStr === 'string'
? JSON.parse(descripcionesStr)
: descripcionesStr;
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
......@@ -169,8 +209,8 @@
}
function goToSearchResult(item) {
const tipoNum = item.tipo;
const targetTipo = tipos.find(t => Math.floor(t.rubro / 1000) === tipoNum);
const tipoNum = getTipoFromRubro(item.rubro);
const targetTipo = tipos.find(t => getTipoFromRubro(t.rubro) === tipoNum);
if (targetTipo) {
selectedTipo = targetTipo;
......@@ -186,7 +226,6 @@
}
}, 100);
// Limpiar highlight después de la animación (2 pulsos de 0.8s = 1.6s + margen)
setTimeout(() => {
highlightedItem = null;
}, 2000);
......@@ -198,15 +237,6 @@
return labels[nivel] || nivel;
}
// Obtener la descripción correcta según el nivel
function getDescripcion(item) {
if (item.nivel === 'tipo') return item.desc_tipo;
if (item.nivel === 'clase') return item.desc_clase;
if (item.nivel === 'cuenta') return item.desc_cuenta;
if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta;
return item.desc_rubros;
}
let tipoContent = $derived(selectedTipo ? getItemsForTipo(selectedTipo.rubro) : { clases: [] });
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
......@@ -307,16 +337,15 @@
<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">{selectedTipo ? getDescripcion(selectedTipo) : 'Seleccionar tipo'}</span>
<span class="font-medium">{selectedTipo ? selectedTipo.desc_rubro : 'Seleccionar tipo'}</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="max-w-screen-xl mx-auto flex px-4">
<div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
<!-- Sidebar izquierda: Tipos -->
<!-- En móvil: overlay, en desktop: sidebar fijo -->
{#if sidebarOpen}
<div
transition:fade={{ duration: 150 }}
......@@ -373,7 +402,7 @@
>
<span class="text-xs block" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
<span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.rubro}</span>
<span class="ml-1">{getDescripcion(result)}</span>
<span class="ml-1">{result.desc_rubro}</span>
</button>
{/each}
{#if searchResults.length === 0}
......@@ -396,7 +425,7 @@
onclick={() => selectTipo(tipo)}
>
<span class="font-mono text-xs block" style="color: var(--theme-texto);">{tipo.rubro}</span>
{getDescripcion(tipo)}
{tipo.desc_rubro}
</button>
</li>
{/each}
......@@ -416,7 +445,7 @@
<div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
<p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">{selectedTipo.rubro}</p>
<h2 class="text-lg sm:text-xl font-medium mb-2 flex items-center gap-2 sm:gap-3 flex-wrap" style="color: var(--theme-titulo);">
{getDescripcion(selectedTipo)}
{selectedTipo.desc_rubro}
<a
href="/rubro/{selectedTipo.rubro}"
class="transition-colors hover:text-[var(--theme-accent)]"
......@@ -442,6 +471,12 @@
{/if}
{/if}
{/if}
<!-- Vigencia temporal -->
{#if selectedTipo.gestiones}
<p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
Vigente: {selectedTipo.gestiones}
</p>
{/if}
</div>
<!-- Clases -->
......@@ -456,7 +491,7 @@
<span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{clase.rubro}</span>
<div class="flex-1">
<h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{getDescripcion(clase)}</span>
<span class="text-left">{clase.desc_rubro}</span>
{#if clase.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
......@@ -496,7 +531,7 @@
<span class="font-mono text-xs pt-0.5" style="color: var(--theme-texto);">{cuenta.rubro}</span>
<div class="flex-1">
<h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
<span class="text-left">{getDescripcion(cuenta)}</span>
<span class="text-left">{cuenta.desc_rubro}</span>
{#if cuenta.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
......@@ -536,7 +571,7 @@
<span class="font-mono text-xs" style="color: var(--theme-texto); opacity: 0.7;">{subcuenta.rubro}</span>
<div class="flex-1">
<span class="flex items-center gap-2 flex-wrap">
<span class="text-xs" style="color: var(--theme-titulo);">{getDescripcion(subcuenta)}</span>
<span class="text-xs" style="color: var(--theme-titulo);">{subcuenta.desc_rubro}</span>
{#if subcuenta.n_variaciones > 1}
<button
class="text-xs text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
......@@ -591,9 +626,9 @@
href="#cl-{clase.rubro}"
class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto);"
title="{getDescripcion(clase)}"
title="{clase.desc_rubro}"
>
{getDescripcion(clase)}
{clase.desc_rubro}
</a>
{#if clase.cuentas?.length > 0}
<div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);">
......@@ -602,9 +637,9 @@
href="#cu-{cuenta.rubro}"
class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]"
style="color: var(--theme-texto); opacity: 0.7;"
title="{getDescripcion(cuenta)}"
title="{cuenta.desc_rubro}"
>
{getDescripcion(cuenta)}
{cuenta.desc_rubro}
</a>
{/each}
{#if clase.cuentas.length > 5}
......@@ -641,7 +676,7 @@
<h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
<span class="font-mono" style="color: var(--theme-texto);">{selectedItem.rubro}</span>
<span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
{getDescripcion(selectedItem)}
{selectedItem.desc_rubro}
</h3>
</div>
<button
......@@ -676,6 +711,15 @@
</div>
{/each}
</div>
<!-- Vigencia temporal -->
{#if selectedItem.gestiones}
<div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
<p class="text-xs font-mono" style="color: var(--theme-texto);">
<span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
</p>
</div>
{/if}
</div>
</div>
</div>
......@@ -721,16 +765,32 @@
margin-left: -0.5rem;
}
/* Transiciones estandarizadas */
:global(.transition-fast) {
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
/* Layout principal - fallback nativo */
.clasificador-layout {
display: flex !important;
flex-direction: row !important;
}
:global(.transition-normal) {
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
/* Sidebar - fallback para responsive */
.sidebar-left {
position: fixed;
transform: translateX(-100%);
}
:global(.transition-slow) {
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
@media (min-width: 1024px) {
.sidebar-left {
position: relative !important;
transform: translateX(0) !important;
flex-shrink: 0;
width: 16rem;
z-index: auto !important;
box-shadow: none !important;
}
}
@media (max-width: 1023px) {
.clasificador-layout {
display: block !important;
}
}
</style>
......
<script>
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { marked } from 'marked';
// Search state
let searchQuery = $state('');
let searchResults = $state([]);
let showSearchResults = $state(false);
let selectedSearchIndex = $state(-1);
let searchInputRef = $state(null);
let searchableItems = $state([]);
let isMac = $state(false);
// Markdown content embedded directly
const MARKDOWN_CONTENT = `# Introducción
......@@ -163,7 +172,7 @@ Descripción de la subárea a la que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: \`Administración Central\`.
Ejemplo: \`Gobiernos Autónomos Departamentales\`.
#### entidad_sigla_sector
......@@ -740,7 +749,7 @@ Descripción de la subárea a la que pertenece la entidad pública.
Tipo: Texto descriptivo.
Ejemplo: \`Administración Central\`.
Ejemplo: \`Gobiernos Autónomos Departamentales\`.
#### entidad_sigla_sector
......@@ -1030,11 +1039,17 @@ Ejemplo: \`5637823.65\`.
}
onMount(async () => {
// Detect Mac for keyboard shortcut display
isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
try {
const result = processMarkdown(MARKDOWN_CONTENT);
content = result.html;
headings = buildHierarchy(result.headings);
// Extract searchable items (variable names and descriptions)
searchableItems = extractSearchableItems(MARKDOWN_CONTENT);
// Expand level 1 by default
const initialExpanded = new Set();
result.headings.filter(h => h.level === 1).forEach(h => initialExpanded.add(h.id));
......@@ -1152,12 +1167,162 @@ Ejemplo: \`5637823.65\`.
}
return false;
}
// Extract searchable items from markdown (h4 = variable names)
function extractSearchableItems(markdown) {
const items = [];
// Split by h4 headings (#### varname)
const sections = markdown.split(/(?=####\s+)/);
for (const section of sections) {
const h4Match = section.match(/^####\s+(\w+)\s*\n\n([\s\S]*?)(?=\n####|\n###|\n##|\n#|$)/);
if (h4Match) {
const varName = h4Match[1];
const description = h4Match[2].trim()
.split('\n\n')[0] // Get first paragraph
.replace(/\n/g, ' ')
.substring(0, 200); // Limit description length
// Find the section it belongs to (look backwards for ### heading)
const fullMarkdownUpToThis = markdown.substring(0, markdown.indexOf(`#### ${varName}`));
const h3Matches = fullMarkdownUpToThis.match(/###\s+(.+)/g);
const section = h3Matches ? h3Matches[h3Matches.length - 1].replace('### ', '') : '';
items.push({
varName,
description,
section,
searchText: `${varName} ${description} ${section}`.toLowerCase()
});
}
}
return items;
}
// Search function with debounce effect
function performSearch(query) {
if (!query || query.length < 2) {
searchResults = [];
showSearchResults = false;
return;
}
const queryLower = query.toLowerCase().trim();
const words = queryLower.split(/\s+/).filter(w => w.length > 0);
const results = searchableItems
.filter(item => {
// All words must match somewhere
return words.every(word => item.searchText.includes(word));
})
.slice(0, 20); // Limit results
searchResults = results;
showSearchResults = results.length > 0;
selectedSearchIndex = -1;
}
// Handle search input
function onSearchInput(e) {
searchQuery = e.target.value;
performSearch(searchQuery);
}
// Handle keyboard navigation in search
function onSearchKeydown(e) {
if (!showSearchResults) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedSearchIndex = Math.min(selectedSearchIndex + 1, searchResults.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedSearchIndex = Math.max(selectedSearchIndex - 1, -1);
} else if (e.key === 'Enter' && selectedSearchIndex >= 0) {
e.preventDefault();
goToSearchResult(searchResults[selectedSearchIndex]);
} else if (e.key === 'Escape') {
e.preventDefault();
closeSearch();
}
}
// Navigate to search result
function goToSearchResult(result) {
// Find the heading ID that matches this variable name
const slug = result.varName
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-');
// Find the element by looking for h4 with this text
const h4Elements = document.querySelectorAll('h4.heading-anchor');
for (const h4 of h4Elements) {
if (h4.textContent.trim() === result.varName) {
// Expand parents in sidebar
const id = h4.id;
const parentIds = findParentIds(id, headings);
if (parentIds && parentIds.length > 0) {
const newExpanded = new Set(expandedSections);
parentIds.forEach(pid => newExpanded.add(pid));
expandedSections = newExpanded;
}
// Scroll to element
setTimeout(() => {
h4.scrollIntoView({ behavior: 'smooth', block: 'start' });
activeId = id;
}, 50);
closeSearch();
return;
}
}
}
// Close search dropdown
function closeSearch() {
showSearchResults = false;
selectedSearchIndex = -1;
searchQuery = '';
}
// Handle click outside search
function handleClickOutside(e) {
if (searchInputRef && !searchInputRef.contains(e.target)) {
showSearchResults = false;
}
}
// Highlight matching text
function highlightMatch(text, query) {
if (!query || query.length < 2) return text;
const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
let result = text;
for (const word of words) {
const regex = new RegExp(`(${word})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
}
return result;
}
// Keyboard shortcut to focus search (Ctrl/Cmd + K)
function handleGlobalKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
searchInputRef?.querySelector('input')?.focus();
}
}
</script>
<svelte:head>
<title>Documentación | Presupuesto Público Bolivia</title>
</svelte:head>
<svelte:window onkeydown={handleGlobalKeydown} onclick={handleClickOutside} />
<div class="docs-page">
<header class="docs-header">
<div class="docs-header-content">
......@@ -1179,6 +1344,51 @@ Ejemplo: \`5637823.65\`.
</div>
<h1 class="docs-title">Documentación</h1>
<p class="docs-subtitle">Estructura de las bases de datos del presupuesto público</p>
<!-- Search bar -->
<div class="docs-search-container" bind:this={searchInputRef}>
<div class="docs-search-input-wrapper">
<svg class="docs-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.3-4.3"/>
</svg>
<input
type="text"
class="docs-search-input"
placeholder="Buscar variables..."
value={searchQuery}
oninput={onSearchInput}
onkeydown={onSearchKeydown}
onfocus={() => searchQuery.length >= 2 && performSearch(searchQuery)}
/>
<span class="docs-search-shortcut">
<kbd>{isMac ? '\u2318' : 'Ctrl'}</kbd><kbd>K</kbd>
</span>
</div>
{#if showSearchResults}
<div class="docs-search-results">
{#if searchResults.length === 0}
<div class="docs-search-no-results">
No se encontraron resultados
</div>
{:else}
{#each searchResults as result, index}
<button
class="docs-search-result"
class:selected={index === selectedSearchIndex}
onclick={() => goToSearchResult(result)}
onmouseenter={() => selectedSearchIndex = index}
>
<span class="docs-search-result-var">{@html highlightMatch(result.varName, searchQuery)}</span>
<span class="docs-search-result-section">{result.section}</span>
<span class="docs-search-result-desc">{@html highlightMatch(result.description.substring(0, 80), searchQuery)}{result.description.length > 80 ? '...' : ''}</span>
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</header>
......@@ -1834,4 +2044,158 @@ Ejemplo: \`5637823.65\`.
padding: 0.75rem;
}
}
/* Search styles */
.docs-search-container {
position: relative;
margin-top: 1rem;
}
.docs-search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.docs-search-icon {
position: absolute;
left: 12px;
color: var(--theme-texto);
opacity: 0.5;
pointer-events: none;
}
.docs-search-input {
width: 100%;
padding: 0.625rem 3.5rem 0.625rem 2.5rem;
font-size: 0.9375rem;
font-family: inherit;
background: var(--theme-fill);
border: 1px solid var(--theme-borde);
border-radius: 8px;
color: var(--theme-titulo);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.docs-search-input::placeholder {
color: var(--theme-texto);
opacity: 0.6;
}
.docs-search-input:focus {
border-color: var(--theme-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 15%, transparent);
}
.docs-search-shortcut {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
gap: 4px;
pointer-events: none;
z-index: 5;
}
.docs-search-shortcut kbd {
font-family: system-ui, -apple-system, sans-serif;
font-size: 11px;
font-weight: 500;
padding: 3px 7px;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 5px;
color: #aaa;
box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.05);
line-height: 1;
}
:global(html:not(.dark)) .docs-search-shortcut kbd {
background: #f5f5f5;
border-color: #d0d0d0;
color: #666;
box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8);
}
@media (max-width: 640px) {
.docs-search-shortcut {
display: none;
}
}
.docs-search-results {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: var(--theme-body);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
max-height: 400px;
overflow-y: auto;
z-index: 100;
}
.docs-search-no-results {
padding: 1rem;
text-align: center;
color: var(--theme-texto);
font-size: 0.875rem;
}
.docs-search-result {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
border-bottom: 1px solid var(--theme-borde);
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
}
.docs-search-result:last-child {
border-bottom: none;
}
.docs-search-result:hover,
.docs-search-result.selected {
background: var(--theme-fill);
}
.docs-search-result-var {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
font-size: 0.9375rem;
font-weight: 600;
color: var(--theme-accent);
}
.docs-search-result-section {
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--theme-texto);
opacity: 0.7;
}
.docs-search-result-desc {
font-size: 0.8125rem;
color: var(--theme-texto);
line-height: 1.4;
}
.docs-search-result :global(mark) {
background: color-mix(in srgb, var(--theme-accent) 25%, transparent);
color: inherit;
border-radius: 2px;
padding: 0 2px;
}
</style>
......
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código finfun
// Jerarquía: Finalidad (1 dígito) → Grupo Función (2 dígitos) → Función (3+ dígitos)
function getFinalidadCode(finfun) {
return finfun.charAt(0);
}
function getGrpFuncionCode(finfun) {
return finfun.substring(0, 2);
}
function getNivel(finfun) {
if (finfun.length === 1) return 'finalidad';
if (finfun.length === 2) return 'grpfuncion';
return 'funcion';
}
export async function load({ params }) {
console.time('[SERVER] Total load finfun');
const { codigo } = params;
console.time('[SERVER] Query clas_finfun');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('finfun', codigo);
console.timeEnd('[SERVER] Query clas_finfun');
if (dbError || !data || data.length === 0) {
throw error(404, 'Finalidad/Función no encontrada');
}
// Tomar el primer resultado
const finfun = data[0];
const nivel = finfun.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
// Buscar padres según nivel
if (nivel === 'funcion') {
// Padres: finalidad y grupo función
const finalidadCode = getFinalidadCode(codigo);
const grpFuncionCode = getGrpFuncionCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.in('finfun', [finalidadCode, grpFuncionCode])
.order('finfun');
if (padresData) padres = padresData;
} else if (nivel === 'grpfuncion') {
// Padre: finalidad
const finalidadCode = getFinalidadCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('finfun', finalidadCode);
if (padresData) padres = padresData;
}
// finalidad no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
if (nivel === 'finalidad') {
// Hijos: grupos de función que empiecen con el mismo dígito
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'grpfuncion')
.like('finfun', `${codigo}%`)
.order('finfun');
hijos = hijosData || [];
} else if (nivel === 'grpfuncion') {
// Hijos: funciones que empiecen con los mismos 2 dígitos
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.eq('nivel', 'funcion')
.like('finfun', `${codigo}%`)
.order('finfun');
hijos = hijosData || [];
}
// funcion no tiene hijos
console.timeEnd('[SERVER] Query hijos');
console.timeEnd('[SERVER] Total load finfun');
return {
finfun,
padres,
hijos
};
}
This diff could not be displayed because it is too large.
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código objeto
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';
}
function getNivel(objeto) {
if (objeto.endsWith('0000')) return 'grupo';
if (objeto.endsWith('000')) return 'subgrupo';
if (objeto.endsWith('00')) return 'partida';
return 'subpartida';
}
export async function load({ params }) {
console.time('[SERVER] Total load objeto');
const { codigo } = params;
......@@ -17,95 +37,102 @@ export async function load({ params }) {
throw error(404, 'Objeto no encontrado');
}
// Tomar el primer resultado (puede haber duplicados)
// Tomar el primer resultado
const objeto = data[0];
const nivel = objeto.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
console.time('[SERVER] Query padres');
// Buscar padre según nivel
if (objeto.nivel === 'subpartida') {
// Padre es partida
const { data: padreData } = await supabase
// Buscar padres según nivel
if (nivel === 'subpartida') {
// Padres: grupo, subgrupo, partida
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const partidaCode = getPartidaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo)
.eq('partida', objeto.partida);
if (padreData?.length) padres.push(padreData[0]);
}
.in('objeto', [grupoCode, subgrupoCode, partidaCode])
.order('objeto');
if (objeto.nivel === 'partida' || objeto.nivel === 'subpartida') {
// Padre es subgrupo
const { data: padreData } = await supabase
if (padresData) padres = padresData;
} else if (nivel === 'partida') {
// Padres: grupo, subgrupo
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo);
if (padreData?.length) padres.unshift(padreData[0]);
}
.in('objeto', [grupoCode, subgrupoCode])
.order('objeto');
if (padresData) padres = padresData;
} else if (nivel === 'subgrupo') {
// Padre: grupo
const grupoCode = getGrupoCode(codigo);
if (objeto.nivel !== 'grupo') {
// Padre es grupo
const grupoCodigo = objeto.grupo + '0000';
const { data: padreData } = await supabase
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'grupo')
.eq('objeto', grupoCodigo);
if (padreData?.length) padres.unshift(padreData[0]);
.eq('objeto', grupoCode);
if (padresData) padres = padresData;
}
// grupo no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Buscar hijos según nivel
if (objeto.nivel === 'grupo') {
if (nivel === 'grupo') {
// Hijos: subgrupos que empiecen con el mismo dígito
const prefix = codigo.charAt(0);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.eq('grupo', objeto.grupo)
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.objeto === item.objeto)) acc.push(item);
return acc;
}, []) || [];
} else if (objeto.nivel === 'subgrupo') {
hijos = hijosData || [];
} else if (nivel === 'subgrupo') {
// Hijos: partidas que empiecen con los mismos 2 dígitos
const prefix = codigo.substring(0, 2);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo)
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.objeto === item.objeto)) acc.push(item);
return acc;
}, []) || [];
} else if (objeto.nivel === 'partida') {
hijos = hijosData || [];
} else if (nivel === 'partida') {
// Hijos: subpartidas que empiecen con los mismos 3 dígitos
const prefix = codigo.substring(0, 3);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subpartida')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo)
.eq('partida', objeto.partida)
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.objeto === item.objeto)) acc.push(item);
return acc;
}, []) || [];
hijos = hijosData || [];
}
// subpartida no tiene hijos
console.timeEnd('[SERVER] Query hijos');
console.timeEnd('[SERVER] Total load objeto');
......
......@@ -357,12 +357,16 @@
// VALORES DERIVADOS
// ══════════════════════════════════════════════════════════════
// Constante de población para cálculos per cápita
const POBLACION = 12000000;
// Datos para el gráfico (monto o per_capita según preferencia)
// Nota: vista_objeto_entidad solo tiene 'devengado', no 'per_capita'
let gastoPerCapita = $derived(
datosAnuales.map(d => ({
año: d.gestion,
monto: selectedEntity ? d.monto : d.total,
perCapita: d.per_capita
monto: selectedEntity ? (d.monto ?? d.devengado) : d.total,
perCapita: d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)
}))
);
......@@ -991,7 +995,7 @@
<div class="page" class:mounted>
<!-- Link a clasificadores -->
<nav class="page-nav">
<a href="/clasificadores/objeto-gasto" class="back-link">
<a href="/clasificadores/objeto-gasto" class="back-link" data-sveltekit-reload>
<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>
......@@ -1149,7 +1153,13 @@
<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="height: 280px;">
<span>Cargando...</span>
</div>
{:else}
<BarChart data={gastoPerCapita} bind:hoveredYear height={280} fill={!!selectedEntity} />
{/if}
<!-- KPIs abajo -->
<div class="kpis">
......@@ -2581,6 +2591,17 @@
letter-spacing: 0;
}
.chart-loading {
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
opacity: 0.5;
font-size: 0.875rem;
border: 1px dashed var(--theme-borde);
border-radius: 8px;
}
.chart-controls {
display: flex;
align-items: center;
......
<script>
let institucion = "Ministerio de Economía y Finanzas Públicas";
let pais = "Bolivia";
</script>
<svelte:head>
<title>Sobre | Presupuesto Público</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,300;0,8..60,400;0,8..60,600;1,8..60,300;1,8..60,400&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet" />
</svelte:head>
<div class="manifesto-outer">
<div class="manifesto-inner">
<div class="manifesto-label">
Presentación &nbsp;·&nbsp; Liberación de la base histórica
</div>
<div class="manifesto-fecha">
A fines de 2025 decidimos abrir los datos históricos<br>
de presupuesto del Estado boliviano.
</div>
<div class="manifesto-cuerpo">
<p class="p1">
Cada año el Estado recibe y gasta dinero a tu nombre. ¿Sabes de dónde viene,
en qué se usa y qué se logra con él? Esa información es tuya. Siempre lo fue,
pero durante décadas permaneció encerrada en archivos PDF, en sistemas que pocos
saben usar y en las paredes de un ministerio.
</p>
<div class="manifesto-hoy">Hoy cambia eso.</div>
<p class="p3">
Aquí puedes explorar más de veinte años de ejecución presupuestaria, con
actualización semanal. Puedes ver qué hizo el Estado en tu región, en tu sector
y en el año que te interesa. Puedes comparar, cuestionar y entender. Y si
encuentras algo que no cuadra o que vale la pena contar, puedes hacerlo tuyo.
</p>
<hr class="manifesto-regla" />
<p class="p4">
No publicamos esto para cumplir un trámite de transparencia. Lo hacemos porque
creemos que un Estado que trabaja de espaldas a su sociedad se vuelve impune,
y una sociedad que no entiende al Estado se desconecta de él.
</p>
<blockquote class="manifesto-apuesta">
Un presupuesto abierto es una apuesta: que la confianza se construye con
<strong>información</strong> y no con promesas.
</blockquote>
</div>
<div class="manifesto-cierre">
<p class="manifesto-cierre-texto">Abrimos para no cerrar.</p>
<p class="manifesto-cierre-sub">{institucion} &nbsp;·&nbsp; {pais}</p>
</div>
</div>
</div>
<style>
.manifesto-outer {
min-height: 100vh;
background: var(--manifesto-bg);
overflow: hidden;
}
.manifesto-inner {
max-width: 640px;
margin: 0 auto;
padding: 72px 48px 88px;
font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
color: var(--manifesto-text);
}
/* === etiqueta superior === */
.manifesto-label {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 10px;
letter-spacing: 0.18em;
color: var(--manifesto-muted);
text-transform: uppercase;
margin-bottom: 56px;
display: flex;
align-items: center;
gap: 12px;
opacity: 0;
animation: fadeUp 0.6s ease forwards;
}
.manifesto-label::after {
content: '';
flex: 1;
height: 0.5px;
background: var(--manifesto-rule);
}
/* === fecha === */
.manifesto-fecha {
font-family: 'Source Serif 4', Georgia, serif;
font-style: italic;
font-weight: 300;
font-size: 14px;
color: var(--manifesto-subtle);
letter-spacing: 0.02em;
margin-bottom: 40px;
opacity: 0;
animation: fadeUp 0.6s ease 0.1s forwards;
}
/* === cuerpo === */
.manifesto-cuerpo {
font-size: 18.5px;
line-height: 1.78;
font-weight: 300;
color: var(--manifesto-body);
letter-spacing: 0.01em;
}
.p1 {
opacity: 0;
animation: fadeUp 0.7s ease 0.2s forwards;
margin-bottom: 28px;
margin-top: 0;
}
.manifesto-hoy {
opacity: 0;
animation: fadeUp 0.7s ease 0.5s forwards;
margin: 44px 0;
font-size: 26px;
font-weight: 400;
color: var(--manifesto-text);
letter-spacing: -0.01em;
line-height: 1.3;
}
.p3 {
opacity: 0;
animation: fadeUp 0.7s ease 0.65s forwards;
margin-bottom: 28px;
margin-top: 0;
}
.manifesto-regla {
opacity: 0;
animation: fadeUp 0.6s ease 0.75s forwards;
border: none;
border-top: 0.5px solid var(--manifesto-rule);
margin: 44px 0;
}
.p4 {
opacity: 0;
animation: fadeUp 0.7s ease 0.85s forwards;
margin-bottom: 0;
margin-top: 0;
}
.manifesto-apuesta {
opacity: 0;
animation: fadeUp 0.7s ease 0.95s forwards;
margin: 44px 0;
padding: 0 0 0 24px;
border-left: 1.5px solid var(--manifesto-quote-border);
font-style: italic;
font-weight: 300;
font-size: 20px;
line-height: 1.65;
color: var(--manifesto-quote);
}
.manifesto-apuesta strong {
font-style: normal;
font-weight: 400;
color: var(--manifesto-body);
}
/* === cierre === */
.manifesto-cierre {
opacity: 0;
animation: fadeUp 0.8s ease 1.1s forwards;
margin-top: 56px;
padding-top: 36px;
border-top: 0.5px solid var(--manifesto-rule);
}
.manifesto-cierre-texto {
font-size: 36px;
font-weight: 400;
color: var(--color-gold);
letter-spacing: -0.02em;
line-height: 1.1;
margin: 0;
}
.manifesto-cierre-sub {
font-family: 'JetBrains Mono', 'Courier New', monospace;
font-size: 10px;
letter-spacing: 0.16em;
color: var(--manifesto-muted);
text-transform: uppercase;
margin-top: 20px;
margin-bottom: 0;
}
/* === animación === */
@keyframes -global-fadeUp {
from {
opacity: 0;
transform: translateY(14px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* === MODO OSCURO (default) === */
.manifesto-outer {
--manifesto-bg: #0C0C0C;
--manifesto-text: #EDE9E1;
--manifesto-body: #D9D4CB;
--manifesto-muted: #5A5650;
--manifesto-subtle: #6B6660;
--manifesto-rule: #2A2822;
--manifesto-quote: #C4BFB6;
--manifesto-quote-border: #3A3530;
}
/* === MODO CLARO === */
:global(html:not(.dark)) .manifesto-outer {
--manifesto-bg: #FAFAF8;
--manifesto-text: #1a1a1a;
--manifesto-body: #3a3a3a;
--manifesto-muted: #8a8a8a;
--manifesto-subtle: #6a6a6a;
--manifesto-rule: #e0e0e0;
--manifesto-quote: #4a4a4a;
--manifesto-quote-border: #d0d0d0;
}
/* === responsive === */
@media (max-width: 600px) {
.manifesto-inner {
padding: 48px 28px 64px;
}
.manifesto-cuerpo {
font-size: 17px;
}
.manifesto-hoy {
font-size: 22px;
}
.manifesto-apuesta {
font-size: 18px;
}
.manifesto-cierre-texto {
font-size: 28px;
}
}
</style>
<script>
import { onMount } from 'svelte';
import * as d3 from 'd3';
let container;
let data = $state([]);
let width = $state(800);
let height = $state(600);
let searchQuery = $state('');
let searchResults = $state([]);
let selectedEntity = $state(null);
let circlesSelection = null;
let nodesData = null;
onMount(async () => {
// Load CSV data
const response = await fetch('/bubbles.csv');
const text = await response.text();
data = d3.csvParse(text, d => ({
entidad: +d.entidad,
sueldos: +d.sueldos,
prop: +d.prop
}));
// Set dimensions based on container
const rect = container.getBoundingClientRect();
width = rect.width;
height = rect.height || 600;
createVisualization();
// Handle resize
const resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
width = entry.contentRect.width;
height = entry.contentRect.height || 600;
createVisualization();
}
});
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
});
function handleSearch(e) {
const query = e.target.value;
searchQuery = query;
if (query.length >= 1) {
// Search by entity code
searchResults = data
.filter(d => d.entidad.toString().includes(query))
.slice(0, 8);
} else {
searchResults = [];
clearHighlight();
}
}
function selectEntity(entity) {
selectedEntity = entity;
searchQuery = entity.entidad.toString();
searchResults = [];
highlightEntity(entity.entidad);
}
function highlightEntity(entidadId) {
if (!circlesSelection) return;
circlesSelection
.transition()
.duration(300)
.attr('opacity', d => d.entidad === entidadId ? 1 : 0.15)
.attr('stroke', d => d.entidad === entidadId ? '#fff' : 'rgba(255,255,255,0.05)')
.attr('stroke-width', d => d.entidad === entidadId ? 3 : 0.5);
// Add pulse animation to selected
const selected = circlesSelection.filter(d => d.entidad === entidadId);
selected
.classed('pulse', true);
}
function clearHighlight() {
selectedEntity = null;
searchQuery = '';
searchResults = [];
if (!circlesSelection) return;
circlesSelection
.classed('pulse', false)
.transition()
.duration(300)
.attr('opacity', 0.9)
.attr('stroke', 'rgba(255,255,255,0.15)')
.attr('stroke-width', 0.5);
}
function createVisualization() {
if (!data.length || !container) return;
// Clear previous
d3.select(container).selectAll('*').remove();
// Create SVG
const svg = d3.select(container)
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height]);
// Scale for radius - responsive to screen size
const minDim = Math.min(width, height);
const maxRadius = minDim * 0.09; // 9% del lado menor
const minRadius = minDim * 0.007; // 0.7% del lado menor
const radiusScale = d3.scaleSqrt()
.domain([0, d3.max(data, d => d.prop)])
.range([minRadius, maxRadius]);
// Paleta cálida: amarillo banana → naranja salmón
const colorScale = d3.scaleThreshold()
.domain([0.001, 0.005, 0.01, 0.03, 0.05]) // 0.1%, 0.5%, 1%, 3%, 5%
.range([
'#5c5448', // < 0.1% - marrón apagado
'#8b7355', // 0.1-0.5% - tierra suave
'#d4c4a8', // 0.5-1% - beige
'#f5e6c4', // 1-3% - amarillo banana claro
'#f8d4a6', // 3-5% - durazno
'#f4a574' // > 5% - salmón naranja
]);
// Create nodes with initial positions
const nodes = data.map(d => ({
...d,
r: radiusScale(d.prop),
x: width / 2 + (Math.random() - 0.5) * 100,
y: height / 2 + (Math.random() - 0.5) * 100
}));
nodesData = nodes;
// Create force simulation with faster convergence
const simulation = d3.forceSimulation(nodes)
.force('charge', d3.forceManyBody().strength(5))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(d => d.r + 1.4).strength(1).iterations(3))
.force('x', d3.forceX(width / 2).strength(0.1))
.force('y', d3.forceY(height / 2).strength(0.1))
.alphaDecay(0.05)
.velocityDecay(0.4);
// Pre-calculate some ticks, but leave room for visible settling
for (let i = 0; i < 60; i++) simulation.tick();
// Create circles
const circles = svg.append('g')
.selectAll('circle')
.data(nodes)
.join('circle')
.attr('r', d => d.r)
.attr('fill', d => colorScale(d.prop))
.attr('stroke', 'rgba(255,255,255,0.15)')
.attr('stroke-width', 0.5)
.attr('opacity', 0.9)
.style('cursor', 'pointer');
// Store reference for highlighting
circlesSelection = circles;
// Add tooltip
const tooltip = d3.select(container)
.append('div')
.attr('class', 'tooltip')
.style('position', 'absolute')
.style('visibility', 'hidden')
.style('background', 'var(--theme-surface, #1a1a1a)')
.style('border', '1px solid var(--theme-borde, #333)')
.style('border-radius', '8px')
.style('padding', '12px')
.style('font-size', '13px')
.style('color', 'var(--theme-titulo, #fff)')
.style('box-shadow', '0 4px 12px rgba(0,0,0,0.3)')
.style('pointer-events', 'none')
.style('z-index', '100');
circles
.on('mouseover', (event, d) => {
tooltip
.style('visibility', 'visible')
.html(`
<div style="font-weight: 600; margin-bottom: 6px;">Entidad ${d.entidad}</div>
<div style="color: var(--theme-texto, #999);">
Sueldos: Bs ${d3.format(',.0f')(d.sueldos)}<br/>
Proporción: ${(d.prop * 100).toFixed(2)}%
</div>
`);
if (!selectedEntity) {
d3.select(event.currentTarget)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('opacity', 1);
}
})
.on('mousemove', (event) => {
tooltip
.style('left', (event.offsetX + 15) + 'px')
.style('top', (event.offsetY - 10) + 'px');
})
.on('mouseout', (event, d) => {
tooltip.style('visibility', 'hidden');
if (!selectedEntity) {
d3.select(event.currentTarget)
.attr('stroke', 'rgba(255,255,255,0.15)')
.attr('stroke-width', 0.5)
.attr('opacity', 0.9);
}
})
.on('click', (event, d) => {
selectEntity(d);
});
// Add drag behavior
circles.call(d3.drag()
.on('start', (event, d) => {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on('drag', (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on('end', (event, d) => {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}));
// Update positions on each tick
simulation.on('tick', () => {
circles
.attr('cx', d => d.x)
.attr('cy', d => d.y);
});
// Restore highlight if there was a selection
if (selectedEntity) {
highlightEntity(selectedEntity.entidad);
}
}
</script>
<div class="page">
<header>
<h1>Distribución de Sueldos por Entidad</h1>
<p class="subtitle">Visualización de partículas proporcionales al gasto en sueldos</p>
</header>
<div class="search-container">
<div class="search-box">
<svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<input
type="text"
placeholder="Buscar entidad por código..."
value={searchQuery}
oninput={handleSearch}
/>
{#if searchQuery}
<button class="clear-btn" onclick={clearHighlight}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
{/if}
</div>
{#if searchResults.length > 0}
<div class="search-dropdown">
{#each searchResults as result}
<button class="search-result" onclick={() => selectEntity(result)}>
<span class="result-code">{result.entidad}</span>
<span class="result-info">Bs {d3.format(',.0f')(result.sueldos)} · {(result.prop * 100).toFixed(2)}%</span>
</button>
{/each}
</div>
{/if}
</div>
{#if selectedEntity}
<div class="selected-info">
<span class="selected-label">Entidad {selectedEntity.entidad}</span>
<span class="selected-value">Bs {d3.format(',.0f')(selectedEntity.sueldos)}</span>
<span class="selected-pct">{(selectedEntity.prop * 100).toFixed(2)}% del total</span>
</div>
{/if}
<div class="container" bind:this={container}></div>
<footer>
<div class="legend">
<span class="legend-item"><span class="dot" style="background: #5c5448"></span> &lt;0.1%</span>
<span class="legend-item"><span class="dot" style="background: #8b7355"></span> 0.1-0.5%</span>
<span class="legend-item"><span class="dot" style="background: #d4c4a8"></span> 0.5-1%</span>
<span class="legend-item"><span class="dot" style="background: #f5e6c4"></span> 1-3%</span>
<span class="legend-item"><span class="dot" style="background: #f8d4a6"></span> 3-5%</span>
<span class="legend-item"><span class="dot" style="background: #f4a574"></span> &gt;5%</span>
</div>
</footer>
</div>
<style>
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
background: var(--theme-fondo, #0d0d0d);
}
header {
text-align: center;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--theme-titulo, #fff);
margin: 0 0 0.5rem 0;
}
.subtitle {
font-size: 0.875rem;
color: var(--theme-texto, #999);
margin: 0;
}
.search-container {
position: relative;
width: 100%;
max-width: 320px;
margin-bottom: 1rem;
}
.search-box {
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--theme-surface, #1a1a1a);
border: 1px solid var(--theme-borde, #333);
border-radius: 8px;
padding: 0.625rem 1rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-box:focus-within {
border-color: #f4a574;
box-shadow: 0 0 0 3px rgba(244, 165, 116, 0.15);
}
.search-icon {
color: var(--theme-texto, #999);
opacity: 0.5;
flex-shrink: 0;
}
.search-box input {
flex: 1;
border: none;
background: transparent;
color: var(--theme-titulo, #fff);
font-size: 0.875rem;
outline: none;
}
.search-box input::placeholder {
color: var(--theme-texto, #999);
opacity: 0.5;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border: none;
background: transparent;
color: var(--theme-texto, #999);
cursor: pointer;
border-radius: 4px;
transition: background 0.2s;
}
.clear-btn:hover {
background: rgba(255,255,255,0.1);
}
.search-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--theme-surface, #1a1a1a);
border: 1px solid var(--theme-borde, #333);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
overflow: hidden;
z-index: 50;
}
.search-result {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: transparent;
color: var(--theme-titulo, #fff);
cursor: pointer;
transition: background 0.15s;
text-align: left;
}
.search-result:hover {
background: rgba(255,255,255,0.05);
}
.result-code {
font-family: 'DM Mono', monospace;
font-weight: 600;
color: #f4a574;
}
.result-info {
font-size: 0.75rem;
color: var(--theme-texto, #999);
}
.selected-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem 1.25rem;
background: rgba(244, 165, 116, 0.1);
border: 1px solid rgba(244, 165, 116, 0.3);
border-radius: 8px;
margin-bottom: 1rem;
}
.selected-label {
font-weight: 600;
color: #f4a574;
}
.selected-value {
color: var(--theme-titulo, #fff);
}
.selected-pct {
font-size: 0.875rem;
color: var(--theme-texto, #999);
}
.container {
flex: 1;
width: 100%;
max-width: 1200px;
min-height: 500px;
position: relative;
background: var(--theme-surface, #1a1a1a);
border-radius: 12px;
overflow: hidden;
}
footer {
margin-top: 1.5rem;
display: flex;
justify-content: center;
}
.legend {
display: flex;
gap: 2rem;
font-size: 0.8125rem;
color: var(--theme-texto, #999);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.15);
}
/* Pulse animation for highlighted bubble */
:global(.pulse) {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
filter: drop-shadow(0 0 0 rgba(255,255,255,0));
}
50% {
filter: drop-shadow(0 0 12px rgba(255,255,255,0.6));
}
}
@media (max-width: 640px) {
.legend {
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
.selected-info {
flex-direction: column;
gap: 0.25rem;
text-align: center;
}
}
</style>
gestion,objeto,devengado
2025,11100,7010195151.280003
2025,11210,5812831318.110004
2025,11220,3960643633.5099993
2025,11310,477377380.7399999
2025,11321,611358929.5699998
2025,11322,344284069.2800001
2025,11323,8492765.11
2025,11324,119472850.73000005
2025,11331,3741918.29
2025,11332,222831.86
2025,11339,991911009.5900005
2025,11400,2598265843.6899953
2025,11510,115618445.64
2025,11520,3684768.92
2025,11600,347937539.3
2025,11700,16012422722.88999
2025,11810,1897633.19
2025,11820,5236920.13
2025,11910,418427037.15000004
2025,11920,49552831.83999999
2025,11930,141140855.32999998
2025,11940,4603197.24
2025,12100,3204535824.6000023
2025,13110,3901953807.7400007
2025,13120,619414677.6200007
2025,13131,1362025647.0700052
2025,13132,16517337.85
2025,13200,780321050.7899984
2025,14100,1139341529.1000001
2025,15100,0
2025,15200,0
2025,15300,0
2025,15400,4691146.74
2025,21100,18994647.069999997
2025,21200,1185053965.7300012
2025,21300,187133399.53000006
2025,21400,53465476.169999935
2025,21500,32025660.46999999
2025,21600,194624988.81999987
2025,22110,91267157.03
2025,22120,18054096.22
2025,22210,150388054.74000007
2025,22220,34629325.89
2025,22300,1959574229.3100035
2025,22400,2341473.77
2025,22500,701307983.6299998
2025,22600,129774059.36000001
2025,23100,177818037.08
2025,23200,705817678.630001
2025,23300,267332.29
2025,23400,102877216.77999996
2025,24110,347305124.4300007
2025,24120,495875030.0700008
2025,24130,4734068.450000001
2025,24200,186441122.7200001
2025,24300,2640074895.480003
2025,25120,968577449.3700005
2025,25130,48331129.9
2025,25210,124279655.01999998
2025,25220,1788157637.100003
2025,25230,38750297.28
2025,25300,561398734.3999997
2025,25400,878953716.1200004
2025,25500,306587141.63999945
2025,25600,313083444.33000004
2025,25700,6976563.890000001
2025,25810,119466843.19999999
2025,25820,224542509.50000012
2025,25900,237665083.13999984
2025,26200,15655261.7
2025,26300,34604066.51
2025,26610,123238100.68000004
2025,26620,65803840.900000006
2025,26630,5856917.720000001
2025,26640,12578208.32
2025,26700,14848951.62
2025,26910,663385.5700000003
2025,26920,235250
2025,26930,8978735.689999998
2025,26940,66563836.099999994
2025,26990,1503662082.0000036
2025,27110,1825790733.1499996
2025,27120,364659427.3
2025,31110,542443427.4200002
2025,31120,216381918.43999982
2025,31130,664933780.7900007
2025,31140,546467610.53
2025,31150,58934973.47
2025,31200,33094567.35000001
2025,31300,2447995585.1100035
2025,32100,143276681.52000007
2025,32200,132315063.70000002
2025,32300,5561928.509999998
2025,32400,159320
2025,32500,1136682.8700000003
2025,33100,24196989.06
2025,33200,44402437.73000002
2025,33300,140159482.78000006
2025,33400,44503112.73000003
2025,34110,1233206780.699983
2025,34120,22281523272.74
2025,34130,210854679.98
2025,34200,2971103666.2100005
2025,34300,107990911.47999994
2025,34400,7452099.93
2025,34500,643334205.2399997
2025,34600,256516535.3800006
2025,34700,4499642859.289998
2025,34800,31720752.09
2025,34900,3886088.93
2025,39100,110497000.54
2025,39200,48738034.710000016
2025,39300,4137761.2899999996
2025,39400,88709616.89000002
2025,39500,308909532.08000034
2025,39600,13789089.190000001
2025,39700,260527546.88999993
2025,39800,711478491.3899997
2025,39911,25451599.84
2025,39912,229716435.09
2025,39990,85810785.37999989
2025,41100,4018555.16
2025,41200,11738231.740000002
2025,41300,0
2025,42210,2475688.8800000004
2025,42220,5772449.3
2025,42230,3644887583.5699887
2025,42240,119884214.51000004
2025,42310,3262092590.7700067
2025,42320,162411814.5
2025,42400,748574159.99
2025,42500,368399418.97999996
2025,43110,68317124.34
2025,43120,223834030.73999995
2025,43200,145257372.71
2025,43310,18770602.6
2025,43320,7603011.960000001
2025,43330,290260838.97
2025,43340,4231983.279999999
2025,43400,398948895.01000005
2025,43500,328470832.01999986
2025,43600,51696765.46999999
2025,43700,169086594.95000005
2025,46110,125733899.86000003
2025,46120,74691013.75999999
2025,46210,57348534.56999999
2025,46220,9583654.360000001
2025,46310,30831570.299999986
2025,46320,2784494.7899999996
2025,49100,41480273
2025,49300,492862.94999999995
2025,49400,85275
2025,49900,660922.38
2025,51100,0
2025,51200,620015014.37
2025,51600,481042090
2025,51700,29014116.87
2025,53410,52050949.82
2025,53420,351090144.51
2025,53430,3378151.15
2025,53440,25716472.12
2025,54300,77289717.23
2025,54800,632675260.65
2025,55130,0
2025,56100,2147721911.8500004
2025,57100,836275.9
2025,57200,0
2025,61100,70227731033.1
2025,61200,3503973693.6899977
2025,61300,7686201.74
2025,61400,165316.78
2025,61600,4660620766.32
2025,61700,3610015023.1800003
2025,61800,578767.5200000001
2025,61900,560031.13
2025,62100,106585021.14999999
2025,62200,38756619.58
2025,62300,3500499.439999999
2025,62400,405.55
2025,62600,4871024753.510001
2025,62700,3091378138.92
2025,62800,66184743.53000001
2025,62900,236631.16
2025,63100,6480763.82
2025,63200,0
2025,63300,100360.43
2025,63400,462857.7
2025,63500,35529.2
2025,63600,0
2025,63700,2369055.65
2025,63800,0
2025,63900,1087614.1800000002
2025,64100,0
2025,64200,0
2025,65100,586447491.8299999
2025,65210,398987753.5799999
2025,65220,240387686.01999998
2025,65230,132581381.01
2025,65240,0
2025,65300,2444818356.6
2025,65400,644864385.6300001
2025,65500,797927308.79
2025,65600,622100032.9
2025,65800,6897016.98
2025,65900,66705420.480000004
2025,66100,403828926.02000004
2025,66210,2597435653.4000015
2025,66220,1829365908.85
2025,66230,646299428.4800001
2025,66240,80232240.73
2025,66250,209697820.00000003
2025,66300,239921321.82000005
2025,66400,134798065.3299999
2025,66900,1844595842.08
2025,67100,23208064.040000003
2025,68200,230881944.88000008
2025,69100,29581681.77
2025,69200,203500678.33
2025,71100,7630007944.97
2025,71210,2392898.6
2025,71220,322149243.09
2025,71230,4408152.6
2025,71300,26644234.430000003
2025,71610,1883091507.0600004
2025,71630,343800974.93
2025,71700,65080225.449999996
2025,71800,4478984170.690001
2025,72200,2046897058.8299997
2025,72420,316010589.22
2025,72520,7584584602.65001
2025,73100,2664574022.970007
2025,73200,24681254941.659996
2025,73410,4845222515.11
2025,73420,315258596.03999996
2025,73430,4314037.58
2025,73440,1652127
2025,73700,15015040108.359999
2025,73820,1364681.67
2025,75110,0
2025,75120,683772757.9200002
2025,75211,469127620.05
2025,75212,50308009.760000005
2025,75221,11649919.909999998
2025,75222,305831148.79999995
2025,75320,0
2025,77100,346626643.30999994
2025,77200,1783047515.49
2025,77410,154404059.40000007
2025,77440,13005182.149999999
2025,77520,34542653.12
2025,77530,1359464924.2300012
2025,77700,0
2025,77820,42931153.11
2025,78100,346333.4
2025,79100,20183063.04
2025,79200,74336.66
2025,79310,69600
2025,81100,189467352
2025,81200,1327189189.5399992
2025,81300,551652107.6699998
2025,81400,299412884.65
2025,81500,2442434
2025,81600,395569
2025,81950,39137
2025,81960,32790665.14
2025,81990,2007174694.16
2025,82100,879007438.5400001
2025,83110,29113391.8
2025,83120,4240482.35
2025,83210,351070.72
2025,83220,50460
2025,84100,65887753.279999994
2025,84230,394819507.59
2025,84240,723835763.5600002
2025,84250,65803251.14
2025,84900,8503359.91
2025,85100,94254502.63999999
2025,85200,10478575.300000003
2025,85400,6221225.630000003
2025,85500,3581209.3799999994
2025,85900,20640115.529999997
2025,86100,29028844.980000008
2025,91200,512102826.06
2025,92100,0
2025,94100,11526054.86
2025,94200,7279202.82
2025,94300,1551638.3499999999
2025,95100,291453722.56
2025,96100,5934986991.019999
2025,96200,614954091.44
2025,96900,2947311655.2299995
2025,97100,70099589.35000001
2025,98300,125000000
2025,99100,0
2025,99200,0
gestion,objeto_grupo,poblacion,top1_entidad_desc_entidad,top2_entidad_desc_entidad,top3_entidad_desc_entidad,top1_monto,top2_monto,top3_monto,top1_per_capita,top2_per_capita,top3_per_capita
2005,1,9475861,Prefectura Del Departamento De La Paz,Ministerio De Defensa Nacional,Prefectura Del Departamento De Santa Cruz,2165989973.2400002,905520472.9100002,783374374.3100002,229,96,83
2005,2,9475861,Municipalidad De La Paz,Servicio Nacional De Caminos,Municipalidad De Santa Cruz De La Sierra,318052922.3799999,262671532.11,239287121.05999997,34,28,25
2005,3,9475861,Yacimientos Petroliferos Fiscales Bolivianos,Ministerio De Gobierno,Ministerio De Defensa Nacional,484404998.67,180713886.04,173790141.18999997,51,19,18
2005,4,9475861,Servicio Nacional De Caminos,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,1602531309.59,426412889.85000026,382531081.14000034,169,45,40
2005,5,9475861,Caja Petrolera De Salud,Secretaría Ejecutiva - Pl 480,Ministerio De Desarrollo Económico,78565985.56999998,54276791.55,40008080,8,6,4
2005,6,9475861,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,Municipalidad De Cochabamba,665938007.79,657951217.52,169294372.42000002,70,69,18
2005,8,9475861,Ministerio De Salud Y Deportes,Ministerio De Gobierno,Ministerio De Asuntos Campesinos Y Agropecuarios,28920132.529999997,22942835.43,20474175.640000004,3,2,2
2005,9,9475861,Municipalidad De Santa Cruz De La Sierra,Ministerio De Hacienda,Ministerio De Salud Y Deportes,32578836.83,28846336.569999993,10778676.76,3,3,1
2006,1,9586372,Prefectura Del Departamento De La Paz,Prefectura Del Departamento De Cochabamba,Ministerio De Defensa Nacional,2521168290.18,1453310487.180001,958445497.76,263,152,100
2006,2,9586372,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,"Ministerio Desarrollo Rural, Agropecuario Y Medio Ambiente",386921568.65,373646357.0700001,335461208.43000007,40,39,35
2006,3,9586372,Yacimientos Petroliferos Fiscales Bolivianos,Ministerio De Gobierno,Ministerio De Defensa Nacional,3688964112.9000006,191526679.58999985,185658779.26999998,385,20,19
2006,4,9586372,Servicio Nacional De Caminos,Prefectura Del Departamento De Cochabamba,Municipalidad De Santa Cruz De La Sierra,1137837735.4099996,968591768.2700002,698595527.3600003,119,101,73
2006,5,9586372,"Ministerio De Obras Públicas, Servicios Y Vivienda",Caja Petrolera De Salud,Municipalidad De Yacuiba,282218297.76,72856150.11,63875244.99,29,8,7
2006,6,9586372,Municipalidad De La Paz,Municipalidad De Cochabamba,Municipalidad De Santa Cruz De La Sierra,462669962.51,288088976.76000005,158705430.61000004,48,30,17
2006,8,9586372,Yacimientos Petroliferos Fiscales Bolivianos,Ministerio De Gobierno,Ministerio De Salud Y Deportes,21867029.55,14706981.85,11468759,2,2,1
2006,9,9586372,Ministerio De Hacienda,Municipalidad De Santa Cruz De La Sierra,Municipalidad De Cochabamba,18617331.94,14999868.350000001,7530275.42,2,2,1
2007,1,9701623,Prefectura Del Departamento De La Paz,Prefectura Del Departamento De Santa Cruz,Prefectura Del Departamento De Cochabamba,2793147580.559999,2142569168.739999,1632277442.7800004,288,221,168
2007,2,9701623,Yacimientos Petrolíferos Fiscales Bolivianos,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,6865924650.49,570437686.4999995,413919119.86999947,708,59,43
2007,3,9701623,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalurgica Vinto - Nacionalizada,Ministerio De Defensa Nacional,7975908085.859999,739852915,299348102.3900001,822,76,31
2007,4,9701623,Prefectura Del Departamento De Tarija,Administradora Boliviana De Carreteras,Municipalidad De Santa Cruz De La Sierra,1754016115.350001,1664453307.4500003,912613170.5799996,181,172,94
2007,5,9701623,Yacimientos Petrolíferos Fiscales Bolivianos,"Ministerio De Obras Públicas, Servicios Y Vivienda",Ministerio De Educación Y Culturas,875200000,323258427.12,293981501.01,90,33,30
2007,6,9701623,Municipalidad De La Paz,Empresa Metalurgica Vinto - Nacionalizada,Municipalidad De Cochabamba,328357628.21,212924215.28,199598641.12,34,22,21
2007,8,9701623,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalurgica Vinto - Nacionalizada,Corporación Minera De Bolivia,6108127707.92,78096547.46,57274853.559999995,630,8,6
2007,9,9701623,Ministerio De Planificación Del Desarrollo,Fondo Nacional De Desarrollo Regional,Universidad Técnica Del Beni Mariscal José Ballivián,23223678.370000005,10374204.82,7515221.790000001,2,1,1
2008,1,9794695,Prefectura Del Departamento De La Paz,Prefectura Del Departamento De Santa Cruz,Prefectura Del Departamento De Cochabamba,3304344591.9199996,2440880970.3999987,1879310925.0499995,337,249,192
2008,2,9794695,Yacimientos Petrolíferos Fiscales Bolivianos,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,10540777751.59,832618443.9699998,486105833.6400003,1076,85,50
2008,3,9794695,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Empresa De Apoyo A La Producción De Alimentos,12750659412.59,553722043.84,323532907.1,1302,57,33
2008,4,9794695,Administradora Boliviana De Carreteras,Municipalidad De Santa Cruz De La Sierra,Prefectura Del Departamento De Tarija,1784228297.3000002,1399548427.8799996,1343000284.6599998,182,143,137
2008,5,9794695,Yacimientos Petrolíferos Fiscales Bolivianos,"Ministerio De Obras Públicas, Servicios Y Vivienda",Ministerio De Educación Y Culturas,971747928.51,510793167.83,375442575.77,99,52,38
2008,6,9794695,Yacimientos Petrolíferos Fiscales Bolivianos,Municipalidad De La Paz,Administradora Boliviana De Carreteras,3224810049.3399997,315244661.84,255818640.30000004,329,32,26
2008,8,9794695,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Ministerio De Gobierno,13519956258.039999,37036040.6,18330223.33,1380,4,2
2008,9,9794695,Administradora Boliviana De Carreteras,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,113603369.12,27399435.18,11836288.28,12,3,1
2009,1,9914126,Prefectura Del Departamento De La Paz,Prefectura Del Departamento De Santa Cruz,Prefectura Del Departamento De Cochabamba,3899616135.439999,2887049199.470001,2235925941.379999,393,291,226
2009,2,9914126,Yacimientos Petrolíferos Fiscales Bolivianos,Municipalidad De Santa Cruz De La Sierra,Municipalidad De La Paz,7564355044.880002,701651121.6799995,580517613.5999999,763,71,59
2009,3,9914126,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Empresa De Apoyo A La Producción De Alimentos,8731254829.150007,925604794.5600002,376708709.00999993,881,93,38
2009,4,9914126,Administradora Boliviana De Carreteras,Municipalidad De Santa Cruz De La Sierra,Prefectura Del Departamento De Tarija,1849296985.0799997,1742795400.2699997,1548134098.3300002,187,176,156
2009,5,9914126,Yacimientos Petrolíferos Fiscales Bolivianos,"Ministerio De Obras Públicas, Servicios Y Vivienda",Ministerio De Educación,1170386094.97,434830717.7,376007546,118,44,38
2009,6,9914126,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Municipalidad De La Paz,9325932684.32,327683508.02,296420207.86,941,33,30
2009,8,9914126,Yacimientos Petrolíferos Fiscales Bolivianos,Insumos Bolivia,Ministerio De Desarrollo Rural Y Tierras,10137117443.170002,52402515.13,41841174.120000005,1022,5,4
2009,9,9914126,"Ministerio De Obras Públicas, Servicios Y Vivienda",Insumos Bolivia,Municipalidad De Santa Cruz De La Sierra,43601631.86000001,37134926.97,28062882.32,4,4,3
2010,1,10076577,Gobierno Autónomo Departamental De La Paz,Gobierno Autónomo Departamental De Santa Cruz,Gobierno Autónomo Departamental De Cochabamba,2810106264.169998,2137233770.1199992,1836796512.7199996,279,212,182
2010,2,10076577,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De La Paz,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,9606702121.769981,676447080.2400005,602260582.9700004,953,67,60
2010,3,10076577,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Caja Nacional De Salud,11529013071.720003,1458247042.1600003,548694335.04,1144,145,54
2010,4,10076577,Administradora Boliviana De Carreteras,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Gobierno Autónomo Municipal De La Paz,1985831172.3600008,1696096057.1899998,1038623520.9000003,197,168,103
2010,5,10076577,"Ministerio De Obras Públicas, Servicios Y Vivienda",Ministerio De Educación,Yacimientos Petrolíferos Fiscales Bolivianos,459994511.27,380911925,340916150,46,38,34
2010,6,10076577,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Gobierno Autónomo Municipal De La Paz,4855770372.599999,1861584187.78,321448950.00000006,482,185,32
2010,8,10076577,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Boliviana De Aviación,12188366174.610003,44830822.26,33681016.910000004,1210,4,3
2010,9,10076577,"Ministerio De Obras Públicas, Servicios Y Vivienda",Servicio Nacional De Caminos Residual,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,58059361,33742361.86,28328004.700000003,6,3,3
2011,1,10245917,Gobierno Autónomo Departamental De La Paz,Gobierno Autónomo Departamental De Santa Cruz,Ministerio De Defensa,1876956964.2700002,1804439437.8100004,1556478749.2300003,183,176,152
2011,2,10245917,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Gobierno Autónomo Municipal De La Paz,13204806505.260015,804986517.4700001,787026639.7400014,1289,79,77
2011,3,10245917,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Caja Nacional De Salud,13780371139.590004,2159717591.2599993,582317617.3299998,1345,211,57
2011,4,10245917,Administradora Boliviana De Carreteras,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Yacimientos Petrolíferos Fiscales Bolivianos,2465488536.6099977,1704115304.1899993,1531693615.8200006,241,166,149
2011,5,10245917,Empresa Nacional De Electricidad,"Ministerio De Obras Públicas, Servicios Y Vivienda",Ministerio De Educación,1510144149.62,901708727.05,385003557,147,88,38
2011,6,10245917,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Gobierno Autónomo Municipal De La Paz,7726615699.2,1290865675.49,375963545.81000006,754,126,37
2011,8,10245917,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Empresa Nacional De Electricidad,15017787489.499998,125388025.87000002,57048585.58,1466,12,6
2011,9,10245917,"Ministerio De Obras Públicas, Servicios Y Vivienda",Caja Nacional De Salud,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,61578780.45,51994601.55,34273741.39,6,5,3
2012,1,10423115,Direc. Dptal. De Educación La Paz,Caja Nacional De Salud,Ministerio De Defensa,2178855137.349999,1925343592.33,1684327813.1800003,209,185,162
2012,2,10423115,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Gobierno Autónomo Municipal De La Paz,17973240238.90001,1311516368.7199996,814786896.2800003,1724,126,78
2012,3,10423115,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Caja Nacional De Salud,19479875947.16001,1521240239.9999998,671854019.02,1869,146,64
2012,4,10423115,Administradora Boliviana De Carreteras,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,3130613069.040002,2822379744.7300005,2049979455.1000009,300,271,197
2012,5,10423115,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Educación,673150100.45,492478179.62,410000000,65,47,39
2012,6,10423115,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Gobierno Autónomo Municipal De La Paz,9572994860.94,1895579903.53,458481402.33000004,918,182,44
2012,8,10423115,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Empresa De Apoyo A La Producción De Alimentos,20916596450.64,131307470.30999999,108596765.45,2007,13,10
2012,9,10423115,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,"Ministerio De Obras Públicas, Servicios Y Vivienda",Boliviana De Aviación,57394472.559999995,38691808.54,35344609.77,6,4,3
2013,1,10594727,Direc. Dptal. De Educación La Paz,Caja Nacional De Salud,Ministerio De Defensa,2465770684.7899985,2102127300.6700006,1841582021.9000006,233,198,174
2013,2,10594727,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Gobierno Autónomo Municipal De La Paz,15222969613.420012,1927333922.0499985,971798623.0599998,1437,182,92
2013,3,10594727,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Empresa De Apoyo A La Producción De Alimentos,18382938405.059982,1558969740.2699997,1097138758.1899996,1735,147,104
2013,4,10594727,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Yacimientos Petrolíferos Fiscales Bolivianos,Administradora Boliviana De Carreteras,3225470030.7300024,3190680164.83,3034059058.33,304,301,286
2013,5,10594727,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Desarrollo Productivo Y Economía Plural,1337786918,799283319.38,691318068,126,75,65
2013,6,10594727,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Gobierno Autónomo Municipal De La Paz,11784575051.26,859275990.37,564119758.4100002,1112,81,53
2013,8,10594727,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Empresa De Apoyo A La Producción De Alimentos,20173943280.710007,194273165.46999997,105063157,1904,18,10
2013,9,10594727,Boliviana De Aviación,Corporación Minera De Bolivia,Banco Central De Bolivia,57684106.2,53791625.059999995,53424591.01,5,5,5
2014,1,10755947,Direc. Dptal. De Educación La Paz,Caja Nacional De Salud,Ministerio De Defensa,2811018706.470001,2304927163.9799995,2035516674.87,261,214,189
2014,2,10755947,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Gobierno Autónomo Municipal De La Paz,21881733206.499973,2047821424.639999,1147577972.3400004,2034,190,107
2014,3,10755947,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Empresa De Apoyo A La Producción De Alimentos,16816252742.549992,1983467373.26,1314485796.1200001,1563,184,122
2014,4,10755947,Administradora Boliviana De Carreteras,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,4471471523.549995,3755573664.629999,3477692771.4199977,416,349,323
2014,5,10755947,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Agencia Estatal De Vivienda,4265946873.46,1462769345.48,818183427.37,397,136,76
2014,6,10755947,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Gobierno Autónomo Municipal De La Paz,11854737861.820002,2781496625.57,544169180.4100001,1102,259,51
2014,8,10755947,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Empresa De Apoyo A La Producción De Alimentos,26673361520.7,214615925.63000003,125756483.42,2480,20,12
2014,9,10755947,Universidad Mayor De San Andrés,Banco Central De Bolivia,Boliviana De Aviación,108160591.77,96364078.23,62332474.08,10,9,6
2015,1,10920682,Direc. Dptal. De Educación La Paz,Caja Nacional De Salud,Direc. Dptal. De Educación Santa Cruz,3196815000.4999995,2738632510.4600005,2271681464.6499996,293,251,208
2015,2,10920682,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Gobierno Autónomo Municipal De La Paz,15225224067.17999,2280195269.949999,1259558084.6999996,1394,209,115
2015,3,10920682,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Caja Nacional De Salud,13962141991.250006,1184540725.5100005,1180563599.2299998,1279,108,108
2015,4,10920682,Administradora Boliviana De Carreteras,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,5283567362.979995,4105956510.9300027,2426933014.4799995,484,376,222
2015,5,10920682,Agencia Estatal De Vivienda,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,1762595577.75,827607758.94,612045786.45,161,76,56
2015,6,10920682,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,11437993835.62,2906358811.79,610331236.6100001,1047,266,56
2015,8,10920682,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Boliviana De Aviación,17929743923.690006,199769833.98,132315787.19,1642,18,12
2015,9,10920682,Banco Central De Bolivia,Caja Nacional De Salud,Boliviana De Aviación,95867427.66,94162422.64,82113934.15000002,9,9,8
2016,1,11083605,Direc. Dptal. De Educación La Paz,Direc. Dptal. De Educación Santa Cruz,Ministerio De Gobierno,3465552656.159999,2449443247.3900003,2330036692.6399994,313,221,210
2016,2,11083605,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Boliviana De Aviación,9415208327.389984,1032000691.63,849049341.5999997,849,93,77
2016,3,11083605,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Empresa Boliviana De Almendras Y Derivados,11995575952.039991,1206341190.64,732246377.3599999,1082,109,66
2016,4,11083605,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Yacimientos Petrolíferos Fiscales Bolivianos,5999681602.410001,5634582638.270006,3098315467.750001,541,508,280
2016,5,11083605,Empresa Nacional De Electricidad,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,5261840669.4,1366523942.8099997,972552767.6100001,475,123,88
2016,6,11083605,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Empresa Metalúrgica Vinto - Nacionalizada,7765027451.250001,394791768.53,330332851.33,701,36,30
2016,8,11083605,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Empresa Nacional De Electricidad,9808513908.54,160757331.21000004,147405871.45000002,885,15,13
2016,9,11083605,Boliviana De Aviación,Caja Nacional De Salud,Administradora Boliviana De Carreteras,109840520.94999999,107514545.48,76898776.27,10,10,7
2017,1,11242712,Direc. Dptal. De Educación La Paz,Direc. Dptal. De Educación Santa Cruz,Ministerio De Gobierno,3874606490.6599994,2744823898.3900023,2493300380.2599983,345,244,222
2017,2,11242712,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Boliviana De Aviación,10362979117.190002,984205158.9000002,967109692.0599998,922,88,86
2017,3,11242712,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Boliviana De Aviación,13886105538.840004,1569234959.89,720203230.6099997,1235,140,64
2017,4,11242712,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Yacimientos Petrolíferos Fiscales Bolivianos,7375401157.710002,5045553363.139998,1778189094.459999,656,449,158
2017,5,11242712,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,Agencia Estatal De Vivienda,4282944874.2,1338320661.5,1080172168.1000001,381,119,96
2017,6,11242712,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Gobierno Autónomo Municipal De La Paz,5593851462.229998,486578573.28999996,306025127.0400001,498,43,27
2017,8,11242712,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Boliviana De Aviación,10025830419.640001,256911748.07999998,158304505.88,892,23,14
2017,9,11242712,Boliviana De Aviación,Caja Nacional De Salud,Yacimientos Petrolíferos Fiscales Bolivianos,117635851.57000001,91799962.57999998,64431692.18,10,8,6
2018,1,11386175,Direc. Dptal. De Educación La Paz,Direc. Dptal. De Educación Santa Cruz,Ministerio De Gobierno,4191569716.8,2975066506.47,2706347368.1199985,368,261,238
2018,2,11386175,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Boliviana De Aviación,12124672578.34001,1301082019.210002,966253684.1299998,1065,114,85
2018,3,11386175,Yacimientos Petrolíferos Fiscales Bolivianos,Servicio De Desarrollo De Las Empresas Púb. Productivas,Empresa Metalúrgica Vinto - Nacionalizada,16387061058.159994,1298236513.18,1298030760.4099998,1439,114,114
2018,4,11386175,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,"Ministerio De Obras Públicas, Servicios Y Vivienda",5730403130.989998,3576398449.12,1899554860.9500005,503,314,167
2018,5,11386175,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,Agencia Estatal De Vivienda,2268651016.11,1245671100.6899996,1143026979.1299999,199,109,100
2018,6,11386175,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,5979699985.56,618413095.1300001,271553612.1600001,525,54,24
2018,8,11386175,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Boliviana De Aviación,10863973990.950005,341118744.01,177250112.42,954,30,16
2018,9,11386175,Ministerio De Minería Y Metalurgia,Boliviana De Aviación,Caja Nacional De Salud,292762367.8,123667362.07000001,87580774.85999998,26,11,8
2019,1,11514867,Direc. Dptal. De Educación La Paz,Direc. Dptal. De Educación Santa Cruz,Ministerio De Gobierno,4445368441.799999,3179774881.9200006,2863143021.3999996,386,276,249
2019,2,11514867,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Boliviana De Aviación,12399169775.690004,1149738875.3499997,984473441.33,1077,100,85
2019,3,11514867,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Servicio De Desarrollo De Las Empresas Púb. Productivas,16128293385.159996,1243175128.7700002,1172150575.88,1401,108,102
2019,4,11514867,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,"Ministerio De Obras Públicas, Servicios Y Vivienda",4742184448.900001,2440511194.83,1505655193.4299994,412,212,131
2019,5,11514867,Empresa Nacional De Electricidad,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,1385406712.56,1245574650.5099995,994833219.8699995,120,108,86
2019,6,11514867,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,5097730559.569998,808853725.55,343341873.52,443,70,30
2019,8,11514867,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Salud,13676470168.299997,349216136.52,94543209.52000001,1188,30,8
2019,9,11514867,Corporación Minera De Bolivia,Boliviana De Aviación,Caja Nacional De Salud,189732167.96,100038655.35,94859332.39,16,9,8
2020,1,11640016,Direc. Dptal. De Educación La Paz,Ministerio De Gobierno,Direc. Dptal. De Educación Santa Cruz,4429675686.170002,3419104302.1800013,3177788417.15,381,294,273
2020,2,11640016,Yacimientos Petrolíferos Fiscales Bolivianos,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,Boliviana De Aviación,11067634469.870028,813835312.9199992,578725648.1,951,70,50
2020,3,11640016,Yacimientos Petrolíferos Fiscales Bolivianos,Servicio De Desarrollo De Las Empresas Púb. Productivas,Caja Nacional De Salud,12120739599.430012,919302775.8099996,691197545.6700004,1041,79,59
2020,4,11640016,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Agencia De Infraestructura En Salud Y Equipamiento Médico,1487141693.4999998,773611906.3300003,576669319.43,128,66,50
2020,5,11640016,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,Ministerio De Planificación Del Desarrollo,1144516445.1899998,781212704.9700003,696917732.38,98,67,60
2020,6,11640016,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,7747696767.070001,932360905.0999999,445927065.0999999,666,80,38
2020,8,11640016,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Gobierno,10404488193.79,441941245.26999986,142071449,894,38,12
2020,9,11640016,Ministerio De Economía Y Finanzas Públicas,Caja Nacional De Salud,Yacimientos Petrolíferos Fiscales Bolivianos,1719605616.72,117836366.7,100171351.09,148,10,9
2021,1,11733918,Direc. Dptal. De Educación La Paz,Ministerio De Gobierno,Direc. Dptal. De Educación Santa Cruz,4564962871.270001,3537614877.54,3285843136.2100024,389,301,280
2021,2,11733918,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,11689007816.880007,895829529.25,779934846.1699996,996,76,66
2021,3,11733918,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Central De Abastecimiento Y Suministros De Salud,18075018944.410007,2160914234.3399997,1462972262.26,1540,184,125
2021,4,11733918,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Empresa Siderúrgica Del Mutún,4190952268.700001,1946375233.7599995,892134155.1199999,357,166,76
2021,5,11733918,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,Empresa Nacional De Electricidad,1227901537.4199998,764948406.0900004,712096992,105,65,61
2021,6,11733918,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Gobierno Autónomo Municipal De La Paz,4149112793.8799987,857640236.7900001,332464021.41,354,73,28
2021,8,11733918,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Corporación Minera De Bolivia,11471035531.929996,290929719.37,121814649.52000001,978,25,10
2021,9,11733918,Ministerio De Economía Y Finanzas Públicas,Caja Nacional De Salud,Yacimientos Petrolíferos Fiscales Bolivianos,2994885782.73,116538207.02999997,92378391.36000001,255,10,8
2022,1,11798231,Direc. Dptal. De Educación La Paz,Ministerio De Gobierno,Direc. Dptal. De Educación Santa Cruz,4758751431.880001,3532934303.2099977,3438997772.909999,403,299,291
2022,2,11798231,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,13717458912.900005,1110985098.7200003,955449487.3299994,1163,94,81
2022,3,11798231,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Servicio De Desarrollo De Las Empresas Púb. Productivas,28312956060.750008,1848141879.07,1192873663.4899995,2400,157,101
2022,4,11798231,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Empresa Siderúrgica Del Mutún,3555223496.210001,1337968824.280001,951681893.51,301,113,81
2022,5,11798231,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,Ministerio De Planificación Del Desarrollo,1320180516.8000002,601282313.7799997,551926936.63,112,51,47
2022,6,11798231,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,5423504844.51,1410832328.29,579018729.71,460,120,49
2022,8,11798231,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Corporación Minera De Bolivia,14205993480.05,229356840.79999998,104066634.27,1204,19,9
2022,9,11798231,Ministerio De Economía Y Finanzas Públicas,Boliviana De Aviación,Yacimientos Petrolíferos Fiscales Bolivianos,902785870.27,137850755.29,110599063.64999999,77,12,9
2023,1,11872175,Direc. Dptal. De Educación La Paz,Ministerio De Gobierno,Direc. Dptal. De Educación Santa Cruz,4973944023.320004,3688766663.190001,3612275492.66,419,311,304
2023,2,11872175,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,11773261573.190014,1438304348.9699996,963027065.9399998,992,121,81
2023,3,11872175,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalúrgica Vinto - Nacionalizada,Empresa De Apoyo A La Producción De Alimentos,25825266301.549976,1564632540.7500002,1488076715.9700003,2175,132,125
2023,4,11872175,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Yacimientos Petrolíferos Fiscales Bolivianos,2379102996.9099994,1022917720.0299999,1020797708.61,200,86,86
2023,5,11872175,Agencia Estatal De Vivienda,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,1358834807.8799996,928522446,502983373.2699999,114,78,42
2023,6,11872175,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Empresa Pública Nacional Estratégica De Yacimientos De Litio Bolivianos,6295789937.63,1502652979.0599997,537055177.3,530,127,45
2023,8,11872175,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Empresa Nacional De Electricidad,13497380795.739998,215762276.84000003,130044604.64,1137,18,11
2023,9,11872175,Ministerio De Economía Y Finanzas Públicas,Banco Central De Bolivia,Boliviana De Aviación,1733803266.3999999,176140756.1,150713617.13,146,15,13
2024,1,11916453,Direc. Dptal. De Educación La Paz,Ministerio De Gobierno,Direc. Dptal. De Educación Santa Cruz,5221331121.370003,3901879569.17,3803816523.320001,438,327,319
2024,2,11916453,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,10545646955.190002,1376143999.17,879273588.7200005,885,115,74
2024,3,11916453,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Empresa Metalúrgica Vinto - Nacionalizada,27468850081.29001,2139035246.8700004,1867921535.0000002,2305,180,157
2024,4,11916453,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Yacimientos Petrolíferos Fiscales Bolivianos,2610044614.9600015,1048343616.21,923019707.7399994,219,88,77
2024,5,11916453,Agencia Estatal De Vivienda,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,1401782953.1999996,434716061,392456028.9200001,118,36,33
2024,6,11916453,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Banco Central De Bolivia,5927935075.01,1780339292.0800002,957010270.5400001,497,149,80
2024,8,11916453,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Empresa Nacional De Electricidad,11910441582.309998,253598777.35999998,154447306.18,999,21,13
2024,9,11916453,Banco Central De Bolivia,Caja Nacional De Salud,Boliviana De Aviación,1263368809.8,141667378.23999998,112172417.82,106,12,9
2025,1,11945263,Direc. Dptal. De Educación La Paz,Caja Nacional De Salud,Ministerio De Gobierno,5619472435.389999,5151839169.6900015,4152695264.629999,470,431,348
2025,2,11945263,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Gobierno Autónomo Municipal De Santa Cruz De La Sierra,10022617652.269995,1307238585.7,776894490.02,839,109,65
2025,3,11945263,Yacimientos Petrolíferos Fiscales Bolivianos,Corporación Minera De Bolivia,Caja Nacional De Salud,25440956502.62997,3031843418.9,2453131090.32,2130,254,205
2025,4,11945263,Administradora Boliviana De Carreteras,Empresa Nacional De Electricidad,Gobierno Autónomo Departamental De Potosí,1805588926,1772658039.970001,613522280.5799998,151,148,51
2025,5,11945263,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,Ministerio De Planificación Del Desarrollo,1495638952.1900003,584557374.4000002,391180329.65999997,125,49,33
2025,6,11945263,Yacimientos Petrolíferos Fiscales Bolivianos,Banco Central De Bolivia,Empresa Nacional De Electricidad,4459749775.39,3691286335.43,1975389838.2800002,373,309,165
2025,8,11945263,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,Empresa Nacional De Electricidad,7472329158.12,319021370.38000005,200966153.25000003,626,27,17
2025,9,11945263,Banco Central De Bolivia,Yacimientos Petrolíferos Fiscales Bolivianos,Boliviana De Aviación,7846357463.4,6611439447.179998,595725550.83,657,553,50