Showing
21 changed files
with
5089 additions
and
864 deletions
| ... | @@ -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> | ... | ... |
src/lib/components/ui/SearchModal.svelte
0 → 100644
| 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 | + × | ||
| 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> | ... | ... |
src/routes/finfun/[codigo]/+page.server.js
0 → 100644
| 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 | +} |
src/routes/finfun/[codigo]/+page.svelte
0 → 100644
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; | ... | ... |
src/routes/sobre/+page.svelte
0 → 100644
| 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 · 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} · {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> |
src/routes/test1/+page.svelte
0 → 100644
| 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> <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> >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> |
static/data.csv
deleted
100644 → 0
| 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 |
static/muetra_base.csv
deleted
100644 → 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 |
-
Please register or login to post a comment