Rafael Lopez

buscador

...@@ -228,3 +228,145 @@ body { ...@@ -228,3 +228,145 @@ body {
228 .font-display { 228 .font-display {
229 font-family: var(--font-display); 229 font-family: var(--font-display);
230 } 230 }
231 +
232 +/* ============================================
233 + FALLBACK LAYOUT UTILITIES
234 + Respaldo para asegurar que flex siempre funcione
235 + ============================================ */
236 +
237 +/* Forzar flex en contenedores principales */
238 +.flex {
239 + display: flex !important;
240 +}
241 +
242 +.flex-1 {
243 + flex: 1 1 0% !important;
244 +}
245 +
246 +.flex-col {
247 + flex-direction: column !important;
248 +}
249 +
250 +.flex-row {
251 + flex-direction: row !important;
252 +}
253 +
254 +.flex-wrap {
255 + flex-wrap: wrap !important;
256 +}
257 +
258 +.flex-shrink-0 {
259 + flex-shrink: 0 !important;
260 +}
261 +
262 +.items-center {
263 + align-items: center !important;
264 +}
265 +
266 +.items-start {
267 + align-items: flex-start !important;
268 +}
269 +
270 +.justify-center {
271 + justify-content: center !important;
272 +}
273 +
274 +.justify-between {
275 + justify-content: space-between !important;
276 +}
277 +
278 +.gap-1 { gap: 0.25rem !important; }
279 +.gap-2 { gap: 0.5rem !important; }
280 +.gap-3 { gap: 0.75rem !important; }
281 +.gap-4 { gap: 1rem !important; }
282 +.gap-6 { gap: 1.5rem !important; }
283 +
284 +/* Grid fallbacks */
285 +.grid {
286 + display: grid !important;
287 +}
288 +
289 +/* Hidden utilities */
290 +.hidden {
291 + display: none !important;
292 +}
293 +
294 +/* Responsive: mostrar en lg+ */
295 +@media (min-width: 1024px) {
296 + .lg\:flex {
297 + display: flex !important;
298 + }
299 + .lg\:block {
300 + display: block !important;
301 + }
302 + .lg\:hidden {
303 + display: none !important;
304 + }
305 + .lg\:relative {
306 + position: relative !important;
307 + }
308 + .lg\:translate-x-0 {
309 + transform: translateX(0) !important;
310 + }
311 +}
312 +
313 +@media (min-width: 1280px) {
314 + .xl\:block {
315 + display: block !important;
316 + }
317 + .xl\:hidden {
318 + display: none !important;
319 + }
320 +}
321 +
322 +/* ============================================
323 + CLASIFICADORES LAYOUT - ESTILOS GLOBALES
324 + Forzar layout correcto en todas las páginas
325 + ============================================ */
326 +
327 +.clasificador-layout {
328 + display: flex !important;
329 + flex-direction: row !important;
330 +}
331 +
332 +.clasificador-layout > .sidebar-left {
333 + position: relative !important;
334 + transform: translateX(0) !important;
335 + flex-shrink: 0 !important;
336 + width: 18rem !important;
337 + z-index: auto !important;
338 + box-shadow: none !important;
339 +}
340 +
341 +.clasificador-layout > main {
342 + flex: 1 1 0% !important;
343 + min-width: 0 !important;
344 +}
345 +
346 +/* Forzar estilos correctos después del mount (para navegación cliente) */
347 +@media (min-width: 1024px) {
348 + .sidebar-mounted.sidebar-left {
349 + position: relative !important;
350 + transform: translateX(0) !important;
351 + flex-shrink: 0 !important;
352 + z-index: auto !important;
353 + box-shadow: none !important;
354 + }
355 +}
356 +
357 +@media (max-width: 1023px) {
358 + .clasificador-layout {
359 + display: block !important;
360 + }
361 +
362 + .clasificador-layout > .sidebar-left {
363 + position: fixed !important;
364 + transform: translateX(-100%) !important;
365 + width: 20rem !important;
366 + z-index: 50 !important;
367 + }
368 +
369 + .clasificador-layout > .sidebar-left.translate-x-0 {
370 + transform: translateX(0) !important;
371 + }
372 +}
......
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
17 17
18 %sveltekit.head% 18 %sveltekit.head%
19 </head> 19 </head>
20 - <body data-sveltekit-preload-data="hover"> 20 + <body data-sveltekit-preload-data="off">
21 <div style="display: contents">%sveltekit.body%</div> 21 <div style="display: contents">%sveltekit.body%</div>
22 </body> 22 </body>
23 </html> 23 </html>
......
...@@ -33,7 +33,12 @@ ...@@ -33,7 +33,12 @@
33 33
34 // Dimensiones del área del gráfico 34 // Dimensiones del área del gráfico
35 let effectiveHeight = $derived(fill ? measuredHeight : height); 35 let effectiveHeight = $derived(fill ? measuredHeight : height);
36 - let chartHeight = $derived(effectiveHeight - marginTop - marginBottom); 36 + let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50));
37 +
38 + // Máximo del eje Y (con guard para data vacía o valores undefined)
39 + let maxPerCapita = $derived(
40 + data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 1) : 1
41 + );
37 42
38 // Escalas D3 43 // Escalas D3
39 let xScale = $derived( 44 let xScale = $derived(
...@@ -46,7 +51,7 @@ ...@@ -46,7 +51,7 @@
46 // Para CSS bottom positioning: 0 → 0%, max → 100% 51 // Para CSS bottom positioning: 0 → 0%, max → 100%
47 let yScale = $derived( 52 let yScale = $derived(
48 scaleLinear() 53 scaleLinear()
49 - .domain([0, Math.max(...data.map(d => d.perCapita))]) 54 + .domain([0, maxPerCapita])
50 .range([0, chartHeight]) 55 .range([0, chartHeight])
51 .nice() 56 .nice()
52 ); 57 );
...@@ -65,7 +70,8 @@ ...@@ -65,7 +70,8 @@
65 } 70 }
66 </script> 71 </script>
67 72
68 -<div class="chart-wrapper" bind:this={wrapperEl} style="height: {fill ? '100%' : height + 'px'}"> 73 +<div class="chart-wrapper" bind:this={wrapperEl} style="height: {fill ? '100%' : height + 'px'}; min-height: {height}px">
74 + {#if data.length > 0 && chartHeight > 0}
69 <!-- Contenedor principal con márgenes --> 75 <!-- Contenedor principal con márgenes -->
70 <div class="chart-inner" style="top: {marginTop}px; bottom: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px"> 76 <div class="chart-inner" style="top: {marginTop}px; bottom: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px">
71 77
...@@ -118,6 +124,11 @@ ...@@ -118,6 +124,11 @@
118 </span> 124 </span>
119 {/each} 125 {/each}
120 </div> 126 </div>
127 + {:else}
128 + <div class="chart-empty">
129 + <span>Sin datos</span>
130 + </div>
131 + {/if}
121 </div> 132 </div>
122 133
123 <style> 134 <style>
...@@ -210,4 +221,15 @@ ...@@ -210,4 +221,15 @@
210 .x-label.visible { 221 .x-label.visible {
211 opacity: 1; 222 opacity: 1;
212 } 223 }
224 +
225 + .chart-empty {
226 + position: absolute;
227 + inset: 0;
228 + display: flex;
229 + align-items: center;
230 + justify-content: center;
231 + color: var(--theme-texto);
232 + opacity: 0.5;
233 + font-size: 0.875rem;
234 + }
213 </style> 235 </style>
......
1 <script> 1 <script>
2 import { goto } from '$app/navigation'; 2 import { goto } from '$app/navigation';
3 - import { 3 + import { indexLoaded } from '$lib/stores/searchStore';
4 - query, 4 + import { search } from '$lib/services/search';
5 - results,
6 - isLoading as searchLoading,
7 - indexLoaded,
8 - selectedIndex,
9 - performSearch,
10 - clearSearch,
11 - navigateResults
12 - } from '$lib/stores/searchStore';
13 5
14 let { 6 let {
15 preserveVista = false, 7 preserveVista = false,
...@@ -19,19 +11,23 @@ ...@@ -19,19 +11,23 @@
19 let searchInputRef = $state(null); 11 let searchInputRef = $state(null);
20 let searchVal = $state(''); 12 let searchVal = $state('');
21 let searchFocused = $state(false); 13 let searchFocused = $state(false);
14 + let isSearching = $state(false);
15 + let localResults = $state([]);
16 + let selectedIdx = $state(-1);
22 let debounceTimer; 17 let debounceTimer;
23 18
24 - // Filtrar solo objeto_gasto
25 - let filteredSearchResults = $derived($results.filter(r => r.tipo === 'objeto_gasto'));
26 -
27 function handleSearchInput(e) { 19 function handleSearchInput(e) {
28 const val = e.target.value; 20 const val = e.target.value;
29 clearTimeout(debounceTimer); 21 clearTimeout(debounceTimer);
30 debounceTimer = setTimeout(() => { 22 debounceTimer = setTimeout(() => {
31 if (val.length >= 2) { 23 if (val.length >= 2) {
32 - performSearch(val); 24 + isSearching = true;
25 + // Buscar y filtrar solo objetos de gasto
26 + const allResults = search(val, 50);
27 + localResults = allResults.filter(r => r.tipo === 'objeto_gasto').slice(0, 15);
28 + isSearching = false;
33 } else { 29 } else {
34 - clearSearch(); 30 + localResults = [];
35 } 31 }
36 }, 200); 32 }, 200);
37 } 33 }
...@@ -39,14 +35,18 @@ ...@@ -39,14 +35,18 @@
39 function handleSearchKeydown(e) { 35 function handleSearchKeydown(e) {
40 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { 36 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
41 e.preventDefault(); 37 e.preventDefault();
42 - navigateResults(e.key === 'ArrowDown' ? 1 : -1, filteredSearchResults.length); 38 + if (e.key === 'ArrowDown') {
43 - } else if (e.key === 'Enter' && filteredSearchResults.length > 0) { 39 + selectedIdx = selectedIdx < localResults.length - 1 ? selectedIdx + 1 : 0;
40 + } else {
41 + selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : localResults.length - 1;
42 + }
43 + } else if (e.key === 'Enter' && localResults.length > 0) {
44 e.preventDefault(); 44 e.preventDefault();
45 - const selected = filteredSearchResults[$selectedIndex]; 45 + const selected = localResults[selectedIdx >= 0 ? selectedIdx : 0];
46 if (selected) handleSearchSelect(selected); 46 if (selected) handleSearchSelect(selected);
47 } else if (e.key === 'Escape') { 47 } else if (e.key === 'Escape') {
48 searchVal = ''; 48 searchVal = '';
49 - clearSearch(); 49 + localResults = [];
50 searchInputRef?.blur(); 50 searchInputRef?.blur();
51 } 51 }
52 } 52 }
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
57 : `/objeto/${result.codigo}`; 57 : `/objeto/${result.codigo}`;
58 goto(url); 58 goto(url);
59 searchVal = ''; 59 searchVal = '';
60 - clearSearch(); 60 + localResults = [];
61 searchFocused = false; 61 searchFocused = false;
62 } 62 }
63 63
...@@ -68,7 +68,7 @@ ...@@ -68,7 +68,7 @@
68 } 68 }
69 69
70 function handleClear() { 70 function handleClear() {
71 - clearSearch(); 71 + localResults = [];
72 searchVal = ''; 72 searchVal = '';
73 } 73 }
74 </script> 74 </script>
...@@ -97,17 +97,17 @@ ...@@ -97,17 +97,17 @@
97 {/if} 97 {/if}
98 {#if searchFocused && searchVal.length >= 2} 98 {#if searchFocused && searchVal.length >= 2}
99 <div class="search-dropdown"> 99 <div class="search-dropdown">
100 - {#if $searchLoading} 100 + {#if isSearching}
101 <div class="search-msg">Buscando...</div> 101 <div class="search-msg">Buscando...</div>
102 - {:else if filteredSearchResults.length === 0} 102 + {:else if localResults.length === 0}
103 <div class="search-msg">Sin resultados</div> 103 <div class="search-msg">Sin resultados</div>
104 {:else} 104 {:else}
105 - {#each filteredSearchResults as result, i} 105 + {#each localResults as result, i}
106 <button 106 <button
107 class="search-item" 107 class="search-item"
108 - class:selected={$selectedIndex === i} 108 + class:selected={selectedIdx === i}
109 onclick={() => handleSearchSelect(result)} 109 onclick={() => handleSearchSelect(result)}
110 - onmouseenter={() => selectedIndex.set(i)} 110 + onmouseenter={() => selectedIdx = i}
111 > 111 >
112 <span class="item-code">{result.codigo}</span> 112 <span class="item-code">{result.codigo}</span>
113 <span class="item-name">{result.nombre}</span> 113 <span class="item-name">{result.nombre}</span>
......
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 3
4 - let { onOpenDrawer = () => {} } = $props(); 4 + let { onOpenDrawer = () => {}, onOpenSearch = () => {} } = $props();
5 5
6 let isDark = $state(false); 6 let isDark = $state(false);
7 + let isMac = $state(false);
7 8
8 function toggleTheme() { 9 function toggleTheme() {
9 isDark = !isDark; 10 isDark = !isDark;
...@@ -12,6 +13,9 @@ ...@@ -12,6 +13,9 @@
12 } 13 }
13 14
14 onMount(() => { 15 onMount(() => {
16 + // Detect Mac for keyboard shortcut display
17 + isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
18 +
15 // Read actual state from document (set by app.html) 19 // Read actual state from document (set by app.html)
16 isDark = document.documentElement.classList.contains('dark'); 20 isDark = document.documentElement.classList.contains('dark');
17 21
...@@ -28,40 +32,67 @@ ...@@ -28,40 +32,67 @@
28 }); 32 });
29 </script> 33 </script>
30 34
31 -<!-- MÓVIL: Solo hamburguesa arriba a la derecha --> 35 +<!-- MÓVIL: Búsqueda + Theme + hamburguesa arriba a la derecha -->
32 -<div class="fixed top-4 right-4 z-[110] md:hidden"> 36 +<div class="navbar-mobile">
37 + <button
38 + onclick={onOpenSearch}
39 + class="nav-btn-icon-mobile"
40 + aria-label="Buscar"
41 + >
42 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round">
43 + <circle cx="11" cy="11" r="8"/>
44 + <path d="m21 21-4.3-4.3"/>
45 + </svg>
46 + </button>
47 + <button
48 + onclick={toggleTheme}
49 + class="nav-btn-icon-mobile"
50 + aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
51 + >
52 + {#if isDark}
53 + <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
54 + <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
55 + </svg>
56 + {:else}
57 + <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
58 + <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"/>
59 + </svg>
60 + {/if}
61 + </button>
33 <button 62 <button
34 onclick={onOpenDrawer} 63 onclick={onOpenDrawer}
35 - class="nav-icon p-2 rounded-full transition-all flex-shrink-0" 64 + class="nav-btn-icon-mobile"
36 aria-label="Abrir menú" 65 aria-label="Abrir menú"
37 > 66 >
38 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> 67 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
39 <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/> 68 <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
40 </svg> 69 </svg>
41 </button> 70 </button>
42 </div> 71 </div>
43 72
44 -<!-- DESKTOP: Barra completa con Home + Theme + Menu --> 73 +<!-- DESKTOP: Barra completa con Search + Theme + Menu -->
45 -<div class="hidden md:flex fixed top-6 right-6 z-[110]"> 74 +<div class="navbar-desktop">
46 - <div class="flex items-center gap-1"> 75 + <div class="navbar-desktop-inner">
47 76
48 - <!-- BOTÓN HOME (4 cuadrados) --> 77 + <!-- BOTÓN BÚSQUEDA GLOBAL -->
49 - <div class="relative group/home"> 78 + <div class="relative group/search">
50 - <a 79 + <button
51 - href="/" 80 + onclick={onOpenSearch}
52 - data-sveltekit-preload-data="off" 81 + class="nav-search-btn"
53 - class="nav-icon p-2 rounded-full transition-all block" 82 + aria-label="Buscar"
54 > 83 >
55 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"> 84 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round">
56 - <rect x="2" y="2" width="7" height="7" rx="1.5"/> 85 + <circle cx="11" cy="11" r="8"/>
57 - <rect x="11" y="2" width="7" height="7" rx="1.5"/> 86 + <path d="m21 21-4.3-4.3"/>
58 - <rect x="2" y="11" width="7" height="7" rx="1.5"/>
59 - <rect x="11" y="11" width="7" height="7" rx="1.5"/>
60 </svg> 87 </svg>
61 - </a> 88 + <span class="nav-search-shortcut">
89 + <kbd>{isMac ? '\u2318' : 'Ctrl'}</kbd>
90 + <kbd>K</kbd>
91 + </span>
92 + </button>
62 <!-- Tooltip --> 93 <!-- Tooltip -->
63 - <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"> 94 + <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">
64 - Página principal 95 + Buscar
65 <div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div> 96 <div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
66 </div> 97 </div>
67 </div> 98 </div>
...@@ -70,17 +101,15 @@ ...@@ -70,17 +101,15 @@
70 <div class="relative group/theme"> 101 <div class="relative group/theme">
71 <button 102 <button
72 onclick={toggleTheme} 103 onclick={toggleTheme}
73 - class="nav-icon p-2 rounded-full transition-all cursor-pointer" 104 + class="nav-btn-icon"
74 aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'} 105 aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}
75 > 106 >
76 {#if isDark} 107 {#if isDark}
77 - <!-- LUNA (modo oscuro activo) --> 108 + <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
78 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
79 <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/> 109 <path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
80 </svg> 110 </svg>
81 {:else} 111 {:else}
82 - <!-- SOL (modo claro activo) --> 112 + <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
83 - <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
84 <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"/> 113 <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"/>
85 </svg> 114 </svg>
86 {/if} 115 {/if}
...@@ -96,10 +125,10 @@ ...@@ -96,10 +125,10 @@
96 <div class="relative group/menu"> 125 <div class="relative group/menu">
97 <button 126 <button
98 onclick={onOpenDrawer} 127 onclick={onOpenDrawer}
99 - class="nav-icon p-2 rounded-full transition-all cursor-pointer" 128 + class="nav-btn-icon"
100 aria-label="Abrir menú" 129 aria-label="Abrir menú"
101 > 130 >
102 - <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> 131 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
103 <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/> 132 <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
104 </svg> 133 </svg>
105 </button> 134 </button>
...@@ -114,6 +143,40 @@ ...@@ -114,6 +143,40 @@
114 </div> 143 </div>
115 144
116 <style> 145 <style>
146 + /* Responsive navbar containers */
147 + .navbar-mobile {
148 + display: flex;
149 + position: fixed;
150 + top: 1rem;
151 + right: 1rem;
152 + z-index: 110;
153 + align-items: center;
154 + gap: 0.25rem;
155 + }
156 +
157 + .navbar-desktop {
158 + display: none;
159 + position: fixed;
160 + top: 1.5rem;
161 + right: 1.5rem;
162 + z-index: 110;
163 + }
164 +
165 + .navbar-desktop-inner {
166 + display: flex;
167 + align-items: center;
168 + gap: 0.25rem;
169 + }
170 +
171 + @media (min-width: 768px) {
172 + .navbar-mobile {
173 + display: none;
174 + }
175 + .navbar-desktop {
176 + display: flex;
177 + }
178 + }
179 +
117 /* Iconos del navbar */ 180 /* Iconos del navbar */
118 .nav-icon { 181 .nav-icon {
119 background-color: transparent; 182 background-color: transparent;
...@@ -131,4 +194,114 @@ ...@@ -131,4 +194,114 @@
131 :global(html:not(.dark)) .nav-icon:hover { 194 :global(html:not(.dark)) .nav-icon:hover {
132 color: #1A1A18; 195 color: #1A1A18;
133 } 196 }
197 +
198 + /* Search button */
199 + .nav-search-btn {
200 + display: flex;
201 + align-items: center;
202 + gap: 10px;
203 + padding: 7px 12px 7px 12px;
204 + background: rgba(255, 255, 255, 0.05);
205 + border: 1px solid rgba(255, 255, 255, 0.12);
206 + border-radius: 10px;
207 + color: #9B9890;
208 + cursor: pointer;
209 + transition: all 0.2s ease;
210 + }
211 +
212 + .nav-search-btn:hover {
213 + background: rgba(255, 255, 255, 0.1);
214 + border-color: rgba(201, 167, 81, 0.4);
215 + color: #F5F0E8;
216 + }
217 +
218 + .nav-search-shortcut {
219 + display: flex;
220 + gap: 4px;
221 + }
222 +
223 + .nav-search-shortcut kbd {
224 + font-family: system-ui, -apple-system, sans-serif;
225 + font-size: 11px;
226 + font-weight: 500;
227 + padding: 3px 7px;
228 + background: #2a2a2a;
229 + border: 1px solid #444;
230 + border-radius: 5px;
231 + color: #aaa;
232 + box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.05);
233 + line-height: 1;
234 + }
235 +
236 + /* Modo claro */
237 + :global(html:not(.dark)) .nav-search-btn {
238 + background: rgba(0, 0, 0, 0.04);
239 + border-color: rgba(0, 0, 0, 0.12);
240 + color: #666;
241 + }
242 +
243 + :global(html:not(.dark)) .nav-search-btn:hover {
244 + background: rgba(0, 0, 0, 0.08);
245 + border-color: rgba(201, 167, 81, 0.5);
246 + color: #1A1A18;
247 + }
248 +
249 + /* Icon buttons (theme, menu) */
250 + .nav-btn-icon {
251 + display: flex;
252 + align-items: center;
253 + justify-content: center;
254 + padding: 8px;
255 + background: rgba(255, 255, 255, 0.05);
256 + border: 1px solid rgba(255, 255, 255, 0.12);
257 + border-radius: 10px;
258 + color: #9B9890;
259 + cursor: pointer;
260 + transition: all 0.2s ease;
261 + }
262 +
263 + .nav-btn-icon:hover {
264 + background: rgba(255, 255, 255, 0.1);
265 + border-color: rgba(201, 167, 81, 0.4);
266 + color: #F5F0E8;
267 + }
268 +
269 + :global(html:not(.dark)) .nav-btn-icon {
270 + background: rgba(0, 0, 0, 0.04);
271 + border-color: rgba(0, 0, 0, 0.12);
272 + color: #666;
273 + }
274 +
275 + :global(html:not(.dark)) .nav-btn-icon:hover {
276 + background: rgba(0, 0, 0, 0.08);
277 + border-color: rgba(201, 167, 81, 0.5);
278 + color: #1A1A18;
279 + }
280 +
281 + :global(html:not(.dark)) .nav-search-shortcut kbd {
282 + background: #f5f5f5;
283 + border-color: #d0d0d0;
284 + color: #666;
285 + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8);
286 + }
287 +
288 + /* Mobile icon buttons */
289 + .nav-btn-icon-mobile {
290 + display: flex;
291 + align-items: center;
292 + justify-content: center;
293 + padding: 8px;
294 + background: rgba(255, 255, 255, 0.05);
295 + border: 1px solid rgba(255, 255, 255, 0.12);
296 + border-radius: 8px;
297 + color: #9B9890;
298 + cursor: pointer;
299 + transition: all 0.2s ease;
300 + }
301 +
302 + :global(html:not(.dark)) .nav-btn-icon-mobile {
303 + background: rgba(0, 0, 0, 0.04);
304 + border-color: rgba(0, 0, 0, 0.12);
305 + color: #666;
306 + }
134 </style> 307 </style>
......
1 +<script>
2 + import { onMount, tick } from 'svelte';
3 + import { goto } from '$app/navigation';
4 + import {
5 + query,
6 + results,
7 + isLoading,
8 + indexLoaded,
9 + error,
10 + selectedIndex,
11 + initIndex,
12 + performSearch,
13 + clearSearch,
14 + navigateResults
15 + } from '$lib/stores/searchStore';
16 +
17 + let { open = $bindable(false) } = $props();
18 +
19 + let searchInput = $state(null);
20 + let searchVal = $state('');
21 + let isMac = $state(false);
22 +
23 + // Search filters
24 + let searchFilters = $state({
25 + entidad: true,
26 + objeto_gasto: true,
27 + rubro: true
28 + });
29 +
30 + // Filtered results based on active filters
31 + let filteredResults = $derived(
32 + $results.filter(item => searchFilters[item.tipo])
33 + );
34 +
35 + function toggleFilter(tipo) {
36 + searchFilters[tipo] = !searchFilters[tipo];
37 + }
38 +
39 + onMount(() => {
40 + isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
41 + initIndex();
42 + });
43 +
44 + // Focus input when modal opens
45 + $effect(() => {
46 + if (open) {
47 + tick().then(() => {
48 + searchInput?.focus();
49 + });
50 + } else {
51 + // Clear on close
52 + searchVal = '';
53 + clearSearch();
54 + }
55 + });
56 +
57 + function closeModal() {
58 + open = false;
59 + }
60 +
61 + function handleInput(e) {
62 + searchVal = e.target.value;
63 + if (searchVal.length >= 2) {
64 + performSearch(searchVal);
65 + } else {
66 + clearSearch();
67 + }
68 + }
69 +
70 + function handleKeydown(e) {
71 + if (e.key === 'Escape') {
72 + closeModal();
73 + } else if (e.key === 'ArrowDown') {
74 + e.preventDefault();
75 + navigateResults('down', filteredResults.length);
76 + } else if (e.key === 'ArrowUp') {
77 + e.preventDefault();
78 + navigateResults('up', filteredResults.length);
79 + } else if (e.key === 'Enter' && $selectedIndex >= 0) {
80 + e.preventDefault();
81 + goToResult(filteredResults[$selectedIndex]);
82 + }
83 + }
84 +
85 + function goToResult(item) {
86 + closeModal();
87 + if (item.tipo === 'entidad') {
88 + goto(`/entidad/${item.codigo}`);
89 + } else if (item.tipo === 'objeto_gasto') {
90 + goto(`/objeto/${item.codigo}`);
91 + } else if (item.tipo === 'rubro') {
92 + goto(`/rubro/${item.codigo}`);
93 + }
94 + }
95 +
96 + function handleBackdropClick(e) {
97 + if (e.target === e.currentTarget) {
98 + closeModal();
99 + }
100 + }
101 +
102 + // Type labels and colors
103 + const typeConfig = {
104 + entidad: { label: 'Institución', color: '#C9A751' },
105 + objeto_gasto: { label: 'Objeto de gasto', color: '#6B9F78' },
106 + rubro: { label: 'Rubro', color: '#7B8EC9' }
107 + };
108 +</script>
109 +
110 +{#if open}
111 + <div class="search-modal-backdrop" onclick={handleBackdropClick} onkeydown={handleKeydown} role="dialog" aria-modal="true">
112 + <div class="search-modal">
113 + <!-- Header with input -->
114 + <div class="search-modal-header">
115 + <svg class="search-modal-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
116 + <circle cx="11" cy="11" r="8"/>
117 + <path d="m21 21-4.3-4.3"/>
118 + </svg>
119 + <input
120 + bind:this={searchInput}
121 + type="text"
122 + class="search-modal-input"
123 + placeholder="Buscar instituciones, gastos, ingresos..."
124 + value={searchVal}
125 + oninput={handleInput}
126 + />
127 + <button class="search-modal-close" onclick={closeModal}>
128 + <kbd>Esc</kbd>
129 + </button>
130 + </div>
131 +
132 + <!-- Filters -->
133 + <div class="search-modal-filters">
134 + <button
135 + class="search-filter-chip"
136 + class:filter-active={searchFilters.entidad}
137 + onclick={() => toggleFilter('entidad')}
138 + >
139 + <span class="filter-dot" style="background:{typeConfig.entidad.color}"></span>
140 + Instituciones
141 + </button>
142 + <button
143 + class="search-filter-chip"
144 + class:filter-active={searchFilters.objeto_gasto}
145 + onclick={() => toggleFilter('objeto_gasto')}
146 + >
147 + <span class="filter-dot" style="background:{typeConfig.objeto_gasto.color}"></span>
148 + Objetos de gasto
149 + </button>
150 + <button
151 + class="search-filter-chip"
152 + class:filter-active={searchFilters.rubro}
153 + onclick={() => toggleFilter('rubro')}
154 + >
155 + <span class="filter-dot" style="background:{typeConfig.rubro.color}"></span>
156 + Rubros
157 + </button>
158 + </div>
159 +
160 + <!-- Results -->
161 + <div class="search-modal-results">
162 + {#if !$indexLoaded}
163 + <div class="search-modal-status">Cargando índice...</div>
164 + {:else if $isLoading}
165 + <div class="search-modal-status">Buscando...</div>
166 + {:else if searchVal.length < 2}
167 + <div class="search-modal-hint">
168 + <span class="hint-icon">
169 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
170 + <circle cx="12" cy="12" r="10"/>
171 + <path d="M12 16v-4M12 8h.01"/>
172 + </svg>
173 + </span>
174 + <span>Escribe al menos 2 caracteres para buscar</span>
175 + </div>
176 + {:else if filteredResults.length === 0}
177 + <div class="search-modal-status">Sin resultados para "{searchVal}"</div>
178 + {:else}
179 + {#each filteredResults as item, i}
180 + <button
181 + class="search-result-item"
182 + class:result-selected={$selectedIndex === i}
183 + onclick={() => goToResult(item)}
184 + onmouseenter={() => selectedIndex.set(i)}
185 + >
186 + <div class="result-main">
187 + <span class="result-type" style="color:{typeConfig[item.tipo]?.color || '#888'}">
188 + {typeConfig[item.tipo]?.label || item.tipo}
189 + </span>
190 + <span class="result-name">{item.nombre}</span>
191 + {#if item.descripcion}
192 + <span class="result-desc">{item.descripcion}</span>
193 + {/if}
194 + </div>
195 + <span class="result-code">{item.codigo}</span>
196 + </button>
197 + {/each}
198 + {/if}
199 + </div>
200 +
201 + <!-- Footer hint -->
202 + <div class="search-modal-footer">
203 + <span class="footer-hint">
204 + <kbd>↑</kbd><kbd>↓</kbd> navegar
205 + </span>
206 + <span class="footer-hint">
207 + <kbd>Enter</kbd> seleccionar
208 + </span>
209 + <span class="footer-hint">
210 + <kbd>Esc</kbd> cerrar
211 + </span>
212 + </div>
213 + </div>
214 + </div>
215 +{/if}
216 +
217 +<style>
218 + .search-modal-backdrop {
219 + position: fixed;
220 + inset: 0;
221 + background: rgba(0, 0, 0, 0.6);
222 + backdrop-filter: blur(4px);
223 + -webkit-backdrop-filter: blur(4px);
224 + z-index: 9999;
225 + display: flex;
226 + align-items: flex-start;
227 + justify-content: center;
228 + padding-top: 12vh;
229 + animation: fadeIn 0.15s ease-out;
230 + }
231 +
232 + .search-modal {
233 + width: 100%;
234 + max-width: 580px;
235 + background: var(--theme-body);
236 + border: 1px solid var(--theme-borde);
237 + border-radius: 16px;
238 + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
239 + overflow: hidden;
240 + animation: slideIn 0.2s ease-out;
241 + }
242 +
243 + @keyframes fadeIn {
244 + from { opacity: 0; }
245 + to { opacity: 1; }
246 + }
247 +
248 + @keyframes slideIn {
249 + from {
250 + opacity: 0;
251 + transform: translateY(-20px) scale(0.98);
252 + }
253 + to {
254 + opacity: 1;
255 + transform: translateY(0) scale(1);
256 + }
257 + }
258 +
259 + /* Header */
260 + .search-modal-header {
261 + display: flex;
262 + align-items: center;
263 + gap: 12px;
264 + padding: 16px 20px;
265 + border-bottom: 1px solid var(--theme-borde);
266 + }
267 +
268 + .search-modal-icon {
269 + flex-shrink: 0;
270 + color: var(--theme-texto);
271 + opacity: 0.5;
272 + }
273 +
274 + .search-modal-input {
275 + flex: 1;
276 + background: none;
277 + border: none;
278 + outline: none;
279 + font-size: 1.0625rem;
280 + font-family: inherit;
281 + color: var(--theme-titulo);
282 + caret-color: var(--color-gold);
283 + }
284 +
285 + .search-modal-input::placeholder {
286 + color: var(--theme-texto);
287 + opacity: 0.5;
288 + }
289 +
290 + .search-modal-close {
291 + background: none;
292 + border: none;
293 + cursor: pointer;
294 + padding: 0;
295 + }
296 +
297 + .search-modal-close kbd {
298 + font-family: inherit;
299 + font-size: 0.6875rem;
300 + padding: 4px 8px;
301 + background: var(--theme-fill);
302 + border: 1px solid var(--theme-borde);
303 + border-radius: 6px;
304 + color: var(--theme-texto);
305 + opacity: 0.7;
306 + transition: opacity 0.15s;
307 + }
308 +
309 + .search-modal-close:hover kbd {
310 + opacity: 1;
311 + }
312 +
313 + /* Filters */
314 + .search-modal-filters {
315 + display: flex;
316 + gap: 8px;
317 + padding: 12px 20px;
318 + border-bottom: 1px solid var(--theme-borde);
319 + background: var(--theme-fill);
320 + }
321 +
322 + .search-filter-chip {
323 + display: flex;
324 + align-items: center;
325 + gap: 6px;
326 + padding: 6px 12px;
327 + background: var(--theme-body);
328 + border: 1px solid var(--theme-borde);
329 + border-radius: 20px;
330 + font-size: 0.8125rem;
331 + color: var(--theme-texto);
332 + cursor: pointer;
333 + transition: all 0.15s;
334 + opacity: 0.5;
335 + }
336 +
337 + .search-filter-chip:hover {
338 + opacity: 0.8;
339 + }
340 +
341 + .search-filter-chip.filter-active {
342 + opacity: 1;
343 + border-color: var(--theme-texto);
344 + }
345 +
346 + .filter-dot {
347 + width: 8px;
348 + height: 8px;
349 + border-radius: 50%;
350 + }
351 +
352 + /* Results */
353 + .search-modal-results {
354 + max-height: 360px;
355 + overflow-y: auto;
356 + }
357 +
358 + .search-modal-status,
359 + .search-modal-hint {
360 + padding: 24px 20px;
361 + text-align: center;
362 + color: var(--theme-texto);
363 + font-size: 0.875rem;
364 + }
365 +
366 + .search-modal-hint {
367 + display: flex;
368 + align-items: center;
369 + justify-content: center;
370 + gap: 8px;
371 + opacity: 0.6;
372 + }
373 +
374 + .hint-icon {
375 + display: flex;
376 + opacity: 0.7;
377 + }
378 +
379 + .search-result-item {
380 + width: 100%;
381 + display: flex;
382 + align-items: center;
383 + justify-content: space-between;
384 + gap: 12px;
385 + padding: 12px 20px;
386 + background: none;
387 + border: none;
388 + border-top: 1px solid var(--theme-borde);
389 + cursor: pointer;
390 + text-align: left;
391 + transition: background 0.1s;
392 + }
393 +
394 + .search-result-item:first-child {
395 + border-top: none;
396 + }
397 +
398 + .search-result-item:hover,
399 + .search-result-item.result-selected {
400 + background: var(--theme-fill);
401 + }
402 +
403 + .result-main {
404 + display: flex;
405 + flex-direction: column;
406 + gap: 2px;
407 + min-width: 0;
408 + }
409 +
410 + .result-type {
411 + font-size: 0.6875rem;
412 + font-weight: 600;
413 + text-transform: uppercase;
414 + letter-spacing: 0.04em;
415 + }
416 +
417 + .result-name {
418 + font-size: 0.9375rem;
419 + font-weight: 500;
420 + color: var(--theme-titulo);
421 + white-space: nowrap;
422 + overflow: hidden;
423 + text-overflow: ellipsis;
424 + }
425 +
426 + .result-desc {
427 + font-size: 0.8125rem;
428 + color: var(--theme-texto);
429 + opacity: 0.7;
430 + white-space: nowrap;
431 + overflow: hidden;
432 + text-overflow: ellipsis;
433 + }
434 +
435 + .result-code {
436 + flex-shrink: 0;
437 + font-family: 'DM Mono', monospace;
438 + font-size: 0.75rem;
439 + color: var(--theme-texto);
440 + opacity: 0.5;
441 + padding: 4px 8px;
442 + background: var(--theme-fill);
443 + border-radius: 4px;
444 + }
445 +
446 + /* Footer */
447 + .search-modal-footer {
448 + display: flex;
449 + gap: 16px;
450 + padding: 12px 20px;
451 + border-top: 1px solid var(--theme-borde);
452 + background: var(--theme-fill);
453 + }
454 +
455 + .footer-hint {
456 + display: flex;
457 + align-items: center;
458 + gap: 4px;
459 + font-size: 0.75rem;
460 + color: var(--theme-texto);
461 + opacity: 0.6;
462 + }
463 +
464 + .footer-hint kbd {
465 + font-family: inherit;
466 + font-size: 0.625rem;
467 + padding: 2px 5px;
468 + background: var(--theme-body);
469 + border: 1px solid var(--theme-borde);
470 + border-radius: 4px;
471 + }
472 +
473 + /* Responsive */
474 + @media (max-width: 640px) {
475 + .search-modal-backdrop {
476 + padding: 0;
477 + align-items: flex-end;
478 + }
479 +
480 + .search-modal {
481 + max-width: 100%;
482 + border-radius: 20px 20px 0 0;
483 + max-height: 85vh;
484 + }
485 +
486 + .search-modal-results {
487 + max-height: 50vh;
488 + }
489 +
490 + .search-modal-filters {
491 + flex-wrap: wrap;
492 + }
493 +
494 + .search-modal-footer {
495 + display: none;
496 + }
497 + }
498 +</style>
...@@ -54,7 +54,7 @@ export async function initSearchIndex() { ...@@ -54,7 +54,7 @@ export async function initSearchIndex() {
54 const { data: rubros, error: errorRubros } = await supabase 54 const { data: rubros, error: errorRubros } = await supabase
55 .schema('ppto') 55 .schema('ppto')
56 .from('clas_rubros') 56 .from('clas_rubros')
57 - .select('rubro, desc_rubros, nivel'); 57 + .select('rubro, desc_rubro, nivel');
58 58
59 if (errorRubros) { 59 if (errorRubros) {
60 console.error('Error cargando rubros:', errorRubros); 60 console.error('Error cargando rubros:', errorRubros);
...@@ -93,11 +93,11 @@ export async function initSearchIndex() { ...@@ -93,11 +93,11 @@ export async function initSearchIndex() {
93 const rubrosIndex = rubros.map(item => ({ 93 const rubrosIndex = rubros.map(item => ({
94 tipo: 'rubro', 94 tipo: 'rubro',
95 codigo: item.rubro, 95 codigo: item.rubro,
96 - nombre: item.desc_rubros, 96 + nombre: item.desc_rubro,
97 sigla: null, 97 sigla: null,
98 contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`, 98 contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`,
99 nivel: item.nivel, 99 nivel: item.nivel,
100 - nombre_normalizado: normalizeText(item.desc_rubros), 100 + nombre_normalizado: normalizeText(item.desc_rubro),
101 sigla_normalizada: '', 101 sigla_normalizada: '',
102 codigo_normalizado: item.rubro?.toString() || '' 102 codigo_normalizado: item.rubro?.toString() || ''
103 })); 103 }));
......
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
4 import favicon from '$lib/assets/favicon.svg'; 4 import favicon from '$lib/assets/favicon.svg';
5 import Navbar from '$lib/components/ui/Navbar.svelte'; 5 import Navbar from '$lib/components/ui/Navbar.svelte';
6 import NavigationDrawer from '$lib/components/layout/NavigationDrawer.svelte'; 6 import NavigationDrawer from '$lib/components/layout/NavigationDrawer.svelte';
7 + import SearchModal from '$lib/components/ui/SearchModal.svelte';
7 import { openDrawer } from '$lib/stores/drawer.js'; 8 import { openDrawer } from '$lib/stores/drawer.js';
8 import Spinner from '$lib/components/ui/Spinner.svelte'; 9 import Spinner from '$lib/components/ui/Spinner.svelte';
9 10
...@@ -12,6 +13,21 @@ ...@@ -12,6 +13,21 @@
12 // Don't show navbar on landing page 13 // Don't show navbar on landing page
13 let isLanding = $derived($page.url.pathname === '/'); 14 let isLanding = $derived($page.url.pathname === '/');
14 15
16 + // Global search modal state
17 + let searchModalOpen = $state(false);
18 +
19 + function openSearchModal() {
20 + searchModalOpen = true;
21 + }
22 +
23 + function handleGlobalKeydown(e) {
24 + // Cmd+K or Ctrl+K to open search
25 + if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
26 + e.preventDefault();
27 + searchModalOpen = true;
28 + }
29 + }
30 +
15 // Navigation loading state 31 // Navigation loading state
16 let showLoader = $state(false); 32 let showLoader = $state(false);
17 let loaderTimeout = null; 33 let loaderTimeout = null;
...@@ -42,12 +58,17 @@ ...@@ -42,12 +58,17 @@
42 <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" /> 58 <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" />
43 </svelte:head> 59 </svelte:head>
44 60
61 +<svelte:window onkeydown={handleGlobalKeydown} />
62 +
45 <!-- Navigation Drawer (siempre presente) --> 63 <!-- Navigation Drawer (siempre presente) -->
46 <NavigationDrawer /> 64 <NavigationDrawer />
47 65
66 +<!-- Global Search Modal -->
67 +<SearchModal bind:open={searchModalOpen} />
68 +
48 <!-- Floating controls bar (fixed position - no afecta el flujo) --> 69 <!-- Floating controls bar (fixed position - no afecta el flujo) -->
49 {#if !isLanding} 70 {#if !isLanding}
50 - <Navbar onOpenDrawer={openDrawer} /> 71 + <Navbar onOpenDrawer={openDrawer} onOpenSearch={openSearchModal} />
51 {/if} 72 {/if}
52 73
53 <!-- Navigation loading overlay --> 74 <!-- Navigation loading overlay -->
......
1 +<script>
2 + import { supabase } from '$lib/supabase';
3 + import { onMount, untrack } from 'svelte';
4 + import { page } from '$app/stores';
5 + import { goto } from '$app/navigation';
6 + import { fade, scale } from 'svelte/transition';
7 + import { cubicOut } from 'svelte/easing';
8 + import * as d3 from 'd3';
9 +
10 + let loading = $state(true);
11 + let searchQuery = $state('');
12 + let finalidades = $state([]);
13 + let allItems = $state([]);
14 + let selectedFinalidad = $state(null);
15 + let selectedItem = $state(null);
16 + let highlightedItem = $state(null);
17 + let sidebarOpen = $state(false);
18 +
19 + // Modo de visualización desde URL
20 + let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
21 +
22 + // ══════════════════════════════════════════════════════════════
23 + // ESTADO PARA TREEMAP (MODO MAPA)
24 + // ══════════════════════════════════════════════════════════════
25 + let availableYears = $state([]);
26 + let selectedYear = $state(null);
27 + let entities = $state([]);
28 + let selectedEntity = $state(null);
29 + let entitySearchQuery = $state('');
30 + let entityDropdownOpen = $state(false);
31 + let yearDropdownOpen = $state(false);
32 + let entityLimit = $state(30);
33 +
34 + let treemapData = $state([]);
35 + let treemapRoot = $state(null);
36 + let treemapNodes = $state([]);
37 + let currentTreemapNode = $state(null);
38 + let treemapBreadcrumb = $state([]);
39 + let treemapWidth = $state(0);
40 + let treemapHeight = $state(500);
41 + let treemapContainer = $state(null);
42 + let hoveredNode = $state(null);
43 + let mapaSidebarOpen = $state(false);
44 + let mapaSidebarCollapsed = $state(false);
45 +
46 + // Selector de nivel para vista aplanada
47 + let treemapViewLevel = $state('grpfuncion'); // 'jerarquico' | 'finalidad' | 'grpfuncion' | 'funcion'
48 + const NIVEL_OPTIONS = [
49 + { value: 'jerarquico', label: 'Jerarquía', desc: 'Navegar por niveles' },
50 + { value: 'finalidad', label: 'Finalidades', desc: '10 categorías' },
51 + { value: 'grpfuncion', label: 'Grupos Función', desc: '~50 categorías' },
52 + { value: 'funcion', label: 'Funciones', desc: '~100 categorías' }
53 + ];
54 +
55 + // Colores por finalidad (primer dígito del código finfun)
56 + const FINALIDAD_COLORS = {
57 + '1': '#4E79A7', // Servicios Públicos Generales - azul
58 + '2': '#A0CBE8', // Defensa - azul claro
59 + '3': '#F28E2B', // Orden Público y Seguridad - naranja
60 + '4': '#FFBE7D', // Asuntos Económicos - naranja claro
61 + '5': '#59A14F', // Protección del Medio Ambiente - verde
62 + '6': '#8CD17D', // Vivienda y Servicios Comunitarios - verde claro
63 + '7': '#E15759', // Salud - rojo
64 + '8': '#B07AA1', // Actividades Recreativas, Cultura y Religión - púrpura
65 + '9': '#EDC948', // Educación - amarillo
66 + '10': '#76B7B2', // Protección Social - teal
67 + };
68 +
69 + const FINALIDAD_NAMES = {
70 + '1': 'Servicios Públicos Generales',
71 + '2': 'Defensa',
72 + '3': 'Orden Público y Seguridad',
73 + '4': 'Asuntos Económicos',
74 + '5': 'Protección del Medio Ambiente',
75 + '6': 'Vivienda y Servicios Comunitarios',
76 + '7': 'Salud',
77 + '8': 'Cultura y Religión',
78 + '9': 'Educación',
79 + '10': 'Protección Social',
80 + };
81 +
82 + // Función para obtener color según la finalidad (primer dígito del código finfun)
83 + function getNodeColor(finfun, opacity = 1) {
84 + const finalidadDigit = String(finfun).charAt(0);
85 + const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
86 + if (opacity === 1) return baseColor;
87 + return d3.color(baseColor).copy({opacity}).formatRgb();
88 + }
89 +
90 + function getTextColor(finfun) {
91 + const finalidadDigit = String(finfun).charAt(0);
92 + const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
93 + const color = d3.color(baseColor);
94 + const r = color.r / 255;
95 + const g = color.g / 255;
96 + const b = color.b / 255;
97 + const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
98 + return luminance > 0.5 ? 'rgba(0,0,0,0.85)' : 'rgba(255,255,255,0.95)';
99 + }
100 +
101 + function getTextColorSecondary(finfun) {
102 + const finalidadDigit = String(finfun).charAt(0);
103 + const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888';
104 + const color = d3.color(baseColor);
105 + const r = color.r / 255;
106 + const g = color.g / 255;
107 + const b = color.b / 255;
108 + const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
109 + return luminance > 0.5 ? 'rgba(0,0,0,0.78)' : 'rgba(255,255,255,0.88)';
110 + }
111 +
112 + // Cerrar dropdowns al hacer clic fuera
113 + function handleClickOutside(e) {
114 + if (entityDropdownOpen && !e.target.closest('.entity-dropdown-sidebar')) {
115 + entityDropdownOpen = false;
116 + }
117 + if (yearDropdownOpen && !e.target.closest('.sidebar-year-dropdown')) {
118 + yearDropdownOpen = false;
119 + }
120 + }
121 +
122 + // Formatear números
123 + function formatMoney(value) {
124 + if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)} mil millones`;
125 + if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)} millones`;
126 + if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)} mil`;
127 + return `Bs ${value.toFixed(0)}`;
128 + }
129 +
130 + function formatMoneyCompact(value) {
131 + if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
132 + if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
133 + if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
134 + return `Bs ${value.toFixed(0)}`;
135 + }
136 +
137 + const POBLACION = 12000000;
138 + function formatPerCapita(value) {
139 + const perCapita = value / POBLACION;
140 + if (perCapita >= 1000) return `Bs ${(perCapita / 1000).toFixed(1)}K/hab`;
141 + if (perCapita >= 1) return `Bs ${perCapita.toFixed(0)}/hab`;
142 + return `Bs ${perCapita.toFixed(2)}/hab`;
143 + }
144 +
145 + // Cargar datos del treemap
146 + async function loadTreemapData() {
147 + treemapNodes = [];
148 + try {
149 + const entidadFiltro = selectedEntity?.entidad ?? 0;
150 + const { data, error: dbError } = await supabase
151 + .schema('ppto')
152 + .from('treemap_finfun')
153 + .select('gestion, nivel, finfun, desc_funcion, parent, devengado')
154 + .eq('gestion', selectedYear)
155 + .eq('entidad', entidadFiltro)
156 + .gt('devengado', 0);
157 +
158 + if (dbError) {
159 + console.error('Error fetching treemap data:', dbError.message);
160 + return;
161 + }
162 +
163 + treemapData = data.map(d => ({
164 + gestion: d.gestion,
165 + nivel: d.nivel,
166 + finfun: d.finfun,
167 + desc_finfun: d.desc_funcion,
168 + parent: d.parent,
169 + devengado: d.devengado || 0
170 + }));
171 +
172 + console.log('Treemap finfun data loaded:', treemapData.length, 'items');
173 + } catch (err) {
174 + console.error('Error loading treemap data:', err);
175 + }
176 + }
177 +
178 + // Cargar entidades para el año
179 + async function loadEntitiesForYear(year) {
180 + if (!year) return;
181 + try {
182 + const { data, error: dbError } = await supabase
183 + .schema('ppto')
184 + .from('entidades_treemap')
185 + .select('entidad, desc_entidad, sigla_entidad')
186 + .eq('gestion', year)
187 + .order('desc_entidad');
188 +
189 + if (dbError) {
190 + console.error('Error loading entities:', dbError.message);
191 + return;
192 + }
193 + entities = data || [];
194 +
195 + if (selectedEntity) {
196 + const existsInYear = entities.some(e => e.entidad === selectedEntity.entidad);
197 + if (!existsInYear) {
198 + selectedEntity = null;
199 + }
200 + }
201 + } catch (err) {
202 + console.error('Error loading entities:', err);
203 + }
204 + }
205 +
206 + // Construir jerarquía D3
207 + function buildTreemapHierarchy() {
208 + if (!treemapData.length) {
209 + console.log('No treemap data to build hierarchy');
210 + return;
211 + }
212 +
213 + // MODO JERÁRQUICO
214 + const stratify = d3.stratify()
215 + .id(d => d.finfun)
216 + .parentId(d => d.parent);
217 +
218 + const dataWithRoot = [
219 + { finfun: 'root', parent: null, desc_finfun: 'Total Presupuesto', devengado: 0, nivel: 'root' },
220 + ...treemapData.map(d => ({
221 + ...d,
222 + parent: d.parent || 'root'
223 + }))
224 + ];
225 +
226 + try {
227 + const root = stratify(dataWithRoot);
228 + root.sum(d => d.devengado)
229 + .sort((a, b) => b.value - a.value);
230 +
231 + root.eachAfter(node => {
232 + if (node.children && node.children.length > 0) {
233 + node.value = node.children.reduce((sum, child) => sum + child.value, 0);
234 + }
235 + });
236 +
237 + treemapRoot = root;
238 + currentTreemapNode = root;
239 + treemapBreadcrumb = [{ id: 'root', name: 'Total' }];
240 + calculateTreemapLayout();
241 + } catch (err) {
242 + console.error('Error building hierarchy:', err);
243 + }
244 + }
245 +
246 + // Construir treemap aplanado
247 + function buildFlatTreemap() {
248 + const levelItems = treemapData.filter(d => d.nivel === treemapViewLevel);
249 +
250 + if (!levelItems.length) {
251 + console.log('No items for level:', treemapViewLevel);
252 + treemapNodes = [];
253 + return;
254 + }
255 +
256 + let hierarchyData;
257 +
258 + if (treemapViewLevel === 'finalidad') {
259 + // Para finalidades: mostrar directamente sin agrupación
260 + hierarchyData = {
261 + id: 'root',
262 + name: 'Total',
263 + children: levelItems.map(item => ({
264 + id: item.finfun,
265 + name: item.desc_finfun,
266 + value: item.devengado,
267 + finalidadCode: item.finfun
268 + }))
269 + };
270 + } else {
271 + // Para grpfuncion y funcion: agrupar por finalidad (primer dígito)
272 + const finalidadesData = treemapData.filter(d => d.nivel === 'finalidad');
273 + const grouped = {};
274 +
275 + levelItems.forEach(item => {
276 + const finalidadCode = String(item.finfun).charAt(0);
277 + if (!grouped[finalidadCode]) {
278 + const finalidadInfo = finalidadesData.find(f => f.finfun === finalidadCode);
279 + grouped[finalidadCode] = {
280 + code: finalidadCode,
281 + name: finalidadInfo?.desc_finfun || FINALIDAD_NAMES[finalidadCode] || `Finalidad ${finalidadCode}`,
282 + items: []
283 + };
284 + }
285 + grouped[finalidadCode].items.push(item);
286 + });
287 +
288 + hierarchyData = {
289 + id: 'root',
290 + name: 'Total',
291 + children: Object.values(grouped).map(grupo => ({
292 + id: `finalidad-${grupo.code}`,
293 + name: grupo.name,
294 + finalidadCode: grupo.code,
295 + children: grupo.items.map(item => ({
296 + id: item.finfun,
297 + name: item.desc_finfun,
298 + value: item.devengado,
299 + finalidadCode: grupo.code
300 + }))
301 + }))
302 + };
303 + }
304 +
305 + const root = d3.hierarchy(hierarchyData)
306 + .sum(d => d.value || 0)
307 + .sort((a, b) => b.value - a.value);
308 +
309 + treemapRoot = root;
310 + currentTreemapNode = root;
311 + treemapBreadcrumb = [{ id: 'root', name: `Todos los ${NIVEL_OPTIONS.find(o => o.value === treemapViewLevel)?.label || 'items'}` }];
312 + calculateFlatLayout();
313 + }
314 +
315 + // Calcular layout aplanado
316 + function calculateFlatLayout() {
317 + if (!treemapRoot || !treemapRoot.children) {
318 + treemapNodes = [];
319 + return;
320 + }
321 +
322 + if (treemapWidth <= 0 || treemapHeight <= 0) {
323 + return;
324 + }
325 +
326 + const nodes = [];
327 +
328 + if (treemapViewLevel === 'finalidad') {
329 + // Para finalidades: layout simple sin grupos anidados
330 + const treemap = d3.treemap()
331 + .size([treemapWidth, treemapHeight])
332 + .paddingOuter(4)
333 + .paddingInner(3)
334 + .round(true);
335 +
336 + treemap(treemapRoot);
337 +
338 + treemapRoot.children.forEach(item => {
339 + nodes.push({
340 + x0: item.x0,
341 + y0: item.y0,
342 + x1: item.x1,
343 + y1: item.y1,
344 + id: item.data.id,
345 + name: item.data.name,
346 + value: item.value,
347 + type: 'item',
348 + grupoCode: item.data.finalidadCode
349 + });
350 + });
351 + } else {
352 + // Para grpfuncion y funcion: grupos con items anidados
353 + const treemap = d3.treemap()
354 + .size([treemapWidth, treemapHeight])
355 + .paddingOuter(4)
356 + .paddingTop(22)
357 + .paddingInner(2)
358 + .round(true);
359 +
360 + treemap(treemapRoot);
361 +
362 + treemapRoot.children.forEach(grupo => {
363 + nodes.push({
364 + x0: grupo.x0,
365 + y0: grupo.y0,
366 + x1: grupo.x1,
367 + y1: grupo.y1,
368 + id: grupo.data.id,
369 + name: grupo.data.name,
370 + value: grupo.value,
371 + type: 'grupo',
372 + grupoCode: grupo.data.finalidadCode
373 + });
374 +
375 + if (grupo.children) {
376 + grupo.children.forEach(item => {
377 + nodes.push({
378 + x0: item.x0,
379 + y0: item.y0,
380 + x1: item.x1,
381 + y1: item.y1,
382 + id: item.data.id,
383 + name: item.data.name,
384 + value: item.value,
385 + type: 'item',
386 + grupoCode: grupo.data.finalidadCode
387 + });
388 + });
389 + }
390 + });
391 + }
392 +
393 + treemapNodes = nodes;
394 + }
395 +
396 + // Calcular layout jerárquico
397 + function calculateTreemapLayout() {
398 + if (!currentTreemapNode) {
399 + treemapNodes = [];
400 + return;
401 + }
402 +
403 + if (treemapWidth <= 0 || treemapHeight <= 0) {
404 + return;
405 + }
406 +
407 + if (currentTreemapNode.children && currentTreemapNode.children.length > 0) {
408 + const treemap = d3.treemap()
409 + .size([treemapWidth, treemapHeight])
410 + .paddingOuter(3)
411 + .paddingInner(2)
412 + .round(true);
413 +
414 + const tempHierarchy = d3.hierarchy({
415 + ...currentTreemapNode.data,
416 + children: currentTreemapNode.children.map(c => ({
417 + ...c.data,
418 + value: c.value,
419 + _originalNode: c
420 + }))
421 + }).sum(d => d.value || 0)
422 + .sort((a, b) => b.value - a.value);
423 +
424 + treemap(tempHierarchy);
425 +
426 + treemapNodes = tempHierarchy.children.map(child => ({
427 + x0: child.x0,
428 + y0: child.y0,
429 + x1: child.x1,
430 + y1: child.y1,
431 + id: child.data.finfun || child.data.id,
432 + name: child.data.desc_finfun || child.data.name,
433 + value: child.value,
434 + data: child.data,
435 + _originalNode: child.data._originalNode
436 + }));
437 + } else {
438 + treemapNodes = [];
439 + }
440 + }
441 +
442 + // Drill down/up
443 + function drillDown(node) {
444 + if (!node._originalNode?.children || node._originalNode.children.length === 0) return;
445 + currentTreemapNode = node._originalNode;
446 + treemapBreadcrumb = [...treemapBreadcrumb, {
447 + id: node.id,
448 + name: node.name || node.data?.desc_finfun || node.id,
449 + nivel: node.data?.nivel
450 + }];
451 + calculateTreemapLayout();
452 + }
453 +
454 + function drillUp(targetIndex) {
455 + if (targetIndex === treemapBreadcrumb.length - 1) return;
456 + if (targetIndex === 0) {
457 + currentTreemapNode = treemapRoot;
458 + treemapBreadcrumb = [{ id: 'root', name: 'Total' }];
459 + } else {
460 + const targetId = treemapBreadcrumb[targetIndex].id;
461 + let targetNode = null;
462 + treemapRoot.each(node => {
463 + if ((node.data.finfun || node.data.id) === targetId) {
464 + targetNode = node;
465 + }
466 + });
467 + if (targetNode) {
468 + currentTreemapNode = targetNode;
469 + treemapBreadcrumb = treemapBreadcrumb.slice(0, targetIndex + 1);
470 + }
471 + }
472 + calculateTreemapLayout();
473 + }
474 +
475 + // Filtrar entidades
476 + let filteredEntities = $derived(() => {
477 + if (!entitySearchQuery) return entities.slice(0, entityLimit);
478 + const q = entitySearchQuery.toLowerCase();
479 + return entities.filter(e =>
480 + e.desc_entidad?.toLowerCase().includes(q) ||
481 + e.sigla_entidad?.toLowerCase().includes(q)
482 + ).slice(0, entityLimit);
483 + });
484 +
485 + // Variables para controlar la carga
486 + let lastLoadedKey = $state('');
487 + let dataReady = $state(false);
488 +
489 + // Cargar datos cuando cambia año o entidad
490 + $effect(() => {
491 + const year = selectedYear;
492 + const entityId = selectedEntity?.entidad ?? 0;
493 + const mode = viewMode;
494 + const loadKey = `${year}-${entityId}`;
495 +
496 + if (mode === 'mapa' && year && loadKey !== lastLoadedKey) {
497 + lastLoadedKey = loadKey;
498 + dataReady = false;
499 +
500 + // Cargar datos sin crear dependencias adicionales
501 + untrack(() => {
502 + loadTreemapData().then(() => {
503 + dataReady = true;
504 + });
505 + loadEntitiesForYear(year);
506 + });
507 + }
508 + });
509 +
510 + // Función para cambiar el nivel (llamada desde onclick)
511 + function changeLevel(newLevel) {
512 + treemapViewLevel = newLevel;
513 + mapaSidebarOpen = false;
514 + // El efecto se encargará de reconstruir el treemap
515 + }
516 +
517 + // Reconstruir cuando los datos están listos
518 + $effect(() => {
519 + // Leer las dependencias explícitamente
520 + const ready = dataReady;
521 + const width = treemapWidth;
522 + const height = treemapHeight;
523 + const level = treemapViewLevel;
524 +
525 + if (ready && width > 0 && height > 0) {
526 + // Usar untrack para evitar que las funciones creen dependencias adicionales
527 + untrack(() => {
528 + if (level === 'jerarquico') {
529 + buildTreemapHierarchy();
530 + } else {
531 + buildFlatTreemap();
532 + }
533 + });
534 + }
535 + });
536 +
537 + // Observar tamaño del contenedor
538 + $effect(() => {
539 + const container = treemapContainer;
540 + const mode = viewMode;
541 +
542 + if (container && mode === 'mapa') {
543 + const observer = new ResizeObserver(entries => {
544 + for (const entry of entries) {
545 + const newWidth = entry.contentRect.width;
546 + const newHeight = Math.max(400, entry.contentRect.height);
547 +
548 + // Actualizar dimensiones
549 + treemapWidth = newWidth;
550 + treemapHeight = newHeight;
551 + }
552 + });
553 + observer.observe(container);
554 + return () => observer.disconnect();
555 + }
556 + });
557 +
558 + // Función para cambiar de modo preservando otros parámetros
559 + function setMode(mode) {
560 + const params = new URLSearchParams($page.url.searchParams);
561 + if (mode === 'lista') {
562 + params.delete('modo');
563 + } else {
564 + params.set('modo', mode);
565 + }
566 + const query = params.toString();
567 + goto(`?${query}`, { replaceState: true, noScroll: true });
568 + }
569 +
570 + onMount(async () => {
571 + // Forzar recálculo de layout para navegación cliente
572 + requestAnimationFrame(() => {
573 + document.body.offsetHeight; // Force reflow
574 + });
575 +
576 + const { data, error } = await supabase
577 + .schema('ppto')
578 + .from('clas_finfun')
579 + .select('*')
580 + .order('finfun');
581 +
582 + if (!error && data) {
583 + allItems = data;
584 +
585 + // Extraer finalidades únicas (nivel más agregado)
586 + finalidades = data
587 + .filter(item => item.nivel === 'finalidad')
588 + .reduce((acc, item) => {
589 + if (!acc.find(f => f.finfun === item.finfun)) {
590 + acc.push(item);
591 + }
592 + return acc;
593 + }, [])
594 + .sort((a, b) => parseInt(a.finfun) - parseInt(b.finfun));
595 +
596 + if (finalidades.length > 0) {
597 + selectedFinalidad = finalidades[0];
598 + }
599 + }
600 +
601 + // Cargar años disponibles para el treemap
602 + const years = [];
603 + for (let y = 2025; y >= 2006; y--) {
604 + years.push(y);
605 + }
606 + availableYears = years;
607 + selectedYear = years[0]; // Año más reciente por defecto
608 +
609 + loading = false;
610 + });
611 +
612 + // Helpers para derivar jerarquía desde código finfun
613 + // Jerarquía: Finalidad (1 dígito) → Grupo Función (2 dígitos) → Función (3+ dígitos)
614 + function getFinalidadFromFinfun(finfun) {
615 + return finfun.charAt(0);
616 + }
617 +
618 + function getGrpFuncionFromFinfun(finfun) {
619 + return finfun.substring(0, 2);
620 + }
621 +
622 + function getItemsForFinalidad(finalidadCode) {
623 + if (!finalidadCode) return { gruposFuncion: [] };
624 +
625 + const finalidadNum = getFinalidadFromFinfun(finalidadCode);
626 +
627 + // Obtener grupos de función únicos de esta finalidad
628 + const grpFuncionesUnicas = allItems
629 + .filter(item => item.nivel === 'grpfuncion' && getFinalidadFromFinfun(item.finfun) === finalidadNum)
630 + .reduce((acc, item) => {
631 + if (!acc.find(g => g.finfun === item.finfun)) {
632 + acc.push(item);
633 + }
634 + return acc;
635 + }, [])
636 + .sort((a, b) => parseInt(a.finfun) - parseInt(b.finfun));
637 +
638 + const gruposFuncion = grpFuncionesUnicas.map(grpFuncion => {
639 + const grpFuncionPrefix = getGrpFuncionFromFinfun(grpFuncion.finfun);
640 +
641 + // Obtener funciones de este grupo
642 + const funciones = allItems
643 + .filter(item => item.nivel === 'funcion' && getGrpFuncionFromFinfun(item.finfun) === grpFuncionPrefix)
644 + .reduce((acc, item) => {
645 + if (!acc.find(f => f.finfun === item.finfun)) {
646 + acc.push(item);
647 + }
648 + return acc;
649 + }, [])
650 + .sort((a, b) => parseInt(a.finfun) - parseInt(b.finfun));
651 +
652 + return { ...grpFuncion, funciones };
653 + });
654 +
655 + return { gruposFuncion };
656 + }
657 +
658 + // Búsqueda global
659 + function searchGlobal(query) {
660 + if (!query || query.length < 2) return [];
661 + const q = query.toLowerCase();
662 +
663 + return allItems
664 + .filter(item =>
665 + item.finfun?.toString().includes(q) ||
666 + item.desc_finfun?.toLowerCase().includes(q)
667 + )
668 + .reduce((acc, item) => {
669 + if (!acc.find(i => i.finfun === item.finfun)) {
670 + acc.push(item);
671 + }
672 + return acc;
673 + }, [])
674 + .slice(0, 20);
675 + }
676 +
677 + function parseDescripciones(descripcionesStr) {
678 + if (!descripcionesStr) return [];
679 + try {
680 + // Si ya es objeto, usarlo directamente
681 + const parsed = typeof descripcionesStr === 'string'
682 + ? JSON.parse(descripcionesStr)
683 + : descripcionesStr;
684 + return parsed.sort((a, b) => {
685 + const maxYearA = getMaxYear(a.rangos);
686 + const maxYearB = getMaxYear(b.rangos);
687 + return maxYearB - maxYearA;
688 + });
689 + } catch {
690 + return [];
691 + }
692 + }
693 +
694 + function getMaxYear(rangos) {
695 + if (!rangos) return 0;
696 + const years = rangos.match(/\d{4}/g);
697 + if (!years) return 0;
698 + return Math.max(...years.map(y => parseInt(y)));
699 + }
700 +
701 + function selectFinalidad(finalidad) {
702 + selectedFinalidad = finalidad;
703 + selectedItem = null;
704 + searchQuery = '';
705 + highlightedItem = null;
706 + sidebarOpen = false;
707 + }
708 +
709 + function openDetail(item) {
710 + selectedItem = item;
711 + }
712 +
713 + function closeDetail() {
714 + selectedItem = null;
715 + }
716 +
717 + function goToSearchResult(item) {
718 + const finalidadNum = getFinalidadFromFinfun(item.finfun);
719 + const targetFinalidad = finalidades.find(f => getFinalidadFromFinfun(f.finfun) === finalidadNum);
720 +
721 + if (targetFinalidad) {
722 + selectedFinalidad = targetFinalidad;
723 + highlightedItem = item.finfun;
724 + searchQuery = '';
725 + sidebarOpen = false;
726 +
727 + setTimeout(() => {
728 + const prefix = item.nivel === 'funcion' ? 'fn' : item.nivel === 'subfuncion' ? 'sf' : 'ac';
729 + const element = document.getElementById(`${prefix}-${item.finfun}`);
730 + if (element) {
731 + element.scrollIntoView({ behavior: 'smooth', block: 'center' });
732 + }
733 + }, 100);
734 +
735 + setTimeout(() => {
736 + highlightedItem = null;
737 + }, 2000);
738 + }
739 + }
740 +
741 + function getNivelLabel(nivel) {
742 + const labels = { finalidad: 'Finalidad', grpfuncion: 'Grupo Función', funcion: 'Función' };
743 + return labels[nivel] || nivel;
744 + }
745 +
746 + let finalidadContent = $derived(selectedFinalidad ? getItemsForFinalidad(selectedFinalidad.finfun) : { gruposFuncion: [] });
747 + let searchResults = $derived(searchGlobal(searchQuery));
748 + let isSearching = $derived(searchQuery.length >= 2);
749 +
750 + // Contadores
751 + let totalItems = $derived(allItems.length);
752 + let countByNivel = $derived({
753 + finalidades: allItems.filter(i => i.nivel === 'finalidad').length,
754 + gruposFuncion: allItems.filter(i => i.nivel === 'grpfuncion').length,
755 + funciones: allItems.filter(i => i.nivel === 'funcion').length
756 + });
757 +</script>
758 +
759 +<svelte:head>
760 + <title>Finalidad y Función | Presupuesto Público</title>
761 + <link rel="preconnect" href="https://fonts.googleapis.com" />
762 + <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" />
763 +</svelte:head>
764 +
765 +<svelte:window onclick={handleClickOutside} />
766 +
767 +<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;">
768 + <!-- Header pedagógico -->
769 + <header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
770 + <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
771 + <!-- Breadcrumb: responsive -->
772 + <nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
773 + <!-- Móvil: solo padre -->
774 + <a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
775 + ← Clasificadores
776 + </a>
777 + <!-- Desktop: ruta completa -->
778 + <div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
779 + <a href="/" class="transition-colors hover:opacity-80">Inicio</a>
780 + <span style="opacity: 0.5;">/</span>
781 + <a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
782 + </div>
783 + </nav>
784 +
785 + <div class="max-w-3xl">
786 + <p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
787 + Clasificador 03
788 + </p>
789 + <h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
790 + ¿Para qué se gasta?
791 + </h1>
792 +
793 + <p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
794 + Organiza el gasto público según su <strong style="color: var(--theme-titulo);">propósito o finalidad</strong>:
795 + administración general, defensa, educación, salud, protección social, servicios económicos.
796 + Es la forma de entender hacia qué objetivos se dirige el presupuesto.
797 + </p>
798 +
799 + {#if totalItems > 0}
800 + <p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
801 + <span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> categorías:
802 + {countByNivel.finalidades} finalidades, {countByNivel.gruposFuncion} grupos de función, {countByNivel.funciones} funciones
803 + </p>
804 + {/if}
805 +
806 + <!-- Jerarquía -->
807 + <div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
808 + <span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
809 + Finalidad
810 + </span>
811 + <span style="color: var(--theme-texto);">→</span>
812 + <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);">
813 + Grupo Función
814 + </span>
815 + <span style="color: var(--theme-texto);">→</span>
816 + <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);">
817 + Función
818 + </span>
819 + </div>
820 + <!-- Versión móvil simplificada -->
821 + <p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
822 + Jerarquía: Finalidad → Grupo Función → Función
823 + </p>
824 +
825 + <!-- Toggle de modos: Lista, Mapa, Comparar + Botón filtros móvil -->
826 + <div class="flex items-center gap-3">
827 + <div class="header-modes">
828 + <button
829 + class="header-mode-btn"
830 + class:active={viewMode === 'lista'}
831 + onclick={() => setMode('lista')}
832 + >
833 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
834 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
835 + </svg>
836 + Lista
837 + </button>
838 + <button
839 + class="header-mode-btn"
840 + class:active={viewMode === 'mapa'}
841 + onclick={() => setMode('mapa')}
842 + >
843 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
844 + <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" />
845 + </svg>
846 + Mapa
847 + </button>
848 + <button
849 + class="header-mode-btn"
850 + class:active={viewMode === 'comparar'}
851 + onclick={() => setMode('comparar')}
852 + >
853 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
854 + <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" />
855 + </svg>
856 + Comparar
857 + </button>
858 + </div>
859 +
860 + <!-- Botón filtros para móvil en modo mapa -->
861 + {#if viewMode === 'mapa'}
862 + <button
863 + class="lg:hidden flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
864 + style="background-color: var(--theme-fill); color: var(--theme-titulo);"
865 + onclick={() => mapaSidebarOpen = !mapaSidebarOpen}
866 + >
867 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
868 + <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" />
869 + </svg>
870 + Filtros
871 + </button>
872 + {/if}
873 + </div>
874 + </div>
875 + </div>
876 + </header>
877 +
878 + {#if loading}
879 + <div class="flex items-center justify-center py-20">
880 + <p style="color: var(--theme-texto);">Cargando clasificador...</p>
881 + </div>
882 + {:else if viewMode === 'mapa'}
883 + <!-- ═══════════════════════════════════════════════════════════ -->
884 + <!-- MODO MAPA: Treemap de proporciones del gasto por finalidad -->
885 + <!-- ═══════════════════════════════════════════════════════════ -->
886 +
887 + <div class="mapa-layout">
888 + <!-- Sidebar con controles -->
889 + <aside class="mapa-sidebar" class:open={mapaSidebarOpen} class:collapsed={mapaSidebarCollapsed}>
890 + <!-- Jalador para colapsar en desktop -->
891 + <button
892 + class="sidebar-puller"
893 + onclick={() => mapaSidebarCollapsed = !mapaSidebarCollapsed}
894 + aria-label={mapaSidebarCollapsed ? 'Mostrar panel de filtros' : 'Ocultar panel de filtros'}
895 + >
896 + <span class="puller-grip">
897 + <span class="grip-dot"></span>
898 + <span class="grip-dot"></span>
899 + <span class="grip-dot"></span>
900 + <span class="grip-dot"></span>
901 + <span class="grip-dot"></span>
902 + <span class="grip-dot"></span>
903 + </span>
904 + </button>
905 +
906 + <!-- Botón cerrar en móvil -->
907 + <button
908 + class="lg:hidden absolute top-3 right-3 p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/5"
909 + onclick={() => mapaSidebarOpen = false}
910 + style="color: var(--theme-texto);"
911 + >
912 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
913 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
914 + </svg>
915 + </button>
916 +
917 + <div class="sidebar-content">
918 + <!-- Selector de Modo (Lista, Mapa, Comparar) -->
919 + <div class="sidebar-section">
920 + <label class="sidebar-label">Explorar</label>
921 + <div class="sidebar-modes">
922 + <button
923 + class="mode-btn"
924 + class:active={viewMode === 'lista'}
925 + onclick={() => { setMode('lista'); mapaSidebarOpen = false; }}
926 + >
927 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
928 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
929 + </svg>
930 + Lista
931 + </button>
932 + <button
933 + class="mode-btn"
934 + class:active={viewMode === 'mapa'}
935 + onclick={() => { setMode('mapa'); mapaSidebarOpen = false; }}
936 + >
937 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
938 + <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" />
939 + </svg>
940 + Mapa
941 + </button>
942 + <button
943 + class="mode-btn"
944 + class:active={viewMode === 'comparar'}
945 + onclick={() => { setMode('comparar'); mapaSidebarOpen = false; }}
946 + >
947 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
948 + <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" />
949 + </svg>
950 + Comparar
951 + </button>
952 + </div>
953 + </div>
954 +
955 + <!-- Navegación drill-down (solo en modo jerárquico con depth > 1) -->
956 + {#if treemapViewLevel === 'jerarquico' && treemapBreadcrumb.length > 1}
957 + <div class="sidebar-section">
958 + <label class="sidebar-label">Navegación</label>
959 + <nav class="sidebar-breadcrumb">
960 + {#each treemapBreadcrumb as crumb, i}
961 + <button
962 + onclick={() => drillUp(i)}
963 + class="breadcrumb-item"
964 + class:active={i === treemapBreadcrumb.length - 1}
965 + >
966 + {#if i > 0}
967 + <svg class="breadcrumb-arrow" width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.5">
968 + <path d="M2 1L5 4L2 7" />
969 + </svg>
970 + {/if}
971 + <span class="truncate">{crumb.name}</span>
972 + </button>
973 + {/each}
974 + </nav>
975 + </div>
976 + {/if}
977 +
978 + <!-- Selector de Nivel de detalle -->
979 + <div class="sidebar-section">
980 + <label class="sidebar-label">Nivel</label>
981 + <div class="sidebar-options">
982 + {#each NIVEL_OPTIONS as opt}
983 + <button
984 + class="sidebar-option"
985 + class:active={treemapViewLevel === opt.value}
986 + onclick={() => changeLevel(opt.value)}
987 + >
988 + <span class="option-label">{opt.label}</span>
989 + <span class="option-desc">{opt.desc}</span>
990 + </button>
991 + {/each}
992 + </div>
993 + </div>
994 +
995 + <!-- Selector de Año -->
996 + <div class="sidebar-section sidebar-year-dropdown">
997 + <label class="sidebar-label">Año</label>
998 + <div class="relative">
999 + <button
1000 + class="sidebar-dropdown-btn"
1001 + onclick={() => yearDropdownOpen = !yearDropdownOpen}
1002 + >
1003 + <span>{selectedYear || 'Seleccionar'}</span>
1004 + <svg class="dropdown-chevron" class:open={yearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1005 + <path d="M6 9l6 6 6-6"/>
1006 + </svg>
1007 + </button>
1008 + {#if yearDropdownOpen}
1009 + <div class="sidebar-dropdown-panel">
1010 + <div class="sidebar-dropdown-list">
1011 + {#each availableYears as year}
1012 + <button
1013 + class="sidebar-dropdown-option"
1014 + class:active={selectedYear === year}
1015 + onclick={() => { selectedYear = year; yearDropdownOpen = false; }}
1016 + >
1017 + {year}
1018 + </button>
1019 + {/each}
1020 + </div>
1021 + </div>
1022 + {/if}
1023 + </div>
1024 + </div>
1025 +
1026 + <!-- Selector de Entidad -->
1027 + <div class="sidebar-section entity-dropdown-sidebar">
1028 + <label class="sidebar-label">Entidad</label>
1029 + <div class="relative">
1030 + <button
1031 + class="sidebar-entity-btn"
1032 + onclick={() => entityDropdownOpen = !entityDropdownOpen}
1033 + >
1034 + <span class="truncate">{selectedEntity?.desc_entidad || 'Todo el Estado'}</span>
1035 + <svg class:open={entityDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1036 + <path d="M6 9l6 6 6-6"/>
1037 + </svg>
1038 + </button>
1039 + {#if entityDropdownOpen}
1040 + <div class="entity-dropdown-panel">
1041 + <div class="p-2 border-b" style="border-color: var(--theme-borde);">
1042 + <input
1043 + type="text"
1044 + bind:value={entitySearchQuery}
1045 + placeholder="Buscar entidad..."
1046 + class="w-full px-3 py-2 text-sm rounded-md"
1047 + style="border: 1px solid var(--theme-borde); background: var(--theme-body); color: var(--theme-titulo);"
1048 + />
1049 + </div>
1050 + <div class="entity-list">
1051 + <button
1052 + class="entity-option"
1053 + class:active={selectedEntity === null}
1054 + onclick={() => { selectedEntity = null; entityDropdownOpen = false; entitySearchQuery = ''; }}
1055 + >
1056 + <span style="color: var(--theme-accent);">Todo el Estado</span>
1057 + </button>
1058 + {#each filteredEntities() as entity}
1059 + <button
1060 + class="entity-option"
1061 + class:active={selectedEntity?.entidad === entity.entidad}
1062 + onclick={() => { selectedEntity = entity; entityDropdownOpen = false; entitySearchQuery = ''; }}
1063 + >
1064 + <span class="truncate">{entity.desc_entidad}</span>
1065 + {#if entity.sigla_entidad}
1066 + <span class="text-xs opacity-50">({entity.sigla_entidad})</span>
1067 + {/if}
1068 + </button>
1069 + {/each}
1070 + {#if filteredEntities().length >= entityLimit}
1071 + <button
1072 + class="entity-option"
1073 + style="color: var(--theme-accent); justify-content: center;"
1074 + onclick={() => entityLimit += 30}
1075 + >
1076 + Cargar más...
1077 + </button>
1078 + {/if}
1079 + </div>
1080 + </div>
1081 + {/if}
1082 + </div>
1083 + </div>
1084 + </div>
1085 + </aside>
1086 +
1087 + <!-- Overlay para cerrar sidebar en móvil -->
1088 + {#if mapaSidebarOpen}
1089 + <button
1090 + class="mapa-overlay lg:hidden"
1091 + onclick={() => mapaSidebarOpen = false}
1092 + aria-label="Cerrar sidebar"
1093 + ></button>
1094 + {/if}
1095 +
1096 + <!-- Área principal del treemap -->
1097 + <div class="mapa-main">
1098 + <!-- Contenedor del Treemap -->
1099 + <div
1100 + bind:this={treemapContainer}
1101 + class="relative rounded-xl overflow-hidden w-full treemap-container"
1102 + style="height: {treemapHeight}px;"
1103 + >
1104 + {#if treemapNodes.length > 0 && treemapWidth > 0}
1105 + <svg
1106 + width={treemapWidth}
1107 + height={treemapHeight}
1108 + class="block"
1109 + >
1110 + {#if treemapViewLevel === 'jerarquico'}
1111 + <!-- MODO JERÁRQUICO: con drill-down -->
1112 + {@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
1113 + {#each treemapNodes as node}
1114 + {@const width = node.x1 - node.x0}
1115 + {@const height = node.y1 - node.y0}
1116 + {@const hasChildren = node._originalNode?.children && node._originalNode.children.length > 0}
1117 + {@const isHovered = hoveredNode === node}
1118 + {@const textColor = getTextColor(node.id)}
1119 + {@const textColorSecondary = getTextColorSecondary(node.id)}
1120 + {@const label = node.data?.desc_finfun || node.name || node.id}
1121 + {@const pct = (node.value / totalValue) * 100}
1122 + {@const area = width * height}
1123 + {@const scale = Math.sqrt(area) / 12}
1124 + {@const pctSize = Math.min(20, Math.max(9, scale * 1.6))}
1125 + {@const nameSize = Math.min(14, Math.max(8, scale * 1.0))}
1126 + {@const valueSize = Math.min(12, Math.max(7, scale * 0.8))}
1127 +
1128 + <g
1129 + class="treemap-node"
1130 + transform="translate({node.x0}, {node.y0})"
1131 + onclick={() => hasChildren && drillDown(node)}
1132 + onmouseenter={() => hoveredNode = node}
1133 + onmouseleave={() => hoveredNode = null}
1134 + style="cursor: {hasChildren ? 'pointer' : 'default'};"
1135 + >
1136 + <rect
1137 + width={width}
1138 + height={height}
1139 + fill={getNodeColor(node.id, isHovered ? 1 : 0.85)}
1140 + stroke={isHovered ? 'var(--theme-titulo)' : 'var(--theme-surface)'}
1141 + stroke-width={isHovered ? 2 : 1}
1142 + rx="2"
1143 + />
1144 +
1145 + {#if width > 45 && height > 30}
1146 + <foreignObject x="5" y="4" width={width - 10} height={height - 8} style="pointer-events: none;">
1147 + <div class="flex flex-col gap-0 overflow-hidden" style="font-family: var(--font-sans); pointer-events: none;">
1148 + <div class="font-semibold leading-none opacity-80" style="font-size: {pctSize}px; color: {textColor};">
1149 + {pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
1150 + </div>
1151 + {#if height > 50 && width > 55}
1152 + <div class="leading-tight mt-1 opacity-90" style="font-size: {nameSize}px; color: {textColor};">
1153 + {width > 120 ? label : label.slice(0, Math.floor(width / 7)) + (label.length > Math.floor(width / 7) ? '…' : '')}
1154 + </div>
1155 + {/if}
1156 + {#if height > 70 && width > 65}
1157 + <div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
1158 + {formatMoneyCompact(node.value)}
1159 + </div>
1160 + <div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
1161 + {formatPerCapita(node.value)}
1162 + </div>
1163 + {/if}
1164 + </div>
1165 + </foreignObject>
1166 + {/if}
1167 +
1168 + {#if hasChildren && width > 30 && height > 30}
1169 + {@const btnSize = Math.min(10, Math.max(6, scale * 0.6))}
1170 + <circle
1171 + cx={width - btnSize - 4}
1172 + cy={btnSize + 4}
1173 + r={btnSize}
1174 + fill={textColor.includes('255') ? 'rgba(255,255,255,0.2)' : 'rgba(0,0,0,0.15)'}
1175 + class="transition-opacity"
1176 + style="opacity: {isHovered ? 1 : 0.5};"
1177 + />
1178 + <text
1179 + x={width - btnSize - 4}
1180 + y={btnSize + 7}
1181 + text-anchor="middle"
1182 + fill={textColor}
1183 + font-size={btnSize * 1.2}
1184 + font-weight="bold"
1185 + >+</text>
1186 + {/if}
1187 + </g>
1188 + {/each}
1189 + {:else}
1190 + <!-- MODO APLANADO: finalidades con títulos + items -->
1191 + {@const flatTotalValue = treemapRoot?.value || 1}
1192 + {#each treemapNodes as node}
1193 + {@const width = node.x1 - node.x0}
1194 + {@const height = node.y1 - node.y0}
1195 + {@const isHovered = hoveredNode === node}
1196 + {@const finalidadId = node.grupoCode}
1197 + {@const textColor = getTextColor(finalidadId)}
1198 + {@const textColorSecondary = getTextColorSecondary(finalidadId)}
1199 +
1200 + {#if node.type === 'grupo'}
1201 + <!-- Contenedor de finalidad con título -->
1202 + <g class="treemap-node" transform="translate({node.x0}, {node.y0})">
1203 + <!-- Fondo de la finalidad (sutil) -->
1204 + <rect
1205 + width={width}
1206 + height={height}
1207 + fill={getNodeColor(finalidadId, 0.08)}
1208 + stroke={getNodeColor(finalidadId, 0.3)}
1209 + stroke-width="1"
1210 + rx="3"
1211 + />
1212 + <!-- Título de la finalidad con fondo para legibilidad -->
1213 + {#if width > 50}
1214 + {@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))}
1215 + <rect
1216 + x="3"
1217 + y="2"
1218 + width={Math.min(labelText.length * 5.8 + 16, width - 6)}
1219 + height="16"
1220 + fill="rgba(0,0,0,0.5)"
1221 + rx="3"
1222 + />
1223 + <text
1224 + x="8"
1225 + y="13"
1226 + fill="#ffffff"
1227 + font-size="10"
1228 + font-weight="600"
1229 + style="font-family: var(--font-sans); font-variant-numeric: tabular-nums; text-transform: uppercase; letter-spacing: 0.5px;"
1230 + >
1231 + {labelText}
1232 + </text>
1233 + {/if}
1234 + </g>
1235 + {:else}
1236 + <!-- Item individual -->
1237 + {@const pct = (node.value / flatTotalValue) * 100}
1238 + {@const area = width * height}
1239 + {@const scale = Math.sqrt(area) / 12}
1240 + {@const pctSize = Math.min(20, Math.max(9, scale * 1.5))}
1241 + {@const nameSize = Math.min(13, Math.max(8, scale * 0.95))}
1242 + {@const valueSize = Math.min(11, Math.max(7, scale * 0.75))}
1243 + <g
1244 + class="treemap-node"
1245 + transform="translate({node.x0}, {node.y0})"
1246 + onmouseenter={() => hoveredNode = node}
1247 + onmouseleave={() => hoveredNode = null}
1248 + style="cursor: pointer;"
1249 + >
1250 + <rect
1251 + width={width}
1252 + height={height}
1253 + fill={getNodeColor(finalidadId, isHovered ? 1 : 0.85)}
1254 + stroke={isHovered ? 'var(--theme-titulo)' : 'transparent'}
1255 + stroke-width={isHovered ? 2 : 0}
1256 + rx="2"
1257 + />
1258 +
1259 + {#if width > 35 && height > 22}
1260 + <foreignObject x="4" y="3" width={width - 8} height={height - 6} style="pointer-events: none;">
1261 + <div class="flex flex-col gap-0 overflow-hidden" style="font-family: var(--font-sans); pointer-events: none;">
1262 + <div class="font-semibold leading-none opacity-75" style="font-size: {pctSize}px; color: {textColor};">
1263 + {pct >= 10 ? pct.toFixed(0) : pct >= 1 ? pct.toFixed(1) : pct.toFixed(2)}%
1264 + </div>
1265 + {#if height > 40 && width > 50}
1266 + <div class="leading-tight mt-0.5 opacity-85" style="font-size: {nameSize}px; color: {textColor};">
1267 + {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) ? '…' : '')}
1268 + </div>
1269 + {/if}
1270 + {#if height > 55 && width > 55}
1271 + <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
1272 + {formatMoneyCompact(node.value)}
1273 + </div>
1274 + <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
1275 + {formatPerCapita(node.value)}
1276 + </div>
1277 + {/if}
1278 + </div>
1279 + </foreignObject>
1280 + {/if}
1281 + </g>
1282 + {/if}
1283 + {/each}
1284 + {/if}
1285 + </svg>
1286 +
1287 + <!-- Tooltip flotante (frosted glass) -->
1288 + {#if hoveredNode && hoveredNode.type !== 'grupo'}
1289 + {@const totalValue = currentTreemapNode?.value || treemapRoot?.value || 1}
1290 + <div
1291 + class="treemap-tooltip"
1292 + style="
1293 + left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px;
1294 + top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
1295 + "
1296 + >
1297 + {#if treemapViewLevel !== 'jerarquico'}
1298 + <!-- Vista aplanada: mostrar contexto de la finalidad -->
1299 + <div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
1300 + <div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
1301 + {formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
1302 + </div>
1303 + <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
1304 + {((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total
1305 + </div>
1306 + {:else}
1307 + <!-- Vista jerárquica -->
1308 + <div class="font-medium text-sm">{hoveredNode.data?.desc_finfun || hoveredNode.name || hoveredNode.id}</div>
1309 + <div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
1310 + {formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
1311 + </div>
1312 + {#if currentTreemapNode && currentTreemapNode.value}
1313 + <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
1314 + {((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}% del gasto total
1315 + </div>
1316 + {/if}
1317 + {#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0}
1318 + <div class="text-xs opacity-60 mt-1">Clic para explorar</div>
1319 + {/if}
1320 + {/if}
1321 + </div>
1322 + {/if}
1323 + {:else}
1324 + <!-- Estado vacío / cargando -->
1325 + <div class="absolute inset-0 flex flex-col items-center justify-center p-8">
1326 + <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>
1327 + <p class="text-sm mt-4" style="color: var(--theme-texto); opacity: 0.6;">Cargando visualización...</p>
1328 + </div>
1329 + {/if}
1330 + </div>
1331 + </div>
1332 + </div>
1333 + {:else if viewMode === 'comparar'}
1334 + <!-- MODO COMPARAR -->
1335 + <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-12">
1336 + <div class="text-center py-20" style="color: var(--theme-texto);">
1337 + <svg class="w-16 h-16 mx-auto mb-4 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1338 + <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" />
1339 + </svg>
1340 + <p class="text-lg font-medium mb-2" style="color: var(--theme-titulo);">Vista Comparar</p>
1341 + <p class="text-sm">Próximamente: comparación lado a lado entre años y entidades</p>
1342 + </div>
1343 + </div>
1344 + {:else}
1345 + <!-- MODO LISTA -->
1346 + <!-- Botón móvil para abrir sidebar -->
1347 + <div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
1348 + <button
1349 + onclick={() => sidebarOpen = !sidebarOpen}
1350 + class="flex items-center gap-2 text-sm"
1351 + style="color: var(--theme-texto);"
1352 + >
1353 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1354 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
1355 + </svg>
1356 + <span class="font-medium">{selectedFinalidad ? selectedFinalidad.desc_finfun : 'Seleccionar finalidad'}</span>
1357 + <svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1358 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
1359 + </svg>
1360 + </button>
1361 + </div>
1362 +
1363 + <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
1364 + <!-- Sidebar izquierda: Finalidades -->
1365 + {#if sidebarOpen}
1366 + <div
1367 + transition:fade={{ duration: 150 }}
1368 + class="fixed inset-0 bg-black/30 z-40 lg:hidden"
1369 + onclick={() => sidebarOpen = false}
1370 + ></div>
1371 + {/if}
1372 + <aside class="
1373 + {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
1374 + lg:translate-x-0
1375 + fixed lg:relative
1376 + inset-y-0 left-0
1377 + w-72 lg:w-64
1378 + z-50 lg:z-auto
1379 + transition-transform duration-200 ease-in-out
1380 + lg:flex-shrink-0
1381 + shadow-xl lg:shadow-none
1382 + sidebar-left
1383 + ">
1384 + <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">
1385 + <!-- Cerrar en móvil -->
1386 + <div class="flex justify-between items-center mb-4 lg:hidden">
1387 + <span class="text-sm font-medium" style="color: var(--theme-titulo);">Finalidades</span>
1388 + <button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
1389 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1390 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
1391 + </svg>
1392 + </button>
1393 + </div>
1394 +
1395 + <!-- Buscador -->
1396 + <div class="mb-6">
1397 + <input
1398 + type="text"
1399 + bind:value={searchQuery}
1400 + placeholder="Buscar..."
1401 + class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
1402 + style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
1403 + />
1404 + </div>
1405 +
1406 + <!-- Resultados de búsqueda -->
1407 + {#if isSearching}
1408 + <div class="mb-4">
1409 + <p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
1410 + {searchResults.length} resultados
1411 + </p>
1412 + <div class="space-y-1">
1413 + {#each searchResults as result}
1414 + <button
1415 + class="w-full text-left px-2 py-2 text-sm rounded transition-all"
1416 + style="color: var(--theme-titulo);"
1417 + onclick={() => goToSearchResult(result)}
1418 + >
1419 + <span class="text-xs block" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
1420 + <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.finfun}</span>
1421 + <span class="ml-1">{result.desc_finfun}</span>
1422 + </button>
1423 + {/each}
1424 + {#if searchResults.length === 0}
1425 + <p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
1426 + {/if}
1427 + </div>
1428 + </div>
1429 + {:else}
1430 + <!-- Lista de finalidades -->
1431 + <nav>
1432 + <p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Finalidades</p>
1433 + <ul class="space-y-1">
1434 + {#each finalidades as finalidad}
1435 + <li>
1436 + <button
1437 + class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
1438 + style="{selectedFinalidad?.finfun === finalidad.finfun
1439 + ? `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);`
1440 + : `color: var(--theme-texto);`}"
1441 + onclick={() => selectFinalidad(finalidad)}
1442 + >
1443 + <span class="font-mono text-xs block" style="color: var(--theme-texto);">{finalidad.finfun}</span>
1444 + {finalidad.desc_finfun}
1445 + </button>
1446 + </li>
1447 + {/each}
1448 + </ul>
1449 + </nav>
1450 + {/if}
1451 + </div>
1452 + </aside>
1453 +
1454 + <!-- Contenido principal -->
1455 + <main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
1456 + <div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
1457 + {#if selectedFinalidad}
1458 + {#key selectedFinalidad.finfun}
1459 + <div in:fade={{ duration: 200, delay: 50 }}>
1460 + <!-- Título de la finalidad -->
1461 + <div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
1462 + <p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">{selectedFinalidad.finfun}</p>
1463 + <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);">
1464 + {selectedFinalidad.desc_finfun}
1465 + <a
1466 + href="/finfun/{selectedFinalidad.finfun}"
1467 + class="transition-colors hover:text-[var(--theme-accent)]"
1468 + style="color: var(--theme-texto);"
1469 + title="Ver detalle"
1470 + >
1471 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1472 + <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" />
1473 + </svg>
1474 + </a>
1475 + </h2>
1476 + {#if selectedFinalidad.descripciones}
1477 + {@const finalidadDescs = parseDescripciones(selectedFinalidad.descripciones)}
1478 + {#if finalidadDescs.length > 0}
1479 + <p class="leading-relaxed" style="color: var(--theme-texto);">{finalidadDescs[0].descripcion}</p>
1480 + {#if selectedFinalidad.n_variaciones > 1}
1481 + <button
1482 + 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"
1483 + onclick={() => openDetail(selectedFinalidad)}
1484 + >
1485 + Ver {selectedFinalidad.n_variaciones} variaciones históricas
1486 + </button>
1487 + {/if}
1488 + {/if}
1489 + {/if}
1490 + <!-- Vigencia temporal -->
1491 + {#if selectedFinalidad.gestiones}
1492 + <p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
1493 + Vigente: {selectedFinalidad.gestiones}
1494 + </p>
1495 + {/if}
1496 + </div>
1497 +
1498 + <!-- Grupos de Función -->
1499 + <div class="space-y-12">
1500 + {#each finalidadContent.gruposFuncion as grpFuncion}
1501 + {@const grpFuncionDescs = parseDescripciones(grpFuncion.descripciones)}
1502 + <section
1503 + id="gf-{grpFuncion.finfun}"
1504 + class="scroll-mt-4 {highlightedItem === grpFuncion.finfun ? 'highlighted' : ''}"
1505 + >
1506 + <div class="flex items-start gap-2 sm:gap-4 mb-3">
1507 + <span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{grpFuncion.finfun}</span>
1508 + <div class="flex-1">
1509 + <h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
1510 + <span class="text-left">{grpFuncion.desc_finfun}</span>
1511 + {#if grpFuncion.n_variaciones > 1}
1512 + <button
1513 + 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"
1514 + onclick={() => openDetail(grpFuncion)}
1515 + title="Ver variaciones de descripción"
1516 + >
1517 + {grpFuncion.n_variaciones} var.
1518 + </button>
1519 + {/if}
1520 + <a
1521 + href="/finfun/{grpFuncion.finfun}"
1522 + class="transition-colors hover:text-[var(--theme-accent)]"
1523 + style="color: var(--theme-texto);"
1524 + title="Ver página de detalle"
1525 + >
1526 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1527 + <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" />
1528 + </svg>
1529 + </a>
1530 + </h3>
1531 + {#if grpFuncionDescs.length > 0}
1532 + <p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{grpFuncionDescs[0].descripcion}</p>
1533 + {/if}
1534 + </div>
1535 + </div>
1536 +
1537 + <!-- Funciones -->
1538 + {#if grpFuncion.funciones?.length > 0}
1539 + <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);">
1540 + {#each grpFuncion.funciones as funcion}
1541 + {@const funcionDescs = parseDescripciones(funcion.descripciones)}
1542 + <div
1543 + id="fn-{funcion.finfun}"
1544 + class="scroll-mt-4 {highlightedItem === funcion.finfun ? 'highlighted-md' : ''}"
1545 + >
1546 + <div class="flex items-start gap-2 sm:gap-3">
1547 + <span class="font-mono text-xs pt-0.5" style="color: var(--theme-texto);">{funcion.finfun}</span>
1548 + <div class="flex-1">
1549 + <h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
1550 + <span class="text-left">{funcion.desc_finfun}</span>
1551 + {#if funcion.n_variaciones > 1}
1552 + <button
1553 + 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"
1554 + onclick={() => openDetail(funcion)}
1555 + title="Ver variaciones de descripción"
1556 + >
1557 + {funcion.n_variaciones} var.
1558 + </button>
1559 + {/if}
1560 + <a
1561 + href="/finfun/{funcion.finfun}"
1562 + class="transition-colors hover:text-[var(--theme-accent)]"
1563 + style="color: var(--theme-texto);"
1564 + title="Ver página de detalle"
1565 + >
1566 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1567 + <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" />
1568 + </svg>
1569 + </a>
1570 + </h4>
1571 + {#if funcionDescs.length > 0}
1572 + <p class="text-xs mt-1 leading-relaxed" style="color: var(--theme-texto);">{funcionDescs[0].descripcion}</p>
1573 + {/if}
1574 + </div>
1575 + </div>
1576 + </div>
1577 + {/each}
1578 + </div>
1579 + {/if}
1580 + </section>
1581 + {/each}
1582 + </div>
1583 + </div>
1584 + {/key}
1585 + {/if}
1586 + </div>
1587 + </main>
1588 +
1589 + <!-- Sidebar derecha: En esta página -->
1590 + <aside class="w-56 flex-shrink-0 hidden xl:block">
1591 + <div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
1592 + <p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
1593 + {#if selectedFinalidad && !isSearching}
1594 + <nav class="space-y-2">
1595 + {#each finalidadContent.gruposFuncion as grpFuncion}
1596 + <div>
1597 + <a
1598 + href="#gf-{grpFuncion.finfun}"
1599 + class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
1600 + style="color: var(--theme-texto);"
1601 + title="{grpFuncion.desc_finfun}"
1602 + >
1603 + {grpFuncion.desc_finfun}
1604 + </a>
1605 + {#if grpFuncion.funciones?.length > 0}
1606 + <div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);">
1607 + {#each grpFuncion.funciones.slice(0, 5) as funcion}
1608 + <a
1609 + href="#fn-{funcion.finfun}"
1610 + class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]"
1611 + style="color: var(--theme-texto); opacity: 0.7;"
1612 + title="{funcion.desc_finfun}"
1613 + >
1614 + {funcion.desc_finfun}
1615 + </a>
1616 + {/each}
1617 + {#if grpFuncion.funciones.length > 5}
1618 + <span class="text-xs" style="color: var(--theme-texto); opacity: 0.5;">+{grpFuncion.funciones.length - 5} más</span>
1619 + {/if}
1620 + </div>
1621 + {/if}
1622 + </div>
1623 + {/each}
1624 + </nav>
1625 + {/if}
1626 + </div>
1627 + </aside>
1628 + </div>
1629 + {/if}
1630 +</div>
1631 +
1632 +<!-- Modal de detalle -->
1633 +{#if selectedItem}
1634 + <div
1635 + transition:fade={{ duration: 150 }}
1636 + class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
1637 + onclick={closeDetail}
1638 + >
1639 + <div
1640 + transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
1641 + class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
1642 + style="background-color: var(--theme-surface);"
1643 + onclick={(e) => e.stopPropagation()}
1644 + >
1645 + <div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
1646 + <div>
1647 + <p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">{getNivelLabel(selectedItem.nivel)}</p>
1648 + <h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
1649 + <span class="font-mono" style="color: var(--theme-texto);">{selectedItem.finfun}</span>
1650 + <span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
1651 + {selectedItem.desc_finfun}
1652 + </h3>
1653 + </div>
1654 + <button
1655 + onclick={closeDetail}
1656 + class="text-2xl leading-none"
1657 + style="color: var(--theme-texto);"
1658 + >
1659 + &times;
1660 + </button>
1661 + </div>
1662 +
1663 + <div class="p-6 overflow-y-auto flex-1">
1664 + <h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
1665 + {#if selectedItem.n_variaciones > 1}
1666 + Descripciones ({selectedItem.n_variaciones} variaciones)
1667 + {:else}
1668 + Descripción
1669 + {/if}
1670 + </h4>
1671 +
1672 + <div class="space-y-4">
1673 + {#each parseDescripciones(selectedItem.descripciones) as desc, i}
1674 + <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);`}">
1675 + <p class="text-sm mb-2" style="color: var(--theme-texto);">
1676 + {#if i === 0 && selectedItem.n_variaciones > 1}
1677 + <span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
1678 + <span class="mx-1" style="opacity: 0.5;">·</span>
1679 + {/if}
1680 + <span class="font-mono">{desc.rangos}</span>
1681 + </p>
1682 + <p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
1683 + </div>
1684 + {/each}
1685 + </div>
1686 +
1687 + <!-- Vigencia temporal -->
1688 + {#if selectedItem.gestiones}
1689 + <div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
1690 + <p class="text-xs font-mono" style="color: var(--theme-texto);">
1691 + <span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
1692 + </p>
1693 + </div>
1694 + {/if}
1695 + </div>
1696 + </div>
1697 + </div>
1698 +{/if}
1699 +
1700 +<style>
1701 + /* Header mode selector */
1702 + .header-modes {
1703 + display: inline-flex;
1704 + gap: 0.25rem;
1705 + background: var(--theme-fill);
1706 + padding: 0.25rem;
1707 + border-radius: 0.5rem;
1708 + }
1709 +
1710 + .header-mode-btn {
1711 + display: flex;
1712 + align-items: center;
1713 + gap: 0.375rem;
1714 + padding: 0.5rem 0.75rem;
1715 + border-radius: 0.375rem;
1716 + font-size: 0.8125rem;
1717 + font-weight: 500;
1718 + color: var(--theme-texto);
1719 + background: transparent;
1720 + border: none;
1721 + cursor: pointer;
1722 + transition: all 0.15s ease;
1723 + }
1724 +
1725 + .header-mode-btn:hover {
1726 + background: var(--theme-surface);
1727 + color: var(--theme-titulo);
1728 + }
1729 +
1730 + .header-mode-btn.active {
1731 + background: var(--theme-surface);
1732 + color: var(--theme-titulo);
1733 + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1734 + }
1735 +
1736 + :global(.light) .header-modes,
1737 + :global(html:not(.dark)) .header-modes {
1738 + background: #f0f0f0;
1739 + }
1740 +
1741 + :global(.light) .header-mode-btn.active,
1742 + :global(html:not(.dark)) .header-mode-btn.active {
1743 + background: #ffffff;
1744 + box-shadow: 0 1px 3px rgba(28,28,26,0.1);
1745 + }
1746 +
1747 + /* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
1748 + .sidebar-left {
1749 + background-color: var(--theme-surface);
1750 + }
1751 + @media (min-width: 1024px) {
1752 + .sidebar-left {
1753 + background-color: transparent;
1754 + }
1755 + }
1756 +
1757 + /* Highlight pulse animation para resultados de búsqueda */
1758 + @keyframes highlight-pulse {
1759 + 0%, 100% {
1760 + box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent);
1761 + }
1762 + 50% {
1763 + box-shadow: 0 0 0 4px color-mix(in srgb, var(--theme-accent) 50%, transparent);
1764 + }
1765 + }
1766 +
1767 + .highlighted {
1768 + animation: highlight-pulse 0.8s ease-in-out 2;
1769 + border-radius: 0.5rem;
1770 + }
1771 +
1772 + .highlighted-sm {
1773 + animation: highlight-pulse 0.8s ease-in-out 2;
1774 + border-radius: 0.25rem;
1775 + padding: 0.25rem;
1776 + margin-left: -0.25rem;
1777 + }
1778 +
1779 + .highlighted-md {
1780 + animation: highlight-pulse 0.8s ease-in-out 2;
1781 + border-radius: 0.5rem;
1782 + padding: 0.5rem;
1783 + margin-left: -0.5rem;
1784 + }
1785 +
1786 + /* Layout principal - fallback nativo */
1787 + .clasificador-layout {
1788 + display: flex !important;
1789 + flex-direction: row !important;
1790 + }
1791 +
1792 + /* Sidebar - fallback para responsive */
1793 + .sidebar-left {
1794 + position: fixed;
1795 + transform: translateX(-100%);
1796 + }
1797 +
1798 + @media (min-width: 1024px) {
1799 + .sidebar-left {
1800 + position: relative !important;
1801 + transform: translateX(0) !important;
1802 + flex-shrink: 0;
1803 + width: 16rem;
1804 + z-index: auto !important;
1805 + box-shadow: none !important;
1806 + }
1807 + }
1808 +
1809 + @media (max-width: 1023px) {
1810 + .clasificador-layout {
1811 + display: block !important;
1812 + }
1813 + }
1814 +
1815 + /* ═══════════════════════════════════════════════════════════ */
1816 + /* MAPA LAYOUT: Sidebar + Treemap */
1817 + /* ═══════════════════════════════════════════════════════════ */
1818 +
1819 + /* Treemap loader */
1820 + .treemap-loader {
1821 + width: 32px;
1822 + height: 32px;
1823 + fill: var(--theme-texto);
1824 + opacity: 0.4;
1825 + }
1826 +
1827 + :global(html.dark) .treemap-loader {
1828 + fill: var(--theme-texto);
1829 + opacity: 0.5;
1830 + }
1831 +
1832 + .mapa-layout {
1833 + display: flex;
1834 + height: calc(100vh - 90px);
1835 + max-height: calc(100vh - 90px);
1836 + position: relative;
1837 + overflow: hidden;
1838 + }
1839 +
1840 + .mapa-sidebar {
1841 + width: 220px;
1842 + flex-shrink: 0;
1843 + background-color: var(--theme-surface);
1844 + border-right: 1px solid var(--theme-borde);
1845 + padding: 1rem;
1846 + overflow-y: auto;
1847 + position: relative;
1848 + transition: width 0.3s ease, padding 0.3s ease, opacity 0.3s ease;
1849 + }
1850 +
1851 + /* Desktop: sidebar colapsable */
1852 + @media (min-width: 1024px) {
1853 + .mapa-sidebar.collapsed {
1854 + width: 0;
1855 + padding: 0;
1856 + overflow: hidden;
1857 + border-right: none;
1858 + }
1859 +
1860 + .mapa-sidebar.collapsed .sidebar-content {
1861 + opacity: 0;
1862 + pointer-events: none;
1863 + }
1864 + }
1865 +
1866 + /* Jalador estilo drawer */
1867 + .sidebar-puller {
1868 + display: none;
1869 + position: absolute;
1870 + top: 50%;
1871 + right: -12px;
1872 + transform: translateY(-50%);
1873 + width: 24px;
1874 + height: 48px;
1875 + border: 1px solid var(--theme-borde);
1876 + border-left: none;
1877 + background: var(--theme-fill);
1878 + border-radius: 0 8px 8px 0;
1879 + box-shadow: 2px 0 8px rgba(0, 0, 0, 0.12);
1880 + cursor: ew-resize;
1881 + z-index: 10;
1882 + transition: background-color 0.15s ease, box-shadow 0.15s ease;
1883 + align-items: center;
1884 + justify-content: center;
1885 + }
1886 +
1887 + .sidebar-puller:hover {
1888 + background: var(--theme-borde);
1889 + box-shadow: 3px 0 12px rgba(0, 0, 0, 0.18);
1890 + }
1891 +
1892 + .sidebar-puller:active {
1893 + cursor: grabbing;
1894 + }
1895 +
1896 + /* Grip dots pattern */
1897 + .puller-grip {
1898 + display: grid;
1899 + grid-template-columns: repeat(2, 4px);
1900 + grid-template-rows: repeat(3, 4px);
1901 + gap: 3px;
1902 + }
1903 +
1904 + .grip-dot {
1905 + width: 4px;
1906 + height: 4px;
1907 + border-radius: 50%;
1908 + background-color: var(--theme-texto);
1909 + opacity: 0.4;
1910 + transition: opacity 0.15s ease;
1911 + }
1912 +
1913 + .sidebar-puller:hover .grip-dot {
1914 + opacity: 0.7;
1915 + }
1916 +
1917 + @media (min-width: 1024px) {
1918 + .sidebar-puller {
1919 + display: flex;
1920 + }
1921 +
1922 + /* Cuando está colapsado, el puller queda visible al borde izquierdo */
1923 + .mapa-sidebar.collapsed .sidebar-puller {
1924 + position: fixed;
1925 + left: 0;
1926 + right: auto;
1927 + }
1928 + }
1929 +
1930 + /* Móvil: sidebar como drawer */
1931 + @media (max-width: 1023px) {
1932 + .mapa-sidebar {
1933 + position: fixed;
1934 + top: 0;
1935 + left: 0;
1936 + bottom: 0;
1937 + z-index: 100;
1938 + transform: translateX(-100%);
1939 + transition: transform 0.3s ease;
1940 + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
1941 + }
1942 + .mapa-sidebar.open {
1943 + transform: translateX(0);
1944 + }
1945 + }
1946 +
1947 + .mapa-overlay {
1948 + position: fixed;
1949 + inset: 0;
1950 + background: rgba(0, 0, 0, 0.4);
1951 + z-index: 99;
1952 + }
1953 +
1954 + .mapa-main {
1955 + flex: 1;
1956 + min-width: 0;
1957 + min-height: 0;
1958 + display: flex;
1959 + flex-direction: column;
1960 + padding: 0.5rem;
1961 + overflow: hidden;
1962 + }
1963 +
1964 + .treemap-container {
1965 + flex: 1;
1966 + min-height: 400px;
1967 + }
1968 +
1969 + /* Sidebar sections */
1970 + .sidebar-content {
1971 + display: flex;
1972 + flex-direction: column;
1973 + gap: 1.25rem;
1974 + }
1975 +
1976 + .sidebar-section {
1977 + display: flex;
1978 + flex-direction: column;
1979 + gap: 0.5rem;
1980 + }
1981 +
1982 + .sidebar-label {
1983 + font-family: var(--font-sans);
1984 + font-size: 0.625rem;
1985 + font-weight: 600;
1986 + text-transform: uppercase;
1987 + letter-spacing: 0.05em;
1988 + color: var(--theme-texto);
1989 + }
1990 +
1991 + /* Vista options */
1992 + .sidebar-options {
1993 + display: flex;
1994 + flex-direction: column;
1995 + gap: 0.25rem;
1996 + }
1997 +
1998 + .sidebar-option {
1999 + display: flex;
2000 + flex-direction: column;
2001 + align-items: flex-start;
2002 + padding: 0.5rem 0.75rem;
2003 + border-radius: 0.5rem;
2004 + border: none;
2005 + background: transparent;
2006 + cursor: pointer;
2007 + transition: background-color 0.15s ease;
2008 + text-align: left;
2009 + }
2010 +
2011 + .sidebar-option:hover {
2012 + background-color: var(--theme-fill);
2013 + }
2014 +
2015 + .sidebar-option.active {
2016 + background-color: var(--theme-borde);
2017 + }
2018 +
2019 + .sidebar-option .option-label {
2020 + font-size: 0.8125rem;
2021 + font-weight: 500;
2022 + color: var(--theme-titulo);
2023 + }
2024 +
2025 + .sidebar-option .option-desc {
2026 + font-size: 0.6875rem;
2027 + color: var(--theme-texto);
2028 + margin-top: 0.125rem;
2029 + }
2030 +
2031 + /* Mode buttons (Lista, Mapa, Comparar) */
2032 + .sidebar-modes {
2033 + display: flex;
2034 + gap: 0.25rem;
2035 + background: var(--theme-fill);
2036 + padding: 0.25rem;
2037 + border-radius: 0.5rem;
2038 + }
2039 +
2040 + .mode-btn {
2041 + flex: 1;
2042 + display: flex;
2043 + flex-direction: column;
2044 + align-items: center;
2045 + gap: 0.25rem;
2046 + padding: 0.5rem 0.25rem;
2047 + border-radius: 0.375rem;
2048 + border: none;
2049 + background: transparent;
2050 + color: var(--theme-texto);
2051 + font-size: 0.6875rem;
2052 + font-weight: 500;
2053 + cursor: pointer;
2054 + transition: all 0.15s ease;
2055 + }
2056 +
2057 + .mode-btn:hover {
2058 + background: var(--theme-surface);
2059 + color: var(--theme-titulo);
2060 + }
2061 +
2062 + .mode-btn.active {
2063 + background: var(--theme-surface);
2064 + color: var(--theme-titulo);
2065 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
2066 + }
2067 +
2068 + /* Sidebar dropdown buttons (año y entidad) */
2069 + .sidebar-dropdown-btn {
2070 + width: 100%;
2071 + display: flex;
2072 + align-items: center;
2073 + justify-content: space-between;
2074 + gap: 0.5rem;
2075 + padding: 0.5rem 0.75rem;
2076 + border-radius: 0.5rem;
2077 + border: none;
2078 + background: var(--theme-fill);
2079 + color: var(--theme-titulo);
2080 + font-size: 0.8125rem;
2081 + cursor: pointer;
2082 + transition: background-color 0.15s ease;
2083 + }
2084 +
2085 + .sidebar-dropdown-btn:hover {
2086 + background: var(--theme-borde);
2087 + }
2088 +
2089 + .dropdown-chevron {
2090 + color: var(--theme-texto);
2091 + transition: transform 0.2s ease;
2092 + }
2093 +
2094 + .dropdown-chevron.open {
2095 + transform: rotate(180deg);
2096 + }
2097 +
2098 + .sidebar-dropdown-panel {
2099 + position: absolute;
2100 + top: 100%;
2101 + left: 0;
2102 + right: 0;
2103 + margin-top: 0.25rem;
2104 + background: var(--theme-surface);
2105 + border: 1px solid var(--theme-borde);
2106 + border-radius: 0.5rem;
2107 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
2108 + z-index: 50;
2109 + overflow: hidden;
2110 + }
2111 +
2112 + .sidebar-dropdown-list {
2113 + max-height: 200px;
2114 + overflow-y: auto;
2115 + }
2116 +
2117 + .sidebar-dropdown-option {
2118 + width: 100%;
2119 + padding: 0.5rem 0.75rem;
2120 + border: none;
2121 + background: transparent;
2122 + text-align: left;
2123 + font-size: 0.8125rem;
2124 + font-variant-numeric: tabular-nums;
2125 + color: var(--theme-titulo);
2126 + cursor: pointer;
2127 + transition: background-color 0.15s ease;
2128 + }
2129 +
2130 + .sidebar-dropdown-option:hover {
2131 + background: var(--theme-fill);
2132 + }
2133 +
2134 + .sidebar-dropdown-option.active {
2135 + background: var(--theme-fill);
2136 + font-weight: 500;
2137 + }
2138 +
2139 + /* Sidebar breadcrumb navigation */
2140 + .sidebar-breadcrumb {
2141 + display: flex;
2142 + flex-direction: column;
2143 + gap: 0.125rem;
2144 + }
2145 +
2146 + .breadcrumb-item {
2147 + display: flex;
2148 + align-items: center;
2149 + gap: 0.375rem;
2150 + padding: 0.375rem 0.5rem;
2151 + border-radius: 0.375rem;
2152 + border: none;
2153 + background: transparent;
2154 + color: var(--theme-texto);
2155 + font-size: 0.75rem;
2156 + text-align: left;
2157 + cursor: pointer;
2158 + transition: all 0.15s ease;
2159 + }
2160 +
2161 + .breadcrumb-item:hover {
2162 + background: var(--theme-fill);
2163 + color: var(--theme-titulo);
2164 + }
2165 +
2166 + .breadcrumb-item.active {
2167 + background: color-mix(in srgb, var(--theme-accent) 15%, transparent);
2168 + color: var(--theme-titulo);
2169 + font-weight: 500;
2170 + }
2171 +
2172 + .breadcrumb-arrow {
2173 + color: var(--theme-texto);
2174 + opacity: 0.5;
2175 + flex-shrink: 0;
2176 + }
2177 +
2178 + /* Entity dropdown in sidebar */
2179 + .sidebar-entity-btn {
2180 + width: 100%;
2181 + display: flex;
2182 + align-items: center;
2183 + justify-content: space-between;
2184 + gap: 0.5rem;
2185 + padding: 0.5rem 0.75rem;
2186 + border-radius: 0.5rem;
2187 + border: none;
2188 + background: var(--theme-fill);
2189 + color: var(--theme-titulo);
2190 + font-size: 0.8125rem;
2191 + cursor: pointer;
2192 + transition: background-color 0.15s ease;
2193 + }
2194 +
2195 + .sidebar-entity-btn:hover {
2196 + background: var(--theme-borde);
2197 + }
2198 +
2199 + .sidebar-entity-btn svg {
2200 + color: var(--theme-texto);
2201 + transition: transform 0.2s ease;
2202 + }
2203 +
2204 + .entity-dropdown-panel {
2205 + position: fixed;
2206 + bottom: auto;
2207 + top: 150px;
2208 + right: 280px;
2209 + background: var(--theme-surface);
2210 + border: 1px solid var(--theme-borde);
2211 + border-radius: 0.5rem;
2212 + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
2213 + z-index: 9999;
2214 + max-height: 400px;
2215 + overflow: hidden;
2216 + width: 380px;
2217 + }
2218 +
2219 + .entity-list {
2220 + max-height: 280px;
2221 + overflow-y: auto;
2222 + }
2223 +
2224 + .entity-option {
2225 + width: 100%;
2226 + display: flex;
2227 + align-items: center;
2228 + gap: 0.5rem;
2229 + padding: 0.5rem 0.75rem;
2230 + border: none;
2231 + background: transparent;
2232 + text-align: left;
2233 + font-size: 0.75rem;
2234 + color: var(--theme-titulo);
2235 + cursor: pointer;
2236 + transition: background-color 0.15s ease;
2237 + }
2238 +
2239 + .entity-option:hover {
2240 + background: var(--theme-fill);
2241 + }
2242 +
2243 + .entity-option.active {
2244 + background: color-mix(in srgb, var(--theme-accent) 15%, transparent);
2245 + }
2246 +
2247 + /* Tooltip frosted glass */
2248 + .treemap-tooltip {
2249 + position: absolute;
2250 + pointer-events: none;
2251 + padding: 0.75rem 1rem;
2252 + border-radius: 12px;
2253 + z-index: 10;
2254 + max-width: 280px;
2255 + background: rgba(255, 255, 255, 0.75);
2256 + backdrop-filter: blur(12px);
2257 + -webkit-backdrop-filter: blur(12px);
2258 + border: 1px solid rgba(255, 255, 255, 0.3);
2259 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
2260 + color: #1a1a1a;
2261 + font-family: var(--font-sans);
2262 + }
2263 +
2264 + :global(html.dark) .treemap-tooltip {
2265 + background: rgba(30, 30, 30, 0.75);
2266 + border: 1px solid rgba(255, 255, 255, 0.1);
2267 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.05);
2268 + color: #f5f5f5;
2269 + }
2270 +
2271 + /* Transiciones suaves para el treemap */
2272 + :global(.treemap-node) {
2273 + transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
2274 + }
2275 + :global(.treemap-node rect) {
2276 + transition: width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
2277 + height 0.5s cubic-bezier(0.4, 0, 0.2, 1),
2278 + fill 0.3s ease;
2279 + }
2280 + :global(.treemap-fade-enter) {
2281 + opacity: 0;
2282 + transform: scale(0.95);
2283 + }
2284 +
2285 + /* Tema claro para sidebar mapa */
2286 + :global(.light) .mapa-sidebar,
2287 + :global(html:not(.dark)) .mapa-sidebar {
2288 + background: #ffffff;
2289 + border-right-color: rgba(28,28,26,0.08);
2290 + }
2291 +
2292 + :global(.light) .sidebar-puller,
2293 + :global(html:not(.dark)) .sidebar-puller {
2294 + background: #f5f5f5;
2295 + border-color: rgba(28,28,26,0.12);
2296 + box-shadow: 2px 0 8px rgba(28,28,26,0.1);
2297 + }
2298 +
2299 + :global(.light) .sidebar-puller:hover,
2300 + :global(html:not(.dark)) .sidebar-puller:hover {
2301 + background: #e8e8e8;
2302 + }
2303 +
2304 + :global(.light) .grip-dot,
2305 + :global(html:not(.dark)) .grip-dot {
2306 + background-color: rgba(28,28,26,0.5);
2307 + }
2308 +
2309 + :global(.light) .sidebar-puller:hover .grip-dot,
2310 + :global(html:not(.dark)) .sidebar-puller:hover .grip-dot {
2311 + background-color: rgba(28,28,26,0.7);
2312 + }
2313 +
2314 + :global(.light) .sidebar-modes,
2315 + :global(html:not(.dark)) .sidebar-modes {
2316 + background: #f0f0f0;
2317 + }
2318 +
2319 + :global(.light) .mode-btn.active,
2320 + :global(html:not(.dark)) .mode-btn.active {
2321 + background: #ffffff;
2322 + box-shadow: 0 1px 3px rgba(28,28,26,0.1);
2323 + }
2324 +
2325 + :global(.light) .sidebar-entity-btn,
2326 + :global(html:not(.dark)) .sidebar-entity-btn,
2327 + :global(.light) .sidebar-dropdown-btn,
2328 + :global(html:not(.dark)) .sidebar-dropdown-btn {
2329 + background: #f0f0f0;
2330 + }
2331 +
2332 + :global(.light) .sidebar-entity-btn:hover,
2333 + :global(html:not(.dark)) .sidebar-entity-btn:hover,
2334 + :global(.light) .sidebar-dropdown-btn:hover,
2335 + :global(html:not(.dark)) .sidebar-dropdown-btn:hover {
2336 + background: #e5e5e5;
2337 + }
2338 +
2339 + :global(.light) .sidebar-dropdown-panel,
2340 + :global(html:not(.dark)) .sidebar-dropdown-panel {
2341 + background: #ffffff;
2342 + border-color: rgba(28,28,26,0.1);
2343 + box-shadow: 0 8px 24px rgba(28,28,26,0.12);
2344 + }
2345 +
2346 + :global(.light) .sidebar-dropdown-option:hover,
2347 + :global(html:not(.dark)) .sidebar-dropdown-option:hover,
2348 + :global(.light) .sidebar-dropdown-option.active,
2349 + :global(html:not(.dark)) .sidebar-dropdown-option.active {
2350 + background: #f5f5f5;
2351 + }
2352 +
2353 + :global(.light) .entity-dropdown-panel,
2354 + :global(html:not(.dark)) .entity-dropdown-panel {
2355 + background: #ffffff;
2356 + border-color: rgba(28,28,26,0.1);
2357 + box-shadow: 0 8px 24px rgba(28,28,26,0.12);
2358 + }
2359 +</style>
...@@ -28,6 +28,11 @@ ...@@ -28,6 +28,11 @@
28 } 28 }
29 29
30 onMount(async () => { 30 onMount(async () => {
31 + // Forzar recálculo de layout para navegación cliente
32 + requestAnimationFrame(() => {
33 + document.body.offsetHeight; // Force reflow
34 + });
35 +
31 const { data, error } = await supabase 36 const { data, error } = await supabase
32 .schema('ppto') 37 .schema('ppto')
33 .from('clas_institucional') 38 .from('clas_institucional')
...@@ -307,7 +312,7 @@ ...@@ -307,7 +312,7 @@
307 </button> 312 </button>
308 </div> 313 </div>
309 314
310 - <div class="max-w-screen-xl mx-auto flex px-4"> 315 + <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
311 <!-- Sidebar izquierda: Áreas --> 316 <!-- Sidebar izquierda: Áreas -->
312 <!-- En móvil: overlay, en desktop: sidebar fijo --> 317 <!-- En móvil: overlay, en desktop: sidebar fijo -->
313 {#if sidebarOpen} 318 {#if sidebarOpen}
...@@ -611,4 +616,33 @@ ...@@ -611,4 +616,33 @@
611 background-color: transparent; 616 background-color: transparent;
612 } 617 }
613 } 618 }
619 +
620 + /* Layout principal - fallback nativo */
621 + .clasificador-layout {
622 + display: flex !important;
623 + flex-direction: row !important;
624 + }
625 +
626 + /* Sidebar - fallback para responsive */
627 + .sidebar-left {
628 + position: fixed;
629 + transform: translateX(-100%);
630 + }
631 +
632 + @media (min-width: 1024px) {
633 + .sidebar-left {
634 + position: relative !important;
635 + transform: translateX(0) !important;
636 + flex-shrink: 0;
637 + width: 16rem;
638 + z-index: auto !important;
639 + box-shadow: none !important;
640 + }
641 + }
642 +
643 + @media (max-width: 1023px) {
644 + .clasificador-layout {
645 + display: block !important;
646 + }
647 + }
614 </style> 648 </style>
......
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
15 let selectedItem = $state(null); 15 let selectedItem = $state(null);
16 let highlightedItem = $state(null); 16 let highlightedItem = $state(null);
17 let sidebarOpen = $state(false); 17 let sidebarOpen = $state(false);
18 + let layoutMounted = $state(false); // Para forzar estilos correctos después del mount
18 19
19 // Modo de visualización desde URL 20 // Modo de visualización desde URL
20 let viewMode = $derived($page.url.searchParams.get('modo') || 'lista'); 21 let viewMode = $derived($page.url.searchParams.get('modo') || 'lista');
...@@ -95,10 +96,10 @@ ...@@ -95,10 +96,10 @@
95 let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida' 96 let treemapViewLevel = $state('partida'); // 'jerarquico' | 'subgrupo' | 'partida' | 'subpartida'
96 let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias) 97 let showConsolidado = $state(true); // true = consolidado (sin grupo 7), false = agregado (con transferencias)
97 const NIVEL_OPTIONS = [ 98 const NIVEL_OPTIONS = [
98 - { value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegación drill-down' }, 99 + { value: 'jerarquico', label: 'Jerarquía/Grupos', desc: 'Navegar por niveles' },
99 { value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' }, 100 { value: 'subgrupo', label: 'Subgrupos', desc: '~30 categorías' },
100 { value: 'partida', label: 'Partidas', desc: '~100 categorías' }, 101 { value: 'partida', label: 'Partidas', desc: '~100 categorías' },
101 - { value: 'subpartida', label: 'Subpartidas', desc: 'Máximo detalle' } 102 + { value: 'subpartida', label: 'Subpartidas', desc: '~300 categorías' }
102 ]; 103 ];
103 104
104 // Colores por grupo (primer dígito del código) 105 // Colores por grupo (primer dígito del código)
...@@ -161,16 +162,24 @@ ...@@ -161,16 +162,24 @@
161 return luminance > 0.5 ? 'rgba(0,0,0,0.78)' : 'rgba(255,255,255,0.88)'; 162 return luminance > 0.5 ? 'rgba(0,0,0,0.78)' : 'rgba(255,255,255,0.88)';
162 } 163 }
163 164
164 - // Formatear números en bolivianos 165 + // Formatear números en bolivianos con texto descriptivo
165 function formatMoney(value) { 166 function formatMoney(value) {
167 + if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)} mil millones`;
168 + if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)} millones`;
169 + if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)} mil`;
170 + return `Bs ${value.toFixed(0)}`;
171 + }
172 +
173 + // Formatear números compacto para etiquetas dentro del treemap
174 + function formatMoneyCompact(value) {
166 if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`; 175 if (value >= 1e9) return `Bs ${(value / 1e9).toFixed(1)}MM`;
167 if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`; 176 if (value >= 1e6) return `Bs ${(value / 1e6).toFixed(1)}M`;
168 if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`; 177 if (value >= 1e3) return `Bs ${(value / 1e3).toFixed(0)}K`;
169 return `Bs ${value.toFixed(0)}`; 178 return `Bs ${value.toFixed(0)}`;
170 } 179 }
171 180
172 - // Formatear per cápita (población ~10M) 181 + // Formatear per cápita (población ~12M)
173 - const POBLACION = 10000000; 182 + const POBLACION = 12000000;
174 function formatPerCapita(value) { 183 function formatPerCapita(value) {
175 const perCapita = value / POBLACION; 184 const perCapita = value / POBLACION;
176 if (perCapita >= 1000) return `Bs ${(perCapita / 1000).toFixed(1)}K/hab`; 185 if (perCapita >= 1000) return `Bs ${(perCapita / 1000).toFixed(1)}K/hab`;
...@@ -711,6 +720,13 @@ ...@@ -711,6 +720,13 @@
711 }); 720 });
712 721
713 onMount(async () => { 722 onMount(async () => {
723 + // Forzar recálculo de layout para navegación cliente
724 + // Esto evita problemas de renderizado cuando se navega desde otra página
725 + requestAnimationFrame(() => {
726 + document.body.offsetHeight; // Force reflow
727 + layoutMounted = true;
728 + });
729 +
714 // Intentar usar cache primero 730 // Intentar usar cache primero
715 let cacheValue; 731 let cacheValue;
716 const unsubscribe = clasificadorCache.subscribe(value => { cacheValue = value; }); 732 const unsubscribe = clasificadorCache.subscribe(value => { cacheValue = value; });
...@@ -1229,13 +1245,26 @@ ...@@ -1229,13 +1245,26 @@
1229 } 1245 }
1230 } 1246 }
1231 1247
1248 + // Helpers para derivar jerarquía desde código objeto
1249 + function getGrupoFromObjeto(objeto) {
1250 + return objeto.charAt(0);
1251 + }
1252 +
1253 + function getSubgrupoFromObjeto(objeto) {
1254 + return objeto.substring(0, 2);
1255 + }
1256 +
1257 + function getPartidaFromObjeto(objeto) {
1258 + return objeto.substring(0, 3);
1259 + }
1260 +
1232 function getItemsForGrupo(grupoCode) { 1261 function getItemsForGrupo(grupoCode) {
1233 if (!grupoCode) return { subgrupos: [] }; 1262 if (!grupoCode) return { subgrupos: [] };
1234 1263
1235 const grupoNum = grupoCode.substring(0, 1); 1264 const grupoNum = grupoCode.substring(0, 1);
1236 1265
1237 const subgruposUnicos = allItems 1266 const subgruposUnicos = allItems
1238 - .filter(item => item.nivel === 'subgrupo' && item.grupo == grupoNum) 1267 + .filter(item => item.nivel === 'subgrupo' && getGrupoFromObjeto(item.objeto) === grupoNum)
1239 .reduce((acc, item) => { 1268 .reduce((acc, item) => {
1240 if (!acc.find(s => s.objeto === item.objeto)) { 1269 if (!acc.find(s => s.objeto === item.objeto)) {
1241 acc.push(item); 1270 acc.push(item);
...@@ -1245,9 +1274,11 @@ ...@@ -1245,9 +1274,11 @@
1245 .sort((a, b) => a.objeto.localeCompare(b.objeto)); 1274 .sort((a, b) => a.objeto.localeCompare(b.objeto));
1246 1275
1247 const subgrupos = subgruposUnicos.map(sg => { 1276 const subgrupos = subgruposUnicos.map(sg => {
1277 + const sgPrefix = getSubgrupoFromObjeto(sg.objeto); // ej: "11"
1278 +
1248 // Obtener partidas existentes 1279 // Obtener partidas existentes
1249 const partidasUnicas = allItems 1280 const partidasUnicas = allItems
1250 - .filter(item => item.nivel === 'partida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo) 1281 + .filter(item => item.nivel === 'partida' && getSubgrupoFromObjeto(item.objeto) === sgPrefix)
1251 .reduce((acc, item) => { 1282 .reduce((acc, item) => {
1252 if (!acc.find(p => p.objeto === item.objeto)) { 1283 if (!acc.find(p => p.objeto === item.objeto)) {
1253 acc.push(item); 1284 acc.push(item);
...@@ -1258,7 +1289,7 @@ ...@@ -1258,7 +1289,7 @@
1258 1289
1259 // Obtener todas las subpartidas del subgrupo 1290 // Obtener todas las subpartidas del subgrupo
1260 const todasSubpartidas = allItems 1291 const todasSubpartidas = allItems
1261 - .filter(item => item.nivel === 'subpartida' && item.grupo == grupoNum && item.subgrupo == sg.subgrupo) 1292 + .filter(item => item.nivel === 'subpartida' && getSubgrupoFromObjeto(item.objeto) === sgPrefix)
1262 .reduce((acc, item) => { 1293 .reduce((acc, item) => {
1263 if (!acc.find(sp => sp.objeto === item.objeto)) { 1294 if (!acc.find(sp => sp.objeto === item.objeto)) {
1264 acc.push(item); 1295 acc.push(item);
...@@ -1267,41 +1298,39 @@ ...@@ -1267,41 +1298,39 @@
1267 }, []) 1298 }, [])
1268 .sort((a, b) => a.objeto.localeCompare(b.objeto)); 1299 .sort((a, b) => a.objeto.localeCompare(b.objeto));
1269 1300
1270 - // Set de números de partida que existen 1301 + // Set de prefijos de partida que existen (ej: "111", "112")
1271 - const partidasExistentes = new Set(partidasUnicas.map(p => p.partida)); 1302 + const partidasExistentes = new Set(partidasUnicas.map(p => getPartidaFromObjeto(p.objeto)));
1272 1303
1273 // Encontrar subpartidas huérfanas (cuya partida no existe) 1304 // Encontrar subpartidas huérfanas (cuya partida no existe)
1274 - const subpartidasHuerfanas = todasSubpartidas.filter(sp => !partidasExistentes.has(sp.partida)); 1305 + const subpartidasHuerfanas = todasSubpartidas.filter(sp => !partidasExistentes.has(getPartidaFromObjeto(sp.objeto)));
1275 1306
1276 - // Agrupar huérfanas por número de partida para crear partidas sintéticas 1307 + // Agrupar huérfanas por prefijo de partida para crear partidas sintéticas
1277 const huerfanasPorPartida = {}; 1308 const huerfanasPorPartida = {};
1278 subpartidasHuerfanas.forEach(sp => { 1309 subpartidasHuerfanas.forEach(sp => {
1279 - if (!huerfanasPorPartida[sp.partida]) { 1310 + const partidaPrefix = getPartidaFromObjeto(sp.objeto);
1280 - huerfanasPorPartida[sp.partida] = []; 1311 + if (!huerfanasPorPartida[partidaPrefix]) {
1312 + huerfanasPorPartida[partidaPrefix] = [];
1281 } 1313 }
1282 - huerfanasPorPartida[sp.partida].push(sp); 1314 + huerfanasPorPartida[partidaPrefix].push(sp);
1283 }); 1315 });
1284 1316
1285 // Crear partidas sintéticas para las huérfanas 1317 // Crear partidas sintéticas para las huérfanas
1286 - const partidasSinteticas = Object.entries(huerfanasPorPartida).map(([partidaNum, subpartidas]) => { 1318 + const partidasSinteticas = Object.entries(huerfanasPorPartida).map(([partidaPrefix, subpartidas]) => {
1287 - // Generar código de partida (ej: grupo=3, subgrupo=4, partida=1 -> 34100) 1319 + const codigoPartida = `${partidaPrefix}00`;
1288 - const codigoPartida = `${grupoNum}${sg.subgrupo}${partidaNum}00`;
1289 return { 1320 return {
1290 objeto: codigoPartida, 1321 objeto: codigoPartida,
1291 desc_objeto: subpartidas[0]?.desc_objeto?.split(',')[0] || `Partida ${codigoPartida}`, 1322 desc_objeto: subpartidas[0]?.desc_objeto?.split(',')[0] || `Partida ${codigoPartida}`,
1292 nivel: 'partida', 1323 nivel: 'partida',
1293 - grupo: parseInt(grupoNum), 1324 + _sintetica: true,
1294 - subgrupo: sg.subgrupo,
1295 - partida: parseInt(partidaNum),
1296 - _sintetica: true, // Marcar como sintética
1297 subpartidas 1325 subpartidas
1298 }; 1326 };
1299 }); 1327 });
1300 1328
1301 // Asignar subpartidas a partidas existentes 1329 // Asignar subpartidas a partidas existentes
1302 const partidas = partidasUnicas.map(p => { 1330 const partidas = partidasUnicas.map(p => {
1331 + const pPrefix = getPartidaFromObjeto(p.objeto);
1303 const subpartidas = todasSubpartidas 1332 const subpartidas = todasSubpartidas
1304 - .filter(item => item.partida == p.partida) 1333 + .filter(item => getPartidaFromObjeto(item.objeto) === pPrefix)
1305 .sort((a, b) => a.objeto.localeCompare(b.objeto)); 1334 .sort((a, b) => a.objeto.localeCompare(b.objeto));
1306 1335
1307 return { ...p, subpartidas }; 1336 return { ...p, subpartidas };
...@@ -1354,49 +1383,19 @@ ...@@ -1354,49 +1383,19 @@
1354 1383
1355 function getMaxYear(rangos) { 1384 function getMaxYear(rangos) {
1356 if (!rangos) return 0; 1385 if (!rangos) return 0;
1357 - // Extraer todos los números de 4 dígitos (años) del string 1386 + // rangos es un string como "2005, 2007-2020, 2022-2026"
1387 + // Extraer todos los números de 4 dígitos
1358 const years = rangos.match(/\d{4}/g); 1388 const years = rangos.match(/\d{4}/g);
1359 if (!years) return 0; 1389 if (!years) return 0;
1360 return Math.max(...years.map(y => parseInt(y))); 1390 return Math.max(...years.map(y => parseInt(y)));
1361 } 1391 }
1362 1392
1363 /** 1393 /**
1364 - * Converts a string with years into compact ranges 1394 + * rangos ya viene formateado como string "2005, 2007-2020, 2022-2026"
1365 - * Example: "2006, 2007, 2008, 2009, 2015, 2016" → "2006-2009, 2015-2016" 1395 + * Solo lo retornamos tal cual
1366 - * Single years: "2006, 2008, 2010" → "2006, 2008, 2010"
1367 */ 1396 */
1368 - function formatYearsAsRanges(rangosStr) { 1397 + function formatYearsAsRanges(rangos) {
1369 - if (!rangosStr) return ''; 1398 + return rangos || '';
1370 -
1371 - // Extract all 4-digit years
1372 - const yearsMatch = rangosStr.match(/\d{4}/g);
1373 - if (!yearsMatch || yearsMatch.length === 0) return rangosStr;
1374 -
1375 - // Convert to numbers and sort
1376 - const years = [...new Set(yearsMatch.map(y => parseInt(y)))].sort((a, b) => a - b);
1377 -
1378 - if (years.length === 1) return String(years[0]);
1379 -
1380 - // Group consecutive years into ranges
1381 - const ranges = [];
1382 - let rangeStart = years[0];
1383 - let rangeEnd = years[0];
1384 -
1385 - for (let i = 1; i < years.length; i++) {
1386 - if (years[i] === rangeEnd + 1) {
1387 - // Consecutive year, extend range
1388 - rangeEnd = years[i];
1389 - } else {
1390 - // Gap found, save current range and start new one
1391 - ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
1392 - rangeStart = years[i];
1393 - rangeEnd = years[i];
1394 - }
1395 - }
1396 - // Add the last range
1397 - ranges.push(rangeStart === rangeEnd ? String(rangeStart) : `${rangeStart}-${rangeEnd}`);
1398 -
1399 - return ranges.join(', ');
1400 } 1399 }
1401 1400
1402 function selectGrupo(grupo) { 1401 function selectGrupo(grupo) {
...@@ -1423,7 +1422,7 @@ ...@@ -1423,7 +1422,7 @@
1423 } 1422 }
1424 1423
1425 // Para otros niveles, encontrar y seleccionar el grupo padre 1424 // Para otros niveles, encontrar y seleccionar el grupo padre
1426 - const grupoNum = item.grupo; 1425 + const grupoNum = getGrupoFromObjeto(item.objeto);
1427 const targetGrupo = grupos.find(g => g.objeto === grupoNum + '0000'); 1426 const targetGrupo = grupos.find(g => g.objeto === grupoNum + '0000');
1428 1427
1429 if (targetGrupo) { 1428 if (targetGrupo) {
...@@ -1495,14 +1494,12 @@ ...@@ -1495,14 +1494,12 @@
1495 </nav> 1494 </nav>
1496 1495
1497 <div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}"> 1496 <div class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'flex-1' : 'max-w-5xl'}">
1498 - {#if viewMode !== 'comparar'}
1499 <p class="text-sm uppercase tracking-widest mb-2 font-semibold" style="font-family: var(--font-sans); color: var(--theme-accent);"> 1497 <p class="text-sm uppercase tracking-widest mb-2 font-semibold" style="font-family: var(--font-sans); color: var(--theme-accent);">
1500 Clasificador de Objeto del Gasto 1498 Clasificador de Objeto del Gasto
1501 </p> 1499 </p>
1502 - {/if}
1503 <div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}"> 1500 <div class="flex items-center gap-3 {viewMode === 'mapa' || viewMode === 'comparar' ? 'mb-1' : 'mb-3'}">
1504 <h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);"> 1501 <h1 class="{viewMode === 'mapa' || viewMode === 'comparar' ? 'text-xl' : 'text-3xl'}" style="font-family: var(--font-display); color: var(--theme-titulo);">
1505 - {viewMode === 'comparar' ? 'Comparar gasto' : '¿En qué se gasta?'} 1502 + ¿En qué se gasta?
1506 </h1> 1503 </h1>
1507 {#if viewMode === 'mapa'} 1504 {#if viewMode === 'mapa'}
1508 <button 1505 <button
...@@ -1591,6 +1588,15 @@ ...@@ -1591,6 +1588,15 @@
1591 </div> 1588 </div>
1592 {/if} 1589 {/if}
1593 </div> 1590 </div>
1591 +
1592 + <!-- Leyenda de unidades -->
1593 + <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);">
1594 + <span>MM = Mil Millones</span>
1595 + <span style="opacity: 0.5;">·</span>
1596 + <span>M = Millones</span>
1597 + <span style="opacity: 0.5;">·</span>
1598 + <span>% sobre gasto total</span>
1599 + </span>
1594 </div> 1600 </div>
1595 {/if} 1601 {/if}
1596 1602
...@@ -1690,7 +1696,7 @@ ...@@ -1690,7 +1696,7 @@
1690 <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> 1696 <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>
1691 para desglose. 1697 para desglose.
1692 {:else} 1698 {:else}
1693 - Colores agrupan por categoría. 1699 + MM = Mil Millones · M = Millones · % sobre gasto total
1694 {/if} 1700 {/if}
1695 </p> 1701 </p>
1696 {:else} 1702 {:else}
...@@ -1707,6 +1713,7 @@ ...@@ -1707,6 +1713,7 @@
1707 <Spinner size={48} color="var(--theme-texto)" /> 1713 <Spinner size={48} color="var(--theme-texto)" />
1708 </div> 1714 </div>
1709 {:else} 1715 {:else}
1716 + {#key layoutMounted}
1710 {#if viewMode === 'lista'} 1717 {#if viewMode === 'lista'}
1711 <!-- ═══════════════════════════════════════════════════════════ --> 1718 <!-- ═══════════════════════════════════════════════════════════ -->
1712 <!-- MODO LISTA: Navegación jerárquica de categorías de gasto --> 1719 <!-- MODO LISTA: Navegación jerárquica de categorías de gasto -->
...@@ -1729,24 +1736,27 @@ ...@@ -1729,24 +1736,27 @@
1729 </button> 1736 </button>
1730 </div> 1737 </div>
1731 1738
1732 - <div class="max-w-screen-xl mx-auto flex px-4 lg:px-6"> 1739 + <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4 lg:px-6" style="display: flex !important; flex-direction: row;">
1733 <!-- Sidebar izquierda: Grupos --> 1740 <!-- Sidebar izquierda: Grupos -->
1734 <!-- En móvil: overlay, en desktop: sidebar fijo --> 1741 <!-- En móvil: overlay, en desktop: sidebar fijo -->
1735 {#if sidebarOpen} 1742 {#if sidebarOpen}
1736 <div class="fixed inset-0 bg-black/30 z-40 lg:hidden" onclick={() => sidebarOpen = false}></div> 1743 <div class="fixed inset-0 bg-black/30 z-40 lg:hidden" onclick={() => sidebarOpen = false}></div>
1737 {/if} 1744 {/if}
1738 - <aside class=" 1745 + <aside
1739 - {sidebarOpen ? 'translate-x-0' : '-translate-x-full'} 1746 + class="
1740 - lg:translate-x-0 1747 + {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
1741 - fixed lg:relative 1748 + lg:translate-x-0
1742 - inset-y-0 left-0 1749 + fixed lg:relative
1743 - w-80 lg:w-72 xl:w-80 1750 + inset-y-0 left-0
1744 - z-50 lg:z-auto 1751 + w-80 lg:w-72 xl:w-80
1745 - transition-transform duration-200 ease-in-out 1752 + z-50 lg:z-auto
1746 - lg:flex-shrink-0 1753 + transition-transform duration-200 ease-in-out
1747 - shadow-xl lg:shadow-none 1754 + lg:flex-shrink-0
1748 - sidebar-left 1755 + shadow-xl lg:shadow-none
1749 - "> 1756 + sidebar-left
1757 + {layoutMounted ? 'sidebar-mounted' : ''}
1758 + "
1759 + >
1750 <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"> 1760 <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">
1751 <!-- Cerrar en móvil --> 1761 <!-- Cerrar en móvil -->
1752 <div class="flex justify-between items-center mb-4 lg:hidden"> 1762 <div class="flex justify-between items-center mb-4 lg:hidden">
...@@ -2261,7 +2271,10 @@ ...@@ -2261,7 +2271,10 @@
2261 {/if} 2271 {/if}
2262 {#if height > 70 && width > 65} 2272 {#if height > 70 && width > 65}
2263 <div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};"> 2273 <div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2264 - {formatMoney(node.value)} 2274 + {formatMoneyCompact(node.value)}
2275 + </div>
2276 + <div class="opacity-70" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2277 + {formatPerCapita(node.value)}
2265 </div> 2278 </div>
2266 {/if} 2279 {/if}
2267 </div> 2280 </div>
...@@ -2372,7 +2385,10 @@ ...@@ -2372,7 +2385,10 @@
2372 {/if} 2385 {/if}
2373 {#if height > 55 && width > 55} 2386 {#if height > 55 && width > 55}
2374 <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};"> 2387 <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2375 - {formatMoney(node.value)} 2388 + {formatMoneyCompact(node.value)}
2389 + </div>
2390 + <div class="opacity-65" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2391 + {formatPerCapita(node.value)}
2376 </div> 2392 </div>
2377 {/if} 2393 {/if}
2378 </div> 2394 </div>
...@@ -2390,34 +2406,30 @@ ...@@ -2390,34 +2406,30 @@
2390 <div 2406 <div
2391 class="treemap-tooltip" 2407 class="treemap-tooltip"
2392 style=" 2408 style="
2393 - left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 220)}px; 2409 + left: {Math.min(hoveredNode.x0 + 10, treemapWidth - 260)}px;
2394 top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px; 2410 top: {Math.min(hoveredNode.y0 + 10, treemapHeight - 100)}px;
2395 " 2411 "
2396 > 2412 >
2397 {#if treemapViewLevel !== 'jerarquico'} 2413 {#if treemapViewLevel !== 'jerarquico'}
2398 <!-- Vista aplanada: mostrar contexto del grupo --> 2414 <!-- Vista aplanada: mostrar contexto del grupo -->
2399 - <div class="text-xs opacity-60 mb-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2400 - {hoveredNode.id}
2401 - </div>
2402 <div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div> 2415 <div class="font-medium text-sm">{hoveredNode.name || hoveredNode.id}</div>
2403 - <div class="text-xs opacity-80 mt-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;"> 2416 + <div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2404 - {formatMoney(hoveredNode.value)} · {((hoveredNode.value / totalValue) * 100).toFixed(1)}% 2417 + {formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
2405 </div> 2418 </div>
2406 <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;"> 2419 <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2407 - {formatPerCapita(hoveredNode.value)} 2420 + {((hoveredNode.value / totalValue) * 100).toFixed(1)}% del gasto total
2408 </div> 2421 </div>
2409 {:else} 2422 {:else}
2410 <!-- Vista jerárquica --> 2423 <!-- Vista jerárquica -->
2411 <div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div> 2424 <div class="font-medium text-sm">{hoveredNode.data?.desc_objeto || hoveredNode.name || hoveredNode.id}</div>
2412 - <div class="text-xs opacity-80 mt-1" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;"> 2425 + <div class="text-xs opacity-80 mt-1.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2413 - {formatMoney(hoveredNode.value)} 2426 + {formatMoney(hoveredNode.value)} · {formatPerCapita(hoveredNode.value)}
2414 - {#if currentTreemapNode && currentTreemapNode.value}
2415 - · {((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}%
2416 - {/if}
2417 - </div>
2418 - <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2419 - {formatPerCapita(hoveredNode.value)}
2420 </div> 2427 </div>
2428 + {#if currentTreemapNode && currentTreemapNode.value}
2429 + <div class="text-xs opacity-60 mt-0.5" style="font-family: var(--font-sans); font-variant-numeric: tabular-nums;">
2430 + {((hoveredNode.value / currentTreemapNode.value) * 100).toFixed(1)}% del gasto total
2431 + </div>
2432 + {/if}
2421 {#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0} 2433 {#if hoveredNode._originalNode?.children && hoveredNode._originalNode.children.length > 0}
2422 <div class="text-xs opacity-60 mt-1">Clic para explorar</div> 2434 <div class="text-xs opacity-60 mt-1">Clic para explorar</div>
2423 {/if} 2435 {/if}
...@@ -2578,57 +2590,58 @@ ...@@ -2578,57 +2590,58 @@
2578 <!-- Panel A --> 2590 <!-- Panel A -->
2579 <div class="compare-panel"> 2591 <div class="compare-panel">
2580 <div class="compare-header"> 2592 <div class="compare-header">
2581 - <span class="compare-badge" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">A</span> 2593 + <div class="flex items-center gap-2">
2582 - <div class="inline-dropdown compare-year-dropdown"> 2594 + <div class="inline-dropdown compare-a-dropdown">
2583 - <button 2595 + <button
2584 - class="inline-dropdown-btn year-btn" 2596 + class="inline-dropdown-btn entity-btn"
2585 - onclick={() => { compareAYearDropdownOpen = !compareAYearDropdownOpen; compareBYearDropdownOpen = false; compareADropdownOpen = false; }} 2597 + onclick={() => { compareADropdownOpen = !compareADropdownOpen; compareAYearDropdownOpen = false; }}
2586 - > 2598 + >
2587 - <span>{compareA.year}</span> 2599 + <span class="truncate">{compareA.entity?.desc_entidad || 'Todo el Estado'}</span>
2588 - <svg class="inline-chevron" class:open={compareAYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 2600 + <svg class="inline-chevron" class:open={compareADropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2589 - <path d="M6 9l6 6 6-6"/> 2601 + <path d="M6 9l6 6 6-6"/>
2590 - </svg> 2602 + </svg>
2591 - </button> 2603 + </button>
2592 - {#if compareAYearDropdownOpen} 2604 + {#if compareADropdownOpen}
2593 - <div class="inline-dropdown-panel year-panel"> 2605 + <div class="inline-dropdown-panel entity-panel">
2594 - {#each availableYears as year} 2606 + <div class="inline-dropdown-search">
2595 - <button 2607 + <input type="text" bind:value={compareASearchQuery} placeholder="Buscar entidad..." />
2596 - class="inline-dropdown-option" 2608 + </div>
2597 - class:active={compareA.year === year} 2609 + <div class="inline-dropdown-list">
2598 - onclick={() => { compareA.year = year; compareAYearDropdownOpen = false; }} 2610 + <button class="inline-dropdown-option" class:active={!compareA.entity} onclick={() => clearCompareEntity('A')}>Todo el Estado</button>
2599 - > 2611 + {#each filteredEntitiesA() as entity}
2600 - {year} 2612 + <button class="inline-dropdown-option" class:active={compareA.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('A', entity)}>
2601 - </button> 2613 + <span class="font-mono text-xs opacity-50">{entity.entidad}</span>
2602 - {/each} 2614 + <span class="truncate">{entity.desc_entidad}</span>
2603 - </div> 2615 + </button>
2604 - {/if} 2616 + {/each}
2605 - </div> 2617 + </div>
2606 - <div class="inline-dropdown compare-a-dropdown flex-1">
2607 - <button
2608 - class="inline-dropdown-btn entity-btn"
2609 - onclick={() => { compareADropdownOpen = !compareADropdownOpen; compareAYearDropdownOpen = false; }}
2610 - >
2611 - <span class="truncate">{compareA.entity?.desc_entidad || 'Todo el Estado'}</span>
2612 - <svg class="inline-chevron" class:open={compareADropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2613 - <path d="M6 9l6 6 6-6"/>
2614 - </svg>
2615 - </button>
2616 - {#if compareADropdownOpen}
2617 - <div class="inline-dropdown-panel entity-panel">
2618 - <div class="inline-dropdown-search">
2619 - <input type="text" bind:value={compareASearchQuery} placeholder="Buscar entidad..." />
2620 </div> 2618 </div>
2621 - <div class="inline-dropdown-list"> 2619 + {/if}
2622 - <button class="inline-dropdown-option" class:active={!compareA.entity} onclick={() => clearCompareEntity('A')}>Todo el Estado</button> 2620 + </div>
2623 - {#each filteredEntitiesA() as entity} 2621 + <div class="inline-dropdown compare-year-dropdown">
2624 - <button class="inline-dropdown-option" class:active={compareA.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('A', entity)}> 2622 + <button
2625 - <span class="font-mono text-xs opacity-50">{entity.entidad}</span> 2623 + class="inline-dropdown-btn year-btn"
2626 - <span class="truncate">{entity.desc_entidad}</span> 2624 + onclick={() => { compareAYearDropdownOpen = !compareAYearDropdownOpen; compareBYearDropdownOpen = false; compareADropdownOpen = false; }}
2625 + >
2626 + <span>{compareA.year}</span>
2627 + <svg class="inline-chevron" class:open={compareAYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2628 + <path d="M6 9l6 6 6-6"/>
2629 + </svg>
2630 + </button>
2631 + {#if compareAYearDropdownOpen}
2632 + <div class="inline-dropdown-panel year-panel">
2633 + {#each availableYears as year}
2634 + <button
2635 + class="inline-dropdown-option"
2636 + class:active={compareA.year === year}
2637 + onclick={() => { compareA.year = year; compareAYearDropdownOpen = false; }}
2638 + >
2639 + {year}
2627 </button> 2640 </button>
2628 {/each} 2641 {/each}
2629 </div> 2642 </div>
2630 - </div> 2643 + {/if}
2631 - {/if} 2644 + </div>
2632 </div> 2645 </div>
2633 </div> 2646 </div>
2634 <!-- Treemap A --> 2647 <!-- Treemap A -->
...@@ -2701,7 +2714,10 @@ ...@@ -2701,7 +2714,10 @@
2701 {/if} 2714 {/if}
2702 {#if height > 55 && width > 55} 2715 {#if height > 55 && width > 55}
2703 <div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};"> 2716 <div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2704 - {formatMoney(node.value)} 2717 + {formatMoneyCompact(node.value)}
2718 + </div>
2719 + <div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2720 + {formatPerCapita(node.value)}
2705 </div> 2721 </div>
2706 {/if} 2722 {/if}
2707 </div> 2723 </div>
...@@ -2736,57 +2752,58 @@ ...@@ -2736,57 +2752,58 @@
2736 <!-- Panel B --> 2752 <!-- Panel B -->
2737 <div class="compare-panel"> 2753 <div class="compare-panel">
2738 <div class="compare-header"> 2754 <div class="compare-header">
2739 - <span class="compare-badge" style="background-color: color-mix(in srgb, #8b5cf6 20%, transparent); color: #8b5cf6;">B</span> 2755 + <div class="flex items-center gap-2">
2740 - <div class="inline-dropdown compare-year-dropdown"> 2756 + <div class="inline-dropdown compare-b-dropdown">
2741 - <button 2757 + <button
2742 - class="inline-dropdown-btn year-btn" 2758 + class="inline-dropdown-btn entity-btn"
2743 - onclick={() => { compareBYearDropdownOpen = !compareBYearDropdownOpen; compareAYearDropdownOpen = false; compareBDropdownOpen = false; }} 2759 + onclick={() => { compareBDropdownOpen = !compareBDropdownOpen; compareBYearDropdownOpen = false; }}
2744 - > 2760 + >
2745 - <span>{compareB.year}</span> 2761 + <span class="truncate">{compareB.entity?.desc_entidad || 'Todo el Estado'}</span>
2746 - <svg class="inline-chevron" class:open={compareBYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 2762 + <svg class="inline-chevron" class:open={compareBDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2747 - <path d="M6 9l6 6 6-6"/> 2763 + <path d="M6 9l6 6 6-6"/>
2748 - </svg> 2764 + </svg>
2749 - </button> 2765 + </button>
2750 - {#if compareBYearDropdownOpen} 2766 + {#if compareBDropdownOpen}
2751 - <div class="inline-dropdown-panel year-panel"> 2767 + <div class="inline-dropdown-panel entity-panel">
2752 - {#each availableYears as year} 2768 + <div class="inline-dropdown-search">
2753 - <button 2769 + <input type="text" bind:value={compareBSearchQuery} placeholder="Buscar entidad..." />
2754 - class="inline-dropdown-option" 2770 + </div>
2755 - class:active={compareB.year === year} 2771 + <div class="inline-dropdown-list">
2756 - onclick={() => { compareB.year = year; compareBYearDropdownOpen = false; }} 2772 + <button class="inline-dropdown-option" class:active={!compareB.entity} onclick={() => clearCompareEntity('B')}>Todo el Estado</button>
2757 - > 2773 + {#each filteredEntitiesB() as entity}
2758 - {year} 2774 + <button class="inline-dropdown-option" class:active={compareB.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('B', entity)}>
2759 - </button> 2775 + <span class="font-mono text-xs opacity-50">{entity.entidad}</span>
2760 - {/each} 2776 + <span class="truncate">{entity.desc_entidad}</span>
2761 - </div> 2777 + </button>
2762 - {/if} 2778 + {/each}
2763 - </div> 2779 + </div>
2764 - <div class="inline-dropdown compare-b-dropdown flex-1">
2765 - <button
2766 - class="inline-dropdown-btn entity-btn"
2767 - onclick={() => { compareBDropdownOpen = !compareBDropdownOpen; compareBYearDropdownOpen = false; }}
2768 - >
2769 - <span class="truncate">{compareB.entity?.desc_entidad || 'Todo el Estado'}</span>
2770 - <svg class="inline-chevron" class:open={compareBDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2771 - <path d="M6 9l6 6 6-6"/>
2772 - </svg>
2773 - </button>
2774 - {#if compareBDropdownOpen}
2775 - <div class="inline-dropdown-panel entity-panel">
2776 - <div class="inline-dropdown-search">
2777 - <input type="text" bind:value={compareBSearchQuery} placeholder="Buscar entidad..." />
2778 </div> 2780 </div>
2779 - <div class="inline-dropdown-list"> 2781 + {/if}
2780 - <button class="inline-dropdown-option" class:active={!compareB.entity} onclick={() => clearCompareEntity('B')}>Todo el Estado</button> 2782 + </div>
2781 - {#each filteredEntitiesB() as entity} 2783 + <div class="inline-dropdown compare-year-dropdown">
2782 - <button class="inline-dropdown-option" class:active={compareB.entity?.entidad === entity.entidad} onclick={() => selectCompareEntity('B', entity)}> 2784 + <button
2783 - <span class="font-mono text-xs opacity-50">{entity.entidad}</span> 2785 + class="inline-dropdown-btn year-btn"
2784 - <span class="truncate">{entity.desc_entidad}</span> 2786 + onclick={() => { compareBYearDropdownOpen = !compareBYearDropdownOpen; compareAYearDropdownOpen = false; compareBDropdownOpen = false; }}
2787 + >
2788 + <span>{compareB.year}</span>
2789 + <svg class="inline-chevron" class:open={compareBYearDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
2790 + <path d="M6 9l6 6 6-6"/>
2791 + </svg>
2792 + </button>
2793 + {#if compareBYearDropdownOpen}
2794 + <div class="inline-dropdown-panel year-panel">
2795 + {#each availableYears as year}
2796 + <button
2797 + class="inline-dropdown-option"
2798 + class:active={compareB.year === year}
2799 + onclick={() => { compareB.year = year; compareBYearDropdownOpen = false; }}
2800 + >
2801 + {year}
2785 </button> 2802 </button>
2786 {/each} 2803 {/each}
2787 </div> 2804 </div>
2788 - </div> 2805 + {/if}
2789 - {/if} 2806 + </div>
2790 </div> 2807 </div>
2791 </div> 2808 </div>
2792 <!-- Treemap B --> 2809 <!-- Treemap B -->
...@@ -2855,7 +2872,10 @@ ...@@ -2855,7 +2872,10 @@
2855 {/if} 2872 {/if}
2856 {#if height > 55 && width > 55} 2873 {#if height > 55 && width > 55}
2857 <div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};"> 2874 <div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2858 - {formatMoney(node.value)} 2875 + {formatMoneyCompact(node.value)}
2876 + </div>
2877 + <div class="opacity-75" style="font-variant-numeric: tabular-nums; font-size: {valueSize}px; color: {textColor};">
2878 + {formatPerCapita(node.value)}
2859 </div> 2879 </div>
2860 {/if} 2880 {/if}
2861 </div> 2881 </div>
...@@ -2887,6 +2907,7 @@ ...@@ -2887,6 +2907,7 @@
2887 </div> 2907 </div>
2888 </div> 2908 </div>
2889 {/if} 2909 {/if}
2910 + {/key}
2890 {/if} 2911 {/if}
2891 </div> 2912 </div>
2892 2913
...@@ -2929,22 +2950,22 @@ ...@@ -2929,22 +2950,22 @@
2929 <!-- Barra A --> 2950 <!-- Barra A -->
2930 <div class="compare-bar-row"> 2951 <div class="compare-bar-row">
2931 <div class="compare-bar-header"> 2952 <div class="compare-bar-header">
2932 - <span class="compare-bar-entity">{entityNameA}</span> 2953 + <span class="compare-bar-entity">{entityNameA} <span class="opacity-60">· {compareA.year}</span></span>
2933 <span class="compare-bar-value">{formatMoney(valueA)}</span> 2954 <span class="compare-bar-value">{formatMoney(valueA)}</span>
2934 </div> 2955 </div>
2935 - <div class="compare-bar-track"> 2956 + <div class="compare-bar-track" style="background-color: color-mix(in srgb, var(--theme-borde) 30%, transparent);">
2936 - <div class="compare-bar-fill a" style="width: {barA}%;"></div> 2957 + <div class="compare-bar-fill" style="width: {barA}%; background-color: var(--theme-accent);"></div>
2937 </div> 2958 </div>
2938 <span class="compare-bar-pct">{pctA >= 1 ? pctA.toFixed(1) : pctA.toFixed(2)}%</span> 2959 <span class="compare-bar-pct">{pctA >= 1 ? pctA.toFixed(1) : pctA.toFixed(2)}%</span>
2939 </div> 2960 </div>
2940 <!-- Barra B --> 2961 <!-- Barra B -->
2941 <div class="compare-bar-row"> 2962 <div class="compare-bar-row">
2942 <div class="compare-bar-header"> 2963 <div class="compare-bar-header">
2943 - <span class="compare-bar-entity">{entityNameB}</span> 2964 + <span class="compare-bar-entity">{entityNameB} <span class="opacity-60">· {compareB.year}</span></span>
2944 <span class="compare-bar-value">{formatMoney(valueB)}</span> 2965 <span class="compare-bar-value">{formatMoney(valueB)}</span>
2945 </div> 2966 </div>
2946 - <div class="compare-bar-track"> 2967 + <div class="compare-bar-track" style="background-color: color-mix(in srgb, var(--theme-borde) 30%, transparent);">
2947 - <div class="compare-bar-fill b" style="width: {barB}%;"></div> 2968 + <div class="compare-bar-fill" style="width: {barB}%; background-color: var(--theme-accent); opacity: 0.7;"></div>
2948 </div> 2969 </div>
2949 <span class="compare-bar-pct">{pctB >= 1 ? pctB.toFixed(1) : pctB.toFixed(2)}%</span> 2970 <span class="compare-bar-pct">{pctB >= 1 ? pctB.toFixed(1) : pctB.toFixed(2)}%</span>
2950 </div> 2971 </div>
...@@ -4336,4 +4357,33 @@ ...@@ -4336,4 +4357,33 @@
4336 text-overflow: ellipsis; 4357 text-overflow: ellipsis;
4337 white-space: nowrap; 4358 white-space: nowrap;
4338 } 4359 }
4360 +
4361 + /* Layout principal - fallback nativo */
4362 + .clasificador-layout {
4363 + display: flex !important;
4364 + flex-direction: row !important;
4365 + }
4366 +
4367 + /* Sidebar - fallback para responsive */
4368 + .sidebar-left {
4369 + position: fixed;
4370 + transform: translateX(-100%);
4371 + }
4372 +
4373 + @media (min-width: 1024px) {
4374 + .sidebar-left {
4375 + position: relative !important;
4376 + transform: translateX(0) !important;
4377 + flex-shrink: 0;
4378 + width: 18rem;
4379 + z-index: auto !important;
4380 + box-shadow: none !important;
4381 + }
4382 + }
4383 +
4384 + @media (max-width: 1023px) {
4385 + .clasificador-layout {
4386 + display: block !important;
4387 + }
4388 + }
4339 </style> 4389 </style>
......
...@@ -14,6 +14,11 @@ ...@@ -14,6 +14,11 @@
14 let sidebarOpen = $state(false); 14 let sidebarOpen = $state(false);
15 15
16 onMount(async () => { 16 onMount(async () => {
17 + // Forzar recálculo de layout para navegación cliente
18 + requestAnimationFrame(() => {
19 + document.body.offsetHeight; // Force reflow
20 + });
21 +
17 const { data, error } = await supabase 22 const { data, error } = await supabase
18 .schema('ppto') 23 .schema('ppto')
19 .from('clas_rubros') 24 .from('clas_rubros')
...@@ -32,7 +37,7 @@ ...@@ -32,7 +37,7 @@
32 } 37 }
33 return acc; 38 return acc;
34 }, []) 39 }, [])
35 - .sort((a, b) => a.rubro - b.rubro); 40 + .sort((a, b) => a.rubro.localeCompare(b.rubro));
36 41
37 if (tipos.length > 0) { 42 if (tipos.length > 0) {
38 selectedTipo = tipos[0]; 43 selectedTipo = tipos[0];
...@@ -42,70 +47,103 @@ ...@@ -42,70 +47,103 @@
42 loading = false; 47 loading = false;
43 }); 48 });
44 49
50 + // Helpers para derivar jerarquía desde código rubro
51 + function getTipoFromRubro(rubro) {
52 + return rubro.charAt(0);
53 + }
54 +
55 + function getClaseFromRubro(rubro) {
56 + return rubro.substring(0, 2);
57 + }
58 +
59 + function getCuentaFromRubro(rubro) {
60 + return rubro.substring(0, 3);
61 + }
62 +
45 function getItemsForTipo(tipoCode) { 63 function getItemsForTipo(tipoCode) {
46 if (!tipoCode) return { clases: [] }; 64 if (!tipoCode) return { clases: [] };
47 65
48 - const tipoNum = Math.floor(tipoCode / 1000); 66 + const tipoNum = getTipoFromRubro(tipoCode);
49 67
50 - // Obtener todos los items de este tipo 68 + // Obtener clases únicas de este tipo
51 - const itemsDelTipo = allItems.filter(item => item.tipo === tipoNum && item.nivel !== 'tipo'); 69 + const clasesUnicas = allItems
70 + .filter(item => item.nivel === 'clase' && getTipoFromRubro(item.rubro) === tipoNum)
71 + .reduce((acc, item) => {
72 + if (!acc.find(c => c.rubro === item.rubro)) {
73 + acc.push(item);
74 + }
75 + return acc;
76 + }, [])
77 + .sort((a, b) => a.rubro.localeCompare(b.rubro));
52 78
53 - // Encontrar clases únicas desde los valores de la columna 'clase' 79 + const clases = clasesUnicas.map(clase => {
54 - const clasesUnicas = [...new Set(itemsDelTipo.map(i => i.clase).filter(c => c != null))].sort((a, b) => a - b); 80 + const clasePrefix = getClaseFromRubro(clase.rubro);
55 81
56 - const clases = clasesUnicas.map(claseNum => { 82 + // Obtener cuentas de esta clase
57 - // Buscar si existe una entrada de nivel 'clase' para esta clase 83 + const cuentasUnicas = allItems
58 - const claseEntry = allItems.find(item => item.nivel === 'clase' && item.tipo === tipoNum && item.clase === claseNum); 84 + .filter(item => item.nivel === 'cuenta' && getClaseFromRubro(item.rubro) === clasePrefix)
85 + .reduce((acc, item) => {
86 + if (!acc.find(c => c.rubro === item.rubro)) {
87 + acc.push(item);
88 + }
89 + return acc;
90 + }, [])
91 + .sort((a, b) => a.rubro.localeCompare(b.rubro));
59 92
60 - // Items de esta clase 93 + // Obtener todas las subcuentas de esta clase
61 - const itemsDeClase = itemsDelTipo.filter(item => item.clase === claseNum); 94 + const todasSubcuentas = allItems
95 + .filter(item => item.nivel === 'sub_cuenta' && getClaseFromRubro(item.rubro) === clasePrefix)
96 + .reduce((acc, item) => {
97 + if (!acc.find(sc => sc.rubro === item.rubro)) {
98 + acc.push(item);
99 + }
100 + return acc;
101 + }, [])
102 + .sort((a, b) => a.rubro.localeCompare(b.rubro));
62 103
63 - // Encontrar cuentas únicas 104 + // Set de prefijos de cuenta que existen
64 - const cuentasUnicas = [...new Set(itemsDeClase.map(i => i.cuenta).filter(c => c != null))].sort((a, b) => a - b); 105 + const cuentasExistentes = new Set(cuentasUnicas.map(c => getCuentaFromRubro(c.rubro)));
65 106
66 - const cuentas = cuentasUnicas.map(cuentaNum => { 107 + // Encontrar subcuentas huérfanas (cuya cuenta no existe)
67 - // Buscar si existe una entrada de nivel 'cuenta' para esta cuenta 108 + const subcuentasHuerfanas = todasSubcuentas.filter(sc => !cuentasExistentes.has(getCuentaFromRubro(sc.rubro)));
68 - const cuentaEntry = allItems.find(item => item.nivel === 'cuenta' && item.tipo === tipoNum && item.clase === claseNum && item.cuenta === cuentaNum);
69 109
70 - // Sub_cuentas de esta cuenta 110 + // Agrupar huérfanas por prefijo de cuenta para crear cuentas sintéticas
71 - const subcuentas = itemsDeClase 111 + const huerfanasPorCuenta = {};
72 - .filter(item => item.nivel === 'sub_cuenta' && item.cuenta === cuentaNum) 112 + subcuentasHuerfanas.forEach(sc => {
73 - .reduce((acc, item) => { 113 + const cuentaPrefix = getCuentaFromRubro(sc.rubro);
74 - if (!acc.find(s => s.rubro === item.rubro)) acc.push(item); 114 + if (!huerfanasPorCuenta[cuentaPrefix]) {
75 - return acc; 115 + huerfanasPorCuenta[cuentaPrefix] = [];
76 - }, []) 116 + }
77 - .sort((a, b) => a.rubro - b.rubro); 117 + huerfanasPorCuenta[cuentaPrefix].push(sc);
118 + });
78 119
79 - // Si existe entrada de cuenta, usarla; si no, crear una virtual 120 + // Crear cuentas sintéticas para las huérfanas
80 - const cuentaData = cuentaEntry || { 121 + const cuentasSinteticas = Object.entries(huerfanasPorCuenta).map(([cuentaPrefix, subcuentas]) => {
81 - rubro: tipoNum * 1000 + claseNum * 100 + cuentaNum * 10, 122 + const codigoCuenta = `${cuentaPrefix}0`;
123 + return {
124 + rubro: codigoCuenta,
125 + desc_rubro: subcuentas[0]?.desc_rubro?.split(',')[0] || `Cuenta ${codigoCuenta}`,
82 nivel: 'cuenta', 126 nivel: 'cuenta',
83 - tipo: tipoNum, 127 + _sintetica: true,
84 - clase: claseNum, 128 + subcuentas
85 - cuenta: cuentaNum,
86 - desc_cuenta: subcuentas[0]?.desc_cuenta || `Cuenta ${cuentaNum}`,
87 - desc_tipo: subcuentas[0]?.desc_tipo,
88 - desc_clase: subcuentas[0]?.desc_clase,
89 - n_variaciones: null,
90 - descripciones: null
91 }; 129 };
130 + });
92 131
93 - return { ...cuentaData, subcuentas }; 132 + // Asignar subcuentas a cuentas existentes
133 + const cuentas = cuentasUnicas.map(cuenta => {
134 + const cuentaPrefix = getCuentaFromRubro(cuenta.rubro);
135 + const subcuentas = todasSubcuentas
136 + .filter(item => getCuentaFromRubro(item.rubro) === cuentaPrefix)
137 + .sort((a, b) => a.rubro.localeCompare(b.rubro));
138 +
139 + return { ...cuenta, subcuentas };
94 }); 140 });
95 141
96 - // Si existe entrada de clase, usarla; si no, crear una virtual 142 + // Combinar cuentas existentes con sintéticas y ordenar
97 - const claseData = claseEntry || { 143 + const todasCuentas = [...cuentas, ...cuentasSinteticas]
98 - rubro: tipoNum * 1000 + claseNum * 100, 144 + .sort((a, b) => a.rubro.localeCompare(b.rubro));
99 - nivel: 'clase', 145 +
100 - tipo: tipoNum, 146 + return { ...clase, cuentas: todasCuentas };
101 - clase: claseNum,
102 - desc_clase: itemsDeClase[0]?.desc_clase || `Clase ${claseNum}`,
103 - desc_tipo: itemsDeClase[0]?.desc_tipo,
104 - n_variaciones: null,
105 - descripciones: null
106 - };
107 -
108 - return { ...claseData, cuentas };
109 }); 147 });
110 148
111 return { clases }; 149 return { clases };
...@@ -119,8 +157,7 @@ ...@@ -119,8 +157,7 @@
119 return allItems 157 return allItems
120 .filter(item => 158 .filter(item =>
121 item.rubro?.toString().includes(q) || 159 item.rubro?.toString().includes(q) ||
122 - item.desc_rubros?.toLowerCase().includes(q) || 160 + item.desc_rubro?.toLowerCase().includes(q)
123 - item.descripciones?.toLowerCase().includes(q)
124 ) 161 )
125 .reduce((acc, item) => { 162 .reduce((acc, item) => {
126 if (!acc.find(i => i.rubro === item.rubro)) { 163 if (!acc.find(i => i.rubro === item.rubro)) {
...@@ -134,7 +171,10 @@ ...@@ -134,7 +171,10 @@
134 function parseDescripciones(descripcionesStr) { 171 function parseDescripciones(descripcionesStr) {
135 if (!descripcionesStr) return []; 172 if (!descripcionesStr) return [];
136 try { 173 try {
137 - const parsed = JSON.parse(descripcionesStr); 174 + // Si ya es objeto, usarlo directamente
175 + const parsed = typeof descripcionesStr === 'string'
176 + ? JSON.parse(descripcionesStr)
177 + : descripcionesStr;
138 return parsed.sort((a, b) => { 178 return parsed.sort((a, b) => {
139 const maxYearA = getMaxYear(a.rangos); 179 const maxYearA = getMaxYear(a.rangos);
140 const maxYearB = getMaxYear(b.rangos); 180 const maxYearB = getMaxYear(b.rangos);
...@@ -169,8 +209,8 @@ ...@@ -169,8 +209,8 @@
169 } 209 }
170 210
171 function goToSearchResult(item) { 211 function goToSearchResult(item) {
172 - const tipoNum = item.tipo; 212 + const tipoNum = getTipoFromRubro(item.rubro);
173 - const targetTipo = tipos.find(t => Math.floor(t.rubro / 1000) === tipoNum); 213 + const targetTipo = tipos.find(t => getTipoFromRubro(t.rubro) === tipoNum);
174 214
175 if (targetTipo) { 215 if (targetTipo) {
176 selectedTipo = targetTipo; 216 selectedTipo = targetTipo;
...@@ -186,7 +226,6 @@ ...@@ -186,7 +226,6 @@
186 } 226 }
187 }, 100); 227 }, 100);
188 228
189 - // Limpiar highlight después de la animación (2 pulsos de 0.8s = 1.6s + margen)
190 setTimeout(() => { 229 setTimeout(() => {
191 highlightedItem = null; 230 highlightedItem = null;
192 }, 2000); 231 }, 2000);
...@@ -198,15 +237,6 @@ ...@@ -198,15 +237,6 @@
198 return labels[nivel] || nivel; 237 return labels[nivel] || nivel;
199 } 238 }
200 239
201 - // Obtener la descripción correcta según el nivel
202 - function getDescripcion(item) {
203 - if (item.nivel === 'tipo') return item.desc_tipo;
204 - if (item.nivel === 'clase') return item.desc_clase;
205 - if (item.nivel === 'cuenta') return item.desc_cuenta;
206 - if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta;
207 - return item.desc_rubros;
208 - }
209 -
210 let tipoContent = $derived(selectedTipo ? getItemsForTipo(selectedTipo.rubro) : { clases: [] }); 240 let tipoContent = $derived(selectedTipo ? getItemsForTipo(selectedTipo.rubro) : { clases: [] });
211 let searchResults = $derived(searchGlobal(searchQuery)); 241 let searchResults = $derived(searchGlobal(searchQuery));
212 let isSearching = $derived(searchQuery.length >= 2); 242 let isSearching = $derived(searchQuery.length >= 2);
...@@ -307,16 +337,15 @@ ...@@ -307,16 +337,15 @@
307 <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 337 <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
308 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> 338 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
309 </svg> 339 </svg>
310 - <span class="font-medium">{selectedTipo ? getDescripcion(selectedTipo) : 'Seleccionar tipo'}</span> 340 + <span class="font-medium">{selectedTipo ? selectedTipo.desc_rubro : 'Seleccionar tipo'}</span>
311 <svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 341 <svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
312 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> 342 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
313 </svg> 343 </svg>
314 </button> 344 </button>
315 </div> 345 </div>
316 346
317 - <div class="max-w-screen-xl mx-auto flex px-4"> 347 + <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
318 <!-- Sidebar izquierda: Tipos --> 348 <!-- Sidebar izquierda: Tipos -->
319 - <!-- En móvil: overlay, en desktop: sidebar fijo -->
320 {#if sidebarOpen} 349 {#if sidebarOpen}
321 <div 350 <div
322 transition:fade={{ duration: 150 }} 351 transition:fade={{ duration: 150 }}
...@@ -373,7 +402,7 @@ ...@@ -373,7 +402,7 @@
373 > 402 >
374 <span class="text-xs block" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span> 403 <span class="text-xs block" style="color: var(--theme-texto);">{getNivelLabel(result.nivel)}</span>
375 <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.rubro}</span> 404 <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.rubro}</span>
376 - <span class="ml-1">{getDescripcion(result)}</span> 405 + <span class="ml-1">{result.desc_rubro}</span>
377 </button> 406 </button>
378 {/each} 407 {/each}
379 {#if searchResults.length === 0} 408 {#if searchResults.length === 0}
...@@ -396,7 +425,7 @@ ...@@ -396,7 +425,7 @@
396 onclick={() => selectTipo(tipo)} 425 onclick={() => selectTipo(tipo)}
397 > 426 >
398 <span class="font-mono text-xs block" style="color: var(--theme-texto);">{tipo.rubro}</span> 427 <span class="font-mono text-xs block" style="color: var(--theme-texto);">{tipo.rubro}</span>
399 - {getDescripcion(tipo)} 428 + {tipo.desc_rubro}
400 </button> 429 </button>
401 </li> 430 </li>
402 {/each} 431 {/each}
...@@ -416,7 +445,7 @@ ...@@ -416,7 +445,7 @@
416 <div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);"> 445 <div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
417 <p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">{selectedTipo.rubro}</p> 446 <p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">{selectedTipo.rubro}</p>
418 <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);"> 447 <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);">
419 - {getDescripcion(selectedTipo)} 448 + {selectedTipo.desc_rubro}
420 <a 449 <a
421 href="/rubro/{selectedTipo.rubro}" 450 href="/rubro/{selectedTipo.rubro}"
422 class="transition-colors hover:text-[var(--theme-accent)]" 451 class="transition-colors hover:text-[var(--theme-accent)]"
...@@ -442,6 +471,12 @@ ...@@ -442,6 +471,12 @@
442 {/if} 471 {/if}
443 {/if} 472 {/if}
444 {/if} 473 {/if}
474 + <!-- Vigencia temporal -->
475 + {#if selectedTipo.gestiones}
476 + <p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
477 + Vigente: {selectedTipo.gestiones}
478 + </p>
479 + {/if}
445 </div> 480 </div>
446 481
447 <!-- Clases --> 482 <!-- Clases -->
...@@ -456,7 +491,7 @@ ...@@ -456,7 +491,7 @@
456 <span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{clase.rubro}</span> 491 <span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{clase.rubro}</span>
457 <div class="flex-1"> 492 <div class="flex-1">
458 <h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);"> 493 <h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
459 - <span class="text-left">{getDescripcion(clase)}</span> 494 + <span class="text-left">{clase.desc_rubro}</span>
460 {#if clase.n_variaciones > 1} 495 {#if clase.n_variaciones > 1}
461 <button 496 <button
462 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" 497 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 @@ ...@@ -496,7 +531,7 @@
496 <span class="font-mono text-xs pt-0.5" style="color: var(--theme-texto);">{cuenta.rubro}</span> 531 <span class="font-mono text-xs pt-0.5" style="color: var(--theme-texto);">{cuenta.rubro}</span>
497 <div class="flex-1"> 532 <div class="flex-1">
498 <h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);"> 533 <h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
499 - <span class="text-left">{getDescripcion(cuenta)}</span> 534 + <span class="text-left">{cuenta.desc_rubro}</span>
500 {#if cuenta.n_variaciones > 1} 535 {#if cuenta.n_variaciones > 1}
501 <button 536 <button
502 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" 537 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 @@ ...@@ -536,7 +571,7 @@
536 <span class="font-mono text-xs" style="color: var(--theme-texto); opacity: 0.7;">{subcuenta.rubro}</span> 571 <span class="font-mono text-xs" style="color: var(--theme-texto); opacity: 0.7;">{subcuenta.rubro}</span>
537 <div class="flex-1"> 572 <div class="flex-1">
538 <span class="flex items-center gap-2 flex-wrap"> 573 <span class="flex items-center gap-2 flex-wrap">
539 - <span class="text-xs" style="color: var(--theme-titulo);">{getDescripcion(subcuenta)}</span> 574 + <span class="text-xs" style="color: var(--theme-titulo);">{subcuenta.desc_rubro}</span>
540 {#if subcuenta.n_variaciones > 1} 575 {#if subcuenta.n_variaciones > 1}
541 <button 576 <button
542 class="text-xs text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer" 577 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 @@ ...@@ -591,9 +626,9 @@
591 href="#cl-{clase.rubro}" 626 href="#cl-{clase.rubro}"
592 class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]" 627 class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
593 style="color: var(--theme-texto);" 628 style="color: var(--theme-texto);"
594 - title="{getDescripcion(clase)}" 629 + title="{clase.desc_rubro}"
595 > 630 >
596 - {getDescripcion(clase)} 631 + {clase.desc_rubro}
597 </a> 632 </a>
598 {#if clase.cuentas?.length > 0} 633 {#if clase.cuentas?.length > 0}
599 <div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);"> 634 <div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);">
...@@ -602,9 +637,9 @@ ...@@ -602,9 +637,9 @@
602 href="#cu-{cuenta.rubro}" 637 href="#cu-{cuenta.rubro}"
603 class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]" 638 class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]"
604 style="color: var(--theme-texto); opacity: 0.7;" 639 style="color: var(--theme-texto); opacity: 0.7;"
605 - title="{getDescripcion(cuenta)}" 640 + title="{cuenta.desc_rubro}"
606 > 641 >
607 - {getDescripcion(cuenta)} 642 + {cuenta.desc_rubro}
608 </a> 643 </a>
609 {/each} 644 {/each}
610 {#if clase.cuentas.length > 5} 645 {#if clase.cuentas.length > 5}
...@@ -641,7 +676,7 @@ ...@@ -641,7 +676,7 @@
641 <h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);"> 676 <h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
642 <span class="font-mono" style="color: var(--theme-texto);">{selectedItem.rubro}</span> 677 <span class="font-mono" style="color: var(--theme-texto);">{selectedItem.rubro}</span>
643 <span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span> 678 <span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
644 - {getDescripcion(selectedItem)} 679 + {selectedItem.desc_rubro}
645 </h3> 680 </h3>
646 </div> 681 </div>
647 <button 682 <button
...@@ -676,6 +711,15 @@ ...@@ -676,6 +711,15 @@
676 </div> 711 </div>
677 {/each} 712 {/each}
678 </div> 713 </div>
714 +
715 + <!-- Vigencia temporal -->
716 + {#if selectedItem.gestiones}
717 + <div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
718 + <p class="text-xs font-mono" style="color: var(--theme-texto);">
719 + <span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
720 + </p>
721 + </div>
722 + {/if}
679 </div> 723 </div>
680 </div> 724 </div>
681 </div> 725 </div>
...@@ -721,16 +765,32 @@ ...@@ -721,16 +765,32 @@
721 margin-left: -0.5rem; 765 margin-left: -0.5rem;
722 } 766 }
723 767
724 - /* Transiciones estandarizadas */ 768 + /* Layout principal - fallback nativo */
725 - :global(.transition-fast) { 769 + .clasificador-layout {
726 - transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); 770 + display: flex !important;
771 + flex-direction: row !important;
772 + }
773 +
774 + /* Sidebar - fallback para responsive */
775 + .sidebar-left {
776 + position: fixed;
777 + transform: translateX(-100%);
727 } 778 }
728 779
729 - :global(.transition-normal) { 780 + @media (min-width: 1024px) {
730 - transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 781 + .sidebar-left {
782 + position: relative !important;
783 + transform: translateX(0) !important;
784 + flex-shrink: 0;
785 + width: 16rem;
786 + z-index: auto !important;
787 + box-shadow: none !important;
788 + }
731 } 789 }
732 790
733 - :global(.transition-slow) { 791 + @media (max-width: 1023px) {
734 - transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1); 792 + .clasificador-layout {
793 + display: block !important;
794 + }
735 } 795 }
736 </style> 796 </style>
......
1 <script> 1 <script>
2 - import { onMount } from 'svelte'; 2 + import { onMount, tick } from 'svelte';
3 import { marked } from 'marked'; 3 import { marked } from 'marked';
4 4
5 + // Search state
6 + let searchQuery = $state('');
7 + let searchResults = $state([]);
8 + let showSearchResults = $state(false);
9 + let selectedSearchIndex = $state(-1);
10 + let searchInputRef = $state(null);
11 + let searchableItems = $state([]);
12 + let isMac = $state(false);
13 +
5 // Markdown content embedded directly 14 // Markdown content embedded directly
6 const MARKDOWN_CONTENT = `# Introducción 15 const MARKDOWN_CONTENT = `# Introducción
7 16
...@@ -163,7 +172,7 @@ Descripción de la subárea a la que pertenece la entidad pública. ...@@ -163,7 +172,7 @@ Descripción de la subárea a la que pertenece la entidad pública.
163 172
164 Tipo: Texto descriptivo. 173 Tipo: Texto descriptivo.
165 174
166 -Ejemplo: \`Administración Central\`. 175 +Ejemplo: \`Gobiernos Autónomos Departamentales\`.
167 176
168 #### entidad_sigla_sector 177 #### entidad_sigla_sector
169 178
...@@ -740,7 +749,7 @@ Descripción de la subárea a la que pertenece la entidad pública. ...@@ -740,7 +749,7 @@ Descripción de la subárea a la que pertenece la entidad pública.
740 749
741 Tipo: Texto descriptivo. 750 Tipo: Texto descriptivo.
742 751
743 -Ejemplo: \`Administración Central\`. 752 +Ejemplo: \`Gobiernos Autónomos Departamentales\`.
744 753
745 #### entidad_sigla_sector 754 #### entidad_sigla_sector
746 755
...@@ -1030,11 +1039,17 @@ Ejemplo: \`5637823.65\`. ...@@ -1030,11 +1039,17 @@ Ejemplo: \`5637823.65\`.
1030 } 1039 }
1031 1040
1032 onMount(async () => { 1041 onMount(async () => {
1042 + // Detect Mac for keyboard shortcut display
1043 + isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
1044 +
1033 try { 1045 try {
1034 const result = processMarkdown(MARKDOWN_CONTENT); 1046 const result = processMarkdown(MARKDOWN_CONTENT);
1035 content = result.html; 1047 content = result.html;
1036 headings = buildHierarchy(result.headings); 1048 headings = buildHierarchy(result.headings);
1037 1049
1050 + // Extract searchable items (variable names and descriptions)
1051 + searchableItems = extractSearchableItems(MARKDOWN_CONTENT);
1052 +
1038 // Expand level 1 by default 1053 // Expand level 1 by default
1039 const initialExpanded = new Set(); 1054 const initialExpanded = new Set();
1040 result.headings.filter(h => h.level === 1).forEach(h => initialExpanded.add(h.id)); 1055 result.headings.filter(h => h.level === 1).forEach(h => initialExpanded.add(h.id));
...@@ -1152,12 +1167,162 @@ Ejemplo: \`5637823.65\`. ...@@ -1152,12 +1167,162 @@ Ejemplo: \`5637823.65\`.
1152 } 1167 }
1153 return false; 1168 return false;
1154 } 1169 }
1170 +
1171 + // Extract searchable items from markdown (h4 = variable names)
1172 + function extractSearchableItems(markdown) {
1173 + const items = [];
1174 + // Split by h4 headings (#### varname)
1175 + const sections = markdown.split(/(?=####\s+)/);
1176 +
1177 + for (const section of sections) {
1178 + const h4Match = section.match(/^####\s+(\w+)\s*\n\n([\s\S]*?)(?=\n####|\n###|\n##|\n#|$)/);
1179 + if (h4Match) {
1180 + const varName = h4Match[1];
1181 + const description = h4Match[2].trim()
1182 + .split('\n\n')[0] // Get first paragraph
1183 + .replace(/\n/g, ' ')
1184 + .substring(0, 200); // Limit description length
1185 +
1186 + // Find the section it belongs to (look backwards for ### heading)
1187 + const fullMarkdownUpToThis = markdown.substring(0, markdown.indexOf(`#### ${varName}`));
1188 + const h3Matches = fullMarkdownUpToThis.match(/###\s+(.+)/g);
1189 + const section = h3Matches ? h3Matches[h3Matches.length - 1].replace('### ', '') : '';
1190 +
1191 + items.push({
1192 + varName,
1193 + description,
1194 + section,
1195 + searchText: `${varName} ${description} ${section}`.toLowerCase()
1196 + });
1197 + }
1198 + }
1199 + return items;
1200 + }
1201 +
1202 + // Search function with debounce effect
1203 + function performSearch(query) {
1204 + if (!query || query.length < 2) {
1205 + searchResults = [];
1206 + showSearchResults = false;
1207 + return;
1208 + }
1209 +
1210 + const queryLower = query.toLowerCase().trim();
1211 + const words = queryLower.split(/\s+/).filter(w => w.length > 0);
1212 +
1213 + const results = searchableItems
1214 + .filter(item => {
1215 + // All words must match somewhere
1216 + return words.every(word => item.searchText.includes(word));
1217 + })
1218 + .slice(0, 20); // Limit results
1219 +
1220 + searchResults = results;
1221 + showSearchResults = results.length > 0;
1222 + selectedSearchIndex = -1;
1223 + }
1224 +
1225 + // Handle search input
1226 + function onSearchInput(e) {
1227 + searchQuery = e.target.value;
1228 + performSearch(searchQuery);
1229 + }
1230 +
1231 + // Handle keyboard navigation in search
1232 + function onSearchKeydown(e) {
1233 + if (!showSearchResults) return;
1234 +
1235 + if (e.key === 'ArrowDown') {
1236 + e.preventDefault();
1237 + selectedSearchIndex = Math.min(selectedSearchIndex + 1, searchResults.length - 1);
1238 + } else if (e.key === 'ArrowUp') {
1239 + e.preventDefault();
1240 + selectedSearchIndex = Math.max(selectedSearchIndex - 1, -1);
1241 + } else if (e.key === 'Enter' && selectedSearchIndex >= 0) {
1242 + e.preventDefault();
1243 + goToSearchResult(searchResults[selectedSearchIndex]);
1244 + } else if (e.key === 'Escape') {
1245 + e.preventDefault();
1246 + closeSearch();
1247 + }
1248 + }
1249 +
1250 + // Navigate to search result
1251 + function goToSearchResult(result) {
1252 + // Find the heading ID that matches this variable name
1253 + const slug = result.varName
1254 + .toLowerCase()
1255 + .normalize('NFD')
1256 + .replace(/[\u0300-\u036f]/g, '')
1257 + .replace(/[^\w\s-]/g, '')
1258 + .replace(/\s+/g, '-');
1259 +
1260 + // Find the element by looking for h4 with this text
1261 + const h4Elements = document.querySelectorAll('h4.heading-anchor');
1262 + for (const h4 of h4Elements) {
1263 + if (h4.textContent.trim() === result.varName) {
1264 + // Expand parents in sidebar
1265 + const id = h4.id;
1266 + const parentIds = findParentIds(id, headings);
1267 + if (parentIds && parentIds.length > 0) {
1268 + const newExpanded = new Set(expandedSections);
1269 + parentIds.forEach(pid => newExpanded.add(pid));
1270 + expandedSections = newExpanded;
1271 + }
1272 +
1273 + // Scroll to element
1274 + setTimeout(() => {
1275 + h4.scrollIntoView({ behavior: 'smooth', block: 'start' });
1276 + activeId = id;
1277 + }, 50);
1278 +
1279 + closeSearch();
1280 + return;
1281 + }
1282 + }
1283 + }
1284 +
1285 + // Close search dropdown
1286 + function closeSearch() {
1287 + showSearchResults = false;
1288 + selectedSearchIndex = -1;
1289 + searchQuery = '';
1290 + }
1291 +
1292 + // Handle click outside search
1293 + function handleClickOutside(e) {
1294 + if (searchInputRef && !searchInputRef.contains(e.target)) {
1295 + showSearchResults = false;
1296 + }
1297 + }
1298 +
1299 + // Highlight matching text
1300 + function highlightMatch(text, query) {
1301 + if (!query || query.length < 2) return text;
1302 + const words = query.toLowerCase().split(/\s+/).filter(w => w.length > 0);
1303 + let result = text;
1304 + for (const word of words) {
1305 + const regex = new RegExp(`(${word})`, 'gi');
1306 + result = result.replace(regex, '<mark>$1</mark>');
1307 + }
1308 + return result;
1309 + }
1310 +
1311 + // Keyboard shortcut to focus search (Ctrl/Cmd + K)
1312 + function handleGlobalKeydown(e) {
1313 + if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
1314 + e.preventDefault();
1315 + searchInputRef?.querySelector('input')?.focus();
1316 + }
1317 + }
1155 </script> 1318 </script>
1156 1319
1157 <svelte:head> 1320 <svelte:head>
1158 <title>Documentación | Presupuesto Público Bolivia</title> 1321 <title>Documentación | Presupuesto Público Bolivia</title>
1159 </svelte:head> 1322 </svelte:head>
1160 1323
1324 +<svelte:window onkeydown={handleGlobalKeydown} onclick={handleClickOutside} />
1325 +
1161 <div class="docs-page"> 1326 <div class="docs-page">
1162 <header class="docs-header"> 1327 <header class="docs-header">
1163 <div class="docs-header-content"> 1328 <div class="docs-header-content">
...@@ -1179,6 +1344,51 @@ Ejemplo: \`5637823.65\`. ...@@ -1179,6 +1344,51 @@ Ejemplo: \`5637823.65\`.
1179 </div> 1344 </div>
1180 <h1 class="docs-title">Documentación</h1> 1345 <h1 class="docs-title">Documentación</h1>
1181 <p class="docs-subtitle">Estructura de las bases de datos del presupuesto público</p> 1346 <p class="docs-subtitle">Estructura de las bases de datos del presupuesto público</p>
1347 +
1348 + <!-- Search bar -->
1349 + <div class="docs-search-container" bind:this={searchInputRef}>
1350 + <div class="docs-search-input-wrapper">
1351 + <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">
1352 + <circle cx="11" cy="11" r="8"/>
1353 + <path d="m21 21-4.3-4.3"/>
1354 + </svg>
1355 + <input
1356 + type="text"
1357 + class="docs-search-input"
1358 + placeholder="Buscar variables..."
1359 + value={searchQuery}
1360 + oninput={onSearchInput}
1361 + onkeydown={onSearchKeydown}
1362 + onfocus={() => searchQuery.length >= 2 && performSearch(searchQuery)}
1363 + />
1364 + <span class="docs-search-shortcut">
1365 + <kbd>{isMac ? '\u2318' : 'Ctrl'}</kbd><kbd>K</kbd>
1366 + </span>
1367 + </div>
1368 +
1369 + {#if showSearchResults}
1370 + <div class="docs-search-results">
1371 + {#if searchResults.length === 0}
1372 + <div class="docs-search-no-results">
1373 + No se encontraron resultados
1374 + </div>
1375 + {:else}
1376 + {#each searchResults as result, index}
1377 + <button
1378 + class="docs-search-result"
1379 + class:selected={index === selectedSearchIndex}
1380 + onclick={() => goToSearchResult(result)}
1381 + onmouseenter={() => selectedSearchIndex = index}
1382 + >
1383 + <span class="docs-search-result-var">{@html highlightMatch(result.varName, searchQuery)}</span>
1384 + <span class="docs-search-result-section">{result.section}</span>
1385 + <span class="docs-search-result-desc">{@html highlightMatch(result.description.substring(0, 80), searchQuery)}{result.description.length > 80 ? '...' : ''}</span>
1386 + </button>
1387 + {/each}
1388 + {/if}
1389 + </div>
1390 + {/if}
1391 + </div>
1182 </div> 1392 </div>
1183 </header> 1393 </header>
1184 1394
...@@ -1834,4 +2044,158 @@ Ejemplo: \`5637823.65\`. ...@@ -1834,4 +2044,158 @@ Ejemplo: \`5637823.65\`.
1834 padding: 0.75rem; 2044 padding: 0.75rem;
1835 } 2045 }
1836 } 2046 }
2047 +
2048 + /* Search styles */
2049 + .docs-search-container {
2050 + position: relative;
2051 + margin-top: 1rem;
2052 + }
2053 +
2054 + .docs-search-input-wrapper {
2055 + position: relative;
2056 + display: flex;
2057 + align-items: center;
2058 + }
2059 +
2060 + .docs-search-icon {
2061 + position: absolute;
2062 + left: 12px;
2063 + color: var(--theme-texto);
2064 + opacity: 0.5;
2065 + pointer-events: none;
2066 + }
2067 +
2068 + .docs-search-input {
2069 + width: 100%;
2070 + padding: 0.625rem 3.5rem 0.625rem 2.5rem;
2071 + font-size: 0.9375rem;
2072 + font-family: inherit;
2073 + background: var(--theme-fill);
2074 + border: 1px solid var(--theme-borde);
2075 + border-radius: 8px;
2076 + color: var(--theme-titulo);
2077 + outline: none;
2078 + transition: border-color 0.15s ease, box-shadow 0.15s ease;
2079 + }
2080 +
2081 + .docs-search-input::placeholder {
2082 + color: var(--theme-texto);
2083 + opacity: 0.6;
2084 + }
2085 +
2086 + .docs-search-input:focus {
2087 + border-color: var(--theme-accent);
2088 + box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 15%, transparent);
2089 + }
2090 +
2091 + .docs-search-shortcut {
2092 + position: absolute;
2093 + right: 12px;
2094 + top: 50%;
2095 + transform: translateY(-50%);
2096 + display: flex;
2097 + gap: 4px;
2098 + pointer-events: none;
2099 + z-index: 5;
2100 + }
2101 +
2102 + .docs-search-shortcut kbd {
2103 + font-family: system-ui, -apple-system, sans-serif;
2104 + font-size: 11px;
2105 + font-weight: 500;
2106 + padding: 3px 7px;
2107 + background: #2a2a2a;
2108 + border: 1px solid #444;
2109 + border-radius: 5px;
2110 + color: #aaa;
2111 + box-shadow: 0 1px 2px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.05);
2112 + line-height: 1;
2113 + }
2114 +
2115 + :global(html:not(.dark)) .docs-search-shortcut kbd {
2116 + background: #f5f5f5;
2117 + border-color: #d0d0d0;
2118 + color: #666;
2119 + box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8);
2120 + }
2121 +
2122 + @media (max-width: 640px) {
2123 + .docs-search-shortcut {
2124 + display: none;
2125 + }
2126 + }
2127 +
2128 + .docs-search-results {
2129 + position: absolute;
2130 + top: calc(100% + 6px);
2131 + left: 0;
2132 + right: 0;
2133 + background: var(--theme-body);
2134 + border: 1px solid var(--theme-borde);
2135 + border-radius: 10px;
2136 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
2137 + max-height: 400px;
2138 + overflow-y: auto;
2139 + z-index: 100;
2140 + }
2141 +
2142 + .docs-search-no-results {
2143 + padding: 1rem;
2144 + text-align: center;
2145 + color: var(--theme-texto);
2146 + font-size: 0.875rem;
2147 + }
2148 +
2149 + .docs-search-result {
2150 + display: flex;
2151 + flex-direction: column;
2152 + align-items: flex-start;
2153 + gap: 4px;
2154 + width: 100%;
2155 + padding: 0.75rem 1rem;
2156 + background: none;
2157 + border: none;
2158 + border-bottom: 1px solid var(--theme-borde);
2159 + cursor: pointer;
2160 + text-align: left;
2161 + transition: background 0.1s ease;
2162 + }
2163 +
2164 + .docs-search-result:last-child {
2165 + border-bottom: none;
2166 + }
2167 +
2168 + .docs-search-result:hover,
2169 + .docs-search-result.selected {
2170 + background: var(--theme-fill);
2171 + }
2172 +
2173 + .docs-search-result-var {
2174 + font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
2175 + font-size: 0.9375rem;
2176 + font-weight: 600;
2177 + color: var(--theme-accent);
2178 + }
2179 +
2180 + .docs-search-result-section {
2181 + font-size: 0.6875rem;
2182 + font-weight: 500;
2183 + text-transform: uppercase;
2184 + letter-spacing: 0.04em;
2185 + color: var(--theme-texto);
2186 + opacity: 0.7;
2187 + }
2188 +
2189 + .docs-search-result-desc {
2190 + font-size: 0.8125rem;
2191 + color: var(--theme-texto);
2192 + line-height: 1.4;
2193 + }
2194 +
2195 + .docs-search-result :global(mark) {
2196 + background: color-mix(in srgb, var(--theme-accent) 25%, transparent);
2197 + color: inherit;
2198 + border-radius: 2px;
2199 + padding: 0 2px;
2200 + }
1837 </style> 2201 </style>
......
1 +import { supabase } from '$lib/supabase';
2 +import { error } from '@sveltejs/kit';
3 +
4 +// Funciones para derivar jerarquía del código finfun
5 +// Jerarquía: Finalidad (1 dígito) → Grupo Función (2 dígitos) → Función (3+ dígitos)
6 +function getFinalidadCode(finfun) {
7 + return finfun.charAt(0);
8 +}
9 +
10 +function getGrpFuncionCode(finfun) {
11 + return finfun.substring(0, 2);
12 +}
13 +
14 +function getNivel(finfun) {
15 + if (finfun.length === 1) return 'finalidad';
16 + if (finfun.length === 2) return 'grpfuncion';
17 + return 'funcion';
18 +}
19 +
20 +export async function load({ params }) {
21 + console.time('[SERVER] Total load finfun');
22 + const { codigo } = params;
23 +
24 + console.time('[SERVER] Query clas_finfun');
25 + const { data, error: dbError } = await supabase
26 + .schema('ppto')
27 + .from('clas_finfun')
28 + .select('*')
29 + .eq('finfun', codigo);
30 + console.timeEnd('[SERVER] Query clas_finfun');
31 +
32 + if (dbError || !data || data.length === 0) {
33 + throw error(404, 'Finalidad/Función no encontrada');
34 + }
35 +
36 + // Tomar el primer resultado
37 + const finfun = data[0];
38 + const nivel = finfun.nivel || getNivel(codigo);
39 +
40 + // Obtener jerarquía (padres e hijos)
41 + let padres = [];
42 + let hijos = [];
43 +
44 + console.time('[SERVER] Query padres');
45 +
46 + // Buscar padres según nivel
47 + if (nivel === 'funcion') {
48 + // Padres: finalidad y grupo función
49 + const finalidadCode = getFinalidadCode(codigo);
50 + const grpFuncionCode = getGrpFuncionCode(codigo);
51 +
52 + const { data: padresData } = await supabase
53 + .schema('ppto')
54 + .from('clas_finfun')
55 + .select('*')
56 + .in('finfun', [finalidadCode, grpFuncionCode])
57 + .order('finfun');
58 +
59 + if (padresData) padres = padresData;
60 + } else if (nivel === 'grpfuncion') {
61 + // Padre: finalidad
62 + const finalidadCode = getFinalidadCode(codigo);
63 +
64 + const { data: padresData } = await supabase
65 + .schema('ppto')
66 + .from('clas_finfun')
67 + .select('*')
68 + .eq('finfun', finalidadCode);
69 +
70 + if (padresData) padres = padresData;
71 + }
72 + // finalidad no tiene padres
73 +
74 + console.timeEnd('[SERVER] Query padres');
75 +
76 + console.time('[SERVER] Query hijos');
77 +
78 + // Buscar hijos según nivel
79 + if (nivel === 'finalidad') {
80 + // Hijos: grupos de función que empiecen con el mismo dígito
81 + const { data: hijosData } = await supabase
82 + .schema('ppto')
83 + .from('clas_finfun')
84 + .select('*')
85 + .eq('nivel', 'grpfuncion')
86 + .like('finfun', `${codigo}%`)
87 + .order('finfun');
88 +
89 + hijos = hijosData || [];
90 + } else if (nivel === 'grpfuncion') {
91 + // Hijos: funciones que empiecen con los mismos 2 dígitos
92 + const { data: hijosData } = await supabase
93 + .schema('ppto')
94 + .from('clas_finfun')
95 + .select('*')
96 + .eq('nivel', 'funcion')
97 + .like('finfun', `${codigo}%`)
98 + .order('finfun');
99 +
100 + hijos = hijosData || [];
101 + }
102 + // funcion no tiene hijos
103 +
104 + console.timeEnd('[SERVER] Query hijos');
105 +
106 + console.timeEnd('[SERVER] Total load finfun');
107 + return {
108 + finfun,
109 + padres,
110 + hijos
111 + };
112 +}
This diff could not be displayed because it is too large.
1 import { supabase } from '$lib/supabase'; 1 import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 2 import { error } from '@sveltejs/kit';
3 3
4 +// Funciones para derivar jerarquía del código objeto
5 +function getGrupoCode(objeto) {
6 + return objeto.charAt(0) + '0000';
7 +}
8 +
9 +function getSubgrupoCode(objeto) {
10 + return objeto.substring(0, 2) + '000';
11 +}
12 +
13 +function getPartidaCode(objeto) {
14 + return objeto.substring(0, 3) + '00';
15 +}
16 +
17 +function getNivel(objeto) {
18 + if (objeto.endsWith('0000')) return 'grupo';
19 + if (objeto.endsWith('000')) return 'subgrupo';
20 + if (objeto.endsWith('00')) return 'partida';
21 + return 'subpartida';
22 +}
23 +
4 export async function load({ params }) { 24 export async function load({ params }) {
5 console.time('[SERVER] Total load objeto'); 25 console.time('[SERVER] Total load objeto');
6 const { codigo } = params; 26 const { codigo } = params;
...@@ -17,95 +37,102 @@ export async function load({ params }) { ...@@ -17,95 +37,102 @@ export async function load({ params }) {
17 throw error(404, 'Objeto no encontrado'); 37 throw error(404, 'Objeto no encontrado');
18 } 38 }
19 39
20 - // Tomar el primer resultado (puede haber duplicados) 40 + // Tomar el primer resultado
21 const objeto = data[0]; 41 const objeto = data[0];
42 + const nivel = objeto.nivel || getNivel(codigo);
22 43
23 // Obtener jerarquía (padres e hijos) 44 // Obtener jerarquía (padres e hijos)
24 let padres = []; 45 let padres = [];
25 let hijos = []; 46 let hijos = [];
26 47
27 console.time('[SERVER] Query padres'); 48 console.time('[SERVER] Query padres');
28 - // Buscar padre según nivel 49 +
29 - if (objeto.nivel === 'subpartida') { 50 + // Buscar padres según nivel
30 - // Padre es partida 51 + if (nivel === 'subpartida') {
31 - const { data: padreData } = await supabase 52 + // Padres: grupo, subgrupo, partida
53 + const grupoCode = getGrupoCode(codigo);
54 + const subgrupoCode = getSubgrupoCode(codigo);
55 + const partidaCode = getPartidaCode(codigo);
56 +
57 + const { data: padresData } = await supabase
32 .schema('ppto') 58 .schema('ppto')
33 .from('clas_objetos') 59 .from('clas_objetos')
34 .select('*') 60 .select('*')
35 - .eq('nivel', 'partida') 61 + .in('objeto', [grupoCode, subgrupoCode, partidaCode])
36 - .eq('grupo', objeto.grupo) 62 + .order('objeto');
37 - .eq('subgrupo', objeto.subgrupo)
38 - .eq('partida', objeto.partida);
39 - if (padreData?.length) padres.push(padreData[0]);
40 - }
41 63
42 - if (objeto.nivel === 'partida' || objeto.nivel === 'subpartida') { 64 + if (padresData) padres = padresData;
43 - // Padre es subgrupo 65 + } else if (nivel === 'partida') {
44 - const { data: padreData } = await supabase 66 + // Padres: grupo, subgrupo
67 + const grupoCode = getGrupoCode(codigo);
68 + const subgrupoCode = getSubgrupoCode(codigo);
69 +
70 + const { data: padresData } = await supabase
45 .schema('ppto') 71 .schema('ppto')
46 .from('clas_objetos') 72 .from('clas_objetos')
47 .select('*') 73 .select('*')
48 - .eq('nivel', 'subgrupo') 74 + .in('objeto', [grupoCode, subgrupoCode])
49 - .eq('grupo', objeto.grupo) 75 + .order('objeto');
50 - .eq('subgrupo', objeto.subgrupo); 76 +
51 - if (padreData?.length) padres.unshift(padreData[0]); 77 + if (padresData) padres = padresData;
52 - } 78 + } else if (nivel === 'subgrupo') {
79 + // Padre: grupo
80 + const grupoCode = getGrupoCode(codigo);
53 81
54 - if (objeto.nivel !== 'grupo') { 82 + const { data: padresData } = await supabase
55 - // Padre es grupo
56 - const grupoCodigo = objeto.grupo + '0000';
57 - const { data: padreData } = await supabase
58 .schema('ppto') 83 .schema('ppto')
59 .from('clas_objetos') 84 .from('clas_objetos')
60 .select('*') 85 .select('*')
61 - .eq('nivel', 'grupo') 86 + .eq('objeto', grupoCode);
62 - .eq('objeto', grupoCodigo); 87 +
63 - if (padreData?.length) padres.unshift(padreData[0]); 88 + if (padresData) padres = padresData;
64 } 89 }
90 + // grupo no tiene padres
91 +
65 console.timeEnd('[SERVER] Query padres'); 92 console.timeEnd('[SERVER] Query padres');
66 93
67 console.time('[SERVER] Query hijos'); 94 console.time('[SERVER] Query hijos');
95 +
68 // Buscar hijos según nivel 96 // Buscar hijos según nivel
69 - if (objeto.nivel === 'grupo') { 97 + if (nivel === 'grupo') {
98 + // Hijos: subgrupos que empiecen con el mismo dígito
99 + const prefix = codigo.charAt(0);
70 const { data: hijosData } = await supabase 100 const { data: hijosData } = await supabase
71 .schema('ppto') 101 .schema('ppto')
72 .from('clas_objetos') 102 .from('clas_objetos')
73 .select('*') 103 .select('*')
74 .eq('nivel', 'subgrupo') 104 .eq('nivel', 'subgrupo')
75 - .eq('grupo', objeto.grupo) 105 + .like('objeto', `${prefix}%`)
76 .order('objeto'); 106 .order('objeto');
77 - hijos = hijosData?.reduce((acc, item) => { 107 +
78 - if (!acc.find(h => h.objeto === item.objeto)) acc.push(item); 108 + hijos = hijosData || [];
79 - return acc; 109 + } else if (nivel === 'subgrupo') {
80 - }, []) || []; 110 + // Hijos: partidas que empiecen con los mismos 2 dígitos
81 - } else if (objeto.nivel === 'subgrupo') { 111 + const prefix = codigo.substring(0, 2);
82 const { data: hijosData } = await supabase 112 const { data: hijosData } = await supabase
83 .schema('ppto') 113 .schema('ppto')
84 .from('clas_objetos') 114 .from('clas_objetos')
85 .select('*') 115 .select('*')
86 .eq('nivel', 'partida') 116 .eq('nivel', 'partida')
87 - .eq('grupo', objeto.grupo) 117 + .like('objeto', `${prefix}%`)
88 - .eq('subgrupo', objeto.subgrupo)
89 .order('objeto'); 118 .order('objeto');
90 - hijos = hijosData?.reduce((acc, item) => { 119 +
91 - if (!acc.find(h => h.objeto === item.objeto)) acc.push(item); 120 + hijos = hijosData || [];
92 - return acc; 121 + } else if (nivel === 'partida') {
93 - }, []) || []; 122 + // Hijos: subpartidas que empiecen con los mismos 3 dígitos
94 - } else if (objeto.nivel === 'partida') { 123 + const prefix = codigo.substring(0, 3);
95 const { data: hijosData } = await supabase 124 const { data: hijosData } = await supabase
96 .schema('ppto') 125 .schema('ppto')
97 .from('clas_objetos') 126 .from('clas_objetos')
98 .select('*') 127 .select('*')
99 .eq('nivel', 'subpartida') 128 .eq('nivel', 'subpartida')
100 - .eq('grupo', objeto.grupo) 129 + .like('objeto', `${prefix}%`)
101 - .eq('subgrupo', objeto.subgrupo)
102 - .eq('partida', objeto.partida)
103 .order('objeto'); 130 .order('objeto');
104 - hijos = hijosData?.reduce((acc, item) => { 131 +
105 - if (!acc.find(h => h.objeto === item.objeto)) acc.push(item); 132 + hijos = hijosData || [];
106 - return acc;
107 - }, []) || [];
108 } 133 }
134 + // subpartida no tiene hijos
135 +
109 console.timeEnd('[SERVER] Query hijos'); 136 console.timeEnd('[SERVER] Query hijos');
110 137
111 console.timeEnd('[SERVER] Total load objeto'); 138 console.timeEnd('[SERVER] Total load objeto');
......
...@@ -357,12 +357,16 @@ ...@@ -357,12 +357,16 @@
357 // VALORES DERIVADOS 357 // VALORES DERIVADOS
358 // ══════════════════════════════════════════════════════════════ 358 // ══════════════════════════════════════════════════════════════
359 359
360 + // Constante de población para cálculos per cápita
361 + const POBLACION = 12000000;
362 +
360 // Datos para el gráfico (monto o per_capita según preferencia) 363 // Datos para el gráfico (monto o per_capita según preferencia)
364 + // Nota: vista_objeto_entidad solo tiene 'devengado', no 'per_capita'
361 let gastoPerCapita = $derived( 365 let gastoPerCapita = $derived(
362 datosAnuales.map(d => ({ 366 datosAnuales.map(d => ({
363 año: d.gestion, 367 año: d.gestion,
364 - monto: selectedEntity ? d.monto : d.total, 368 + monto: selectedEntity ? (d.monto ?? d.devengado) : d.total,
365 - perCapita: d.per_capita 369 + perCapita: d.per_capita ?? (d.devengado ? d.devengado / POBLACION : 0)
366 })) 370 }))
367 ); 371 );
368 372
...@@ -991,7 +995,7 @@ ...@@ -991,7 +995,7 @@
991 <div class="page" class:mounted> 995 <div class="page" class:mounted>
992 <!-- Link a clasificadores --> 996 <!-- Link a clasificadores -->
993 <nav class="page-nav"> 997 <nav class="page-nav">
994 - <a href="/clasificadores/objeto-gasto" class="back-link"> 998 + <a href="/clasificadores/objeto-gasto" class="back-link" data-sveltekit-reload>
995 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 999 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
996 <path d="M19 12H5M12 19l-7-7 7-7"/> 1000 <path d="M19 12H5M12 19l-7-7 7-7"/>
997 </svg> 1001 </svg>
...@@ -1149,7 +1153,13 @@ ...@@ -1149,7 +1153,13 @@
1149 <div class="chart-header"> 1153 <div class="chart-header">
1150 <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span> 1154 <span class="chart-title">Bs/habitante <span class="chart-period">· {displayPeriodo}</span></span>
1151 </div> 1155 </div>
1152 - <BarChart data={gastoPerCapita} bind:hoveredYear height={280} fill={!!selectedEntity} /> 1156 + {#if loading}
1157 + <div class="chart-loading" style="height: 280px;">
1158 + <span>Cargando...</span>
1159 + </div>
1160 + {:else}
1161 + <BarChart data={gastoPerCapita} bind:hoveredYear height={280} fill={!!selectedEntity} />
1162 + {/if}
1153 1163
1154 <!-- KPIs abajo --> 1164 <!-- KPIs abajo -->
1155 <div class="kpis"> 1165 <div class="kpis">
...@@ -2581,6 +2591,17 @@ ...@@ -2581,6 +2591,17 @@
2581 letter-spacing: 0; 2591 letter-spacing: 0;
2582 } 2592 }
2583 2593
2594 + .chart-loading {
2595 + display: flex;
2596 + align-items: center;
2597 + justify-content: center;
2598 + color: var(--theme-texto);
2599 + opacity: 0.5;
2600 + font-size: 0.875rem;
2601 + border: 1px dashed var(--theme-borde);
2602 + border-radius: 8px;
2603 + }
2604 +
2584 .chart-controls { 2605 .chart-controls {
2585 display: flex; 2606 display: flex;
2586 align-items: center; 2607 align-items: center;
......
1 +<script>
2 + let institucion = "Ministerio de Economía y Finanzas Públicas";
3 + let pais = "Bolivia";
4 +</script>
5 +
6 +<svelte:head>
7 + <title>Sobre | Presupuesto Público</title>
8 + <link rel="preconnect" href="https://fonts.googleapis.com" />
9 + <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" />
10 +</svelte:head>
11 +
12 +<div class="manifesto-outer">
13 + <div class="manifesto-inner">
14 +
15 + <div class="manifesto-label">
16 + Presentación &nbsp;·&nbsp; Liberación de la base histórica
17 + </div>
18 +
19 + <div class="manifesto-fecha">
20 + A fines de 2025 decidimos abrir los datos históricos<br>
21 + de presupuesto del Estado boliviano.
22 + </div>
23 +
24 + <div class="manifesto-cuerpo">
25 +
26 + <p class="p1">
27 + Cada año el Estado recibe y gasta dinero a tu nombre. ¿Sabes de dónde viene,
28 + en qué se usa y qué se logra con él? Esa información es tuya. Siempre lo fue,
29 + pero durante décadas permaneció encerrada en archivos PDF, en sistemas que pocos
30 + saben usar y en las paredes de un ministerio.
31 + </p>
32 +
33 + <div class="manifesto-hoy">Hoy cambia eso.</div>
34 +
35 + <p class="p3">
36 + Aquí puedes explorar más de veinte años de ejecución presupuestaria, con
37 + actualización semanal. Puedes ver qué hizo el Estado en tu región, en tu sector
38 + y en el año que te interesa. Puedes comparar, cuestionar y entender. Y si
39 + encuentras algo que no cuadra o que vale la pena contar, puedes hacerlo tuyo.
40 + </p>
41 +
42 + <hr class="manifesto-regla" />
43 +
44 + <p class="p4">
45 + No publicamos esto para cumplir un trámite de transparencia. Lo hacemos porque
46 + creemos que un Estado que trabaja de espaldas a su sociedad se vuelve impune,
47 + y una sociedad que no entiende al Estado se desconecta de él.
48 + </p>
49 +
50 + <blockquote class="manifesto-apuesta">
51 + Un presupuesto abierto es una apuesta: que la confianza se construye con
52 + <strong>información</strong> y no con promesas.
53 + </blockquote>
54 +
55 + </div>
56 +
57 + <div class="manifesto-cierre">
58 + <p class="manifesto-cierre-texto">Abrimos para no cerrar.</p>
59 + <p class="manifesto-cierre-sub">{institucion} &nbsp;·&nbsp; {pais}</p>
60 + </div>
61 +
62 + </div>
63 +</div>
64 +
65 +<style>
66 + .manifesto-outer {
67 + min-height: 100vh;
68 + background: var(--manifesto-bg);
69 + overflow: hidden;
70 + }
71 +
72 + .manifesto-inner {
73 + max-width: 640px;
74 + margin: 0 auto;
75 + padding: 72px 48px 88px;
76 + font-family: 'Source Serif 4', Georgia, 'Times New Roman', serif;
77 + color: var(--manifesto-text);
78 + }
79 +
80 + /* === etiqueta superior === */
81 +
82 + .manifesto-label {
83 + font-family: 'JetBrains Mono', 'Courier New', monospace;
84 + font-size: 10px;
85 + letter-spacing: 0.18em;
86 + color: var(--manifesto-muted);
87 + text-transform: uppercase;
88 + margin-bottom: 56px;
89 + display: flex;
90 + align-items: center;
91 + gap: 12px;
92 + opacity: 0;
93 + animation: fadeUp 0.6s ease forwards;
94 + }
95 +
96 + .manifesto-label::after {
97 + content: '';
98 + flex: 1;
99 + height: 0.5px;
100 + background: var(--manifesto-rule);
101 + }
102 +
103 + /* === fecha === */
104 +
105 + .manifesto-fecha {
106 + font-family: 'Source Serif 4', Georgia, serif;
107 + font-style: italic;
108 + font-weight: 300;
109 + font-size: 14px;
110 + color: var(--manifesto-subtle);
111 + letter-spacing: 0.02em;
112 + margin-bottom: 40px;
113 + opacity: 0;
114 + animation: fadeUp 0.6s ease 0.1s forwards;
115 + }
116 +
117 + /* === cuerpo === */
118 +
119 + .manifesto-cuerpo {
120 + font-size: 18.5px;
121 + line-height: 1.78;
122 + font-weight: 300;
123 + color: var(--manifesto-body);
124 + letter-spacing: 0.01em;
125 + }
126 +
127 + .p1 {
128 + opacity: 0;
129 + animation: fadeUp 0.7s ease 0.2s forwards;
130 + margin-bottom: 28px;
131 + margin-top: 0;
132 + }
133 +
134 + .manifesto-hoy {
135 + opacity: 0;
136 + animation: fadeUp 0.7s ease 0.5s forwards;
137 + margin: 44px 0;
138 + font-size: 26px;
139 + font-weight: 400;
140 + color: var(--manifesto-text);
141 + letter-spacing: -0.01em;
142 + line-height: 1.3;
143 + }
144 +
145 + .p3 {
146 + opacity: 0;
147 + animation: fadeUp 0.7s ease 0.65s forwards;
148 + margin-bottom: 28px;
149 + margin-top: 0;
150 + }
151 +
152 + .manifesto-regla {
153 + opacity: 0;
154 + animation: fadeUp 0.6s ease 0.75s forwards;
155 + border: none;
156 + border-top: 0.5px solid var(--manifesto-rule);
157 + margin: 44px 0;
158 + }
159 +
160 + .p4 {
161 + opacity: 0;
162 + animation: fadeUp 0.7s ease 0.85s forwards;
163 + margin-bottom: 0;
164 + margin-top: 0;
165 + }
166 +
167 + .manifesto-apuesta {
168 + opacity: 0;
169 + animation: fadeUp 0.7s ease 0.95s forwards;
170 + margin: 44px 0;
171 + padding: 0 0 0 24px;
172 + border-left: 1.5px solid var(--manifesto-quote-border);
173 + font-style: italic;
174 + font-weight: 300;
175 + font-size: 20px;
176 + line-height: 1.65;
177 + color: var(--manifesto-quote);
178 + }
179 +
180 + .manifesto-apuesta strong {
181 + font-style: normal;
182 + font-weight: 400;
183 + color: var(--manifesto-body);
184 + }
185 +
186 + /* === cierre === */
187 +
188 + .manifesto-cierre {
189 + opacity: 0;
190 + animation: fadeUp 0.8s ease 1.1s forwards;
191 + margin-top: 56px;
192 + padding-top: 36px;
193 + border-top: 0.5px solid var(--manifesto-rule);
194 + }
195 +
196 + .manifesto-cierre-texto {
197 + font-size: 36px;
198 + font-weight: 400;
199 + color: var(--color-gold);
200 + letter-spacing: -0.02em;
201 + line-height: 1.1;
202 + margin: 0;
203 + }
204 +
205 + .manifesto-cierre-sub {
206 + font-family: 'JetBrains Mono', 'Courier New', monospace;
207 + font-size: 10px;
208 + letter-spacing: 0.16em;
209 + color: var(--manifesto-muted);
210 + text-transform: uppercase;
211 + margin-top: 20px;
212 + margin-bottom: 0;
213 + }
214 +
215 + /* === animación === */
216 +
217 + @keyframes -global-fadeUp {
218 + from {
219 + opacity: 0;
220 + transform: translateY(14px);
221 + }
222 + to {
223 + opacity: 1;
224 + transform: translateY(0);
225 + }
226 + }
227 +
228 + /* === MODO OSCURO (default) === */
229 + .manifesto-outer {
230 + --manifesto-bg: #0C0C0C;
231 + --manifesto-text: #EDE9E1;
232 + --manifesto-body: #D9D4CB;
233 + --manifesto-muted: #5A5650;
234 + --manifesto-subtle: #6B6660;
235 + --manifesto-rule: #2A2822;
236 + --manifesto-quote: #C4BFB6;
237 + --manifesto-quote-border: #3A3530;
238 + }
239 +
240 + /* === MODO CLARO === */
241 + :global(html:not(.dark)) .manifesto-outer {
242 + --manifesto-bg: #FAFAF8;
243 + --manifesto-text: #1a1a1a;
244 + --manifesto-body: #3a3a3a;
245 + --manifesto-muted: #8a8a8a;
246 + --manifesto-subtle: #6a6a6a;
247 + --manifesto-rule: #e0e0e0;
248 + --manifesto-quote: #4a4a4a;
249 + --manifesto-quote-border: #d0d0d0;
250 + }
251 +
252 + /* === responsive === */
253 +
254 + @media (max-width: 600px) {
255 + .manifesto-inner {
256 + padding: 48px 28px 64px;
257 + }
258 +
259 + .manifesto-cuerpo {
260 + font-size: 17px;
261 + }
262 +
263 + .manifesto-hoy {
264 + font-size: 22px;
265 + }
266 +
267 + .manifesto-apuesta {
268 + font-size: 18px;
269 + }
270 +
271 + .manifesto-cierre-texto {
272 + font-size: 28px;
273 + }
274 + }
275 +</style>
1 +<script>
2 + import { onMount } from 'svelte';
3 + import * as d3 from 'd3';
4 +
5 + let container;
6 + let data = $state([]);
7 + let width = $state(800);
8 + let height = $state(600);
9 + let searchQuery = $state('');
10 + let searchResults = $state([]);
11 + let selectedEntity = $state(null);
12 + let circlesSelection = null;
13 + let nodesData = null;
14 +
15 + onMount(async () => {
16 + // Load CSV data
17 + const response = await fetch('/bubbles.csv');
18 + const text = await response.text();
19 + data = d3.csvParse(text, d => ({
20 + entidad: +d.entidad,
21 + sueldos: +d.sueldos,
22 + prop: +d.prop
23 + }));
24 +
25 + // Set dimensions based on container
26 + const rect = container.getBoundingClientRect();
27 + width = rect.width;
28 + height = rect.height || 600;
29 +
30 + createVisualization();
31 +
32 + // Handle resize
33 + const resizeObserver = new ResizeObserver(entries => {
34 + for (const entry of entries) {
35 + width = entry.contentRect.width;
36 + height = entry.contentRect.height || 600;
37 + createVisualization();
38 + }
39 + });
40 + resizeObserver.observe(container);
41 +
42 + return () => resizeObserver.disconnect();
43 + });
44 +
45 + function handleSearch(e) {
46 + const query = e.target.value;
47 + searchQuery = query;
48 +
49 + if (query.length >= 1) {
50 + // Search by entity code
51 + searchResults = data
52 + .filter(d => d.entidad.toString().includes(query))
53 + .slice(0, 8);
54 + } else {
55 + searchResults = [];
56 + clearHighlight();
57 + }
58 + }
59 +
60 + function selectEntity(entity) {
61 + selectedEntity = entity;
62 + searchQuery = entity.entidad.toString();
63 + searchResults = [];
64 + highlightEntity(entity.entidad);
65 + }
66 +
67 + function highlightEntity(entidadId) {
68 + if (!circlesSelection) return;
69 +
70 + circlesSelection
71 + .transition()
72 + .duration(300)
73 + .attr('opacity', d => d.entidad === entidadId ? 1 : 0.15)
74 + .attr('stroke', d => d.entidad === entidadId ? '#fff' : 'rgba(255,255,255,0.05)')
75 + .attr('stroke-width', d => d.entidad === entidadId ? 3 : 0.5);
76 +
77 + // Add pulse animation to selected
78 + const selected = circlesSelection.filter(d => d.entidad === entidadId);
79 + selected
80 + .classed('pulse', true);
81 + }
82 +
83 + function clearHighlight() {
84 + selectedEntity = null;
85 + searchQuery = '';
86 + searchResults = [];
87 +
88 + if (!circlesSelection) return;
89 +
90 + circlesSelection
91 + .classed('pulse', false)
92 + .transition()
93 + .duration(300)
94 + .attr('opacity', 0.9)
95 + .attr('stroke', 'rgba(255,255,255,0.15)')
96 + .attr('stroke-width', 0.5);
97 + }
98 +
99 + function createVisualization() {
100 + if (!data.length || !container) return;
101 +
102 + // Clear previous
103 + d3.select(container).selectAll('*').remove();
104 +
105 + // Create SVG
106 + const svg = d3.select(container)
107 + .append('svg')
108 + .attr('width', width)
109 + .attr('height', height)
110 + .attr('viewBox', [0, 0, width, height]);
111 +
112 + // Scale for radius - responsive to screen size
113 + const minDim = Math.min(width, height);
114 + const maxRadius = minDim * 0.09; // 9% del lado menor
115 + const minRadius = minDim * 0.007; // 0.7% del lado menor
116 +
117 + const radiusScale = d3.scaleSqrt()
118 + .domain([0, d3.max(data, d => d.prop)])
119 + .range([minRadius, maxRadius]);
120 +
121 + // Paleta cálida: amarillo banana → naranja salmón
122 + const colorScale = d3.scaleThreshold()
123 + .domain([0.001, 0.005, 0.01, 0.03, 0.05]) // 0.1%, 0.5%, 1%, 3%, 5%
124 + .range([
125 + '#5c5448', // < 0.1% - marrón apagado
126 + '#8b7355', // 0.1-0.5% - tierra suave
127 + '#d4c4a8', // 0.5-1% - beige
128 + '#f5e6c4', // 1-3% - amarillo banana claro
129 + '#f8d4a6', // 3-5% - durazno
130 + '#f4a574' // > 5% - salmón naranja
131 + ]);
132 +
133 + // Create nodes with initial positions
134 + const nodes = data.map(d => ({
135 + ...d,
136 + r: radiusScale(d.prop),
137 + x: width / 2 + (Math.random() - 0.5) * 100,
138 + y: height / 2 + (Math.random() - 0.5) * 100
139 + }));
140 + nodesData = nodes;
141 +
142 + // Create force simulation with faster convergence
143 + const simulation = d3.forceSimulation(nodes)
144 + .force('charge', d3.forceManyBody().strength(5))
145 + .force('center', d3.forceCenter(width / 2, height / 2))
146 + .force('collision', d3.forceCollide().radius(d => d.r + 1.4).strength(1).iterations(3))
147 + .force('x', d3.forceX(width / 2).strength(0.1))
148 + .force('y', d3.forceY(height / 2).strength(0.1))
149 + .alphaDecay(0.05)
150 + .velocityDecay(0.4);
151 +
152 + // Pre-calculate some ticks, but leave room for visible settling
153 + for (let i = 0; i < 60; i++) simulation.tick();
154 +
155 + // Create circles
156 + const circles = svg.append('g')
157 + .selectAll('circle')
158 + .data(nodes)
159 + .join('circle')
160 + .attr('r', d => d.r)
161 + .attr('fill', d => colorScale(d.prop))
162 + .attr('stroke', 'rgba(255,255,255,0.15)')
163 + .attr('stroke-width', 0.5)
164 + .attr('opacity', 0.9)
165 + .style('cursor', 'pointer');
166 +
167 + // Store reference for highlighting
168 + circlesSelection = circles;
169 +
170 + // Add tooltip
171 + const tooltip = d3.select(container)
172 + .append('div')
173 + .attr('class', 'tooltip')
174 + .style('position', 'absolute')
175 + .style('visibility', 'hidden')
176 + .style('background', 'var(--theme-surface, #1a1a1a)')
177 + .style('border', '1px solid var(--theme-borde, #333)')
178 + .style('border-radius', '8px')
179 + .style('padding', '12px')
180 + .style('font-size', '13px')
181 + .style('color', 'var(--theme-titulo, #fff)')
182 + .style('box-shadow', '0 4px 12px rgba(0,0,0,0.3)')
183 + .style('pointer-events', 'none')
184 + .style('z-index', '100');
185 +
186 + circles
187 + .on('mouseover', (event, d) => {
188 + tooltip
189 + .style('visibility', 'visible')
190 + .html(`
191 + <div style="font-weight: 600; margin-bottom: 6px;">Entidad ${d.entidad}</div>
192 + <div style="color: var(--theme-texto, #999);">
193 + Sueldos: Bs ${d3.format(',.0f')(d.sueldos)}<br/>
194 + Proporción: ${(d.prop * 100).toFixed(2)}%
195 + </div>
196 + `);
197 + if (!selectedEntity) {
198 + d3.select(event.currentTarget)
199 + .attr('stroke', '#fff')
200 + .attr('stroke-width', 2)
201 + .attr('opacity', 1);
202 + }
203 + })
204 + .on('mousemove', (event) => {
205 + tooltip
206 + .style('left', (event.offsetX + 15) + 'px')
207 + .style('top', (event.offsetY - 10) + 'px');
208 + })
209 + .on('mouseout', (event, d) => {
210 + tooltip.style('visibility', 'hidden');
211 + if (!selectedEntity) {
212 + d3.select(event.currentTarget)
213 + .attr('stroke', 'rgba(255,255,255,0.15)')
214 + .attr('stroke-width', 0.5)
215 + .attr('opacity', 0.9);
216 + }
217 + })
218 + .on('click', (event, d) => {
219 + selectEntity(d);
220 + });
221 +
222 + // Add drag behavior
223 + circles.call(d3.drag()
224 + .on('start', (event, d) => {
225 + if (!event.active) simulation.alphaTarget(0.3).restart();
226 + d.fx = d.x;
227 + d.fy = d.y;
228 + })
229 + .on('drag', (event, d) => {
230 + d.fx = event.x;
231 + d.fy = event.y;
232 + })
233 + .on('end', (event, d) => {
234 + if (!event.active) simulation.alphaTarget(0);
235 + d.fx = null;
236 + d.fy = null;
237 + }));
238 +
239 + // Update positions on each tick
240 + simulation.on('tick', () => {
241 + circles
242 + .attr('cx', d => d.x)
243 + .attr('cy', d => d.y);
244 + });
245 +
246 + // Restore highlight if there was a selection
247 + if (selectedEntity) {
248 + highlightEntity(selectedEntity.entidad);
249 + }
250 + }
251 +</script>
252 +
253 +<div class="page">
254 + <header>
255 + <h1>Distribución de Sueldos por Entidad</h1>
256 + <p class="subtitle">Visualización de partículas proporcionales al gasto en sueldos</p>
257 + </header>
258 +
259 + <div class="search-container">
260 + <div class="search-box">
261 + <svg class="search-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
262 + <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
263 + </svg>
264 + <input
265 + type="text"
266 + placeholder="Buscar entidad por código..."
267 + value={searchQuery}
268 + oninput={handleSearch}
269 + />
270 + {#if searchQuery}
271 + <button class="clear-btn" onclick={clearHighlight}>
272 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
273 + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
274 + </svg>
275 + </button>
276 + {/if}
277 + </div>
278 +
279 + {#if searchResults.length > 0}
280 + <div class="search-dropdown">
281 + {#each searchResults as result}
282 + <button class="search-result" onclick={() => selectEntity(result)}>
283 + <span class="result-code">{result.entidad}</span>
284 + <span class="result-info">Bs {d3.format(',.0f')(result.sueldos)} · {(result.prop * 100).toFixed(2)}%</span>
285 + </button>
286 + {/each}
287 + </div>
288 + {/if}
289 + </div>
290 +
291 + {#if selectedEntity}
292 + <div class="selected-info">
293 + <span class="selected-label">Entidad {selectedEntity.entidad}</span>
294 + <span class="selected-value">Bs {d3.format(',.0f')(selectedEntity.sueldos)}</span>
295 + <span class="selected-pct">{(selectedEntity.prop * 100).toFixed(2)}% del total</span>
296 + </div>
297 + {/if}
298 +
299 + <div class="container" bind:this={container}></div>
300 +
301 + <footer>
302 + <div class="legend">
303 + <span class="legend-item"><span class="dot" style="background: #5c5448"></span> &lt;0.1%</span>
304 + <span class="legend-item"><span class="dot" style="background: #8b7355"></span> 0.1-0.5%</span>
305 + <span class="legend-item"><span class="dot" style="background: #d4c4a8"></span> 0.5-1%</span>
306 + <span class="legend-item"><span class="dot" style="background: #f5e6c4"></span> 1-3%</span>
307 + <span class="legend-item"><span class="dot" style="background: #f8d4a6"></span> 3-5%</span>
308 + <span class="legend-item"><span class="dot" style="background: #f4a574"></span> &gt;5%</span>
309 + </div>
310 + </footer>
311 +</div>
312 +
313 +<style>
314 + .page {
315 + min-height: 100vh;
316 + display: flex;
317 + flex-direction: column;
318 + align-items: center;
319 + padding: 2rem;
320 + background: var(--theme-fondo, #0d0d0d);
321 + }
322 +
323 + header {
324 + text-align: center;
325 + margin-bottom: 1rem;
326 + }
327 +
328 + h1 {
329 + font-size: 1.5rem;
330 + font-weight: 600;
331 + color: var(--theme-titulo, #fff);
332 + margin: 0 0 0.5rem 0;
333 + }
334 +
335 + .subtitle {
336 + font-size: 0.875rem;
337 + color: var(--theme-texto, #999);
338 + margin: 0;
339 + }
340 +
341 + .search-container {
342 + position: relative;
343 + width: 100%;
344 + max-width: 320px;
345 + margin-bottom: 1rem;
346 + }
347 +
348 + .search-box {
349 + display: flex;
350 + align-items: center;
351 + gap: 0.5rem;
352 + background: var(--theme-surface, #1a1a1a);
353 + border: 1px solid var(--theme-borde, #333);
354 + border-radius: 8px;
355 + padding: 0.625rem 1rem;
356 + transition: border-color 0.2s, box-shadow 0.2s;
357 + }
358 +
359 + .search-box:focus-within {
360 + border-color: #f4a574;
361 + box-shadow: 0 0 0 3px rgba(244, 165, 116, 0.15);
362 + }
363 +
364 + .search-icon {
365 + color: var(--theme-texto, #999);
366 + opacity: 0.5;
367 + flex-shrink: 0;
368 + }
369 +
370 + .search-box input {
371 + flex: 1;
372 + border: none;
373 + background: transparent;
374 + color: var(--theme-titulo, #fff);
375 + font-size: 0.875rem;
376 + outline: none;
377 + }
378 +
379 + .search-box input::placeholder {
380 + color: var(--theme-texto, #999);
381 + opacity: 0.5;
382 + }
383 +
384 + .clear-btn {
385 + display: flex;
386 + align-items: center;
387 + justify-content: center;
388 + padding: 0.25rem;
389 + border: none;
390 + background: transparent;
391 + color: var(--theme-texto, #999);
392 + cursor: pointer;
393 + border-radius: 4px;
394 + transition: background 0.2s;
395 + }
396 +
397 + .clear-btn:hover {
398 + background: rgba(255,255,255,0.1);
399 + }
400 +
401 + .search-dropdown {
402 + position: absolute;
403 + top: calc(100% + 4px);
404 + left: 0;
405 + right: 0;
406 + background: var(--theme-surface, #1a1a1a);
407 + border: 1px solid var(--theme-borde, #333);
408 + border-radius: 8px;
409 + box-shadow: 0 8px 24px rgba(0,0,0,0.3);
410 + overflow: hidden;
411 + z-index: 50;
412 + }
413 +
414 + .search-result {
415 + display: flex;
416 + align-items: center;
417 + justify-content: space-between;
418 + width: 100%;
419 + padding: 0.75rem 1rem;
420 + border: none;
421 + background: transparent;
422 + color: var(--theme-titulo, #fff);
423 + cursor: pointer;
424 + transition: background 0.15s;
425 + text-align: left;
426 + }
427 +
428 + .search-result:hover {
429 + background: rgba(255,255,255,0.05);
430 + }
431 +
432 + .result-code {
433 + font-family: 'DM Mono', monospace;
434 + font-weight: 600;
435 + color: #f4a574;
436 + }
437 +
438 + .result-info {
439 + font-size: 0.75rem;
440 + color: var(--theme-texto, #999);
441 + }
442 +
443 + .selected-info {
444 + display: flex;
445 + align-items: center;
446 + gap: 1rem;
447 + padding: 0.75rem 1.25rem;
448 + background: rgba(244, 165, 116, 0.1);
449 + border: 1px solid rgba(244, 165, 116, 0.3);
450 + border-radius: 8px;
451 + margin-bottom: 1rem;
452 + }
453 +
454 + .selected-label {
455 + font-weight: 600;
456 + color: #f4a574;
457 + }
458 +
459 + .selected-value {
460 + color: var(--theme-titulo, #fff);
461 + }
462 +
463 + .selected-pct {
464 + font-size: 0.875rem;
465 + color: var(--theme-texto, #999);
466 + }
467 +
468 + .container {
469 + flex: 1;
470 + width: 100%;
471 + max-width: 1200px;
472 + min-height: 500px;
473 + position: relative;
474 + background: var(--theme-surface, #1a1a1a);
475 + border-radius: 12px;
476 + overflow: hidden;
477 + }
478 +
479 + footer {
480 + margin-top: 1.5rem;
481 + display: flex;
482 + justify-content: center;
483 + }
484 +
485 + .legend {
486 + display: flex;
487 + gap: 2rem;
488 + font-size: 0.8125rem;
489 + color: var(--theme-texto, #999);
490 + }
491 +
492 + .legend-item {
493 + display: flex;
494 + align-items: center;
495 + gap: 0.5rem;
496 + }
497 +
498 + .dot {
499 + width: 12px;
500 + height: 12px;
501 + border-radius: 50%;
502 + border: 1px solid rgba(255,255,255,0.15);
503 + }
504 +
505 + /* Pulse animation for highlighted bubble */
506 + :global(.pulse) {
507 + animation: pulse 1.5s ease-in-out infinite;
508 + }
509 +
510 + @keyframes pulse {
511 + 0%, 100% {
512 + filter: drop-shadow(0 0 0 rgba(255,255,255,0));
513 + }
514 + 50% {
515 + filter: drop-shadow(0 0 12px rgba(255,255,255,0.6));
516 + }
517 + }
518 +
519 + @media (max-width: 640px) {
520 + .legend {
521 + flex-wrap: wrap;
522 + justify-content: center;
523 + gap: 1rem;
524 + }
525 +
526 + .selected-info {
527 + flex-direction: column;
528 + gap: 0.25rem;
529 + text-align: center;
530 + }
531 + }
532 +</style>
1 -gestion,objeto,devengado
2 -2025,11100,7010195151.280003
3 -2025,11210,5812831318.110004
4 -2025,11220,3960643633.5099993
5 -2025,11310,477377380.7399999
6 -2025,11321,611358929.5699998
7 -2025,11322,344284069.2800001
8 -2025,11323,8492765.11
9 -2025,11324,119472850.73000005
10 -2025,11331,3741918.29
11 -2025,11332,222831.86
12 -2025,11339,991911009.5900005
13 -2025,11400,2598265843.6899953
14 -2025,11510,115618445.64
15 -2025,11520,3684768.92
16 -2025,11600,347937539.3
17 -2025,11700,16012422722.88999
18 -2025,11810,1897633.19
19 -2025,11820,5236920.13
20 -2025,11910,418427037.15000004
21 -2025,11920,49552831.83999999
22 -2025,11930,141140855.32999998
23 -2025,11940,4603197.24
24 -2025,12100,3204535824.6000023
25 -2025,13110,3901953807.7400007
26 -2025,13120,619414677.6200007
27 -2025,13131,1362025647.0700052
28 -2025,13132,16517337.85
29 -2025,13200,780321050.7899984
30 -2025,14100,1139341529.1000001
31 -2025,15100,0
32 -2025,15200,0
33 -2025,15300,0
34 -2025,15400,4691146.74
35 -2025,21100,18994647.069999997
36 -2025,21200,1185053965.7300012
37 -2025,21300,187133399.53000006
38 -2025,21400,53465476.169999935
39 -2025,21500,32025660.46999999
40 -2025,21600,194624988.81999987
41 -2025,22110,91267157.03
42 -2025,22120,18054096.22
43 -2025,22210,150388054.74000007
44 -2025,22220,34629325.89
45 -2025,22300,1959574229.3100035
46 -2025,22400,2341473.77
47 -2025,22500,701307983.6299998
48 -2025,22600,129774059.36000001
49 -2025,23100,177818037.08
50 -2025,23200,705817678.630001
51 -2025,23300,267332.29
52 -2025,23400,102877216.77999996
53 -2025,24110,347305124.4300007
54 -2025,24120,495875030.0700008
55 -2025,24130,4734068.450000001
56 -2025,24200,186441122.7200001
57 -2025,24300,2640074895.480003
58 -2025,25120,968577449.3700005
59 -2025,25130,48331129.9
60 -2025,25210,124279655.01999998
61 -2025,25220,1788157637.100003
62 -2025,25230,38750297.28
63 -2025,25300,561398734.3999997
64 -2025,25400,878953716.1200004
65 -2025,25500,306587141.63999945
66 -2025,25600,313083444.33000004
67 -2025,25700,6976563.890000001
68 -2025,25810,119466843.19999999
69 -2025,25820,224542509.50000012
70 -2025,25900,237665083.13999984
71 -2025,26200,15655261.7
72 -2025,26300,34604066.51
73 -2025,26610,123238100.68000004
74 -2025,26620,65803840.900000006
75 -2025,26630,5856917.720000001
76 -2025,26640,12578208.32
77 -2025,26700,14848951.62
78 -2025,26910,663385.5700000003
79 -2025,26920,235250
80 -2025,26930,8978735.689999998
81 -2025,26940,66563836.099999994
82 -2025,26990,1503662082.0000036
83 -2025,27110,1825790733.1499996
84 -2025,27120,364659427.3
85 -2025,31110,542443427.4200002
86 -2025,31120,216381918.43999982
87 -2025,31130,664933780.7900007
88 -2025,31140,546467610.53
89 -2025,31150,58934973.47
90 -2025,31200,33094567.35000001
91 -2025,31300,2447995585.1100035
92 -2025,32100,143276681.52000007
93 -2025,32200,132315063.70000002
94 -2025,32300,5561928.509999998
95 -2025,32400,159320
96 -2025,32500,1136682.8700000003
97 -2025,33100,24196989.06
98 -2025,33200,44402437.73000002
99 -2025,33300,140159482.78000006
100 -2025,33400,44503112.73000003
101 -2025,34110,1233206780.699983
102 -2025,34120,22281523272.74
103 -2025,34130,210854679.98
104 -2025,34200,2971103666.2100005
105 -2025,34300,107990911.47999994
106 -2025,34400,7452099.93
107 -2025,34500,643334205.2399997
108 -2025,34600,256516535.3800006
109 -2025,34700,4499642859.289998
110 -2025,34800,31720752.09
111 -2025,34900,3886088.93
112 -2025,39100,110497000.54
113 -2025,39200,48738034.710000016
114 -2025,39300,4137761.2899999996
115 -2025,39400,88709616.89000002
116 -2025,39500,308909532.08000034
117 -2025,39600,13789089.190000001
118 -2025,39700,260527546.88999993
119 -2025,39800,711478491.3899997
120 -2025,39911,25451599.84
121 -2025,39912,229716435.09
122 -2025,39990,85810785.37999989
123 -2025,41100,4018555.16
124 -2025,41200,11738231.740000002
125 -2025,41300,0
126 -2025,42210,2475688.8800000004
127 -2025,42220,5772449.3
128 -2025,42230,3644887583.5699887
129 -2025,42240,119884214.51000004
130 -2025,42310,3262092590.7700067
131 -2025,42320,162411814.5
132 -2025,42400,748574159.99
133 -2025,42500,368399418.97999996
134 -2025,43110,68317124.34
135 -2025,43120,223834030.73999995
136 -2025,43200,145257372.71
137 -2025,43310,18770602.6
138 -2025,43320,7603011.960000001
139 -2025,43330,290260838.97
140 -2025,43340,4231983.279999999
141 -2025,43400,398948895.01000005
142 -2025,43500,328470832.01999986
143 -2025,43600,51696765.46999999
144 -2025,43700,169086594.95000005
145 -2025,46110,125733899.86000003
146 -2025,46120,74691013.75999999
147 -2025,46210,57348534.56999999
148 -2025,46220,9583654.360000001
149 -2025,46310,30831570.299999986
150 -2025,46320,2784494.7899999996
151 -2025,49100,41480273
152 -2025,49300,492862.94999999995
153 -2025,49400,85275
154 -2025,49900,660922.38
155 -2025,51100,0
156 -2025,51200,620015014.37
157 -2025,51600,481042090
158 -2025,51700,29014116.87
159 -2025,53410,52050949.82
160 -2025,53420,351090144.51
161 -2025,53430,3378151.15
162 -2025,53440,25716472.12
163 -2025,54300,77289717.23
164 -2025,54800,632675260.65
165 -2025,55130,0
166 -2025,56100,2147721911.8500004
167 -2025,57100,836275.9
168 -2025,57200,0
169 -2025,61100,70227731033.1
170 -2025,61200,3503973693.6899977
171 -2025,61300,7686201.74
172 -2025,61400,165316.78
173 -2025,61600,4660620766.32
174 -2025,61700,3610015023.1800003
175 -2025,61800,578767.5200000001
176 -2025,61900,560031.13
177 -2025,62100,106585021.14999999
178 -2025,62200,38756619.58
179 -2025,62300,3500499.439999999
180 -2025,62400,405.55
181 -2025,62600,4871024753.510001
182 -2025,62700,3091378138.92
183 -2025,62800,66184743.53000001
184 -2025,62900,236631.16
185 -2025,63100,6480763.82
186 -2025,63200,0
187 -2025,63300,100360.43
188 -2025,63400,462857.7
189 -2025,63500,35529.2
190 -2025,63600,0
191 -2025,63700,2369055.65
192 -2025,63800,0
193 -2025,63900,1087614.1800000002
194 -2025,64100,0
195 -2025,64200,0
196 -2025,65100,586447491.8299999
197 -2025,65210,398987753.5799999
198 -2025,65220,240387686.01999998
199 -2025,65230,132581381.01
200 -2025,65240,0
201 -2025,65300,2444818356.6
202 -2025,65400,644864385.6300001
203 -2025,65500,797927308.79
204 -2025,65600,622100032.9
205 -2025,65800,6897016.98
206 -2025,65900,66705420.480000004
207 -2025,66100,403828926.02000004
208 -2025,66210,2597435653.4000015
209 -2025,66220,1829365908.85
210 -2025,66230,646299428.4800001
211 -2025,66240,80232240.73
212 -2025,66250,209697820.00000003
213 -2025,66300,239921321.82000005
214 -2025,66400,134798065.3299999
215 -2025,66900,1844595842.08
216 -2025,67100,23208064.040000003
217 -2025,68200,230881944.88000008
218 -2025,69100,29581681.77
219 -2025,69200,203500678.33
220 -2025,71100,7630007944.97
221 -2025,71210,2392898.6
222 -2025,71220,322149243.09
223 -2025,71230,4408152.6
224 -2025,71300,26644234.430000003
225 -2025,71610,1883091507.0600004
226 -2025,71630,343800974.93
227 -2025,71700,65080225.449999996
228 -2025,71800,4478984170.690001
229 -2025,72200,2046897058.8299997
230 -2025,72420,316010589.22
231 -2025,72520,7584584602.65001
232 -2025,73100,2664574022.970007
233 -2025,73200,24681254941.659996
234 -2025,73410,4845222515.11
235 -2025,73420,315258596.03999996
236 -2025,73430,4314037.58
237 -2025,73440,1652127
238 -2025,73700,15015040108.359999
239 -2025,73820,1364681.67
240 -2025,75110,0
241 -2025,75120,683772757.9200002
242 -2025,75211,469127620.05
243 -2025,75212,50308009.760000005
244 -2025,75221,11649919.909999998
245 -2025,75222,305831148.79999995
246 -2025,75320,0
247 -2025,77100,346626643.30999994
248 -2025,77200,1783047515.49
249 -2025,77410,154404059.40000007
250 -2025,77440,13005182.149999999
251 -2025,77520,34542653.12
252 -2025,77530,1359464924.2300012
253 -2025,77700,0
254 -2025,77820,42931153.11
255 -2025,78100,346333.4
256 -2025,79100,20183063.04
257 -2025,79200,74336.66
258 -2025,79310,69600
259 -2025,81100,189467352
260 -2025,81200,1327189189.5399992
261 -2025,81300,551652107.6699998
262 -2025,81400,299412884.65
263 -2025,81500,2442434
264 -2025,81600,395569
265 -2025,81950,39137
266 -2025,81960,32790665.14
267 -2025,81990,2007174694.16
268 -2025,82100,879007438.5400001
269 -2025,83110,29113391.8
270 -2025,83120,4240482.35
271 -2025,83210,351070.72
272 -2025,83220,50460
273 -2025,84100,65887753.279999994
274 -2025,84230,394819507.59
275 -2025,84240,723835763.5600002
276 -2025,84250,65803251.14
277 -2025,84900,8503359.91
278 -2025,85100,94254502.63999999
279 -2025,85200,10478575.300000003
280 -2025,85400,6221225.630000003
281 -2025,85500,3581209.3799999994
282 -2025,85900,20640115.529999997
283 -2025,86100,29028844.980000008
284 -2025,91200,512102826.06
285 -2025,92100,0
286 -2025,94100,11526054.86
287 -2025,94200,7279202.82
288 -2025,94300,1551638.3499999999
289 -2025,95100,291453722.56
290 -2025,96100,5934986991.019999
291 -2025,96200,614954091.44
292 -2025,96900,2947311655.2299995
293 -2025,97100,70099589.35000001
294 -2025,98300,125000000
295 -2025,99100,0
296 -2025,99200,0
1 -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
2 -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
3 -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
4 -2005,3,9475861,Yacimientos Petroliferos Fiscales Bolivianos,Ministerio De Gobierno,Ministerio De Defensa Nacional,484404998.67,180713886.04,173790141.18999997,51,19,18
5 -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
6 -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
7 -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
8 -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
9 -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
10 -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
11 -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
12 -2006,3,9586372,Yacimientos Petroliferos Fiscales Bolivianos,Ministerio De Gobierno,Ministerio De Defensa Nacional,3688964112.9000006,191526679.58999985,185658779.26999998,385,20,19
13 -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
14 -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
15 -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
16 -2006,8,9586372,Yacimientos Petroliferos Fiscales Bolivianos,Ministerio De Gobierno,Ministerio De Salud Y Deportes,21867029.55,14706981.85,11468759,2,2,1
17 -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
18 -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
19 -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
20 -2007,3,9701623,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Metalurgica Vinto - Nacionalizada,Ministerio De Defensa Nacional,7975908085.859999,739852915,299348102.3900001,822,76,31
21 -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
22 -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
23 -2007,6,9701623,Municipalidad De La Paz,Empresa Metalurgica Vinto - Nacionalizada,Municipalidad De Cochabamba,328357628.21,212924215.28,199598641.12,34,22,21
24 -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
25 -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
26 -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
27 -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
28 -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
29 -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
30 -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
31 -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
32 -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
33 -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
34 -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
35 -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
36 -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
37 -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
38 -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
39 -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
40 -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
41 -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
42 -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
43 -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
44 -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
45 -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
46 -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
47 -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
48 -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
49 -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
50 -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
51 -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
52 -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
53 -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
54 -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
55 -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
56 -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
57 -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
58 -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
59 -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
60 -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
61 -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
62 -2012,5,10423115,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Educación,673150100.45,492478179.62,410000000,65,47,39
63 -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
64 -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
65 -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
66 -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
67 -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
68 -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
69 -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
70 -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
71 -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
72 -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
73 -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
74 -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
75 -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
76 -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
77 -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
78 -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
79 -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
80 -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
81 -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
82 -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
83 -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
84 -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
85 -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
86 -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
87 -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
88 -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
89 -2015,9,10920682,Banco Central De Bolivia,Caja Nacional De Salud,Boliviana De Aviación,95867427.66,94162422.64,82113934.15000002,9,9,8
90 -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
91 -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
92 -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
93 -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
94 -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
95 -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
96 -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
97 -2016,9,11083605,Boliviana De Aviación,Caja Nacional De Salud,Administradora Boliviana De Carreteras,109840520.94999999,107514545.48,76898776.27,10,10,7
98 -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
99 -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
100 -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
101 -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
102 -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
103 -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
104 -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
105 -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
106 -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
107 -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
108 -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
109 -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
110 -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
111 -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
112 -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
113 -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
114 -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
115 -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
116 -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
117 -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
118 -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
119 -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
120 -2019,8,11514867,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Salud,13676470168.299997,349216136.52,94543209.52000001,1188,30,8
121 -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
122 -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
123 -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
124 -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
125 -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
126 -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
127 -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
128 -2020,8,11640016,Yacimientos Petrolíferos Fiscales Bolivianos,Empresa Nacional De Electricidad,Ministerio De Gobierno,10404488193.79,441941245.26999986,142071449,894,38,12
129 -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
130 -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
131 -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
132 -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
133 -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
134 -2021,5,11733918,Agencia Estatal De Vivienda,Fondo Nacional De Desarrollo Regional,Empresa Nacional De Electricidad,1227901537.4199998,764948406.0900004,712096992,105,65,61
135 -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
136 -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
137 -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
138 -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
139 -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
140 -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
141 -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
142 -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
143 -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
144 -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
145 -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
146 -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
147 -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
148 -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
149 -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
150 -2023,5,11872175,Agencia Estatal De Vivienda,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,1358834807.8799996,928522446,502983373.2699999,114,78,42
151 -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
152 -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
153 -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
154 -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
155 -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
156 -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
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
158 -2024,5,11916453,Agencia Estatal De Vivienda,Empresa Nacional De Electricidad,Fondo Nacional De Desarrollo Regional,1401782953.1999996,434716061,392456028.9200001,118,36,33
159 -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
160 -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
161 -2024,9,11916453,Banco Central De Bolivia,Caja Nacional De Salud,Boliviana De Aviación,1263368809.8,141667378.23999998,112172417.82,106,12,9
162 -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
163 -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
164 -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
165 -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
166 -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
167 -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
168 -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
169 -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