Rafael Lopez

vista diaria

...@@ -8,9 +8,11 @@ ...@@ -8,9 +8,11 @@
8 "name": "ppto", 8 "name": "ppto",
9 "version": "0.0.1", 9 "version": "0.0.1",
10 "dependencies": { 10 "dependencies": {
11 + "@observablehq/plot": "^0.6.17",
11 "@supabase/supabase-js": "^2.98.0", 12 "@supabase/supabase-js": "^2.98.0",
12 "d3": "^7.9.0", 13 "d3": "^7.9.0",
13 "fuse.js": "^7.1.0", 14 "fuse.js": "^7.1.0",
15 + "hyparquet": "^1.25.1",
14 "marked": "^17.0.4" 16 "marked": "^17.0.4"
15 }, 17 },
16 "devDependencies": { 18 "devDependencies": {
...@@ -515,6 +517,20 @@ ...@@ -515,6 +517,20 @@
515 "@jridgewell/sourcemap-codec": "^1.4.14" 517 "@jridgewell/sourcemap-codec": "^1.4.14"
516 } 518 }
517 }, 519 },
520 + "node_modules/@observablehq/plot": {
521 + "version": "0.6.17",
522 + "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
523 + "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
524 + "license": "ISC",
525 + "dependencies": {
526 + "d3": "^7.9.0",
527 + "interval-tree-1d": "^1.0.0",
528 + "isoformat": "^0.2.0"
529 + },
530 + "engines": {
531 + "node": ">=12"
532 + }
533 + },
518 "node_modules/@polka/url": { 534 "node_modules/@polka/url": {
519 "version": "1.0.0-next.29", 535 "version": "1.0.0-next.29",
520 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", 536 "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
...@@ -1410,6 +1426,12 @@ ...@@ -1410,6 +1426,12 @@
1410 "node": ">= 0.4" 1426 "node": ">= 0.4"
1411 } 1427 }
1412 }, 1428 },
1429 + "node_modules/binary-search-bounds": {
1430 + "version": "2.0.5",
1431 + "resolved": "https://registry.npmjs.org/binary-search-bounds/-/binary-search-bounds-2.0.5.tgz",
1432 + "integrity": "sha512-H0ea4Fd3lS1+sTEB2TgcLoK21lLhwEJzlQv3IN47pJS976Gx4zoWe0ak3q+uYh60ppQxg9F16Ri4tS1sfD4+jA==",
1433 + "license": "MIT"
1434 + },
1413 "node_modules/clsx": { 1435 "node_modules/clsx": {
1414 "version": "2.1.1", 1436 "version": "2.1.1",
1415 "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", 1437 "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
...@@ -1998,6 +2020,12 @@ ...@@ -1998,6 +2020,12 @@
1998 "dev": true, 2020 "dev": true,
1999 "license": "ISC" 2021 "license": "ISC"
2000 }, 2022 },
2023 + "node_modules/hyparquet": {
2024 + "version": "1.25.1",
2025 + "resolved": "https://registry.npmjs.org/hyparquet/-/hyparquet-1.25.1.tgz",
2026 + "integrity": "sha512-CXcN/u6RdQqsK8IphUptpAEqY8IzgwzHY+MuXX+2wpoWTumfxPVr6JYbbywsNsiAl9aEbM5sRtxkwRBa22b49w==",
2027 + "license": "MIT"
2028 + },
2001 "node_modules/iceberg-js": { 2029 "node_modules/iceberg-js": {
2002 "version": "0.8.1", 2030 "version": "0.8.1",
2003 "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", 2031 "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
...@@ -2028,6 +2056,15 @@ ...@@ -2028,6 +2056,15 @@
2028 "node": ">=12" 2056 "node": ">=12"
2029 } 2057 }
2030 }, 2058 },
2059 + "node_modules/interval-tree-1d": {
2060 + "version": "1.0.4",
2061 + "resolved": "https://registry.npmjs.org/interval-tree-1d/-/interval-tree-1d-1.0.4.tgz",
2062 + "integrity": "sha512-wY8QJH+6wNI0uh4pDQzMvl+478Qh7Rl4qLmqiluxALlNvl+I+o5x38Pw3/z7mDPTPS1dQalZJXsmbvxx5gclhQ==",
2063 + "license": "MIT",
2064 + "dependencies": {
2065 + "binary-search-bounds": "^2.0.0"
2066 + }
2067 + },
2031 "node_modules/is-reference": { 2068 "node_modules/is-reference": {
2032 "version": "3.0.3", 2069 "version": "3.0.3",
2033 "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", 2070 "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
...@@ -2038,6 +2075,12 @@ ...@@ -2038,6 +2075,12 @@
2038 "@types/estree": "^1.0.6" 2075 "@types/estree": "^1.0.6"
2039 } 2076 }
2040 }, 2077 },
2078 + "node_modules/isoformat": {
2079 + "version": "0.2.1",
2080 + "resolved": "https://registry.npmjs.org/isoformat/-/isoformat-0.2.1.tgz",
2081 + "integrity": "sha512-tFLRAygk9NqrRPhJSnNGh7g7oaVWDwR0wKh/GM2LgmPa50Eg4UfyaCO4I8k6EqJHl1/uh2RAD6g06n5ygEnrjQ==",
2082 + "license": "ISC"
2083 + },
2041 "node_modules/jiti": { 2084 "node_modules/jiti": {
2042 "version": "2.6.1", 2085 "version": "2.6.1",
2043 "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", 2086 "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
......
...@@ -19,9 +19,11 @@ ...@@ -19,9 +19,11 @@
19 "vite": "^7.3.1" 19 "vite": "^7.3.1"
20 }, 20 },
21 "dependencies": { 21 "dependencies": {
22 + "@observablehq/plot": "^0.6.17",
22 "@supabase/supabase-js": "^2.98.0", 23 "@supabase/supabase-js": "^2.98.0",
23 "d3": "^7.9.0", 24 "d3": "^7.9.0",
24 "fuse.js": "^7.1.0", 25 "fuse.js": "^7.1.0",
26 + "hyparquet": "^1.25.1",
25 "marked": "^17.0.4" 27 "marked": "^17.0.4"
26 } 28 }
27 } 29 }
......
...@@ -32,8 +32,14 @@ ...@@ -32,8 +32,14 @@
32 }); 32 });
33 </script> 33 </script>
34 34
35 -<!-- MÓVIL: Búsqueda + Theme + hamburguesa arriba a la derecha --> 35 +<!-- MÓVIL: Home + Búsqueda + Theme + hamburguesa arriba a la derecha -->
36 <div class="navbar-mobile"> 36 <div class="navbar-mobile">
37 + <a href="/" class="nav-home-link" aria-label="Ir al inicio">
38 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
39 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
40 + <polyline points="9 22 9 12 15 12 15 22"/>
41 + </svg>
42 + </a>
37 <button 43 <button
38 onclick={onOpenSearch} 44 onclick={onOpenSearch}
39 class="nav-btn-icon-mobile" 45 class="nav-btn-icon-mobile"
...@@ -70,10 +76,25 @@ ...@@ -70,10 +76,25 @@
70 </button> 76 </button>
71 </div> 77 </div>
72 78
73 -<!-- DESKTOP: Barra completa con Search + Theme + Menu --> 79 +<!-- DESKTOP: Barra completa con Home + Search + Theme + Menu -->
74 <div class="navbar-desktop"> 80 <div class="navbar-desktop">
75 <div class="navbar-desktop-inner"> 81 <div class="navbar-desktop-inner">
76 82
83 + <!-- BOTÓN HOME -->
84 + <div class="relative group/home">
85 + <a href="/" class="nav-btn-icon" aria-label="Ir al inicio">
86 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
87 + <path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
88 + <polyline points="9 22 9 12 15 12 15 22"/>
89 + </svg>
90 + </a>
91 + <!-- Tooltip -->
92 + <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">
93 + Inicio
94 + <div class="absolute right-3 bottom-full mb-[-4px] w-2 h-2 bg-gray-900/90 rotate-45"></div>
95 + </div>
96 + </div>
97 +
77 <!-- BOTÓN BÚSQUEDA GLOBAL --> 98 <!-- BOTÓN BÚSQUEDA GLOBAL -->
78 <div class="relative group/search"> 99 <div class="relative group/search">
79 <button 100 <button
...@@ -152,6 +173,13 @@ ...@@ -152,6 +173,13 @@
152 z-index: 110; 173 z-index: 110;
153 align-items: center; 174 align-items: center;
154 gap: 0.25rem; 175 gap: 0.25rem;
176 + padding: 6px;
177 + background: rgba(40, 40, 38, 0.7);
178 + backdrop-filter: blur(20px) saturate(180%);
179 + -webkit-backdrop-filter: blur(20px) saturate(180%);
180 + border: 1px solid rgba(255, 255, 255, 0.1);
181 + border-radius: 14px;
182 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
155 } 183 }
156 184
157 .navbar-desktop { 185 .navbar-desktop {
...@@ -160,6 +188,20 @@ ...@@ -160,6 +188,20 @@
160 top: 1.5rem; 188 top: 1.5rem;
161 right: 1.5rem; 189 right: 1.5rem;
162 z-index: 110; 190 z-index: 110;
191 + padding: 6px;
192 + background: rgba(40, 40, 38, 0.7);
193 + backdrop-filter: blur(20px) saturate(180%);
194 + -webkit-backdrop-filter: blur(20px) saturate(180%);
195 + border: 1px solid rgba(255, 255, 255, 0.1);
196 + border-radius: 14px;
197 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15);
198 + }
199 +
200 + :global(html:not(.dark)) .navbar-mobile,
201 + :global(html:not(.dark)) .navbar-desktop {
202 + background: rgba(255, 255, 255, 0.7);
203 + border-color: rgba(0, 0, 0, 0.08);
204 + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
163 } 205 }
164 206
165 .navbar-desktop-inner { 207 .navbar-desktop-inner {
...@@ -201,16 +243,16 @@ ...@@ -201,16 +243,16 @@
201 align-items: center; 243 align-items: center;
202 gap: 10px; 244 gap: 10px;
203 padding: 7px 12px 7px 12px; 245 padding: 7px 12px 7px 12px;
204 - background: rgba(255, 255, 255, 0.05); 246 + background: rgba(255, 255, 255, 0.08);
205 - border: 1px solid rgba(255, 255, 255, 0.12); 247 + border: 1px solid rgba(255, 255, 255, 0.1);
206 border-radius: 10px; 248 border-radius: 10px;
207 - color: #9B9890; 249 + color: #B8B5AD;
208 cursor: pointer; 250 cursor: pointer;
209 transition: all 0.2s ease; 251 transition: all 0.2s ease;
210 } 252 }
211 253
212 .nav-search-btn:hover { 254 .nav-search-btn:hover {
213 - background: rgba(255, 255, 255, 0.1); 255 + background: rgba(255, 255, 255, 0.15);
214 border-color: rgba(201, 167, 81, 0.4); 256 border-color: rgba(201, 167, 81, 0.4);
215 color: #F5F0E8; 257 color: #F5F0E8;
216 } 258 }
...@@ -252,28 +294,28 @@ ...@@ -252,28 +294,28 @@
252 align-items: center; 294 align-items: center;
253 justify-content: center; 295 justify-content: center;
254 padding: 8px; 296 padding: 8px;
255 - background: rgba(255, 255, 255, 0.05); 297 + background: rgba(255, 255, 255, 0.08);
256 - border: 1px solid rgba(255, 255, 255, 0.12); 298 + border: 1px solid rgba(255, 255, 255, 0.1);
257 border-radius: 10px; 299 border-radius: 10px;
258 - color: #9B9890; 300 + color: #B8B5AD;
259 cursor: pointer; 301 cursor: pointer;
260 transition: all 0.2s ease; 302 transition: all 0.2s ease;
261 } 303 }
262 304
263 .nav-btn-icon:hover { 305 .nav-btn-icon:hover {
264 - background: rgba(255, 255, 255, 0.1); 306 + background: rgba(255, 255, 255, 0.15);
265 border-color: rgba(201, 167, 81, 0.4); 307 border-color: rgba(201, 167, 81, 0.4);
266 color: #F5F0E8; 308 color: #F5F0E8;
267 } 309 }
268 310
269 :global(html:not(.dark)) .nav-btn-icon { 311 :global(html:not(.dark)) .nav-btn-icon {
270 - background: rgba(0, 0, 0, 0.04); 312 + background: rgba(0, 0, 0, 0.05);
271 - border-color: rgba(0, 0, 0, 0.12); 313 + border-color: rgba(0, 0, 0, 0.08);
272 - color: #666; 314 + color: #555;
273 } 315 }
274 316
275 :global(html:not(.dark)) .nav-btn-icon:hover { 317 :global(html:not(.dark)) .nav-btn-icon:hover {
276 - background: rgba(0, 0, 0, 0.08); 318 + background: rgba(0, 0, 0, 0.1);
277 border-color: rgba(201, 167, 81, 0.5); 319 border-color: rgba(201, 167, 81, 0.5);
278 color: #1A1A18; 320 color: #1A1A18;
279 } 321 }
...@@ -285,23 +327,66 @@ ...@@ -285,23 +327,66 @@
285 box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8); 327 box-shadow: 0 1px 2px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.8);
286 } 328 }
287 329
330 + /* Home link - hereda estilos de nav-btn-icon */
331 + .nav-home-link {
332 + display: flex;
333 + align-items: center;
334 + justify-content: center;
335 + padding: 8px;
336 + background: rgba(255, 255, 255, 0.08);
337 + border: 1px solid rgba(255, 255, 255, 0.1);
338 + border-radius: 10px;
339 + color: #B8B5AD;
340 + cursor: pointer;
341 + transition: all 0.2s ease;
342 + text-decoration: none;
343 + }
344 +
345 + .nav-home-link:hover {
346 + background: rgba(255, 255, 255, 0.15);
347 + border-color: rgba(201, 167, 81, 0.4);
348 + color: #F5F0E8;
349 + }
350 +
351 + :global(html:not(.dark)) .nav-home-link {
352 + background: rgba(0, 0, 0, 0.05);
353 + border-color: rgba(0, 0, 0, 0.08);
354 + color: #555;
355 + }
356 +
357 + :global(html:not(.dark)) .nav-home-link:hover {
358 + background: rgba(0, 0, 0, 0.1);
359 + border-color: rgba(201, 167, 81, 0.5);
360 + color: #1A1A18;
361 + }
362 +
288 /* Mobile icon buttons */ 363 /* Mobile icon buttons */
289 .nav-btn-icon-mobile { 364 .nav-btn-icon-mobile {
290 display: flex; 365 display: flex;
291 align-items: center; 366 align-items: center;
292 justify-content: center; 367 justify-content: center;
293 padding: 8px; 368 padding: 8px;
294 - background: rgba(255, 255, 255, 0.05); 369 + background: rgba(255, 255, 255, 0.1);
295 - border: 1px solid rgba(255, 255, 255, 0.12); 370 + border: 1px solid rgba(255, 255, 255, 0.08);
296 border-radius: 8px; 371 border-radius: 8px;
297 - color: #9B9890; 372 + color: #B8B5AD;
298 cursor: pointer; 373 cursor: pointer;
299 transition: all 0.2s ease; 374 transition: all 0.2s ease;
300 } 375 }
301 376
377 + .nav-btn-icon-mobile:hover {
378 + background: rgba(255, 255, 255, 0.18);
379 + color: #F5F0E8;
380 + }
381 +
302 :global(html:not(.dark)) .nav-btn-icon-mobile { 382 :global(html:not(.dark)) .nav-btn-icon-mobile {
303 - background: rgba(0, 0, 0, 0.04); 383 + background: rgba(0, 0, 0, 0.06);
304 - border-color: rgba(0, 0, 0, 0.12); 384 + border-color: rgba(0, 0, 0, 0.06);
305 - color: #666; 385 + color: #555;
386 + }
387 +
388 + :global(html:not(.dark)) .nav-btn-icon-mobile:hover {
389 + background: rgba(0, 0, 0, 0.12);
390 + color: #1A1A18;
306 } 391 }
307 </style> 392 </style>
......
...@@ -24,7 +24,10 @@ ...@@ -24,7 +24,10 @@
24 let searchFilters = $state({ 24 let searchFilters = $state({
25 entidad: true, 25 entidad: true,
26 objeto_gasto: true, 26 objeto_gasto: true,
27 - rubro: true 27 + rubro: true,
28 + finfun: true,
29 + organismo: true,
30 + fuente: true
28 }); 31 });
29 32
30 // Filtered results based on active filters 33 // Filtered results based on active filters
...@@ -90,6 +93,12 @@ ...@@ -90,6 +93,12 @@
90 goto(`/objeto/${item.codigo}`); 93 goto(`/objeto/${item.codigo}`);
91 } else if (item.tipo === 'rubro') { 94 } else if (item.tipo === 'rubro') {
92 goto(`/rubro/${item.codigo}`); 95 goto(`/rubro/${item.codigo}`);
96 + } else if (item.tipo === 'finfun') {
97 + goto(`/finfun/${item.codigo}`);
98 + } else if (item.tipo === 'organismo') {
99 + goto(`/organismo/${item.codigo}`);
100 + } else if (item.tipo === 'fuente') {
101 + goto(`/fuente/${item.codigo}`);
93 } 102 }
94 } 103 }
95 104
...@@ -99,11 +108,38 @@ ...@@ -99,11 +108,38 @@
99 } 108 }
100 } 109 }
101 110
102 - // Type labels and colors 111 + // Type labels, colors and descriptions
103 const typeConfig = { 112 const typeConfig = {
104 - entidad: { label: 'Institución', color: '#C9A751' }, 113 + entidad: {
105 - objeto_gasto: { label: 'Objeto de gasto', color: '#6B9F78' }, 114 + label: 'Institución',
106 - rubro: { label: 'Rubro', color: '#7B8EC9' } 115 + color: '#C9A751',
116 + description: 'Ministerios, municipios, universidades...'
117 + },
118 + objeto_gasto: {
119 + label: 'Objeto de gasto',
120 + color: '#6B9F78',
121 + description: 'Sueldos, viáticos, servicios básicos...'
122 + },
123 + rubro: {
124 + label: 'Rubro',
125 + color: '#7B8EC9',
126 + description: 'Impuestos, tasas, transferencias...'
127 + },
128 + finfun: {
129 + label: 'Finalidad',
130 + color: '#C97B8E',
131 + description: 'Manufacturas, construcción, transporte...'
132 + },
133 + organismo: {
134 + label: 'Organismo',
135 + color: '#9B7BC9',
136 + description: 'Tipo de entidad: central, descentralizada...'
137 + },
138 + fuente: {
139 + label: 'Fuente',
140 + color: '#7BC9A1',
141 + description: 'Origen del financiamiento: TGN, créditos...'
142 + }
107 }; 143 };
108 </script> 144 </script>
109 145
...@@ -129,32 +165,85 @@ ...@@ -129,32 +165,85 @@
129 </button> 165 </button>
130 </div> 166 </div>
131 167
132 - <!-- Filters --> 168 + <!-- Filters section with explicit descriptions -->
133 <div class="search-modal-filters"> 169 <div class="search-modal-filters">
134 - <button 170 + <div class="filters-header">
135 - class="search-filter-chip" 171 + <span class="filters-label">Buscar en:</span>
136 - class:filter-active={searchFilters.entidad} 172 + </div>
137 - onclick={() => toggleFilter('entidad')} 173 + <div class="filters-grid">
138 - > 174 + <button
139 - <span class="filter-dot" style="background:{typeConfig.entidad.color}"></span> 175 + class="search-filter-card"
140 - Instituciones 176 + class:filter-active={searchFilters.entidad}
141 - </button> 177 + onclick={() => toggleFilter('entidad')}
142 - <button 178 + >
143 - class="search-filter-chip" 179 + <div class="filter-card-header">
144 - class:filter-active={searchFilters.objeto_gasto} 180 + <span class="filter-dot" style="background:{typeConfig.entidad.color}"></span>
145 - onclick={() => toggleFilter('objeto_gasto')} 181 + <span class="filter-title">Instituciones</span>
146 - > 182 + <span class="filter-check">{searchFilters.entidad ? '✓' : ''}</span>
147 - <span class="filter-dot" style="background:{typeConfig.objeto_gasto.color}"></span> 183 + </div>
148 - Objetos de gasto 184 + <span class="filter-desc">{typeConfig.entidad.description}</span>
149 - </button> 185 + </button>
150 - <button 186 + <button
151 - class="search-filter-chip" 187 + class="search-filter-card"
152 - class:filter-active={searchFilters.rubro} 188 + class:filter-active={searchFilters.objeto_gasto}
153 - onclick={() => toggleFilter('rubro')} 189 + onclick={() => toggleFilter('objeto_gasto')}
154 - > 190 + >
155 - <span class="filter-dot" style="background:{typeConfig.rubro.color}"></span> 191 + <div class="filter-card-header">
156 - Rubros 192 + <span class="filter-dot" style="background:{typeConfig.objeto_gasto.color}"></span>
157 - </button> 193 + <span class="filter-title">Gastos</span>
194 + <span class="filter-check">{searchFilters.objeto_gasto ? '✓' : ''}</span>
195 + </div>
196 + <span class="filter-desc">{typeConfig.objeto_gasto.description}</span>
197 + </button>
198 + <button
199 + class="search-filter-card"
200 + class:filter-active={searchFilters.rubro}
201 + onclick={() => toggleFilter('rubro')}
202 + >
203 + <div class="filter-card-header">
204 + <span class="filter-dot" style="background:{typeConfig.rubro.color}"></span>
205 + <span class="filter-title">Ingresos</span>
206 + <span class="filter-check">{searchFilters.rubro ? '✓' : ''}</span>
207 + </div>
208 + <span class="filter-desc">{typeConfig.rubro.description}</span>
209 + </button>
210 + <button
211 + class="search-filter-card"
212 + class:filter-active={searchFilters.finfun}
213 + onclick={() => toggleFilter('finfun')}
214 + >
215 + <div class="filter-card-header">
216 + <span class="filter-dot" style="background:{typeConfig.finfun.color}"></span>
217 + <span class="filter-title">Finalidad</span>
218 + <span class="filter-check">{searchFilters.finfun ? '✓' : ''}</span>
219 + </div>
220 + <span class="filter-desc">{typeConfig.finfun.description}</span>
221 + </button>
222 + <button
223 + class="search-filter-card"
224 + class:filter-active={searchFilters.organismo}
225 + onclick={() => toggleFilter('organismo')}
226 + >
227 + <div class="filter-card-header">
228 + <span class="filter-dot" style="background:{typeConfig.organismo.color}"></span>
229 + <span class="filter-title">Organismos</span>
230 + <span class="filter-check">{searchFilters.organismo ? '✓' : ''}</span>
231 + </div>
232 + <span class="filter-desc">{typeConfig.organismo.description}</span>
233 + </button>
234 + <button
235 + class="search-filter-card"
236 + class:filter-active={searchFilters.fuente}
237 + onclick={() => toggleFilter('fuente')}
238 + >
239 + <div class="filter-card-header">
240 + <span class="filter-dot" style="background:{typeConfig.fuente.color}"></span>
241 + <span class="filter-title">Fuentes</span>
242 + <span class="filter-check">{searchFilters.fuente ? '✓' : ''}</span>
243 + </div>
244 + <span class="filter-desc">{typeConfig.fuente.description}</span>
245 + </button>
246 + </div>
158 </div> 247 </div>
159 248
160 <!-- Results --> 249 <!-- Results -->
...@@ -164,15 +253,7 @@ ...@@ -164,15 +253,7 @@
164 {:else if $isLoading} 253 {:else if $isLoading}
165 <div class="search-modal-status">Buscando...</div> 254 <div class="search-modal-status">Buscando...</div>
166 {:else if searchVal.length < 2} 255 {:else if searchVal.length < 2}
167 - <div class="search-modal-hint"> 256 + <div class="search-modal-empty"></div>
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} 257 {:else if filteredResults.length === 0}
177 <div class="search-modal-status">Sin resultados para "{searchVal}"</div> 258 <div class="search-modal-status">Sin resultados para "{searchVal}"</div>
178 {:else} 259 {:else}
...@@ -218,9 +299,7 @@ ...@@ -218,9 +299,7 @@
218 .search-modal-backdrop { 299 .search-modal-backdrop {
219 position: fixed; 300 position: fixed;
220 inset: 0; 301 inset: 0;
221 - background: rgba(0, 0, 0, 0.6); 302 + background: rgba(0, 0, 0, 0.5);
222 - backdrop-filter: blur(4px);
223 - -webkit-backdrop-filter: blur(4px);
224 z-index: 9999; 303 z-index: 9999;
225 display: flex; 304 display: flex;
226 align-items: flex-start; 305 align-items: flex-start;
...@@ -231,13 +310,27 @@ ...@@ -231,13 +310,27 @@
231 310
232 .search-modal { 311 .search-modal {
233 width: 100%; 312 width: 100%;
234 - max-width: 580px; 313 + max-width: 620px;
235 - background: var(--theme-body); 314 + background: rgba(40, 40, 38, 0.75);
236 - border: 1px solid var(--theme-borde); 315 + backdrop-filter: blur(40px) saturate(200%);
316 + -webkit-backdrop-filter: blur(40px) saturate(200%);
317 + border: 1px solid rgba(255, 255, 255, 0.15);
237 border-radius: 16px; 318 border-radius: 16px;
238 - box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4); 319 + box-shadow:
320 + 0 25px 50px -12px rgba(0, 0, 0, 0.6),
321 + 0 0 0 1px rgba(255, 255, 255, 0.08) inset,
322 + 0 1px 0 rgba(255, 255, 255, 0.1) inset;
239 overflow: hidden; 323 overflow: hidden;
240 - animation: slideIn 0.2s ease-out; 324 + animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
325 + }
326 +
327 + :global(html:not(.dark)) .search-modal {
328 + background: rgba(255, 255, 255, 0.72);
329 + border-color: rgba(0, 0, 0, 0.08);
330 + box-shadow:
331 + 0 25px 50px -12px rgba(0, 0, 0, 0.25),
332 + 0 0 0 1px rgba(255, 255, 255, 0.5) inset,
333 + 0 1px 0 rgba(255, 255, 255, 0.8) inset;
241 } 334 }
242 335
243 @keyframes fadeIn { 336 @keyframes fadeIn {
...@@ -248,7 +341,7 @@ ...@@ -248,7 +341,7 @@
248 @keyframes slideIn { 341 @keyframes slideIn {
249 from { 342 from {
250 opacity: 0; 343 opacity: 0;
251 - transform: translateY(-20px) scale(0.98); 344 + transform: translateY(-8px) scale(0.96);
252 } 345 }
253 to { 346 to {
254 opacity: 1; 347 opacity: 1;
...@@ -261,8 +354,12 @@ ...@@ -261,8 +354,12 @@
261 display: flex; 354 display: flex;
262 align-items: center; 355 align-items: center;
263 gap: 12px; 356 gap: 12px;
264 - padding: 16px 20px; 357 + padding: 18px 20px;
265 - border-bottom: 1px solid var(--theme-borde); 358 + border-bottom: 1px solid rgba(255, 255, 255, 0.08);
359 + }
360 +
361 + :global(html:not(.dark)) .search-modal-header {
362 + border-bottom-color: rgba(0, 0, 0, 0.08);
266 } 363 }
267 364
268 .search-modal-icon { 365 .search-modal-icon {
...@@ -297,56 +394,125 @@ ...@@ -297,56 +394,125 @@
297 .search-modal-close kbd { 394 .search-modal-close kbd {
298 font-family: inherit; 395 font-family: inherit;
299 font-size: 0.6875rem; 396 font-size: 0.6875rem;
300 - padding: 4px 8px; 397 + padding: 5px 10px;
301 - background: var(--theme-fill); 398 + background: rgba(255, 255, 255, 0.1);
302 - border: 1px solid var(--theme-borde); 399 + border: 1px solid rgba(255, 255, 255, 0.15);
303 border-radius: 6px; 400 border-radius: 6px;
304 color: var(--theme-texto); 401 color: var(--theme-texto);
305 - opacity: 0.7; 402 + opacity: 0.8;
306 - transition: opacity 0.15s; 403 + transition: all 0.15s;
404 + }
405 +
406 + :global(html:not(.dark)) .search-modal-close kbd {
407 + background: rgba(0, 0, 0, 0.06);
408 + border-color: rgba(0, 0, 0, 0.12);
307 } 409 }
308 410
309 .search-modal-close:hover kbd { 411 .search-modal-close:hover kbd {
310 opacity: 1; 412 opacity: 1;
413 + background: rgba(255, 255, 255, 0.15);
414 + }
415 +
416 + :global(html:not(.dark)) .search-modal-close:hover kbd {
417 + background: rgba(0, 0, 0, 0.1);
311 } 418 }
312 419
313 /* Filters */ 420 /* Filters */
314 .search-modal-filters { 421 .search-modal-filters {
315 - display: flex; 422 + padding: 12px 16px;
423 + border-bottom: 1px solid rgba(255, 255, 255, 0.08);
424 + background: rgba(255, 255, 255, 0.03);
425 + }
426 +
427 + :global(html:not(.dark)) .search-modal-filters {
428 + border-bottom-color: rgba(0, 0, 0, 0.08);
429 + background: rgba(0, 0, 0, 0.02);
430 + }
431 +
432 + .filters-header {
433 + margin-bottom: 8px;
434 + }
435 +
436 + .filters-label {
437 + font-size: 0.6875rem;
438 + font-weight: 600;
439 + text-transform: uppercase;
440 + letter-spacing: 0.08em;
441 + color: var(--theme-texto);
442 + opacity: 0.5;
443 + }
444 +
445 + .filters-grid {
446 + display: grid;
447 + grid-template-columns: repeat(2, 1fr);
316 gap: 8px; 448 gap: 8px;
317 - padding: 12px 20px;
318 - border-bottom: 1px solid var(--theme-borde);
319 - background: var(--theme-fill);
320 } 449 }
321 450
322 - .search-filter-chip { 451 + .search-filter-card {
323 display: flex; 452 display: flex;
324 - align-items: center; 453 + flex-direction: column;
325 - gap: 6px; 454 + gap: 3px;
326 - padding: 6px 12px; 455 + padding: 8px 10px;
327 - background: var(--theme-body); 456 + background: rgba(255, 255, 255, 0.05);
328 - border: 1px solid var(--theme-borde); 457 + border: 1px solid rgba(255, 255, 255, 0.1);
329 - border-radius: 20px; 458 + border-radius: 8px;
330 - font-size: 0.8125rem;
331 - color: var(--theme-texto);
332 cursor: pointer; 459 cursor: pointer;
333 - transition: all 0.15s; 460 + transition: all 0.2s ease;
461 + text-align: left;
334 opacity: 0.5; 462 opacity: 0.5;
335 } 463 }
336 464
337 - .search-filter-chip:hover { 465 + :global(html:not(.dark)) .search-filter-card {
466 + background: rgba(0, 0, 0, 0.03);
467 + border-color: rgba(0, 0, 0, 0.1);
468 + }
469 +
470 + .search-filter-card:hover {
338 opacity: 0.8; 471 opacity: 0.8;
472 + background: rgba(255, 255, 255, 0.08);
473 + }
474 +
475 + :global(html:not(.dark)) .search-filter-card:hover {
476 + background: rgba(0, 0, 0, 0.06);
339 } 477 }
340 478
341 - .search-filter-chip.filter-active { 479 + .search-filter-card.filter-active {
342 opacity: 1; 480 opacity: 1;
343 - border-color: var(--theme-texto); 481 + border-color: rgba(201, 167, 81, 0.5);
482 + background: rgba(201, 167, 81, 0.1);
483 + }
484 +
485 + .filter-card-header {
486 + display: flex;
487 + align-items: center;
488 + gap: 6px;
344 } 489 }
345 490
346 .filter-dot { 491 .filter-dot {
347 width: 8px; 492 width: 8px;
348 height: 8px; 493 height: 8px;
349 border-radius: 50%; 494 border-radius: 50%;
495 + flex-shrink: 0;
496 + }
497 +
498 + .filter-title {
499 + font-size: 0.8125rem;
500 + font-weight: 600;
501 + color: var(--theme-titulo);
502 + flex: 1;
503 + }
504 +
505 + .filter-check {
506 + font-size: 0.6875rem;
507 + color: #C9A751;
508 + font-weight: 600;
509 + }
510 +
511 + .filter-desc {
512 + font-size: 0.625rem;
513 + color: var(--theme-texto);
514 + opacity: 0.6;
515 + line-height: 1.3;
350 } 516 }
351 517
352 /* Results */ 518 /* Results */
...@@ -355,25 +521,15 @@ ...@@ -355,25 +521,15 @@
355 overflow-y: auto; 521 overflow-y: auto;
356 } 522 }
357 523
358 - .search-modal-status, 524 + .search-modal-status {
359 - .search-modal-hint {
360 padding: 24px 20px; 525 padding: 24px 20px;
361 text-align: center; 526 text-align: center;
362 color: var(--theme-texto); 527 color: var(--theme-texto);
363 font-size: 0.875rem; 528 font-size: 0.875rem;
364 } 529 }
365 530
366 - .search-modal-hint { 531 + .search-modal-empty {
367 - display: flex; 532 + padding: 16px;
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 } 533 }
378 534
379 .search-result-item { 535 .search-result-item {
...@@ -385,10 +541,14 @@ ...@@ -385,10 +541,14 @@
385 padding: 12px 20px; 541 padding: 12px 20px;
386 background: none; 542 background: none;
387 border: none; 543 border: none;
388 - border-top: 1px solid var(--theme-borde); 544 + border-top: 1px solid rgba(255, 255, 255, 0.06);
389 cursor: pointer; 545 cursor: pointer;
390 text-align: left; 546 text-align: left;
391 - transition: background 0.1s; 547 + transition: background 0.15s ease;
548 + }
549 +
550 + :global(html:not(.dark)) .search-result-item {
551 + border-top-color: rgba(0, 0, 0, 0.06);
392 } 552 }
393 553
394 .search-result-item:first-child { 554 .search-result-item:first-child {
...@@ -397,7 +557,12 @@ ...@@ -397,7 +557,12 @@
397 557
398 .search-result-item:hover, 558 .search-result-item:hover,
399 .search-result-item.result-selected { 559 .search-result-item.result-selected {
400 - background: var(--theme-fill); 560 + background: rgba(255, 255, 255, 0.08);
561 + }
562 +
563 + :global(html:not(.dark)) .search-result-item:hover,
564 + :global(html:not(.dark)) .search-result-item.result-selected {
565 + background: rgba(0, 0, 0, 0.05);
401 } 566 }
402 567
403 .result-main { 568 .result-main {
...@@ -437,10 +602,14 @@ ...@@ -437,10 +602,14 @@
437 font-family: 'DM Mono', monospace; 602 font-family: 'DM Mono', monospace;
438 font-size: 0.75rem; 603 font-size: 0.75rem;
439 color: var(--theme-texto); 604 color: var(--theme-texto);
440 - opacity: 0.5; 605 + opacity: 0.6;
441 - padding: 4px 8px; 606 + padding: 4px 10px;
442 - background: var(--theme-fill); 607 + background: rgba(255, 255, 255, 0.08);
443 - border-radius: 4px; 608 + border-radius: 6px;
609 + }
610 +
611 + :global(html:not(.dark)) .result-code {
612 + background: rgba(0, 0, 0, 0.06);
444 } 613 }
445 614
446 /* Footer */ 615 /* Footer */
...@@ -448,8 +617,13 @@ ...@@ -448,8 +617,13 @@
448 display: flex; 617 display: flex;
449 gap: 16px; 618 gap: 16px;
450 padding: 12px 20px; 619 padding: 12px 20px;
451 - border-top: 1px solid var(--theme-borde); 620 + border-top: 1px solid rgba(255, 255, 255, 0.08);
452 - background: var(--theme-fill); 621 + background: rgba(255, 255, 255, 0.03);
622 + }
623 +
624 + :global(html:not(.dark)) .search-modal-footer {
625 + border-top-color: rgba(0, 0, 0, 0.08);
626 + background: rgba(0, 0, 0, 0.02);
453 } 627 }
454 628
455 .footer-hint { 629 .footer-hint {
...@@ -464,31 +638,53 @@ ...@@ -464,31 +638,53 @@
464 .footer-hint kbd { 638 .footer-hint kbd {
465 font-family: inherit; 639 font-family: inherit;
466 font-size: 0.625rem; 640 font-size: 0.625rem;
467 - padding: 2px 5px; 641 + padding: 3px 6px;
468 - background: var(--theme-body); 642 + background: rgba(255, 255, 255, 0.1);
469 - border: 1px solid var(--theme-borde); 643 + border: 1px solid rgba(255, 255, 255, 0.12);
470 border-radius: 4px; 644 border-radius: 4px;
471 } 645 }
472 646
647 + :global(html:not(.dark)) .footer-hint kbd {
648 + background: rgba(0, 0, 0, 0.06);
649 + border-color: rgba(0, 0, 0, 0.1);
650 + }
651 +
473 /* Responsive */ 652 /* Responsive */
474 @media (max-width: 640px) { 653 @media (max-width: 640px) {
475 .search-modal-backdrop { 654 .search-modal-backdrop {
476 padding: 0; 655 padding: 0;
477 align-items: flex-end; 656 align-items: flex-end;
657 + background: rgba(0, 0, 0, 0.4);
478 } 658 }
479 659
480 .search-modal { 660 .search-modal {
481 max-width: 100%; 661 max-width: 100%;
482 border-radius: 20px 20px 0 0; 662 border-radius: 20px 20px 0 0;
483 - max-height: 85vh; 663 + max-height: 90vh;
664 + border-bottom: none;
484 } 665 }
485 666
486 .search-modal-results { 667 .search-modal-results {
487 - max-height: 50vh; 668 + max-height: 45vh;
669 + }
670 +
671 + .filters-grid {
672 + grid-template-columns: 1fr;
673 + gap: 8px;
488 } 674 }
489 675
490 - .search-modal-filters { 676 + .search-filter-card {
491 - flex-wrap: wrap; 677 + flex-direction: row;
678 + align-items: center;
679 + padding: 10px 14px;
680 + }
681 +
682 + .filter-card-header {
683 + flex: 1;
684 + }
685 +
686 + .filter-desc {
687 + display: none;
492 } 688 }
493 689
494 .search-modal-footer { 690 .search-modal-footer {
......
...@@ -61,6 +61,43 @@ export async function initSearchIndex() { ...@@ -61,6 +61,43 @@ export async function initSearchIndex() {
61 throw errorRubros; 61 throw errorRubros;
62 } 62 }
63 63
64 + // Cargar finalidad y función
65 + const { data: finfun, error: errorFinfun } = await supabase
66 + .schema('ppto')
67 + .from('clas_finfun')
68 + .select('finfun, desc_finfun, nivel');
69 +
70 + if (errorFinfun) {
71 + console.error('Error cargando finalidad/función:', errorFinfun);
72 + throw errorFinfun;
73 + }
74 +
75 + // Cargar organismos (no bloquea si falla)
76 + let organismos = [];
77 + const { data: organismosData, error: errorOrganismos } = await supabase
78 + .schema('ppto')
79 + .from('clas_organismos')
80 + .select('organismo, organismo_desc_organismo, organismo_grupo, organismo_sub_grupo');
81 +
82 + if (errorOrganismos) {
83 + console.warn('Tabla clas_organismos no disponible:', errorOrganismos.message);
84 + } else {
85 + organismos = organismosData || [];
86 + }
87 +
88 + // Cargar fuentes (no bloquea si falla)
89 + let fuentes = [];
90 + const { data: fuentesData, error: errorFuentes } = await supabase
91 + .schema('ppto')
92 + .from('clas_fuentes')
93 + .select('fuente, desc_fuente, grupo_fuente, desc_grupo_fuente');
94 +
95 + if (errorFuentes) {
96 + console.warn('Tabla clas_fuentes no disponible:', errorFuentes.message);
97 + } else {
98 + fuentes = fuentesData || [];
99 + }
100 +
64 // Transformar entidades para el índice 101 // Transformar entidades para el índice
65 const entidadesIndex = entidades.map(item => ({ 102 const entidadesIndex = entidades.map(item => ({
66 tipo: 'entidad', 103 tipo: 'entidad',
...@@ -102,8 +139,49 @@ export async function initSearchIndex() { ...@@ -102,8 +139,49 @@ export async function initSearchIndex() {
102 codigo_normalizado: item.rubro?.toString() || '' 139 codigo_normalizado: item.rubro?.toString() || ''
103 })); 140 }));
104 141
142 + // Transformar finalidad y función para el índice
143 + const nivelLabelsFinfun = { finalidad: 'Finalidad', grpfuncion: 'Grupo Función', funcion: 'Función' };
144 + const finfunIndex = finfun.map(item => ({
145 + tipo: 'finfun',
146 + codigo: item.finfun,
147 + nombre: item.desc_finfun,
148 + sigla: null,
149 + contexto: `Finalidad y Función · ${nivelLabelsFinfun[item.nivel] || item.nivel}`,
150 + nivel: item.nivel,
151 + nombre_normalizado: normalizeText(item.desc_finfun),
152 + sigla_normalizada: '',
153 + codigo_normalizado: item.finfun?.toString() || ''
154 + }));
155 +
156 + // Transformar organismos para el índice
157 + // Todos los registros en la tabla son organismos (nivel hoja)
158 + const organismosIndex = organismos.map(item => ({
159 + tipo: 'organismo',
160 + codigo: item.organismo,
161 + nombre: item.organismo_desc_organismo,
162 + sigla: null,
163 + contexto: `Organismo · Grupo ${item.organismo_grupo}`,
164 + nivel: 'organismo',
165 + nombre_normalizado: normalizeText(item.organismo_desc_organismo),
166 + sigla_normalizada: '',
167 + codigo_normalizado: item.organismo?.toString() || ''
168 + }));
169 +
170 + // Transformar fuentes para el índice
171 + const fuentesIndex = fuentes.map(item => ({
172 + tipo: 'fuente',
173 + codigo: item.fuente,
174 + nombre: item.desc_fuente,
175 + sigla: null,
176 + contexto: `Fuente · ${item.desc_grupo_fuente}`,
177 + nivel: 'fuente',
178 + nombre_normalizado: normalizeText(item.desc_fuente),
179 + sigla_normalizada: '',
180 + codigo_normalizado: item.fuente?.toString() || ''
181 + }));
182 +
105 // Combinar todos los conjuntos 183 // Combinar todos los conjuntos
106 - searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex]; 184 + searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex, ...finfunIndex, ...organismosIndex, ...fuentesIndex];
107 185
108 fuseInstance = new Fuse(searchData, fuseOptions); 186 fuseInstance = new Fuse(searchData, fuseOptions);
109 187
......
...@@ -8,7 +8,7 @@ export const isLoading = writable(false); ...@@ -8,7 +8,7 @@ export const isLoading = writable(false);
8 export const indexLoaded = writable(false); 8 export const indexLoaded = writable(false);
9 export const error = writable(null); 9 export const error = writable(null);
10 export const selectedIndex = writable(-1); 10 export const selectedIndex = writable(-1);
11 -export const filterType = writable('todos'); // todos, entidad, objeto_gasto, rubro 11 +export const filterType = writable('todos'); // todos, entidad, objeto_gasto, rubro, finfun
12 12
13 // Estado derivado: si está mostrando resultados 13 // Estado derivado: si está mostrando resultados
14 export const showResults = derived( 14 export const showResults = derived(
......
...@@ -294,9 +294,9 @@ ...@@ -294,9 +294,9 @@
294 <!-- Bloque 1: Identidad --> 294 <!-- Bloque 1: Identidad -->
295 <div class="hero-identity"> 295 <div class="hero-identity">
296 <h1 class="hero-title anim" style="--d:200ms">Presupuesto <span class="gold">Abierto</span></h1> 296 <h1 class="hero-title anim" style="--d:200ms">Presupuesto <span class="gold">Abierto</span></h1>
297 - <p class="hero-sub anim" style="--d:340ms">Información diaria e histórica de todo el Estado Boliviano.</p> 297 + <p class="hero-sub anim" style="--d:340ms"><a href="/vista-diaria" class="hero-sub-link">Información diaria</a> e histórica de todo el Estado Boliviano.</p>
298 <div class="hero-stats anim" style="--d:420ms"> 298 <div class="hero-stats anim" style="--d:420ms">
299 - <span class="stat-num">21</span> años<span class="stat-dot">·</span><span class="stat-num">8.3M</span> registros<span class="stat-dot">·</span><span class="stat-num">647</span> instituciones<span class="stat-dot">·</span><span class="stat-num">7</span> clasificadores<span class="stat-dot">·</span><span class="stat-num">10</span> visualizaciones<span class="stat-dot">·</span><span class="stat-num">4</span> historias 299 + <span class="stat-num">21</span> años<span class="stat-dot">·</span><span class="stat-num">8.3M</span> registros<span class="stat-dot">·</span><span class="stat-num">647</span> instituciones<span class="stat-dot">·</span><span class="stat-num">7</span> <a href="#clasificadores" class="stat-link">clasificadores</a><span class="stat-dot">·</span><span class="stat-num">10</span> <a href="#visualizaciones" class="stat-link">visualizaciones</a><span class="stat-dot">·</span><span class="stat-num">4</span> <a href="#historias" class="stat-link">historias</a>
300 </div> 300 </div>
301 </div> 301 </div>
302 302
...@@ -305,20 +305,20 @@ ...@@ -305,20 +305,20 @@
305 <div class="search-wrap"> 305 <div class="search-wrap">
306 <!-- Search bar unificado --> 306 <!-- Search bar unificado -->
307 <div class="search-bar" class:search-focused={searchFocused} class:search-has-results={searchFocused && $query.length >= 2 && ($results.length > 0 || $results.length === 0 || $isLoading)} style="--accent:{accent}"> 307 <div class="search-bar" class:search-focused={searchFocused} class:search-has-results={searchFocused && $query.length >= 2 && ($results.length > 0 || $results.length === 0 || $isLoading)} style="--accent:{accent}">
308 - <!-- Toggle integrado (desktop) --> 308 + <!-- Link al diario + Badge informativo (desktop) -->
309 - <div class="toggle-bar toggle-desktop"> 309 + <div class="search-meta toggle-desktop">
310 - <button class="toggle-btn" class:toggle-active={mode === 'archivo'} on:click={() => switchMode('archivo')}> 310 + <a href="/vista-diaria" class="search-meta-item search-diario-link">
311 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={mode === 'archivo' ? '#C9A751' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 311 + Ver gasto de hoy
312 - <rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/> 312 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
313 + <line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
313 </svg> 314 </svg>
314 - 2005 a 2025 315 + </a>
315 - </button> 316 + <span class="search-meta-item search-badge">
316 - <button class="toggle-btn" class:toggle-active={mode === 'diario'} on:click={() => switchMode('diario')}> 317 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
317 - <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={mode === 'diario' ? '#4A8B6E' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 318 + <rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
318 - <polyline points="3 12 7 12 9 6 12 18 15 9 17 12 21 12"/>
319 </svg> 319 </svg>
320 - Diario 320 + Viendo 2005 – 2025
321 - </button> 321 + </span>
322 </div> 322 </div>
323 <div class="search-divider"></div> 323 <div class="search-divider"></div>
324 324
...@@ -451,21 +451,19 @@ ...@@ -451,21 +451,19 @@
451 451
452 <!-- Mobile settings panel --> 452 <!-- Mobile settings panel -->
453 <div class="mobile-settings-panel" class:mobile-settings-panel-open={mobileSettingsOpen}> 453 <div class="mobile-settings-panel" class:mobile-settings-panel-open={mobileSettingsOpen}>
454 - <div class="mobile-toggle-bar"> 454 + <div class="mobile-meta-bar">
455 - <button class="mobile-toggle-btn" class:toggle-active={mode === 'archivo'} on:click={() => { switchMode('archivo'); mobileSettingsOpen = false; }}> 455 + <a href="/vista-diaria" class="mobile-meta-item mobile-diario-link">
456 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={mode === 'archivo' ? '#C9A751' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 456 + Ver hoy
457 - <rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/> 457 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
458 + <line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/>
458 </svg> 459 </svg>
459 - <span>Histórico</span> 460 + </a>
460 - <span class="mobile-toggle-sub">2005 — 2025</span> 461 + <span class="mobile-meta-item mobile-search-badge">
461 - </button> 462 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
462 - <button class="mobile-toggle-btn" class:toggle-active={mode === 'diario'} on:click={() => { switchMode('diario'); mobileSettingsOpen = false; }}> 463 + <rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
463 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={mode === 'diario' ? '#4A8B6E' : '#B8B5AD'} stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
464 - <polyline points="3 12 7 12 9 6 12 18 15 9 17 12 21 12"/>
465 </svg> 464 </svg>
466 - <span>Diario</span> 465 + Viendo 2005 – 2025
467 - <span class="mobile-toggle-sub">En vivo</span> 466 + </span>
468 - </button>
469 </div> 467 </div>
470 <div class="mobile-filter-section"> 468 <div class="mobile-filter-section">
471 <div class="mobile-filter-title">Buscar</div> 469 <div class="mobile-filter-title">Buscar</div>
...@@ -944,20 +942,27 @@ ...@@ -944,20 +942,27 @@
944 .hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD} 942 .hero-tag{font-family:var(--mono);font-size:12px;letter-spacing:0.25em;color:#B8B5AD}
945 .hero-title{font-size:clamp(44px,5.5vw,72px);font-weight:900;line-height:1.02;letter-spacing:-0.035em;max-width:720px;margin:14px auto 0} 943 .hero-title{font-size:clamp(44px,5.5vw,72px);font-weight:900;line-height:1.02;letter-spacing:-0.035em;max-width:720px;margin:14px auto 0}
946 .hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#A5A29A;max-width:100%;margin-top:14px;line-height:1.6} 944 .hero-sub{font-family:var(--sans);font-size:clamp(16px,1.6vw,18px);color:#A5A29A;max-width:100%;margin-top:14px;line-height:1.6}
945 + .hero-sub-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(165,162,154,0.5);transition:all 0.2s;padding-bottom:1px}
946 + .hero-sub-link:hover{color:#4A8B6E;border-bottom-color:#4A8B6E}
947 .hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1} 947 .hero-stats{font-family:var(--mono);font-size:13px;letter-spacing:0.04em;color:#B8B5AD;margin-top:16px;line-height:1}
948 .stat-num{color:#F5F0E8} 948 .stat-num{color:#F5F0E8}
949 .stat-dot{margin:0 10px;opacity:0.3} 949 .stat-dot{margin:0 10px;opacity:0.3}
950 + .stat-link{color:inherit;text-decoration:none;border-bottom:1px dotted rgba(184,181,173,0.4);transition:all 0.2s;padding-bottom:1px}
951 + .stat-link:hover{color:#C9A751;border-bottom-color:#C9A751}
950 952
951 /* Search */ 953 /* Search */
952 - .search-wrap{max-width:860px;width:100%;margin:0 auto;position:relative;z-index:9999} 954 + .search-wrap{max-width:960px;width:100%;margin:0 auto;position:relative;z-index:9999}
953 .search-bar{display:flex;align-items:center;background:rgba(255,255,255,0.04);border-radius:16px;padding:8px 14px 8px 8px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1);box-shadow:0 0 0 1px rgba(201,167,81,0.12),0 8px 32px rgba(0,0,0,0.25);position:relative} 955 .search-bar{display:flex;align-items:center;background:rgba(255,255,255,0.04);border-radius:16px;padding:8px 14px 8px 8px;transition:all 0.4s cubic-bezier(0.16,1,0.3,1);box-shadow:0 0 0 1px rgba(201,167,81,0.12),0 8px 32px rgba(0,0,0,0.25);position:relative}
954 .search-focused{background:rgba(255,255,255,0.07);box-shadow:0 0 0 1.5px rgba(201,167,81,0.35),0 8px 40px rgba(0,0,0,0.3)} 956 .search-focused{background:rgba(255,255,255,0.07);box-shadow:0 0 0 1.5px rgba(201,167,81,0.35),0 8px 40px rgba(0,0,0,0.3)}
955 .search-has-results{border-radius:16px 16px 0 0} 957 .search-has-results{border-radius:16px 16px 0 0}
956 - .toggle-bar{display:flex;border-radius:10px;padding:2px;background:rgba(0,0,0,0.2);flex-shrink:0} 958 + .search-meta{display:flex;align-items:center;gap:8px;flex-shrink:0;padding:0 4px}
957 - .toggle-btn{background:transparent;border:none;border-radius:8px;padding:12px 16px;cursor:pointer;display:flex;align-items:center;gap:7px;transition:all 0.3s;font-family:var(--sans);font-size:14px;font-weight:400;color:#9B9890} 959 + .search-meta-item{display:flex;align-items:center;gap:7px;font-family:var(--sans);font-size:13px;font-weight:500;padding:10px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
958 - .toggle-btn svg{display:block;flex-shrink:0} 960 + .search-meta-item svg{display:block;flex-shrink:0}
959 - .toggle-btn:hover{color:#B8B5AD} 961 + .search-badge{color:#C9A751;background:rgba(201,167,81,0.12)}
960 - .toggle-active{background:rgba(255,255,255,0.1);font-weight:600;color:#F5F0E8} 962 + .search-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)}
963 + .search-diario-link:hover{background:rgba(74,139,110,0.22);color:#5AA080}
964 + .search-diario-link svg{transition:transform 0.2s}
965 + .search-diario-link:hover svg{transform:translateX(3px)}
961 .search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0} 966 .search-divider{width:1px;height:32px;background:rgba(255,255,255,0.08);margin:0 16px;flex-shrink:0}
962 .search-icon{flex-shrink:0;transition:stroke 0.3s} 967 .search-icon{flex-shrink:0;transition:stroke 0.3s}
963 .search-input-wrap{flex:1;position:relative;display:flex;align-items:center} 968 .search-input-wrap{flex:1;position:relative;display:flex;align-items:center}
...@@ -995,12 +1000,12 @@ ...@@ -995,12 +1000,12 @@
995 /* Mobile settings panel (hidden on desktop) */ 1000 /* Mobile settings panel (hidden on desktop) */
996 .mobile-settings-panel{display:none;overflow:hidden;max-height:0;opacity:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)} 1001 .mobile-settings-panel{display:none;overflow:hidden;max-height:0;opacity:0;transition:all 0.3s cubic-bezier(0.16,1,0.3,1)}
997 .mobile-settings-panel-open{max-height:300px;opacity:1;margin-top:12px} 1002 .mobile-settings-panel-open{max-height:300px;opacity:1;margin-top:12px}
998 - .mobile-toggle-bar{display:flex;gap:10px} 1003 + .mobile-meta-bar{display:flex;gap:10px;padding:4px 0}
999 - .mobile-toggle-btn{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;padding:16px 12px;border-radius:12px;background:rgba(0,0,0,0.2);border:1px solid rgba(255,255,255,0.06);cursor:pointer;transition:all 0.3s} 1004 + .mobile-meta-item{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-family:var(--sans);font-size:13px;font-weight:500;padding:12px 14px;border-radius:10px;text-decoration:none;transition:all 0.2s}
1000 - .mobile-toggle-btn.toggle-active{background:rgba(255,255,255,0.1);border-color:rgba(255,255,255,0.15)} 1005 + .mobile-meta-item svg{flex-shrink:0}
1001 - .mobile-toggle-btn span{font-family:var(--sans);font-size:14px;font-weight:600;color:#F5F0E8} 1006 + .mobile-search-badge{color:#C9A751;background:rgba(201,167,81,0.12)}
1002 - .mobile-toggle-btn svg{margin-bottom:4px} 1007 + .mobile-diario-link{color:#4A8B6E;background:rgba(74,139,110,0.12)}
1003 - .mobile-toggle-sub{font-size:11px!important;font-weight:400!important;color:#9B9890!important;font-family:var(--mono)!important;letter-spacing:0.05em} 1008 + .mobile-diario-link:hover,.mobile-diario-link:active{background:rgba(74,139,110,0.22)}
1004 .mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)} 1009 .mobile-filter-section{margin-top:12px;padding-top:12px;border-top:1px solid rgba(255,255,255,0.06)}
1005 .mobile-filter-title{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:0.1em;color:#6B6860;text-transform:uppercase;margin-bottom:8px} 1010 .mobile-filter-title{font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:0.1em;color:#6B6860;text-transform:uppercase;margin-bottom:8px}
1006 .mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap} 1011 .mobile-filter-bar{display:flex;gap:8px;flex-wrap:wrap}
...@@ -1337,8 +1342,7 @@ ...@@ -1337,8 +1342,7 @@
1337 /* Search */ 1342 /* Search */
1338 .search-wrap{padding:0} 1343 .search-wrap{padding:0}
1339 .mobile-settings-btn{width:36px;height:36px} 1344 .mobile-settings-btn{width:36px;height:36px}
1340 - .mobile-toggle-btn{padding:14px 10px} 1345 + .mobile-meta-item{padding:10px 12px;font-size:12px}
1341 - .mobile-toggle-btn span{font-size:13px}
1342 .search-results{max-height:220px} 1346 .search-results{max-height:220px}
1343 .search-result-item{padding:10px 14px} 1347 .search-result-item{padding:10px 14px}
1344 .result-name{font-size:12px} 1348 .result-name{font-size:12px}
......
1 -<script> 1 +me pare<script>
2 import { supabase } from '$lib/supabase'; 2 import { supabase } from '$lib/supabase';
3 import { onMount, untrack } from 'svelte'; 3 import { onMount, untrack } from 'svelte';
4 import { page } from '$app/stores'; 4 import { page } from '$app/stores';
...@@ -79,17 +79,24 @@ ...@@ -79,17 +79,24 @@
79 '10': 'Protección Social', 79 '10': 'Protección Social',
80 }; 80 };
81 81
82 - // Función para obtener color según la finalidad (primer dígito del código finfun) 82 + // Helper para obtener la finalidad de un código (maneja caso especial de "10")
83 + function getFinalidadKey(finfun) {
84 + const code = String(finfun);
85 + if (code.startsWith('10')) return '10';
86 + return code.charAt(0);
87 + }
88 +
89 + // Función para obtener color según la finalidad
83 function getNodeColor(finfun, opacity = 1) { 90 function getNodeColor(finfun, opacity = 1) {
84 - const finalidadDigit = String(finfun).charAt(0); 91 + const finalidadKey = getFinalidadKey(finfun);
85 - const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888'; 92 + const baseColor = FINALIDAD_COLORS[finalidadKey] || '#888888';
86 if (opacity === 1) return baseColor; 93 if (opacity === 1) return baseColor;
87 return d3.color(baseColor).copy({opacity}).formatRgb(); 94 return d3.color(baseColor).copy({opacity}).formatRgb();
88 } 95 }
89 96
90 function getTextColor(finfun) { 97 function getTextColor(finfun) {
91 - const finalidadDigit = String(finfun).charAt(0); 98 + const finalidadKey = getFinalidadKey(finfun);
92 - const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888'; 99 + const baseColor = FINALIDAD_COLORS[finalidadKey] || '#888888';
93 const color = d3.color(baseColor); 100 const color = d3.color(baseColor);
94 const r = color.r / 255; 101 const r = color.r / 255;
95 const g = color.g / 255; 102 const g = color.g / 255;
...@@ -99,8 +106,8 @@ ...@@ -99,8 +106,8 @@
99 } 106 }
100 107
101 function getTextColorSecondary(finfun) { 108 function getTextColorSecondary(finfun) {
102 - const finalidadDigit = String(finfun).charAt(0); 109 + const finalidadKey = getFinalidadKey(finfun);
103 - const baseColor = FINALIDAD_COLORS[finalidadDigit] || '#888888'; 110 + const baseColor = FINALIDAD_COLORS[finalidadKey] || '#888888';
104 const color = d3.color(baseColor); 111 const color = d3.color(baseColor);
105 const r = color.r / 255; 112 const r = color.r / 255;
106 const g = color.g / 255; 113 const g = color.g / 255;
...@@ -610,13 +617,26 @@ ...@@ -610,13 +617,26 @@
610 }); 617 });
611 618
612 // Helpers para derivar jerarquía desde código finfun 619 // 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) 620 + // Jerarquía: Finalidad (1-9 o 10) → Grupo Función (2-3 dígitos) → Función (3+ dígitos)
621 + // Nota: Finalidad 10 es especial - tiene 2 dígitos, sus hijos empiezan con "10"
614 function getFinalidadFromFinfun(finfun) { 622 function getFinalidadFromFinfun(finfun) {
615 - return finfun.charAt(0); 623 + const code = String(finfun);
624 + // Si empieza con "10", la finalidad es "10"
625 + if (code.startsWith('10')) {
626 + return '10';
627 + }
628 + // Si no, la finalidad es el primer dígito
629 + return code.charAt(0);
616 } 630 }
617 631
618 function getGrpFuncionFromFinfun(finfun) { 632 function getGrpFuncionFromFinfun(finfun) {
619 - return finfun.substring(0, 2); 633 + const code = String(finfun);
634 + // Si empieza con "10", el grupo función son los primeros 3 dígitos (ej: "101", "102")
635 + if (code.startsWith('10')) {
636 + return code.substring(0, 3);
637 + }
638 + // Si no, son los primeros 2 dígitos (ej: "11", "21", "93")
639 + return code.substring(0, 2);
620 } 640 }
621 641
622 function getItemsForFinalidad(finalidadCode) { 642 function getItemsForFinalidad(finalidadCode) {
......
1 +<script>
2 + import { supabase } from '$lib/supabase';
3 + import { onMount } from 'svelte';
4 + import { fade, scale } from 'svelte/transition';
5 + import { cubicOut } from 'svelte/easing';
6 +
7 + let loading = $state(true);
8 + let searchQuery = $state('');
9 + let grupos = $state([]);
10 + let allItems = $state([]);
11 + let selectedGrupo = $state(null);
12 + let selectedItem = $state(null);
13 + let highlightedItem = $state(null);
14 + let sidebarOpen = $state(false);
15 +
16 + onMount(async () => {
17 + // Forzar recálculo de layout para navegación cliente
18 + requestAnimationFrame(() => {
19 + document.body.offsetHeight; // Force reflow
20 + });
21 +
22 + const { data, error } = await supabase
23 + .schema('ppto')
24 + .from('clas_fuentes')
25 + .select('*')
26 + .order('fuente');
27 +
28 + if (!error && data) {
29 + allItems = data;
30 +
31 + // Crear grupos sintéticos desde valores únicos de grupo_fuente
32 + const gruposUnicos = [...new Set(data.map(item => item.grupo_fuente))].filter(g => g != null);
33 + grupos = gruposUnicos.map(grupoNum => {
34 + const sample = data.find(item => item.grupo_fuente === grupoNum);
35 + return {
36 + grupo_fuente: grupoNum,
37 + desc_grupo_fuente: sample?.desc_grupo_fuente || `Grupo ${grupoNum}`
38 + };
39 + }).sort((a, b) => Number(a.grupo_fuente) - Number(b.grupo_fuente));
40 +
41 + if (grupos.length > 0) {
42 + selectedGrupo = grupos[0];
43 + }
44 + }
45 +
46 + loading = false;
47 + });
48 +
49 + // Obtener fuentes para un grupo específico
50 + function getFuentesForGrupo(grupo) {
51 + if (!grupo) return [];
52 + return allItems
53 + .filter(item => item.grupo_fuente === grupo.grupo_fuente)
54 + .sort((a, b) => String(a.fuente).localeCompare(String(b.fuente)));
55 + }
56 +
57 + // Búsqueda global
58 + function searchGlobal(query) {
59 + if (!query || query.length < 2) return [];
60 + const q = query.toLowerCase();
61 +
62 + return allItems
63 + .filter(item =>
64 + item.fuente?.toString().includes(q) ||
65 + item.desc_fuente?.toLowerCase().includes(q) ||
66 + item.desc_grupo_fuente?.toLowerCase().includes(q)
67 + )
68 + .slice(0, 20);
69 + }
70 +
71 + function parseDescripciones(descripcionesStr) {
72 + if (!descripcionesStr) return [];
73 + try {
74 + const parsed = typeof descripcionesStr === 'string'
75 + ? JSON.parse(descripcionesStr)
76 + : descripcionesStr;
77 + return parsed.sort((a, b) => {
78 + const maxYearA = getMaxYear(a.rangos);
79 + const maxYearB = getMaxYear(b.rangos);
80 + return maxYearB - maxYearA;
81 + });
82 + } catch {
83 + return [];
84 + }
85 + }
86 +
87 + function getMaxYear(rangos) {
88 + if (!rangos) return 0;
89 + const years = rangos.match(/\d{4}/g);
90 + if (!years) return 0;
91 + return Math.max(...years.map(y => parseInt(y)));
92 + }
93 +
94 + function selectGrupo(grupo) {
95 + selectedGrupo = grupo;
96 + selectedItem = null;
97 + searchQuery = '';
98 + highlightedItem = null;
99 + sidebarOpen = false;
100 + }
101 +
102 + function openDetail(item) {
103 + selectedItem = item;
104 + }
105 +
106 + function closeDetail() {
107 + selectedItem = null;
108 + }
109 +
110 + function goToSearchResult(item) {
111 + const targetGrupo = grupos.find(g => g.grupo_fuente === item.grupo_fuente);
112 +
113 + if (targetGrupo) {
114 + selectedGrupo = targetGrupo;
115 + highlightedItem = item.fuente;
116 + searchQuery = '';
117 + sidebarOpen = false;
118 +
119 + setTimeout(() => {
120 + const element = document.getElementById(`f-${item.fuente}`);
121 + if (element) {
122 + element.scrollIntoView({ behavior: 'smooth', block: 'center' });
123 + }
124 + }, 100);
125 +
126 + setTimeout(() => {
127 + highlightedItem = null;
128 + }, 2000);
129 + }
130 + }
131 +
132 + let grupoContent = $derived(selectedGrupo ? getFuentesForGrupo(selectedGrupo) : []);
133 + let searchResults = $derived(searchGlobal(searchQuery));
134 + let isSearching = $derived(searchQuery.length >= 2);
135 +
136 + // Contadores
137 + let totalItems = $derived(allItems.length);
138 + let totalGrupos = $derived(grupos.length);
139 +</script>
140 +
141 +<svelte:head>
142 + <title>Fuentes de Financiamiento | Presupuesto Público</title>
143 + <link rel="preconnect" href="https://fonts.googleapis.com" />
144 + <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" />
145 +</svelte:head>
146 +
147 +<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;">
148 + <!-- Header pedagógico -->
149 + <header class="border-b" style="border-color: var(--theme-borde); background-color: var(--theme-body);">
150 + <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
151 + <!-- Breadcrumb: responsive -->
152 + <nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
153 + <!-- Móvil: solo padre -->
154 + <a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
155 + ← Clasificadores
156 + </a>
157 + <!-- Desktop: ruta completa -->
158 + <div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
159 + <a href="/" class="transition-colors hover:opacity-80">Inicio</a>
160 + <span style="opacity: 0.5;">/</span>
161 + <a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
162 + </div>
163 + </nav>
164 +
165 + <div class="max-w-3xl">
166 + <p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
167 + Clasificador 02
168 + </p>
169 + <h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
170 + ¿Con qué recursos se financia?
171 + </h1>
172 +
173 + <p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
174 + Organiza los recursos según su <strong style="color: var(--theme-titulo);">origen de financiamiento</strong>:
175 + Tesoro General, recursos específicos, créditos externos e internos, donaciones.
176 + Permite entender de dónde provienen los fondos para el gasto público.
177 + </p>
178 +
179 + {#if totalItems > 0}
180 + <p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
181 + <span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> fuentes de financiamiento en
182 + <span style="color: var(--theme-titulo); font-weight: 500;">{totalGrupos}</span> grupos
183 + </p>
184 + {/if}
185 +
186 + <!-- Jerarquía -->
187 + <div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
188 + <span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body); opacity: 0.9;">
189 + Grupo
190 + </span>
191 + <span style="color: var(--theme-texto);">→</span>
192 + <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);">
193 + Fuente
194 + </span>
195 + </div>
196 + <!-- Versión móvil simplificada -->
197 + <p class="sm:hidden text-sm mb-4" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
198 + Jerarquía: Grupo → Fuente
199 + </p>
200 + </div>
201 + </div>
202 + </header>
203 +
204 + {#if loading}
205 + <div class="flex items-center justify-center py-20">
206 + <p style="color: var(--theme-texto);">Cargando clasificador...</p>
207 + </div>
208 + {:else}
209 + <!-- Botón móvil para abrir sidebar -->
210 + <div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
211 + <button
212 + onclick={() => sidebarOpen = !sidebarOpen}
213 + class="flex items-center gap-2 text-sm"
214 + style="color: var(--theme-texto);"
215 + >
216 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
217 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
218 + </svg>
219 + <span class="font-medium">{selectedGrupo ? selectedGrupo.desc_grupo_fuente : 'Seleccionar grupo'}</span>
220 + <svg class="w-4 h-4 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
221 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
222 + </svg>
223 + </button>
224 + </div>
225 +
226 + <div class="clasificador-layout max-w-screen-xl mx-auto flex px-4" style="display: flex !important; flex-direction: row;">
227 + <!-- Sidebar izquierda: Grupos -->
228 + {#if sidebarOpen}
229 + <div
230 + transition:fade={{ duration: 150 }}
231 + class="fixed inset-0 bg-black/30 z-40 lg:hidden"
232 + onclick={() => sidebarOpen = false}
233 + ></div>
234 + {/if}
235 + <aside class="
236 + {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
237 + lg:translate-x-0
238 + fixed lg:relative
239 + inset-y-0 left-0
240 + w-72 lg:w-64
241 + z-50 lg:z-auto
242 + transition-transform duration-200 ease-in-out
243 + lg:flex-shrink-0
244 + shadow-xl lg:shadow-none
245 + sidebar-left
246 + ">
247 + <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">
248 + <!-- Cerrar en móvil -->
249 + <div class="flex justify-between items-center mb-4 lg:hidden">
250 + <span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos de fuentes</span>
251 + <button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
252 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
253 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
254 + </svg>
255 + </button>
256 + </div>
257 +
258 + <!-- Buscador -->
259 + <div class="mb-6">
260 + <input
261 + type="text"
262 + bind:value={searchQuery}
263 + placeholder="Buscar..."
264 + class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
265 + style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
266 + />
267 + </div>
268 +
269 + <!-- Resultados de búsqueda -->
270 + {#if isSearching}
271 + <div class="mb-4">
272 + <p class="text-xs uppercase tracking-wide mb-2" style="color: var(--theme-texto);">
273 + {searchResults.length} resultados
274 + </p>
275 + <div class="space-y-1">
276 + {#each searchResults as result}
277 + <button
278 + class="w-full text-left px-2 py-2 text-sm rounded transition-all"
279 + style="color: var(--theme-titulo);"
280 + onclick={() => goToSearchResult(result)}
281 + >
282 + <span class="text-xs block" style="color: var(--theme-texto);">Fuente</span>
283 + <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.fuente}</span>
284 + <span class="ml-1">{result.desc_fuente}</span>
285 + </button>
286 + {/each}
287 + {#if searchResults.length === 0}
288 + <p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
289 + {/if}
290 + </div>
291 + </div>
292 + {:else}
293 + <!-- Lista de grupos -->
294 + <nav>
295 + <p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
296 + <ul class="space-y-1">
297 + {#each grupos as grupo}
298 + <li>
299 + <button
300 + class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
301 + style="{selectedGrupo?.grupo_fuente === grupo.grupo_fuente
302 + ? `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);`
303 + : `color: var(--theme-texto);`}"
304 + onclick={() => selectGrupo(grupo)}
305 + >
306 + <span class="font-mono text-xs block" style="color: var(--theme-texto);">Grupo {grupo.grupo_fuente}</span>
307 + {grupo.desc_grupo_fuente}
308 + </button>
309 + </li>
310 + {/each}
311 + </ul>
312 + </nav>
313 + {/if}
314 + </div>
315 + </aside>
316 +
317 + <!-- Contenido principal -->
318 + <main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
319 + <div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
320 + {#if selectedGrupo}
321 + {#key selectedGrupo.grupo_fuente}
322 + <div in:fade={{ duration: 200, delay: 50 }}>
323 + <!-- Título del grupo -->
324 + <div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
325 + <p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">Grupo {selectedGrupo.grupo_fuente}</p>
326 + <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);">
327 + {selectedGrupo.desc_grupo_fuente}
328 + </h2>
329 + <p class="text-sm" style="color: var(--theme-texto);">
330 + {grupoContent.length} fuentes de financiamiento en este grupo
331 + </p>
332 + </div>
333 +
334 + <!-- Fuentes -->
335 + <div class="space-y-8">
336 + {#each grupoContent as fuente}
337 + {@const fuenteDescs = parseDescripciones(fuente.descripciones)}
338 + <section
339 + id="f-{fuente.fuente}"
340 + class="scroll-mt-4 {highlightedItem === fuente.fuente ? 'highlighted' : ''}"
341 + >
342 + <div class="flex items-start gap-2 sm:gap-4 mb-3">
343 + <span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{fuente.fuente}</span>
344 + <div class="flex-1">
345 + <h3 class="text-base sm:text-lg font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
346 + <span class="text-left">{fuente.desc_fuente}</span>
347 + {#if fuente.n_variaciones > 1}
348 + <button
349 + 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"
350 + onclick={() => openDetail(fuente)}
351 + title="Ver variaciones de descripción"
352 + >
353 + {fuente.n_variaciones} var.
354 + </button>
355 + {/if}
356 + <a
357 + href="/fuente/{fuente.fuente}"
358 + class="transition-colors hover:text-[var(--theme-accent)]"
359 + style="color: var(--theme-texto);"
360 + title="Ver página de detalle"
361 + >
362 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
363 + <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" />
364 + </svg>
365 + </a>
366 + </h3>
367 + {#if fuenteDescs.length > 0}
368 + <p class="text-sm mt-1 leading-relaxed" style="color: var(--theme-texto);">{fuenteDescs[0].descripcion}</p>
369 + {/if}
370 + <!-- Vigencia temporal -->
371 + {#if fuente.gestiones}
372 + <p class="text-xs mt-3 font-mono" style="color: var(--theme-texto); opacity: 0.7;">
373 + Vigente: {fuente.gestiones}
374 + </p>
375 + {/if}
376 + </div>
377 + </div>
378 + </section>
379 + {/each}
380 + </div>
381 + </div>
382 + {/key}
383 + {/if}
384 + </div>
385 + </main>
386 +
387 + <!-- Sidebar derecha: En esta página -->
388 + <aside class="w-56 flex-shrink-0 hidden xl:block">
389 + <div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
390 + <p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
391 + {#if selectedGrupo && !isSearching}
392 + <nav class="space-y-2">
393 + {#each grupoContent as fuente}
394 + <a
395 + href="#f-{fuente.fuente}"
396 + class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
397 + style="color: var(--theme-texto);"
398 + title="{fuente.desc_fuente}"
399 + >
400 + <span class="font-mono text-xs" style="opacity: 0.6;">{fuente.fuente}</span>
401 + {fuente.desc_fuente}
402 + </a>
403 + {/each}
404 + </nav>
405 + {/if}
406 + </div>
407 + </aside>
408 + </div>
409 + {/if}
410 +</div>
411 +
412 +<!-- Modal de detalle -->
413 +{#if selectedItem}
414 + <div
415 + transition:fade={{ duration: 150 }}
416 + class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
417 + onclick={closeDetail}
418 + >
419 + <div
420 + transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
421 + class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
422 + style="background-color: var(--theme-surface);"
423 + onclick={(e) => e.stopPropagation()}
424 + >
425 + <div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
426 + <div>
427 + <p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">Fuente de Financiamiento</p>
428 + <h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
429 + <span class="font-mono" style="color: var(--theme-texto);">{selectedItem.fuente}</span>
430 + <span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
431 + {selectedItem.desc_fuente}
432 + </h3>
433 + </div>
434 + <button
435 + onclick={closeDetail}
436 + class="text-2xl leading-none"
437 + style="color: var(--theme-texto);"
438 + >
439 + &times;
440 + </button>
441 + </div>
442 +
443 + <div class="p-6 overflow-y-auto flex-1">
444 + <h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
445 + {#if selectedItem.n_variaciones > 1}
446 + Descripciones ({selectedItem.n_variaciones} variaciones)
447 + {:else}
448 + Descripción
449 + {/if}
450 + </h4>
451 +
452 + <div class="space-y-4">
453 + {#each parseDescripciones(selectedItem.descripciones) as desc, i}
454 + <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);`}">
455 + <p class="text-sm mb-2" style="color: var(--theme-texto);">
456 + {#if i === 0 && selectedItem.n_variaciones > 1}
457 + <span class="font-medium" style="color: var(--theme-accent);">Vigente</span>
458 + <span class="mx-1" style="opacity: 0.5;">·</span>
459 + {/if}
460 + <span class="font-mono">{desc.rangos}</span>
461 + </p>
462 + <p class="text-base leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion}</p>
463 + </div>
464 + {/each}
465 + </div>
466 +
467 + <!-- Vigencia temporal -->
468 + {#if selectedItem.gestiones}
469 + <div class="mt-6 pt-4 border-t" style="border-color: var(--theme-borde);">
470 + <p class="text-xs font-mono" style="color: var(--theme-texto);">
471 + <span class="font-medium">Años con datos:</span> {selectedItem.gestiones}
472 + </p>
473 + </div>
474 + {/if}
475 + </div>
476 + </div>
477 + </div>
478 +{/if}
479 +
480 +<style>
481 + /* Sidebar izquierdo: con fondo en móvil, transparente en desktop */
482 + .sidebar-left {
483 + background-color: var(--theme-surface);
484 + }
485 + @media (min-width: 1024px) {
486 + .sidebar-left {
487 + background-color: transparent;
488 + }
489 + }
490 +
491 + /* Highlight pulse animation para resultados de búsqueda */
492 + @keyframes highlight-pulse {
493 + 0%, 100% {
494 + box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-accent) 30%, transparent);
495 + }
496 + 50% {
497 + box-shadow: 0 0 0 4px color-mix(in srgb, var(--theme-accent) 50%, transparent);
498 + }
499 + }
500 +
501 + .highlighted {
502 + animation: highlight-pulse 0.8s ease-in-out 2;
503 + border-radius: 0.5rem;
504 + padding: 0.5rem;
505 + margin-left: -0.5rem;
506 + }
507 +
508 + /* Layout principal - fallback nativo */
509 + .clasificador-layout {
510 + display: flex !important;
511 + flex-direction: row !important;
512 + }
513 +
514 + /* Sidebar - fallback para responsive */
515 + .sidebar-left {
516 + position: fixed;
517 + transform: translateX(-100%);
518 + }
519 +
520 + @media (min-width: 1024px) {
521 + .sidebar-left {
522 + position: relative !important;
523 + transform: translateX(0) !important;
524 + flex-shrink: 0;
525 + width: 16rem;
526 + z-index: auto !important;
527 + box-shadow: none !important;
528 + }
529 + }
530 +
531 + @media (max-width: 1023px) {
532 + .clasificador-layout {
533 + display: block !important;
534 + }
535 + }
536 +</style>
1 +<script>
2 + import { supabase } from '$lib/supabase';
3 + import { onMount } from 'svelte';
4 + import { fade, scale } from 'svelte/transition';
5 + import { cubicOut } from 'svelte/easing';
6 +
7 + let loading = $state(true);
8 + let searchQuery = $state('');
9 + let grupos = $state([]);
10 + let allItems = $state([]);
11 + let selectedGrupo = $state(null);
12 + let selectedItem = $state(null);
13 + let highlightedItem = $state(null);
14 + let sidebarOpen = $state(false);
15 +
16 + onMount(async () => {
17 + requestAnimationFrame(() => {
18 + document.body.offsetHeight;
19 + });
20 +
21 + const { data, error } = await supabase
22 + .schema('ppto')
23 + .from('clas_organismos')
24 + .select('*')
25 + .order('organismo');
26 +
27 + if (!error && data) {
28 + allItems = data;
29 +
30 + // Extraer grupos únicos desde organismo_grupo
31 + const gruposUnicos = [...new Set(data.map(d => d.organismo_grupo))].filter(g => g != null);
32 +
33 + grupos = gruposUnicos.map(grupoNum => {
34 + const sample = data.find(d => d.organismo_grupo === grupoNum);
35 + return {
36 + organismo_grupo: grupoNum,
37 + organismo_desc_grupo: sample?.organismo_desc_grupo || `Grupo ${grupoNum}`,
38 + organismo_sigla_grupo: sample?.organismo_sigla_grupo || ''
39 + };
40 + }).sort((a, b) => Number(a.organismo_grupo) - Number(b.organismo_grupo));
41 +
42 + if (grupos.length > 0) {
43 + selectedGrupo = grupos[0];
44 + }
45 + }
46 +
47 + loading = false;
48 + });
49 +
50 + // Helper para obtener subgrupos de un grupo
51 + function getItemsForGrupo(grupo) {
52 + if (!grupo) return { subgrupos: [] };
53 +
54 + const grupoNum = grupo.organismo_grupo;
55 + const itemsDelGrupo = allItems.filter(item => item.organismo_grupo === grupoNum);
56 + const subgruposUnicos = [...new Set(itemsDelGrupo.map(i => i.organismo_sub_grupo))].filter(s => s != null);
57 +
58 + const subgrupos = subgruposUnicos.map(subgrupoNum => {
59 + const itemsDelSubgrupo = itemsDelGrupo.filter(i => i.organismo_sub_grupo === subgrupoNum);
60 + const sample = itemsDelSubgrupo[0];
61 +
62 + return {
63 + organismo_sub_grupo: subgrupoNum,
64 + organismo_desc_sub_grupo: sample?.organismo_desc_sub_grupo || `Subgrupo ${subgrupoNum}`,
65 + organismo_sigla_sub_grupo: sample?.organismo_sigla_sub_grupo || '',
66 + organismo_grupo: grupoNum,
67 + organismos: itemsDelSubgrupo.sort((a, b) => String(a.organismo).localeCompare(String(b.organismo)))
68 + };
69 + }).sort((a, b) => Number(a.organismo_sub_grupo) - Number(b.organismo_sub_grupo));
70 +
71 + return { subgrupos };
72 + }
73 +
74 + // Búsqueda global
75 + function searchGlobal(query) {
76 + if (!query || query.length < 2) return [];
77 + const q = query.toLowerCase();
78 +
79 + return allItems
80 + .filter(item =>
81 + item.organismo?.toString().toLowerCase().includes(q) ||
82 + item.organismo_desc_organismo?.toLowerCase().includes(q)
83 + )
84 + .slice(0, 20);
85 + }
86 +
87 + function selectGrupo(grupo) {
88 + selectedGrupo = grupo;
89 + searchQuery = '';
90 + highlightedItem = null;
91 + sidebarOpen = false;
92 + }
93 +
94 + function openDetail(item) {
95 + selectedItem = item;
96 + }
97 +
98 + function closeDetail() {
99 + selectedItem = null;
100 + }
101 +
102 + function goToSearchResult(item) {
103 + const targetGrupo = grupos.find(g => g.organismo_grupo === item.organismo_grupo);
104 +
105 + if (targetGrupo) {
106 + selectedGrupo = targetGrupo;
107 + highlightedItem = item.organismo;
108 + searchQuery = '';
109 + sidebarOpen = false;
110 +
111 + setTimeout(() => {
112 + const element = document.getElementById(`org-${item.organismo}`);
113 + if (element) {
114 + element.scrollIntoView({ behavior: 'smooth', block: 'center' });
115 + }
116 + }, 100);
117 +
118 + setTimeout(() => {
119 + highlightedItem = null;
120 + }, 2000);
121 + }
122 + }
123 +
124 + function parseDescripciones(descripcionesStr) {
125 + if (!descripcionesStr) return [];
126 + try {
127 + const parsed = typeof descripcionesStr === 'string'
128 + ? JSON.parse(descripcionesStr)
129 + : descripcionesStr;
130 + return parsed.sort((a, b) => {
131 + const maxYearA = getMaxYear(a.rangos);
132 + const maxYearB = getMaxYear(b.rangos);
133 + return maxYearB - maxYearA;
134 + });
135 + } catch {
136 + return [];
137 + }
138 + }
139 +
140 + function getMaxYear(rangos) {
141 + if (!rangos) return 0;
142 + const years = rangos.match(/\d{4}/g);
143 + if (!years) return 0;
144 + return Math.max(...years.map(y => parseInt(y)));
145 + }
146 +
147 + let grupoContent = $derived(selectedGrupo ? getItemsForGrupo(selectedGrupo) : { subgrupos: [] });
148 + let searchResults = $derived(searchGlobal(searchQuery));
149 + let isSearching = $derived(searchQuery.length >= 2);
150 +
151 + // Contadores
152 + let totalItems = $derived(allItems.length);
153 + let numSubgrupos = $derived(() => {
154 + const pairs = new Set();
155 + allItems.forEach(i => pairs.add(`${i.organismo_grupo}-${i.organismo_sub_grupo}`));
156 + return pairs.size;
157 + });
158 +</script>
159 +
160 +<svelte:head>
161 + <title>Organismos | Presupuesto Público</title>
162 + <link rel="preconnect" href="https://fonts.googleapis.com" />
163 + <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" />
164 +</svelte:head>
165 +
166 +<div class="min-h-screen" style="font-family: 'Instrument Sans', sans-serif; background-color: var(--theme-body); color: var(--theme-titulo);">
167 + <!-- Header -->
168 + <header class="border-b" style="border-color: var(--theme-borde);">
169 + <div class="max-w-screen-xl mx-auto px-4 sm:px-6 py-6">
170 + <nav class="mb-4" style="font-family: 'DM Mono', monospace; font-size: 0.75rem;">
171 + <a href="/clasificadores" class="sm:hidden transition-colors" style="color: var(--theme-texto);">
172 + ← Clasificadores
173 + </a>
174 + <div class="hidden sm:flex items-center gap-2" style="color: var(--theme-texto);">
175 + <a href="/" class="transition-colors hover:opacity-80">Inicio</a>
176 + <span style="opacity: 0.5;">/</span>
177 + <a href="/clasificadores" class="transition-colors hover:opacity-80">Clasificadores</a>
178 + </div>
179 + </nav>
180 +
181 + <div class="max-w-3xl">
182 + <p class="text-xs uppercase tracking-widest mb-2" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
183 + Clasificador de Organismos Financiadores
184 + </p>
185 + <h1 class="text-3xl mb-3" style="font-family: 'DM Serif Display', serif; color: var(--theme-titulo);">
186 + ¿Quién financia el gasto?
187 + </h1>
188 +
189 + <p class="leading-relaxed mb-3" style="color: var(--theme-texto);">
190 + Clasifica las fuentes de financiamiento según su <strong style="color: var(--theme-titulo);">origen</strong>:
191 + recursos internos, crédito externo, donaciones, etc.
192 + </p>
193 +
194 + {#if totalItems > 0}
195 + <p class="text-sm mb-5" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">
196 + <span style="color: var(--theme-titulo); font-weight: 500;">{totalItems}</span> organismos en
197 + {grupos.length} grupos y {numSubgrupos()} subgrupos
198 + </p>
199 + {/if}
200 +
201 + <div class="hidden sm:flex flex-wrap items-center gap-2 sm:gap-3 text-xs mb-6" style="font-family: 'DM Mono', monospace;">
202 + <span class="px-2.5 py-1 rounded-full font-medium" style="background-color: var(--theme-accent); color: var(--theme-body);">
203 + Grupo
204 + </span>
205 + <span style="color: var(--theme-texto);">→</span>
206 + <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);">
207 + Subgrupo
208 + </span>
209 + <span style="color: var(--theme-texto);">→</span>
210 + <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);">
211 + Organismo
212 + </span>
213 + </div>
214 + </div>
215 + </div>
216 + </header>
217 +
218 + {#if loading}
219 + <div class="flex items-center justify-center py-20">
220 + <p style="color: var(--theme-texto);">Cargando clasificador...</p>
221 + </div>
222 + {:else}
223 + <!-- Botón móvil -->
224 + <div class="lg:hidden px-4 py-3 border-b" style="border-color: var(--theme-borde); background-color: var(--theme-fill);">
225 + <button
226 + onclick={() => sidebarOpen = !sidebarOpen}
227 + class="flex items-center gap-2 text-sm w-full"
228 + style="color: var(--theme-texto);"
229 + >
230 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
231 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
232 + </svg>
233 + <span class="font-medium truncate">{selectedGrupo ? selectedGrupo.organismo_desc_grupo : 'Seleccionar grupo'}</span>
234 + <svg class="w-4 h-4 ml-auto flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
235 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
236 + </svg>
237 + </button>
238 + </div>
239 +
240 + <div class="max-w-screen-xl mx-auto flex">
241 + <!-- Sidebar izquierda -->
242 + {#if sidebarOpen}
243 + <div
244 + transition:fade={{ duration: 150 }}
245 + class="fixed inset-0 bg-black/30 z-40 lg:hidden"
246 + onclick={() => sidebarOpen = false}
247 + ></div>
248 + {/if}
249 + <aside class="
250 + {sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
251 + lg:translate-x-0
252 + fixed lg:relative
253 + inset-y-0 left-0
254 + w-72 lg:w-56
255 + z-50 lg:z-auto
256 + transition-transform duration-200 ease-in-out
257 + lg:flex-shrink-0
258 + shadow-xl lg:shadow-none
259 + " style="background-color: var(--theme-body);">
260 + <div class="h-full lg:h-screen lg:sticky lg:top-0 overflow-y-auto py-6 px-4 lg:px-0 lg:pr-4">
261 + <div class="flex justify-between items-center mb-4 lg:hidden">
262 + <span class="text-sm font-medium" style="color: var(--theme-titulo);">Grupos</span>
263 + <button onclick={() => sidebarOpen = false} style="color: var(--theme-texto);">
264 + <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
265 + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
266 + </svg>
267 + </button>
268 + </div>
269 +
270 + <!-- Buscador -->
271 + <div class="mb-6">
272 + <input
273 + type="text"
274 + bind:value={searchQuery}
275 + placeholder="Buscar organismo..."
276 + class="w-full px-3 py-2 text-sm rounded-md focus:outline-none focus:ring-2"
277 + style="border: 1px solid var(--theme-borde); background-color: var(--theme-surface); color: var(--theme-titulo);"
278 + />
279 + </div>
280 +
281 + {#if isSearching}
282 + <div class="space-y-1">
283 + <p class="text-xs uppercase tracking-wide mb-2 px-2" style="color: var(--theme-texto);">
284 + {searchResults.length} resultados
285 + </p>
286 + {#each searchResults as result}
287 + <button
288 + class="w-full text-left px-2 py-2 text-sm rounded transition-all"
289 + style="color: var(--theme-titulo);"
290 + onclick={() => goToSearchResult(result)}
291 + >
292 + <span class="font-mono text-xs font-medium" style="color: var(--theme-accent);">{result.organismo}</span>
293 + <span class="ml-1 block truncate">{result.organismo_desc_organismo}</span>
294 + </button>
295 + {/each}
296 + {#if searchResults.length === 0}
297 + <p class="text-sm px-2" style="color: var(--theme-texto);">Sin resultados</p>
298 + {/if}
299 + </div>
300 + {:else}
301 + <nav>
302 + <p class="text-xs uppercase tracking-wide mb-3 px-2 hidden lg:block" style="color: var(--theme-texto);">Grupos</p>
303 + <ul class="space-y-1">
304 + {#each grupos as grupo}
305 + <li>
306 + <button
307 + class="w-full text-left px-3 py-2 rounded-md text-sm transition-all"
308 + style="{selectedGrupo?.organismo_grupo === grupo.organismo_grupo
309 + ? `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);`
310 + : `color: var(--theme-texto);`}"
311 + onclick={() => selectGrupo(grupo)}
312 + >
313 + <span class="font-mono text-xs block" style="color: var(--theme-texto);">{grupo.organismo_grupo}</span>
314 + {grupo.organismo_desc_grupo}
315 + </button>
316 + </li>
317 + {/each}
318 + </ul>
319 + </nav>
320 + {/if}
321 + </div>
322 + </aside>
323 +
324 + <!-- Contenido principal -->
325 + <main class="flex-1 min-w-0 lg:border-l xl:border-r" style="border-color: var(--theme-borde);">
326 + <div class="px-4 sm:px-6 lg:px-10 py-6 lg:py-8">
327 + {#if selectedGrupo}
328 + {#key selectedGrupo.organismo_grupo}
329 + <div in:fade={{ duration: 200, delay: 50 }}>
330 + <!-- Título del grupo -->
331 + <div class="mb-8 lg:mb-10 pb-6 lg:pb-8 border-b" style="border-color: var(--theme-borde);">
332 + <p class="text-sm font-mono mb-1" style="color: var(--theme-texto);">Grupo {selectedGrupo.organismo_grupo}</p>
333 + <h2 class="text-lg sm:text-xl font-medium mb-2" style="color: var(--theme-titulo);">
334 + {selectedGrupo.organismo_desc_grupo}
335 + </h2>
336 + </div>
337 +
338 + <!-- Subgrupos -->
339 + <div class="space-y-12">
340 + {#each grupoContent.subgrupos as subgrupo}
341 + <section
342 + id="sg-{subgrupo.organismo_sub_grupo}"
343 + class="scroll-mt-4"
344 + >
345 + <div class="flex items-start gap-2 sm:gap-4 mb-3">
346 + <span class="font-mono text-xs sm:text-sm pt-1" style="color: var(--theme-texto);">{subgrupo.organismo_sub_grupo}</span>
347 + <div class="flex-1">
348 + <h3 class="text-base sm:text-lg font-medium" style="color: var(--theme-titulo);">
349 + {subgrupo.organismo_desc_sub_grupo}
350 + </h3>
351 + </div>
352 + </div>
353 +
354 + <!-- Organismos -->
355 + {#if subgrupo.organismos?.length > 0}
356 + <div class="ml-4 sm:ml-8 lg:ml-16 space-y-3 border-l pl-4 sm:pl-6 lg:pl-8" style="border-color: var(--theme-borde);">
357 + {#each subgrupo.organismos as org}
358 + {@const orgDescs = parseDescripciones(org.descripciones)}
359 + <div
360 + id="org-{org.organismo}"
361 + class="scroll-mt-4 py-2 transition-all duration-300 rounded-md px-2 -mx-2"
362 + style="{highlightedItem === org.organismo ? 'background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent);' : ''}"
363 + >
364 + <div class="flex items-start gap-2 sm:gap-3">
365 + <span class="font-mono text-xs pt-0.5 flex-shrink-0" style="color: var(--theme-texto);">{org.organismo}</span>
366 + <div class="flex-1 min-w-0">
367 + <h4 class="text-sm font-medium flex items-center gap-2 flex-wrap" style="color: var(--theme-titulo);">
368 + <span class="text-left">{org.organismo_desc_organismo}</span>
369 + {#if org.n_variaciones > 1}
370 + <button
371 + 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"
372 + onclick={() => openDetail(org)}
373 + title="Ver variaciones de descripción"
374 + >
375 + {org.n_variaciones} var.
376 + </button>
377 + {/if}
378 + <a
379 + href="/organismo/{org.organismo}"
380 + class="transition-colors hover:text-[var(--theme-accent)]"
381 + style="color: var(--theme-texto);"
382 + title="Ver página de detalle"
383 + >
384 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
385 + <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" />
386 + </svg>
387 + </a>
388 + </h4>
389 + {#if org.organismo_sigla_organismo && org.organismo_sigla_organismo !== 'NC'}
390 + <p class="text-xs font-mono mt-0.5" style="color: var(--theme-texto); opacity: 0.7;">{org.organismo_sigla_organismo}</p>
391 + {/if}
392 + {#if orgDescs.length > 0 && orgDescs[0].descripcion}
393 + <p class="text-xs mt-1 leading-relaxed" style="color: var(--theme-texto);">{orgDescs[0].descripcion}</p>
394 + {/if}
395 + {#if org.gestiones}
396 + <p class="text-xs mt-1 font-mono" style="color: var(--theme-texto); opacity: 0.6;">
397 + Vigente: {org.gestiones}
398 + </p>
399 + {/if}
400 + </div>
401 + </div>
402 + </div>
403 + {/each}
404 + </div>
405 + {/if}
406 + </section>
407 + {/each}
408 + </div>
409 + </div>
410 + {/key}
411 + {/if}
412 + </div>
413 + </main>
414 +
415 + <!-- Sidebar derecha: En esta página -->
416 + <aside class="w-56 flex-shrink-0 hidden xl:block">
417 + <div class="sticky top-0 h-screen overflow-y-auto p-4 border-l" style="border-color: var(--theme-borde);">
418 + <p class="text-xs uppercase tracking-wide mb-3" style="font-family: 'DM Mono', monospace; color: var(--theme-texto);">En esta página</p>
419 + {#if selectedGrupo && !isSearching}
420 + <nav class="space-y-2">
421 + {#each grupoContent.subgrupos as subgrupo}
422 + <div>
423 + <a
424 + href="#sg-{subgrupo.organismo_sub_grupo}"
425 + class="block text-sm truncate transition-colors hover:text-[var(--theme-accent)]"
426 + style="color: var(--theme-texto);"
427 + title="{subgrupo.organismo_desc_sub_grupo}"
428 + >
429 + {subgrupo.organismo_desc_sub_grupo}
430 + </a>
431 + {#if subgrupo.organismos?.length > 0}
432 + <div class="ml-3 mt-1 space-y-1 border-l pl-2" style="border-color: var(--theme-borde);">
433 + {#each subgrupo.organismos.slice(0, 5) as org}
434 + <a
435 + href="#org-{org.organismo}"
436 + class="block text-xs truncate transition-colors hover:text-[var(--theme-accent)]"
437 + style="color: var(--theme-texto); opacity: 0.7;"
438 + title="{org.organismo_desc_organismo}"
439 + >
440 + {org.organismo_desc_organismo}
441 + </a>
442 + {/each}
443 + {#if subgrupo.organismos.length > 5}
444 + <span class="text-xs" style="color: var(--theme-texto); opacity: 0.5;">+{subgrupo.organismos.length - 5} más</span>
445 + {/if}
446 + </div>
447 + {/if}
448 + </div>
449 + {/each}
450 + </nav>
451 + {/if}
452 + </div>
453 + </aside>
454 + </div>
455 + {/if}
456 +</div>
457 +
458 +<!-- Modal de detalle -->
459 +{#if selectedItem}
460 + <div
461 + transition:fade={{ duration: 150 }}
462 + class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
463 + onclick={closeDetail}
464 + >
465 + <div
466 + transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
467 + class="rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
468 + style="background-color: var(--theme-surface);"
469 + onclick={(e) => e.stopPropagation()}
470 + >
471 + <div class="px-6 py-4 border-b flex justify-between items-start" style="background-color: var(--theme-fill); border-color: var(--theme-borde);">
472 + <div>
473 + <p class="text-xs uppercase tracking-wide font-medium" style="color: var(--theme-texto);">Organismo</p>
474 + <h3 class="text-lg font-medium mt-1" style="color: var(--theme-titulo);">
475 + <span class="font-mono" style="color: var(--theme-texto);">{selectedItem.organismo}</span>
476 + <span class="mx-2" style="color: var(--theme-texto); opacity: 0.5;">·</span>
477 + {selectedItem.organismo_desc_organismo}
478 + </h3>
479 + </div>
480 + <button
481 + onclick={closeDetail}
482 + class="text-2xl leading-none"
483 + style="color: var(--theme-texto);"
484 + >
485 + &times;
486 + </button>
487 + </div>
488 +
489 + <div class="p-6 overflow-y-auto flex-1">
490 + <h4 class="text-sm font-medium mb-4" style="color: var(--theme-titulo);">
491 + {#if selectedItem.n_variaciones > 1}
492 + Descripciones ({selectedItem.n_variaciones} variaciones)
493 + {:else}
494 + Descripción
495 + {/if}
496 + </h4>
497 +
498 + {#if selectedItem.descripciones}
499 + {@const descs = parseDescripciones(selectedItem.descripciones)}
500 + {#if descs.length > 0}
501 + <div class="space-y-4">
502 + {#each descs as desc}
503 + <div class="p-4 rounded-lg" style="background-color: var(--theme-fill);">
504 + <p class="text-xs font-mono mb-2" style="color: var(--theme-accent);">{desc.rangos}</p>
505 + <p class="text-sm leading-relaxed" style="color: var(--theme-titulo);">{desc.descripcion || 'Sin descripción'}</p>
506 + </div>
507 + {/each}
508 + </div>
509 + {:else}
510 + <p class="text-sm" style="color: var(--theme-texto);">Sin descripciones registradas.</p>
511 + {/if}
512 + {:else}
513 + <p class="text-sm" style="color: var(--theme-texto);">Sin descripciones registradas.</p>
514 + {/if}
515 + </div>
516 + </div>
517 + </div>
518 +{/if}
...@@ -48,16 +48,20 @@ ...@@ -48,16 +48,20 @@
48 }); 48 });
49 49
50 // Helpers para derivar jerarquía desde código rubro 50 // Helpers para derivar jerarquía desde código rubro
51 + // Jerarquía: Tipo (XX000) → Clase (XXX00) → Cuenta (XXXX0) → Subcuenta (XXXXX)
51 function getTipoFromRubro(rubro) { 52 function getTipoFromRubro(rubro) {
52 - return rubro.charAt(0); 53 + // Tipo = primeros 2 dígitos (XX de XX000)
54 + return rubro.substring(0, 2);
53 } 55 }
54 56
55 function getClaseFromRubro(rubro) { 57 function getClaseFromRubro(rubro) {
56 - return rubro.substring(0, 2); 58 + // Clase = primeros 3 dígitos (XXX de XXX00)
59 + return rubro.substring(0, 3);
57 } 60 }
58 61
59 function getCuentaFromRubro(rubro) { 62 function getCuentaFromRubro(rubro) {
60 - return rubro.substring(0, 3); 63 + // Cuenta = primeros 4 dígitos (XXXX de XXXX0)
64 + return rubro.substring(0, 4);
61 } 65 }
62 66
63 function getItemsForTipo(tipoCode) { 67 function getItemsForTipo(tipoCode) {
......
...@@ -2,18 +2,40 @@ import { supabase } from '$lib/supabase'; ...@@ -2,18 +2,40 @@ 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 finfun 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) 5 +// Jerarquía: Finalidad (1-9 o 10) → Grupo Función (2-3 dígitos) → Función (3+ dígitos)
6 +// Nota: Finalidad 10 es especial - tiene 2 dígitos, sus hijos empiezan con "10"
7 +
6 function getFinalidadCode(finfun) { 8 function getFinalidadCode(finfun) {
7 - return finfun.charAt(0); 9 + const code = String(finfun);
10 + // Si empieza con "10", la finalidad es "10"
11 + if (code.startsWith('10')) {
12 + return '10';
13 + }
14 + // Si no, la finalidad es el primer dígito
15 + return code.charAt(0);
8 } 16 }
9 17
10 function getGrpFuncionCode(finfun) { 18 function getGrpFuncionCode(finfun) {
11 - return finfun.substring(0, 2); 19 + const code = String(finfun);
20 + // Si empieza con "10", el grupo función son los primeros 3 dígitos (ej: "101", "102")
21 + if (code.startsWith('10')) {
22 + return code.substring(0, 3);
23 + }
24 + // Si no, son los primeros 2 dígitos (ej: "11", "21", "93")
25 + return code.substring(0, 2);
12 } 26 }
13 27
14 function getNivel(finfun) { 28 function getNivel(finfun) {
15 - if (finfun.length === 1) return 'finalidad'; 29 + const code = String(finfun);
16 - if (finfun.length === 2) return 'grpfuncion'; 30 + // Finalidades 1-9 tienen longitud 1, finalidad 10 tiene longitud 2
31 + if (code.length === 1) return 'finalidad';
32 + if (code === '10') return 'finalidad';
33 + // GrpFuncion: 2 dígitos para 1-9, 3 dígitos para 10
34 + if (code.startsWith('10')) {
35 + if (code.length === 3) return 'grpfuncion';
36 + return 'funcion';
37 + }
38 + if (code.length === 2) return 'grpfuncion';
17 return 'funcion'; 39 return 'funcion';
18 } 40 }
19 41
...@@ -77,27 +99,40 @@ export async function load({ params }) { ...@@ -77,27 +99,40 @@ export async function load({ params }) {
77 99
78 // Buscar hijos según nivel 100 // Buscar hijos según nivel
79 if (nivel === 'finalidad') { 101 if (nivel === 'finalidad') {
80 - // Hijos: grupos de función que empiecen con el mismo dígito 102 + // Hijos: grupos de función de esta finalidad
81 const { data: hijosData } = await supabase 103 const { data: hijosData } = await supabase
82 .schema('ppto') 104 .schema('ppto')
83 .from('clas_finfun') 105 .from('clas_finfun')
84 .select('*') 106 .select('*')
85 .eq('nivel', 'grpfuncion') 107 .eq('nivel', 'grpfuncion')
86 - .like('finfun', `${codigo}%`)
87 .order('finfun'); 108 .order('finfun');
88 109
89 - hijos = hijosData || []; 110 + // Filtrar solo los hijos directos de esta finalidad
111 + // Para finalidad "1": hijos son "11", "12", ..., "19" (NO "10x")
112 + // Para finalidad "10": hijos son "101", "102", ..., "109"
113 + if (hijosData) {
114 + hijos = hijosData.filter(h => {
115 + const hijoPadre = getFinalidadCode(h.finfun);
116 + return hijoPadre === codigo;
117 + });
118 + }
90 } else if (nivel === 'grpfuncion') { 119 } else if (nivel === 'grpfuncion') {
91 - // Hijos: funciones que empiecen con los mismos 2 dígitos 120 + // Hijos: funciones de este grupo
121 + const grpCode = getGrpFuncionCode(codigo);
92 const { data: hijosData } = await supabase 122 const { data: hijosData } = await supabase
93 .schema('ppto') 123 .schema('ppto')
94 .from('clas_finfun') 124 .from('clas_finfun')
95 .select('*') 125 .select('*')
96 .eq('nivel', 'funcion') 126 .eq('nivel', 'funcion')
97 - .like('finfun', `${codigo}%`)
98 .order('finfun'); 127 .order('finfun');
99 128
100 - hijos = hijosData || []; 129 + // Filtrar solo los hijos directos de este grupo
130 + if (hijosData) {
131 + hijos = hijosData.filter(h => {
132 + const hijoGrp = getGrpFuncionCode(h.finfun);
133 + return hijoGrp === grpCode;
134 + });
135 + }
101 } 136 }
102 // funcion no tiene hijos 137 // funcion no tiene hijos
103 138
......
...@@ -1029,9 +1029,9 @@ ...@@ -1029,9 +1029,9 @@
1029 <p class="context-hint">Funciones relacionadas. <span class="desktop-hint">Haz click</span><span class="mobile-hint">Toca</span> para explorar.</p> 1029 <p class="context-hint">Funciones relacionadas. <span class="desktop-hint">Haz click</span><span class="mobile-hint">Toca</span> para explorar.</p>
1030 <div class="tree"> 1030 <div class="tree">
1031 {#each padres as padre, i} 1031 {#each padres as padre, i}
1032 - <a href="/finfun/{padre.objeto}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_finfun}"> 1032 + <a href="/finfun/{padre.finfun}" class="tree-node has-tooltip" style="--indent: {i}" data-tooltip="{padre.desc_finfun}">
1033 <span class="tree-dot"></span> 1033 <span class="tree-dot"></span>
1034 - <span class="tree-code">{padre.objeto}</span> 1034 + <span class="tree-code">{padre.finfun}</span>
1035 <span class="tree-name">{padre.desc_finfun}</span> 1035 <span class="tree-name">{padre.desc_finfun}</span>
1036 </a> 1036 </a>
1037 {/each} 1037 {/each}
...@@ -1045,9 +1045,9 @@ ...@@ -1045,9 +1045,9 @@
1045 {#if hijos.length > 0} 1045 {#if hijos.length > 0}
1046 <div class="tree-children" style="--indent: {padres.length + 1}"> 1046 <div class="tree-children" style="--indent: {padres.length + 1}">
1047 {#each hijos as hijo} 1047 {#each hijos as hijo}
1048 - <a href="/finfun/{hijo.objeto}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_finfun}"> 1048 + <a href="/finfun/{hijo.finfun}" class="tree-node tree-node-child has-tooltip" data-tooltip="{hijo.desc_finfun}">
1049 <span class="tree-dot"></span> 1049 <span class="tree-dot"></span>
1050 - <span class="tree-code">{hijo.objeto}</span> 1050 + <span class="tree-code">{hijo.finfun}</span>
1051 <span class="tree-name">{hijo.desc_finfun}</span> 1051 <span class="tree-name">{hijo.desc_finfun}</span>
1052 </a> 1052 </a>
1053 {/each} 1053 {/each}
......
1 +import { supabase } from '$lib/supabase';
2 +import { error } from '@sveltejs/kit';
3 +
4 +export async function load({ params }) {
5 + console.time('[SERVER] Total load fuente');
6 + const { codigo } = params;
7 +
8 + console.time('[SERVER] Query clas_fuentes');
9 + const { data, error: dbError } = await supabase
10 + .schema('ppto')
11 + .from('clas_fuentes')
12 + .select('*')
13 + .eq('fuente', codigo);
14 + console.timeEnd('[SERVER] Query clas_fuentes');
15 +
16 + if (dbError || !data || data.length === 0) {
17 + throw error(404, 'Fuente no encontrada');
18 + }
19 +
20 + const fuente = data[0];
21 +
22 + // Obtener hermanos (otras fuentes del mismo grupo)
23 + let hermanos = [];
24 +
25 + console.time('[SERVER] Query hermanos');
26 + const { data: hermanosData } = await supabase
27 + .schema('ppto')
28 + .from('clas_fuentes')
29 + .select('*')
30 + .eq('grupo_fuente', fuente.grupo_fuente)
31 + .neq('fuente', codigo)
32 + .order('fuente');
33 +
34 + if (hermanosData) {
35 + hermanos = hermanosData;
36 + }
37 + console.timeEnd('[SERVER] Query hermanos');
38 +
39 + console.timeEnd('[SERVER] Total load fuente');
40 + return {
41 + fuente,
42 + hermanos
43 + };
44 +}
1 +<script>
2 + import { onMount } from 'svelte';
3 +
4 + // Datos del servidor (reactivos)
5 + let { data } = $props();
6 + let fuenteData = $derived(data.fuente);
7 + let hermanos = $derived(data.hermanos);
8 +
9 + // Estado
10 + let mounted = $state(false);
11 +
12 + onMount(() => {
13 + setTimeout(() => { mounted = true; }, 50);
14 + });
15 +
16 + function parseDescripciones(descripcionesStr) {
17 + if (!descripcionesStr) return [];
18 + try {
19 + const parsed = typeof descripcionesStr === 'string'
20 + ? JSON.parse(descripcionesStr)
21 + : descripcionesStr;
22 + return parsed.sort((a, b) => {
23 + const maxYearA = getMaxYear(a.rangos);
24 + const maxYearB = getMaxYear(b.rangos);
25 + return maxYearB - maxYearA;
26 + });
27 + } catch {
28 + return [];
29 + }
30 + }
31 +
32 + function getMaxYear(rangos) {
33 + if (!rangos) return 0;
34 + const years = rangos.match(/\d{4}/g);
35 + if (!years) return 0;
36 + return Math.max(...years.map(y => parseInt(y)));
37 + }
38 +
39 + let descripciones = $derived(parseDescripciones(fuenteData.descripciones));
40 +</script>
41 +
42 +<svelte:head>
43 + <title>{fuenteData.desc_fuente || fuenteData.fuente} | Clasificador de Fuentes</title>
44 + <link rel="preconnect" href="https://fonts.googleapis.com" />
45 + <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" />
46 +</svelte:head>
47 +
48 +<div class="page" class:mounted>
49 + <!-- Link a clasificadores -->
50 + <nav class="page-nav">
51 + <a href="/clasificadores/fuentes" class="back-link">
52 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
53 + <path d="M19 12H5M12 19l-7-7 7-7"/>
54 + </svg>
55 + Clasificador
56 + </a>
57 + </nav>
58 +
59 + <div class="dashboard-layout">
60 + <!-- CONTEXTO IZQUIERDO -->
61 + <aside class="dashboard-context">
62 + <div class="context-card">
63 + <h2 class="context-main-title">Contexto</h2>
64 +
65 + <!-- Info del grupo -->
66 + <div class="context-section">
67 + <h3 class="context-subtitle">Ubicación</h3>
68 + <div class="ubicacion-info">
69 + <div class="ubicacion-item">
70 + <span class="ubicacion-label">Grupo {fuenteData.grupo_fuente}</span>
71 + <span class="ubicacion-value">{fuenteData.desc_grupo_fuente}</span>
72 + </div>
73 + </div>
74 + </div>
75 +
76 + <!-- Hermanos -->
77 + {#if hermanos.length > 0}
78 + <div class="context-separator"></div>
79 + <div class="context-section">
80 + <h3 class="context-subtitle">Mismo grupo ({hermanos.length})</h3>
81 + <p class="context-hint">Otras fuentes del mismo grupo.</p>
82 + <div class="hermanos-list">
83 + {#each hermanos.slice(0, 10) as hermano}
84 + <a href="/fuente/{hermano.fuente}" class="hermano-link">
85 + <span class="hermano-code">{hermano.fuente}</span>
86 + <span class="hermano-name">{hermano.desc_fuente}</span>
87 + </a>
88 + {/each}
89 + {#if hermanos.length > 10}
90 + <span class="hermanos-more">+{hermanos.length - 10} más</span>
91 + {/if}
92 + </div>
93 + </div>
94 + {/if}
95 + </div>
96 + </aside>
97 +
98 + <!-- CONTENIDO PRINCIPAL -->
99 + <main class="dashboard-main">
100 + <div class="main-content">
101 + <!-- Header -->
102 + <div class="main-header">
103 + <div class="main-title-pills">
104 + <span class="pill pill-codigo">{fuenteData.fuente}</span>
105 + </div>
106 + <h1 class="main-title-name">{fuenteData.desc_fuente}</h1>
107 + </div>
108 +
109 + <!-- Info cards -->
110 + <div class="info-grid">
111 + <div class="info-card">
112 + <span class="info-label">Grupo</span>
113 + <span class="info-value">{fuenteData.grupo_fuente}</span>
114 + <span class="info-desc">{fuenteData.desc_grupo_fuente}</span>
115 + </div>
116 +
117 + {#if fuenteData.gestiones}
118 + <div class="info-card">
119 + <span class="info-label">Gestiones</span>
120 + <span class="info-value">{fuenteData.n_gestiones || ''} años</span>
121 + <span class="info-desc">{fuenteData.gestiones}</span>
122 + </div>
123 + {/if}
124 +
125 + {#if fuenteData.n_variaciones > 1}
126 + <div class="info-card">
127 + <span class="info-label">Variaciones</span>
128 + <span class="info-value">{fuenteData.n_variaciones}</span>
129 + <span class="info-desc">Cambios en la descripción a lo largo del tiempo</span>
130 + </div>
131 + {/if}
132 + </div>
133 +
134 + <!-- Descripciones -->
135 + {#if descripciones.length > 0}
136 + <div class="descripciones-section">
137 + <h2 class="descripciones-title">
138 + {#if descripciones.length > 1}
139 + Descripciones ({descripciones.length} variaciones)
140 + {:else}
141 + Descripción
142 + {/if}
143 + </h2>
144 + <div class="descripciones-list">
145 + {#each descripciones as desc, i}
146 + <div class="descripcion-item" class:vigente={i === 0}>
147 + <p class="descripcion-rangos">
148 + {#if i === 0 && descripciones.length > 1}
149 + <span class="vigente-badge">Vigente</span>
150 + {/if}
151 + {desc.rangos}
152 + </p>
153 + <p class="descripcion-texto">{desc.descripcion}</p>
154 + </div>
155 + {/each}
156 + </div>
157 + </div>
158 + {/if}
159 + </div>
160 + </main>
161 + </div>
162 +</div>
163 +
164 +<style>
165 + /* ═══════════════════════════════════════════════════════════════
166 + BASE
167 + ═══════════════════════════════════════════════════════════════ */
168 + .page {
169 + min-height: 100vh;
170 + padding: 1rem;
171 + opacity: 0;
172 + transform: translateY(10px);
173 + transition: opacity 0.4s ease, transform 0.4s ease;
174 + }
175 +
176 + .page.mounted {
177 + opacity: 1;
178 + transform: translateY(0);
179 + }
180 +
181 + /* ═══════════════════════════════════════════════════════════════
182 + NAV
183 + ═══════════════════════════════════════════════════════════════ */
184 + .page-nav {
185 + max-width: 1400px;
186 + margin: 0 auto 1.5rem;
187 + }
188 +
189 + .back-link {
190 + display: inline-flex;
191 + align-items: center;
192 + gap: 0.5rem;
193 + color: var(--text-secondary, #888);
194 + text-decoration: none;
195 + font-size: 0.875rem;
196 + transition: color 0.2s;
197 + }
198 +
199 + .back-link:hover {
200 + color: var(--text-primary, #fff);
201 + }
202 +
203 + /* ═══════════════════════════════════════════════════════════════
204 + LAYOUT
205 + ═══════════════════════════════════════════════════════════════ */
206 + .dashboard-layout {
207 + display: grid;
208 + grid-template-columns: 300px 1fr;
209 + gap: 2rem;
210 + max-width: 1400px;
211 + margin: 0 auto;
212 + }
213 +
214 + @media (max-width: 900px) {
215 + .dashboard-layout {
216 + grid-template-columns: 1fr;
217 + }
218 +
219 + .dashboard-context {
220 + order: 2;
221 + }
222 + }
223 +
224 + /* ═══════════════════════════════════════════════════════════════
225 + SIDEBAR
226 + ═══════════════════════════════════════════════════════════════ */
227 + .dashboard-context {
228 + position: sticky;
229 + top: 5rem;
230 + height: fit-content;
231 + }
232 +
233 + .context-card {
234 + background: var(--card-bg, rgba(255, 255, 255, 0.03));
235 + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
236 + border-radius: 16px;
237 + padding: 1.5rem;
238 + }
239 +
240 + .context-main-title {
241 + font-family: 'Instrument Sans', sans-serif;
242 + font-size: 0.75rem;
243 + font-weight: 600;
244 + text-transform: uppercase;
245 + letter-spacing: 0.05em;
246 + color: var(--text-tertiary, #666);
247 + margin-bottom: 1.5rem;
248 + }
249 +
250 + .context-section {
251 + margin-bottom: 1.5rem;
252 + }
253 +
254 + .context-subtitle {
255 + font-family: 'Instrument Sans', sans-serif;
256 + font-size: 0.8rem;
257 + font-weight: 500;
258 + color: var(--text-secondary, #aaa);
259 + margin-bottom: 0.75rem;
260 + }
261 +
262 + .context-hint {
263 + font-size: 0.75rem;
264 + color: var(--text-tertiary, #666);
265 + margin-bottom: 1rem;
266 + }
267 +
268 + .context-separator {
269 + height: 1px;
270 + background: var(--border-color, rgba(255, 255, 255, 0.08));
271 + margin: 1.5rem 0;
272 + }
273 +
274 + /* ═══════════════════════════════════════════════════════════════
275 + UBICACION INFO
276 + ═══════════════════════════════════════════════════════════════ */
277 + .ubicacion-info {
278 + display: flex;
279 + flex-direction: column;
280 + gap: 0.75rem;
281 + }
282 +
283 + .ubicacion-item {
284 + display: flex;
285 + flex-direction: column;
286 + gap: 0.25rem;
287 + padding: 0.75rem;
288 + background: var(--hover-bg, rgba(255, 255, 255, 0.03));
289 + border-radius: 8px;
290 + }
291 +
292 + .ubicacion-label {
293 + font-family: 'DM Mono', monospace;
294 + font-size: 0.7rem;
295 + color: var(--text-tertiary, #666);
296 + text-transform: uppercase;
297 + }
298 +
299 + .ubicacion-value {
300 + font-size: 0.85rem;
301 + color: var(--text-primary, #fff);
302 + }
303 +
304 + /* ═══════════════════════════════════════════════════════════════
305 + HERMANOS LIST
306 + ═══════════════════════════════════════════════════════════════ */
307 + .hermanos-list {
308 + display: flex;
309 + flex-direction: column;
310 + gap: 0.25rem;
311 + }
312 +
313 + .hermano-link {
314 + display: flex;
315 + align-items: center;
316 + gap: 0.5rem;
317 + padding: 0.5rem 0.75rem;
318 + border-radius: 6px;
319 + text-decoration: none;
320 + color: var(--text-secondary, #aaa);
321 + font-size: 0.8rem;
322 + transition: all 0.2s ease;
323 + }
324 +
325 + .hermano-link:hover {
326 + background: var(--hover-bg, rgba(255, 255, 255, 0.05));
327 + color: var(--text-primary, #fff);
328 + }
329 +
330 + .hermano-code {
331 + font-family: 'DM Mono', monospace;
332 + font-size: 0.7rem;
333 + opacity: 0.6;
334 + flex-shrink: 0;
335 + min-width: 2.5rem;
336 + }
337 +
338 + .hermano-name {
339 + white-space: nowrap;
340 + overflow: hidden;
341 + text-overflow: ellipsis;
342 + }
343 +
344 + .hermanos-more {
345 + font-size: 0.75rem;
346 + color: var(--text-tertiary, #666);
347 + padding: 0.5rem 0.75rem;
348 + }
349 +
350 + /* ═══════════════════════════════════════════════════════════════
351 + MAIN CONTENT
352 + ═══════════════════════════════════════════════════════════════ */
353 + .dashboard-main {
354 + min-width: 0;
355 + }
356 +
357 + .main-content {
358 + background: var(--card-bg, rgba(255, 255, 255, 0.03));
359 + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
360 + border-radius: 16px;
361 + padding: 2rem;
362 + }
363 +
364 + .main-header {
365 + margin-bottom: 2rem;
366 + }
367 +
368 + .main-title-pills {
369 + display: flex;
370 + gap: 0.5rem;
371 + margin-bottom: 0.75rem;
372 + }
373 +
374 + .pill {
375 + padding: 0.25rem 0.75rem;
376 + border-radius: 999px;
377 + font-size: 0.75rem;
378 + font-weight: 500;
379 + }
380 +
381 + .pill-codigo {
382 + background: var(--hover-bg, rgba(255, 255, 255, 0.05));
383 + color: var(--text-secondary, #aaa);
384 + font-family: 'DM Mono', monospace;
385 + }
386 +
387 + .main-title-name {
388 + font-family: 'DM Serif Display', serif;
389 + font-size: 2rem;
390 + font-weight: 400;
391 + color: var(--text-primary, #fff);
392 + line-height: 1.2;
393 + margin: 0;
394 + }
395 +
396 + /* ═══════════════════════════════════════════════════════════════
397 + INFO GRID
398 + ═══════════════════════════════════════════════════════════════ */
399 + .info-grid {
400 + display: grid;
401 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
402 + gap: 1rem;
403 + margin-bottom: 2rem;
404 + }
405 +
406 + .info-card {
407 + display: flex;
408 + flex-direction: column;
409 + gap: 0.25rem;
410 + padding: 1rem;
411 + background: var(--hover-bg, rgba(255, 255, 255, 0.03));
412 + border-radius: 12px;
413 + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
414 + }
415 +
416 + .info-label {
417 + font-size: 0.75rem;
418 + text-transform: uppercase;
419 + letter-spacing: 0.05em;
420 + color: var(--text-tertiary, #666);
421 + }
422 +
423 + .info-value {
424 + font-family: 'DM Mono', monospace;
425 + font-size: 1.25rem;
426 + color: var(--text-primary, #fff);
427 + }
428 +
429 + .info-desc {
430 + font-size: 0.8rem;
431 + color: var(--text-secondary, #aaa);
432 + line-height: 1.4;
433 + }
434 +
435 + /* ═══════════════════════════════════════════════════════════════
436 + DESCRIPCIONES
437 + ═══════════════════════════════════════════════════════════════ */
438 + .descripciones-section {
439 + border-top: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
440 + padding-top: 2rem;
441 + }
442 +
443 + .descripciones-title {
444 + font-family: 'Instrument Sans', sans-serif;
445 + font-size: 0.875rem;
446 + font-weight: 600;
447 + color: var(--text-secondary, #aaa);
448 + margin-bottom: 1rem;
449 + }
450 +
451 + .descripciones-list {
452 + display: flex;
453 + flex-direction: column;
454 + gap: 1rem;
455 + }
456 +
457 + .descripcion-item {
458 + padding: 1rem;
459 + border-left: 2px solid var(--border-color, rgba(255, 255, 255, 0.1));
460 + border-radius: 0 8px 8px 0;
461 + background: var(--hover-bg, rgba(255, 255, 255, 0.02));
462 + }
463 +
464 + .descripcion-item.vigente {
465 + border-left-color: var(--theme-accent, #3b82f6);
466 + background: color-mix(in srgb, var(--theme-accent, #3b82f6) 10%, transparent);
467 + }
468 +
469 + .descripcion-rangos {
470 + font-family: 'DM Mono', monospace;
471 + font-size: 0.75rem;
472 + color: var(--text-tertiary, #666);
473 + margin-bottom: 0.5rem;
474 + }
475 +
476 + .vigente-badge {
477 + display: inline-block;
478 + padding: 0.125rem 0.5rem;
479 + border-radius: 999px;
480 + background: var(--theme-accent, #3b82f6);
481 + color: white;
482 + font-size: 0.65rem;
483 + font-weight: 600;
484 + text-transform: uppercase;
485 + letter-spacing: 0.05em;
486 + margin-right: 0.5rem;
487 + }
488 +
489 + .descripcion-texto {
490 + font-size: 0.9rem;
491 + color: var(--text-primary, #fff);
492 + line-height: 1.6;
493 + }
494 +</style>
1 +import { supabase } from '$lib/supabase';
2 +import { error } from '@sveltejs/kit';
3 +
4 +export async function load({ params }) {
5 + console.time('[SERVER] Total load organismo');
6 + const { codigo } = params;
7 +
8 + console.time('[SERVER] Query clas_organismos');
9 + const { data, error: dbError } = await supabase
10 + .schema('ppto')
11 + .from('clas_organismos')
12 + .select('*')
13 + .eq('organismo', codigo);
14 + console.timeEnd('[SERVER] Query clas_organismos');
15 +
16 + if (dbError || !data || data.length === 0) {
17 + throw error(404, 'Organismo no encontrado');
18 + }
19 +
20 + const organismo = data[0];
21 +
22 + // Obtener hermanos (otros organismos del mismo subgrupo)
23 + let hermanos = [];
24 +
25 + console.time('[SERVER] Query hermanos');
26 + const { data: hermanosData } = await supabase
27 + .schema('ppto')
28 + .from('clas_organismos')
29 + .select('*')
30 + .eq('organismo_grupo', organismo.organismo_grupo)
31 + .eq('organismo_sub_grupo', organismo.organismo_sub_grupo)
32 + .neq('organismo', codigo)
33 + .order('organismo');
34 +
35 + if (hermanosData) {
36 + hermanos = hermanosData;
37 + }
38 + console.timeEnd('[SERVER] Query hermanos');
39 +
40 + console.timeEnd('[SERVER] Total load organismo');
41 + return {
42 + organismo,
43 + hermanos
44 + };
45 +}
1 +<script>
2 + import { onMount } from 'svelte';
3 +
4 + // Datos del servidor (reactivos)
5 + let { data } = $props();
6 + let organismoData = $derived(data.organismo);
7 + let hermanos = $derived(data.hermanos);
8 +
9 + // Estado
10 + let mounted = $state(false);
11 +
12 + onMount(() => {
13 + setTimeout(() => { mounted = true; }, 50);
14 + });
15 +</script>
16 +
17 +<svelte:head>
18 + <title>{organismoData.organismo_desc_organismo || organismoData.organismo} | Clasificador de Organismos</title>
19 + <link rel="preconnect" href="https://fonts.googleapis.com" />
20 + <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" />
21 +</svelte:head>
22 +
23 +<div class="page" class:mounted>
24 + <!-- Link a clasificadores -->
25 + <nav class="page-nav">
26 + <a href="/clasificadores/organismos" class="back-link">
27 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
28 + <path d="M19 12H5M12 19l-7-7 7-7"/>
29 + </svg>
30 + Clasificador
31 + </a>
32 + </nav>
33 +
34 + <div class="dashboard-layout">
35 + <!-- CONTEXTO IZQUIERDO -->
36 + <aside class="dashboard-context">
37 + <div class="context-card">
38 + <h2 class="context-main-title">Contexto</h2>
39 +
40 + <!-- Info del grupo y subgrupo -->
41 + <div class="context-section">
42 + <h3 class="context-subtitle">Ubicación</h3>
43 + <div class="ubicacion-info">
44 + <div class="ubicacion-item">
45 + <span class="ubicacion-label">Grupo {organismoData.organismo_grupo}</span>
46 + <span class="ubicacion-value">{organismoData.organismo_desc_grupo}</span>
47 + </div>
48 + <div class="ubicacion-item">
49 + <span class="ubicacion-label">Subgrupo {organismoData.organismo_sub_grupo}</span>
50 + <span class="ubicacion-value">{organismoData.organismo_desc_sub_grupo}</span>
51 + </div>
52 + </div>
53 + </div>
54 +
55 + <!-- Hermanos -->
56 + {#if hermanos.length > 0}
57 + <div class="context-separator"></div>
58 + <div class="context-section">
59 + <h3 class="context-subtitle">Mismo subgrupo ({hermanos.length})</h3>
60 + <p class="context-hint">Otros organismos del mismo subgrupo.</p>
61 + <div class="hermanos-list">
62 + {#each hermanos.slice(0, 10) as hermano}
63 + <a href="/organismo/{hermano.organismo}" class="hermano-link">
64 + <span class="hermano-code">{hermano.organismo}</span>
65 + <span class="hermano-name">{hermano.organismo_desc_organismo}</span>
66 + </a>
67 + {/each}
68 + {#if hermanos.length > 10}
69 + <span class="hermanos-more">+{hermanos.length - 10} más</span>
70 + {/if}
71 + </div>
72 + </div>
73 + {/if}
74 + </div>
75 + </aside>
76 +
77 + <!-- CONTENIDO PRINCIPAL -->
78 + <main class="dashboard-main">
79 + <div class="main-content">
80 + <!-- Header -->
81 + <div class="main-header">
82 + <div class="main-title-pills">
83 + <span class="pill pill-codigo">{organismoData.organismo}</span>
84 + </div>
85 + <h1 class="main-title-name">{organismoData.organismo_desc_organismo}</h1>
86 + {#if organismoData.organismo_sigla_organismo && organismoData.organismo_sigla_organismo !== 'NC'}
87 + <p class="main-sigla">{organismoData.organismo_sigla_organismo}</p>
88 + {/if}
89 + </div>
90 +
91 + <!-- Info cards -->
92 + <div class="info-grid">
93 + <div class="info-card">
94 + <span class="info-label">Grupo</span>
95 + <span class="info-value">{organismoData.organismo_grupo}</span>
96 + <span class="info-desc">{organismoData.organismo_desc_grupo}</span>
97 + </div>
98 +
99 + <div class="info-card">
100 + <span class="info-label">Subgrupo</span>
101 + <span class="info-value">{organismoData.organismo_sub_grupo}</span>
102 + <span class="info-desc">{organismoData.organismo_desc_sub_grupo}</span>
103 + </div>
104 +
105 + {#if organismoData.gestiones}
106 + <div class="info-card">
107 + <span class="info-label">Gestiones</span>
108 + <span class="info-value">{organismoData.n_gestiones || ''} años</span>
109 + <span class="info-desc">{organismoData.gestiones}</span>
110 + </div>
111 + {/if}
112 + </div>
113 + </div>
114 + </main>
115 + </div>
116 +</div>
117 +
118 +<style>
119 + /* ═══════════════════════════════════════════════════════════════
120 + BASE
121 + ═══════════════════════════════════════════════════════════════ */
122 + .page {
123 + min-height: 100vh;
124 + padding: 1rem;
125 + opacity: 0;
126 + transform: translateY(10px);
127 + transition: opacity 0.4s ease, transform 0.4s ease;
128 + }
129 +
130 + .page.mounted {
131 + opacity: 1;
132 + transform: translateY(0);
133 + }
134 +
135 + /* ═══════════════════════════════════════════════════════════════
136 + NAV
137 + ═══════════════════════════════════════════════════════════════ */
138 + .page-nav {
139 + max-width: 1400px;
140 + margin: 0 auto 1.5rem;
141 + }
142 +
143 + .back-link {
144 + display: inline-flex;
145 + align-items: center;
146 + gap: 0.5rem;
147 + color: var(--text-secondary, #888);
148 + text-decoration: none;
149 + font-size: 0.875rem;
150 + transition: color 0.2s;
151 + }
152 +
153 + .back-link:hover {
154 + color: var(--text-primary, #fff);
155 + }
156 +
157 + /* ═══════════════════════════════════════════════════════════════
158 + LAYOUT
159 + ═══════════════════════════════════════════════════════════════ */
160 + .dashboard-layout {
161 + display: grid;
162 + grid-template-columns: 300px 1fr;
163 + gap: 2rem;
164 + max-width: 1400px;
165 + margin: 0 auto;
166 + }
167 +
168 + @media (max-width: 900px) {
169 + .dashboard-layout {
170 + grid-template-columns: 1fr;
171 + }
172 +
173 + .dashboard-context {
174 + order: 2;
175 + }
176 + }
177 +
178 + /* ═══════════════════════════════════════════════════════════════
179 + SIDEBAR
180 + ═══════════════════════════════════════════════════════════════ */
181 + .dashboard-context {
182 + position: sticky;
183 + top: 5rem;
184 + height: fit-content;
185 + }
186 +
187 + .context-card {
188 + background: var(--card-bg, rgba(255, 255, 255, 0.03));
189 + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
190 + border-radius: 16px;
191 + padding: 1.5rem;
192 + }
193 +
194 + .context-main-title {
195 + font-family: 'Instrument Sans', sans-serif;
196 + font-size: 0.75rem;
197 + font-weight: 600;
198 + text-transform: uppercase;
199 + letter-spacing: 0.05em;
200 + color: var(--text-tertiary, #666);
201 + margin-bottom: 1.5rem;
202 + }
203 +
204 + .context-section {
205 + margin-bottom: 1.5rem;
206 + }
207 +
208 + .context-subtitle {
209 + font-family: 'Instrument Sans', sans-serif;
210 + font-size: 0.8rem;
211 + font-weight: 500;
212 + color: var(--text-secondary, #aaa);
213 + margin-bottom: 0.75rem;
214 + }
215 +
216 + .context-hint {
217 + font-size: 0.75rem;
218 + color: var(--text-tertiary, #666);
219 + margin-bottom: 1rem;
220 + }
221 +
222 + .context-separator {
223 + height: 1px;
224 + background: var(--border-color, rgba(255, 255, 255, 0.08));
225 + margin: 1.5rem 0;
226 + }
227 +
228 + /* ═══════════════════════════════════════════════════════════════
229 + UBICACION INFO
230 + ═══════════════════════════════════════════════════════════════ */
231 + .ubicacion-info {
232 + display: flex;
233 + flex-direction: column;
234 + gap: 0.75rem;
235 + }
236 +
237 + .ubicacion-item {
238 + display: flex;
239 + flex-direction: column;
240 + gap: 0.25rem;
241 + padding: 0.75rem;
242 + background: var(--hover-bg, rgba(255, 255, 255, 0.03));
243 + border-radius: 8px;
244 + }
245 +
246 + .ubicacion-label {
247 + font-family: 'DM Mono', monospace;
248 + font-size: 0.7rem;
249 + color: var(--text-tertiary, #666);
250 + text-transform: uppercase;
251 + }
252 +
253 + .ubicacion-value {
254 + font-size: 0.85rem;
255 + color: var(--text-primary, #fff);
256 + }
257 +
258 + /* ═══════════════════════════════════════════════════════════════
259 + HERMANOS LIST
260 + ═══════════════════════════════════════════════════════════════ */
261 + .hermanos-list {
262 + display: flex;
263 + flex-direction: column;
264 + gap: 0.25rem;
265 + }
266 +
267 + .hermano-link {
268 + display: flex;
269 + align-items: center;
270 + gap: 0.5rem;
271 + padding: 0.5rem 0.75rem;
272 + border-radius: 6px;
273 + text-decoration: none;
274 + color: var(--text-secondary, #aaa);
275 + font-size: 0.8rem;
276 + transition: all 0.2s ease;
277 + }
278 +
279 + .hermano-link:hover {
280 + background: var(--hover-bg, rgba(255, 255, 255, 0.05));
281 + color: var(--text-primary, #fff);
282 + }
283 +
284 + .hermano-code {
285 + font-family: 'DM Mono', monospace;
286 + font-size: 0.7rem;
287 + opacity: 0.6;
288 + flex-shrink: 0;
289 + min-width: 2.5rem;
290 + }
291 +
292 + .hermano-name {
293 + white-space: nowrap;
294 + overflow: hidden;
295 + text-overflow: ellipsis;
296 + }
297 +
298 + .hermanos-more {
299 + font-size: 0.75rem;
300 + color: var(--text-tertiary, #666);
301 + padding: 0.5rem 0.75rem;
302 + }
303 +
304 + /* ═══════════════════════════════════════════════════════════════
305 + MAIN CONTENT
306 + ═══════════════════════════════════════════════════════════════ */
307 + .dashboard-main {
308 + min-width: 0;
309 + }
310 +
311 + .main-content {
312 + background: var(--card-bg, rgba(255, 255, 255, 0.03));
313 + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
314 + border-radius: 16px;
315 + padding: 2rem;
316 + }
317 +
318 + .main-header {
319 + margin-bottom: 2rem;
320 + }
321 +
322 + .main-title-pills {
323 + display: flex;
324 + gap: 0.5rem;
325 + margin-bottom: 0.75rem;
326 + }
327 +
328 + .pill {
329 + padding: 0.25rem 0.75rem;
330 + border-radius: 999px;
331 + font-size: 0.75rem;
332 + font-weight: 500;
333 + }
334 +
335 + .pill-codigo {
336 + background: var(--hover-bg, rgba(255, 255, 255, 0.05));
337 + color: var(--text-secondary, #aaa);
338 + font-family: 'DM Mono', monospace;
339 + }
340 +
341 + .main-title-name {
342 + font-family: 'DM Serif Display', serif;
343 + font-size: 2rem;
344 + font-weight: 400;
345 + color: var(--text-primary, #fff);
346 + line-height: 1.2;
347 + margin: 0;
348 + }
349 +
350 + .main-sigla {
351 + font-family: 'DM Mono', monospace;
352 + font-size: 0.9rem;
353 + color: var(--text-tertiary, #666);
354 + margin-top: 0.5rem;
355 + }
356 +
357 + /* ═══════════════════════════════════════════════════════════════
358 + INFO GRID
359 + ═══════════════════════════════════════════════════════════════ */
360 + .info-grid {
361 + display: grid;
362 + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
363 + gap: 1rem;
364 + }
365 +
366 + .info-card {
367 + display: flex;
368 + flex-direction: column;
369 + gap: 0.25rem;
370 + padding: 1rem;
371 + background: var(--hover-bg, rgba(255, 255, 255, 0.03));
372 + border-radius: 12px;
373 + border: 1px solid var(--border-color, rgba(255, 255, 255, 0.05));
374 + }
375 +
376 + .info-label {
377 + font-size: 0.75rem;
378 + text-transform: uppercase;
379 + letter-spacing: 0.05em;
380 + color: var(--text-tertiary, #666);
381 + }
382 +
383 + .info-value {
384 + font-family: 'DM Mono', monospace;
385 + font-size: 1.25rem;
386 + color: var(--text-primary, #fff);
387 + }
388 +
389 + .info-desc {
390 + font-size: 0.8rem;
391 + color: var(--text-secondary, #aaa);
392 + line-height: 1.4;
393 + }
394 +</style>
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 rubro
5 +// Jerarquía: Tipo (XX000) → Clase (XXX00) → Cuenta (XXXX0) → Subcuenta (XXXXX)
6 +
7 +function getTipoCode(rubro) {
8 + // Primeros 2 dígitos + 000 → ej: 11000, 12000
9 + return rubro.substring(0, 2) + '000';
10 +}
11 +
12 +function getClaseCode(rubro) {
13 + // Primeros 3 dígitos + 00 → ej: 11100, 11200
14 + return rubro.substring(0, 3) + '00';
15 +}
16 +
17 +function getCuentaCode(rubro) {
18 + // Primeros 4 dígitos + 0 → ej: 11110, 11120
19 + return rubro.substring(0, 4) + '0';
20 +}
21 +
22 +function getNivelFromCode(rubro) {
23 + // Determinar nivel basado en el patrón de ceros
24 + if (rubro.endsWith('000')) return 'tipo'; // XX000
25 + if (rubro.endsWith('00')) return 'clase'; // XXX00
26 + if (rubro.endsWith('0')) return 'cuenta'; // XXXX0
27 + return 'sub_cuenta'; // XXXXX
28 +}
29 +
30 +function getNextNivel(nivel) {
31 + const niveles = { tipo: 'clase', clase: 'cuenta', cuenta: 'sub_cuenta' };
32 + return niveles[nivel] || null;
33 +}
34 +
4 export async function load({ params }) { 35 export async function load({ params }) {
5 const { codigo } = params; 36 const { codigo } = params;
6 37
...@@ -15,88 +46,81 @@ export async function load({ params }) { ...@@ -15,88 +46,81 @@ export async function load({ params }) {
15 } 46 }
16 47
17 const rubro = data[0]; 48 const rubro = data[0];
49 + const nivel = rubro.nivel || getNivelFromCode(codigo);
18 50
19 // Obtener jerarquía (padres e hijos) 51 // Obtener jerarquía (padres e hijos)
20 let padres = []; 52 let padres = [];
21 let hijos = []; 53 let hijos = [];
22 54
23 - // Buscar padre según nivel 55 + // Buscar padres según nivel
24 - if (rubro.nivel === 'sub_cuenta') { 56 + if (nivel === 'sub_cuenta') {
25 - // Padre es cuenta 57 + // Padres: tipo, clase, cuenta
26 - const { data: padreData } = await supabase 58 + const tipoCode = getTipoCode(codigo);
59 + const claseCode = getClaseCode(codigo);
60 + const cuentaCode = getCuentaCode(codigo);
61 +
62 + const { data: padresData } = await supabase
27 .schema('ppto') 63 .schema('ppto')
28 .from('clas_rubros') 64 .from('clas_rubros')
29 .select('*') 65 .select('*')
30 - .eq('nivel', 'cuenta') 66 + .in('rubro', [tipoCode, claseCode, cuentaCode])
31 - .eq('tipo', rubro.tipo) 67 + .order('rubro');
32 - .eq('clase', rubro.clase) 68 +
33 - .eq('cuenta', rubro.cuenta); 69 + if (padresData) padres = padresData;
34 - if (padreData?.length) padres.push(padreData[0]); 70 + } else if (nivel === 'cuenta') {
35 - } 71 + // Padres: tipo, clase
72 + const tipoCode = getTipoCode(codigo);
73 + const claseCode = getClaseCode(codigo);
36 74
37 - if (rubro.nivel === 'cuenta' || rubro.nivel === 'sub_cuenta') { 75 + const { data: padresData } = await supabase
38 - // Padre es clase
39 - const { data: padreData } = await supabase
40 .schema('ppto') 76 .schema('ppto')
41 .from('clas_rubros') 77 .from('clas_rubros')
42 .select('*') 78 .select('*')
43 - .eq('nivel', 'clase') 79 + .in('rubro', [tipoCode, claseCode])
44 - .eq('tipo', rubro.tipo) 80 + .order('rubro');
45 - .eq('clase', rubro.clase);
46 - if (padreData?.length) padres.unshift(padreData[0]);
47 - }
48 81
49 - if (rubro.nivel !== 'tipo') { 82 + if (padresData) padres = padresData;
50 - // Padre es tipo 83 + } else if (nivel === 'clase') {
51 - const { data: padreData } = await supabase 84 + // Padre: tipo
85 + const tipoCode = getTipoCode(codigo);
86 +
87 + const { data: padresData } = await supabase
52 .schema('ppto') 88 .schema('ppto')
53 .from('clas_rubros') 89 .from('clas_rubros')
54 .select('*') 90 .select('*')
55 - .eq('nivel', 'tipo') 91 + .eq('rubro', tipoCode);
56 - .eq('tipo', rubro.tipo); 92 +
57 - if (padreData?.length) padres.unshift(padreData[0]); 93 + if (padresData) padres = padresData;
58 } 94 }
95 + // tipo no tiene padres
59 96
60 // Buscar hijos según nivel 97 // Buscar hijos según nivel
61 - if (rubro.nivel === 'tipo') { 98 + const nextNivel = getNextNivel(nivel);
99 + if (nextNivel) {
62 const { data: hijosData } = await supabase 100 const { data: hijosData } = await supabase
63 .schema('ppto') 101 .schema('ppto')
64 .from('clas_rubros') 102 .from('clas_rubros')
65 .select('*') 103 .select('*')
66 - .eq('nivel', 'clase') 104 + .eq('nivel', nextNivel)
67 - .eq('tipo', rubro.tipo) 105 + .like('rubro', `${codigo.replace(/0+$/, '')}%`)
68 .order('rubro'); 106 .order('rubro');
69 - hijos = hijosData?.reduce((acc, item) => { 107 +
70 - if (!acc.find(h => h.rubro === item.rubro)) acc.push(item); 108 + if (hijosData) {
71 - return acc; 109 + // Filtrar solo hijos directos (siguiente nivel)
72 - }, []) || []; 110 + hijos = hijosData.filter(h => {
73 - } else if (rubro.nivel === 'clase') { 111 + if (nivel === 'tipo') {
74 - const { data: hijosData } = await supabase 112 + // Hijos de tipo son clase: XXX00 donde XX = tipo
75 - .schema('ppto') 113 + return h.rubro.startsWith(codigo.substring(0, 2)) && h.nivel === 'clase';
76 - .from('clas_rubros') 114 + } else if (nivel === 'clase') {
77 - .select('*') 115 + // Hijos de clase son cuenta: XXXX0 donde XXX = clase
78 - .eq('nivel', 'cuenta') 116 + return h.rubro.startsWith(codigo.substring(0, 3)) && h.nivel === 'cuenta';
79 - .eq('tipo', rubro.tipo) 117 + } else if (nivel === 'cuenta') {
80 - .eq('clase', rubro.clase) 118 + // Hijos de cuenta son sub_cuenta: XXXXX donde XXXX = cuenta
81 - .order('rubro'); 119 + return h.rubro.startsWith(codigo.substring(0, 4)) && h.nivel === 'sub_cuenta';
82 - hijos = hijosData?.reduce((acc, item) => { 120 + }
83 - if (!acc.find(h => h.rubro === item.rubro)) acc.push(item); 121 + return false;
84 - return acc; 122 + });
85 - }, []) || []; 123 + }
86 - } else if (rubro.nivel === 'cuenta') {
87 - const { data: hijosData } = await supabase
88 - .schema('ppto')
89 - .from('clas_rubros')
90 - .select('*')
91 - .eq('nivel', 'sub_cuenta')
92 - .eq('tipo', rubro.tipo)
93 - .eq('clase', rubro.clase)
94 - .eq('cuenta', rubro.cuenta)
95 - .order('rubro');
96 - hijos = hijosData?.reduce((acc, item) => {
97 - if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
98 - return acc;
99 - }, []) || [];
100 } 124 }
101 125
102 return { 126 return {
......
1 +// Desactivar SSR para esta página porque hyparquet y @observablehq/plot
2 +// requieren APIs del navegador (fetch con ArrayBuffer, DOM)
3 +export const ssr = false;
1 +<script>
2 + import { onMount } from 'svelte';
3 + import { parquetReadObjects, asyncBufferFromUrl } from 'hyparquet';
4 + import * as Plot from '@observablehq/plot';
5 + import * as d3 from 'd3';
6 +
7 + // Estado de datos
8 + let timeserie = $state([]);
9 + let ejecucionEntidades = $state([]);
10 + let ejecucionClasificadores = $state([]);
11 + let timeline = $state([]);
12 + let loading = $state(true);
13 + let error = $state(null);
14 +
15 + // Controles interactivos
16 + let diasSeleccionados = $state('total');
17 + let incluirTransferencias = $state(false); // Por defecto sin transferencias (como el código R)
18 + let busquedaEntidad = $state('');
19 +
20 + // Clasificadores disponibles con sus labels (keys deben coincidir con el parquet)
21 + const clasificadores = [
22 + { key: 'Entidades', label: 'Quién gastó más' },
23 + { key: 'Objetos', label: 'En qué se gastó más' },
24 + { key: 'Finalidad y Función', label: 'Para qué función' },
25 + { key: 'Actividad Económica', label: 'En qué sector económico' },
26 + { key: 'Ubicación', label: 'En qué municipio se gastó más' }
27 + ];
28 +
29 + // Estado de hover por clasificador
30 + let hoverData = $state({
31 + 'Entidades': null,
32 + 'Objetos': null,
33 + 'Finalidad y Función': null,
34 + 'Actividad Económica': null,
35 + 'Ubicación': null
36 + });
37 +
38 + // Contenedores SVG para cada clasificador
39 + let barContainers = $state({});
40 +
41 + // Contenedor y estado para entidades
42 + let entidadesBarContainer = $state(null);
43 + let entidadHover = $state(null);
44 +
45 + // Control de visualización de entidades
46 + let entidadesVisibles = $state(10);
47 + let rangoEjecucionMin = $state(0);
48 + let rangoEjecucionMax = $state(100);
49 +
50 + // Drag del rango
51 + let sliderContainer = $state(null);
52 + let isDraggingRange = $state(false);
53 + let dragStartX = $state(0);
54 + let dragStartMin = $state(0);
55 + let dragStartMax = $state(0);
56 +
57 + function handleRangeMouseDown(e) {
58 + isDraggingRange = true;
59 + dragStartX = e.clientX;
60 + dragStartMin = rangoEjecucionMin;
61 + dragStartMax = rangoEjecucionMax;
62 + document.addEventListener('mousemove', handleRangeMouseMove);
63 + document.addEventListener('mouseup', handleRangeMouseUp);
64 + }
65 +
66 + function handleRangeMouseMove(e) {
67 + if (!isDraggingRange || !sliderContainer) return;
68 +
69 + const rect = sliderContainer.getBoundingClientRect();
70 + const deltaX = e.clientX - dragStartX;
71 + const deltaPercent = (deltaX / rect.width) * 100;
72 + const rangeSize = dragStartMax - dragStartMin;
73 +
74 + let newMin = dragStartMin + deltaPercent;
75 + let newMax = dragStartMax + deltaPercent;
76 +
77 + // Limitar al rango 0-100
78 + if (newMin < 0) {
79 + newMin = 0;
80 + newMax = rangeSize;
81 + }
82 + if (newMax > 100) {
83 + newMax = 100;
84 + newMin = 100 - rangeSize;
85 + }
86 +
87 + rangoEjecucionMin = Math.round(newMin);
88 + rangoEjecucionMax = Math.round(newMax);
89 + }
90 +
91 + function handleRangeMouseUp() {
92 + isDraggingRange = false;
93 + document.removeEventListener('mousemove', handleRangeMouseMove);
94 + document.removeEventListener('mouseup', handleRangeMouseUp);
95 + }
96 +
97 + // Búsqueda para timeline cards
98 + let busquedaTimeline = $state('');
99 +
100 + // Labels dinámicos para periodos de tiempo
101 + function formatearFechaCorta(fecha) {
102 + return fecha.toLocaleDateString('es-BO', { day: 'numeric', month: 'short' }).replace('.', '');
103 + }
104 +
105 + let labelsPeriodo = $derived.by(() => {
106 + const hoy = new Date();
107 + const ayer = new Date(hoy);
108 + ayer.setDate(ayer.getDate() - 1);
109 +
110 + const hace3 = new Date(hoy);
111 + hace3.setDate(hace3.getDate() - 3);
112 +
113 + const hace7 = new Date(hoy);
114 + hace7.setDate(hace7.getDate() - 7);
115 +
116 + return [
117 + { value: '1', label: `Hoy, ${formatearFechaCorta(hoy)}` },
118 + { value: '3', label: `Últimos 3 días` },
119 + { value: '7', label: `Última semana` },
120 + { value: 'total', label: `En ${hoy.getFullYear()}` }
121 + ];
122 + });
123 +
124 + // Navegación interna entre secciones
125 + let seccionActiva = $state(0);
126 + const secciones = [
127 + { id: 'clasificadores', titulo: '¿En qué se gasta?' },
128 + { id: 'entidades', titulo: '¿Quién ejecuta más?' },
129 + { id: 'programas', titulo: '¿En qué programas?' },
130 + { id: 'evolucion', titulo: '¿Cómo evoluciona?' }
131 + ];
132 +
133 + function irASeccion(index) {
134 + seccionActiva = index;
135 + const elemento = document.getElementById(secciones[index].id);
136 + if (elemento && vistaContainer) {
137 + elemento.scrollIntoView({ behavior: 'smooth', block: 'start' });
138 + }
139 + // Actualizar URL hash
140 + history.replaceState(null, '', `#${secciones[index].id}`);
141 + }
142 +
143 + // Contenedor para scroll-snap
144 + let vistaContainer = $state(null);
145 +
146 + // Detectar sección activa al hacer scroll
147 + function detectarSeccion() {
148 + if (!vistaContainer) return;
149 + const scrollY = vistaContainer.scrollTop;
150 + const vh = window.innerHeight;
151 +
152 + let nuevaSeccion = 0;
153 + if (scrollY < vh * 0.5) {
154 + nuevaSeccion = 0;
155 + } else if (scrollY < vh * 1.5) {
156 + nuevaSeccion = 1;
157 + } else if (scrollY < vh * 2.5) {
158 + nuevaSeccion = 2;
159 + } else {
160 + nuevaSeccion = 3;
161 + }
162 +
163 + // Solo actualizar URL si cambió la sección
164 + if (nuevaSeccion !== seccionActiva) {
165 + seccionActiva = nuevaSeccion;
166 + history.replaceState(null, '', `#${secciones[nuevaSeccion].id}`);
167 + }
168 + }
169 +
170 + // Ir a sección según hash en URL al cargar
171 + function irASeccionDesdeHash() {
172 + const hash = window.location.hash.slice(1);
173 + if (hash) {
174 + const index = secciones.findIndex(s => s.id === hash);
175 + if (index !== -1) {
176 + setTimeout(() => {
177 + const elemento = document.getElementById(hash);
178 + if (elemento) {
179 + elemento.scrollIntoView({ behavior: 'auto', block: 'start' });
180 + seccionActiva = index;
181 + }
182 + }, 100);
183 + }
184 + }
185 + }
186 +
187 + // Cards expandidas (por id de entidad)
188 + let cardsExpandidas = $state({});
189 +
190 + // Elementos DOM para los gráficos
191 + let chartDiariaContainer = $state(null);
192 + let chartAcumuladaContainer = $state(null);
193 +
194 + // Colores del tema
195 + const colores = {
196 + base: '#363534',
197 + claro: '#F3EAD8',
198 + rojo: '#DB281C',
199 + amarillo: '#F4E410',
200 + verde: '#007C38',
201 + acento: '#C9A751'
202 + };
203 +
204 + // Función para obtener variables CSS
205 + function getCSSVar(name) {
206 + return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
207 + }
208 +
209 + // Colores de gráficos (se actualizan según el tema)
210 + function getChartColors() {
211 + return {
212 + barDefault: getCSSVar('--chart-bar-default') || '#e0e0e0',
213 + barHighlight: getCSSVar('--chart-bar-highlight') || '#F3EAD8',
214 + barStroke: getCSSVar('--chart-bar-stroke') || '#b0b0b0',
215 + barStrokeDefault: getCSSVar('--chart-bar-stroke-default') || 'white',
216 + trackBg: getCSSVar('--chart-track-bg') || '#f5f5f5',
217 + grid: getCSSVar('--chart-grid') || '#bbb',
218 + label: getCSSVar('--chart-label') || '#888'
219 + };
220 + }
221 +
222 + // Cargar archivo Parquet usando asyncBufferFromUrl
223 + async function cargarParquet(url) {
224 + const file = await asyncBufferFromUrl({ url });
225 + const data = await parquetReadObjects({ file });
226 + return data;
227 + }
228 +
229 + onMount(async () => {
230 + // Observer para detectar cambios de tema y re-renderizar gráficos
231 + const themeObserver = new MutationObserver((mutations) => {
232 + mutations.forEach((mutation) => {
233 + if (mutation.attributeName === 'class') {
234 + // Re-renderizar gráficos cuando cambia el tema
235 + if (!loading) {
236 + setTimeout(() => {
237 + clasificadores.forEach(({ key }) => {
238 + if (barContainers[key]) {
239 + renderizarBarras(key);
240 + }
241 + });
242 + if (entidadesBarContainer) {
243 + renderizarEntidades();
244 + }
245 + }, 50);
246 + }
247 + }
248 + });
249 + });
250 +
251 + themeObserver.observe(document.documentElement, { attributes: true });
252 +
253 + try {
254 + // Cargar todos los parquets en paralelo
255 + const [tsData, entData, clasData, tlData] = await Promise.all([
256 + cargarParquet('/timeserie@4.parquet'),
257 + cargarParquet('/ejecucion_entidades@1.parquet'),
258 + cargarParquet('/ejecucion_clasificadores@3.parquet'),
259 + cargarParquet('/timeline@1.parquet')
260 + ]);
261 +
262 + // Los datos ya vienen como objetos
263 + timeserie = tsData;
264 + ejecucionEntidades = entData;
265 + ejecucionClasificadores = clasData;
266 + timeline = tlData;
267 +
268 + loading = false;
269 +
270 + // Ir a sección según hash en URL
271 + irASeccionDesdeHash();
272 + } catch (e) {
273 + error = e.message;
274 + loading = false;
275 + console.error('Error cargando datos:', e);
276 + }
277 +
278 + return () => {
279 + themeObserver.disconnect();
280 + };
281 + });
282 +
283 + // Filtrar datos por transferencias y días
284 + let timeserieFiltered = $derived(
285 + timeserie.filter(d => d.transferencias === incluirTransferencias)
286 + );
287 +
288 + // Datos por clasificador para las barras apiladas (sin agrupar, cada hijo es un segmento)
289 + let datosPorClasificador = $derived.by(() => {
290 + const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados;
291 + const result = {};
292 +
293 + clasificadores.forEach(({ key }) => {
294 + const filtered = ejecucionClasificadores.filter(d =>
295 + d.transferencias === incluirTransferencias &&
296 + String(d.dias) === String(dias) &&
297 + d.clasificador === key
298 + );
299 +
300 + // Ordenar por devengado descendente (el más grande primero)
301 + result[key] = filtered.sort((a, b) => b.devengado - a.devengado);
302 + });
303 +
304 + return result;
305 + });
306 +
307 + // Total por clasificador
308 + let totalesPorClasificador = $derived.by(() => {
309 + const result = {};
310 + clasificadores.forEach(({ key }) => {
311 + const datos = datosPorClasificador[key] || [];
312 + result[key] = datos.reduce((sum, d) => sum + d.devengado, 0);
313 + });
314 + return result;
315 + });
316 +
317 + // Item mostrado (hover o primero)
318 + function getDisplayItem(key) {
319 + const hover = hoverData[key];
320 + if (hover) return hover;
321 + const datos = datosPorClasificador[key] || [];
322 + return datos[0] || null;
323 + }
324 +
325 + // Renderizar barras D3 para un clasificador
326 + function renderizarBarras(key) {
327 + const container = barContainers[key];
328 + if (!container) return;
329 +
330 + const datos = datosPorClasificador[key] || [];
331 + if (datos.length === 0) return;
332 +
333 + const total = totalesPorClasificador[key] || 1;
334 + const height = 52;
335 + const radius = 4;
336 +
337 + // Limpiar
338 + d3.select(container).selectAll('*').remove();
339 +
340 + // Obtener ancho real del contenedor
341 + const containerRect = container.getBoundingClientRect();
342 + const width = containerRect.width || container.clientWidth || 800;
343 +
344 + const svg = d3.select(container)
345 + .append('svg')
346 + .attr('width', '100%')
347 + .attr('height', height)
348 + .attr('viewBox', `0 0 ${width} ${height}`)
349 + .attr('preserveAspectRatio', 'none')
350 + .style('display', 'block');
351 +
352 + // Calcular posiciones acumuladas
353 + let cumulative = 0;
354 + const segments = datos.map(d => {
355 + const segWidth = (d.devengado / total) * width;
356 + const segment = {
357 + ...d,
358 + x: cumulative,
359 + width: segWidth
360 + };
361 + cumulative += segWidth;
362 + return segment;
363 + });
364 +
365 + // Colores dinámicos según el tema
366 + const chartColors = getChartColors();
367 + const colorDefault = chartColors.barDefault;
368 + const colorHover = chartColors.barHighlight;
369 + const colorFirst = chartColors.barHighlight;
370 +
371 + // Crear grupo para cada segmento
372 + const bars = svg.selectAll('rect')
373 + .data(segments)
374 + .enter()
375 + .append('rect')
376 + .attr('x', d => d.x)
377 + .attr('y', 2)
378 + .attr('width', d => Math.max(d.width - 1, 1))
379 + .attr('height', height - 4)
380 + .attr('rx', (d, i) => {
381 + // Bordes redondeados solo en extremos
382 + if (i === 0) return radius;
383 + if (i === segments.length - 1) return radius;
384 + return 0;
385 + })
386 + .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
387 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
388 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
389 + .style('cursor', 'pointer')
390 + .on('mouseenter', function(event, d) {
391 + // Resetear todos los segmentos primero
392 + bars
393 + .attr('fill', colorDefault)
394 + .attr('stroke', chartColors.barStrokeDefault)
395 + .attr('stroke-width', 0.5);
396 + // Highlight el actual
397 + d3.select(this)
398 + .attr('fill', colorHover)
399 + .attr('stroke', chartColors.barStroke)
400 + .attr('stroke-width', 1);
401 + // Actualizar estado
402 + hoverData[key] = d;
403 + hoverData = { ...hoverData };
404 + })
405 + .on('mouseleave', function(event, d) {
406 + // Restaurar el primero como destacado
407 + bars
408 + .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
409 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
410 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
411 + // Limpiar hover
412 + hoverData[key] = null;
413 + hoverData = { ...hoverData };
414 + });
415 + }
416 +
417 + // Renderizar todas las barras cuando cambian los datos o controles
418 + $effect(() => {
419 + // Dependencias explícitas
420 + const datos = datosPorClasificador;
421 + const trans = incluirTransferencias;
422 + const dias = diasSeleccionados;
423 +
424 + if (!loading) {
425 + setTimeout(() => {
426 + clasificadores.forEach(({ key }) => {
427 + if (barContainers[key]) {
428 + renderizarBarras(key);
429 + }
430 + });
431 + }, 100);
432 + }
433 + });
434 +
435 + // Renderizar barras de entidades
436 + $effect(() => {
437 + const datos = entidadesFiltered;
438 + const trans = incluirTransferencias;
439 + const dias = diasSeleccionados;
440 +
441 + if (!loading && entidadesBarContainer) {
442 + setTimeout(() => {
443 + renderizarEntidades();
444 + }, 150);
445 + }
446 + });
447 +
448 + // Todas las entidades filtradas (sin límite)
449 + let entidadesTodas = $derived.by(() => {
450 + const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados;
451 + let filtered = ejecucionEntidades.filter(d =>
452 + d.transferencias === incluirTransferencias &&
453 + String(d.dias) === String(dias) &&
454 + d.ejecucion >= rangoEjecucionMin &&
455 + d.ejecucion <= rangoEjecucionMax
456 + );
457 +
458 + if (busquedaEntidad) {
459 + const q = busquedaEntidad.toLowerCase();
460 + filtered = filtered.filter(d =>
461 + d.entidad_desc_entidad?.toLowerCase().includes(q) ||
462 + d.entidad_desc_area?.toLowerCase().includes(q)
463 + );
464 + }
465 +
466 + // Ordenar por ejecución descendente
467 + return filtered.sort((a, b) => b.ejecucion - a.ejecucion);
468 + });
469 +
470 + // Entidades visibles (con límite)
471 + let entidadesFiltered = $derived(entidadesTodas.slice(0, entidadesVisibles));
472 +
473 + // Entidad mostrada (hover o primera)
474 + let displayEntidad = $derived(entidadHover || entidadesFiltered[0] || null);
475 +
476 + // Agrupar timeline por entidad con sus programas
477 + let timelineByEntity = $derived.by(() => {
478 + if (timeline.length === 0) return [];
479 +
480 + // Agrupar por entidad
481 + const grouped = {};
482 + timeline.forEach(item => {
483 + const key = item.entidad;
484 + if (!grouped[key]) {
485 + grouped[key] = {
486 + entidad: item.entidad,
487 + entidad_desc_area: item.entidad_desc_area,
488 + entidad_desc_entidad: item.entidad_desc_entidad,
489 + total: 0,
490 + programas: []
491 + };
492 + }
493 + grouped[key].total += item.devengado;
494 + grouped[key].programas.push({
495 + programa: item.desc_programa,
496 + actividad: item.desc_actividad_proyecto,
497 + devengado: item.devengado
498 + });
499 + });
500 +
501 + // Convertir a array y ordenar por total
502 + let result = Object.values(grouped)
503 + .map(g => ({
504 + ...g,
505 + programas: g.programas.sort((a, b) => b.devengado - a.devengado)
506 + }))
507 + .sort((a, b) => b.total - a.total);
508 +
509 + // Filtrar por búsqueda si hay texto
510 + if (busquedaTimeline.trim()) {
511 + const q = busquedaTimeline.toLowerCase();
512 + result = result.filter(e =>
513 + e.entidad_desc_entidad?.toLowerCase().includes(q) ||
514 + e.entidad_desc_area?.toLowerCase().includes(q)
515 + );
516 + }
517 +
518 + return result.slice(0, 10);
519 + });
520 +
521 + // Renderizar barras de entidades con D3
522 + function renderizarEntidades() {
523 + const container = entidadesBarContainer;
524 + if (!container) return;
525 +
526 + const datos = entidadesFiltered;
527 + if (datos.length === 0) return;
528 +
529 + const barHeight = 32;
530 + const gap = 2;
531 + const headerHeight = 24;
532 + const barsHeight = datos.length * (barHeight + gap);
533 + const height = headerHeight + barsHeight;
534 +
535 + // Limpiar
536 + d3.select(container).selectAll('*').remove();
537 +
538 + // Obtener ancho real del contenedor
539 + const containerRect = container.getBoundingClientRect();
540 + const width = containerRect.width || container.clientWidth || 800;
541 +
542 + const svg = d3.select(container)
543 + .append('svg')
544 + .attr('width', '100%')
545 + .attr('height', height)
546 + .attr('viewBox', `0 0 ${width} ${height}`)
547 + .attr('preserveAspectRatio', 'xMinYMin meet')
548 + .style('display', 'block');
549 +
550 + // Calcular rango dinámico basado en los datos
551 + const ejecuciones = datos.map(d => d.ejecucion);
552 + const minEjec = Math.min(...ejecuciones);
553 + const maxEjec = Math.max(...ejecuciones);
554 +
555 + // Redondear el mínimo hacia abajo y máximo hacia arriba (múltiplos de 5)
556 + const domainMin = Math.max(0, Math.floor(minEjec / 5) * 5 - 5);
557 + const domainMax = Math.min(100, Math.ceil(maxEjec / 5) * 5 + 5);
558 +
559 + // Escala dinámica
560 + const xScale = d3.scaleLinear()
561 + .domain([domainMin, domainMax])
562 + .range([0, width]);
563 +
564 + // Generar porcentajes para el rango dinámico (cada 10% dentro del rango)
565 + const step = Math.max(5, Math.round((domainMax - domainMin) / 8 / 5) * 5);
566 + const porcentajes = [];
567 + for (let p = domainMin; p <= domainMax; p += step) {
568 + porcentajes.push(p);
569 + }
570 + if (porcentajes[porcentajes.length - 1] !== domainMax) {
571 + porcentajes.push(domainMax);
572 + }
573 +
574 + // Colores dinámicos según el tema
575 + const chartColors = getChartColors();
576 + const colorDefault = chartColors.barDefault;
577 + const colorHighlight = chartColors.barHighlight;
578 +
579 + // Labels arriba (se dibujan primero)
580 + svg.selectAll('text.pct-label')
581 + .data(porcentajes)
582 + .enter()
583 + .append('text')
584 + .attr('class', 'pct-label')
585 + .attr('x', d => xScale(d))
586 + .attr('y', 14)
587 + .attr('text-anchor', (d, i) => i === 0 ? 'start' : i === porcentajes.length - 1 ? 'end' : 'middle')
588 + .attr('font-size', '11px')
589 + .attr('font-family', 'Instrument Sans, sans-serif')
590 + .attr('fill', chartColors.label)
591 + .text(d => `${d}%`);
592 +
593 + // Grupo para las barras (offset por el header)
594 + const barsGroup = svg.append('g')
595 + .attr('transform', `translate(0, ${headerHeight})`);
596 +
597 + // Crear barras
598 + const bars = barsGroup.selectAll('g.entidad-bar')
599 + .data(datos)
600 + .enter()
601 + .append('g')
602 + .attr('class', 'entidad-bar')
603 + .attr('transform', (d, i) => `translate(0, ${i * (barHeight + gap)})`);
604 +
605 + // Barra de fondo (100%)
606 + bars.append('rect')
607 + .attr('x', 0)
608 + .attr('y', 0)
609 + .attr('width', width)
610 + .attr('height', barHeight)
611 + .attr('fill', chartColors.trackBg)
612 + .attr('rx', 2);
613 +
614 + // Barra de ejecución (ancho proporcional al rango dinámico)
615 + bars.append('rect')
616 + .attr('class', 'ejecucion-bar')
617 + .attr('x', 0)
618 + .attr('y', 0)
619 + .attr('width', d => {
620 + const clamped = Math.max(domainMin, Math.min(d.ejecucion, domainMax));
621 + return xScale(clamped);
622 + })
623 + .attr('height', barHeight)
624 + .attr('fill', (d, i) => i === 0 ? colorHighlight : colorDefault)
625 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : 'none')
626 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0)
627 + .attr('rx', 2)
628 + .style('cursor', 'pointer');
629 +
630 + // Área invisible para hover (cubre toda la barra)
631 + bars.append('rect')
632 + .attr('x', 0)
633 + .attr('y', 0)
634 + .attr('width', width)
635 + .attr('height', barHeight)
636 + .attr('fill', 'transparent')
637 + .style('cursor', 'pointer')
638 + .on('mouseenter', function(event, d) {
639 + // Quitar highlight de todas
640 + barsGroup.selectAll('.ejecucion-bar')
641 + .attr('fill', colorDefault)
642 + .attr('stroke', 'none');
643 + // Highlight la seleccionada con ring
644 + d3.select(this.parentNode).select('.ejecucion-bar')
645 + .attr('fill', colorHighlight)
646 + .attr('stroke', chartColors.barStroke)
647 + .attr('stroke-width', 1);
648 + // Actualizar estado
649 + entidadHover = d;
650 + })
651 + .on('mouseleave', function() {
652 + // Restaurar primera como destacada con ring
653 + barsGroup.selectAll('.ejecucion-bar')
654 + .attr('fill', (d, i) => i === 0 ? colorHighlight : colorDefault)
655 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : 'none')
656 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0);
657 + entidadHover = null;
658 + });
659 +
660 + // Grid lines verticales punteadas finísimas (dibujadas al final para que estén encima)
661 + svg.selectAll('line.grid-line')
662 + .data(porcentajes.slice(1, -1)) // excluir 0 y 100
663 + .enter()
664 + .append('line')
665 + .attr('class', 'grid-line')
666 + .attr('x1', d => xScale(d))
667 + .attr('x2', d => xScale(d))
668 + .attr('y1', headerHeight)
669 + .attr('y2', height)
670 + .attr('stroke', chartColors.grid)
671 + .attr('stroke-width', 0.5)
672 + .attr('stroke-dasharray', '1,4')
673 + .attr('stroke-linecap', 'round')
674 + .attr('opacity', 0.7)
675 + .style('pointer-events', 'none'); // para que no interfiera con hover
676 + }
677 +
678 + // Formatear números en texto literal
679 + function formatearMonto(valor) {
680 + if (valor >= 1e9) {
681 + const num = (valor / 1e9).toFixed(1).replace('.0', '');
682 + return `Bs ${num} mil millones`;
683 + }
684 + if (valor >= 1e6) {
685 + const num = (valor / 1e6).toFixed(1).replace('.0', '');
686 + return `Bs ${num} millones`;
687 + }
688 + if (valor >= 1e3) {
689 + const num = (valor / 1e3).toFixed(1).replace('.0', '');
690 + return `Bs ${num} mil`;
691 + }
692 + return `Bs ${valor.toFixed(0)}`;
693 + }
694 +
695 + // Formatear número completo con separadores de miles
696 + function formatearNumeroCompleto(valor) {
697 + return new Intl.NumberFormat('es-BO', {
698 + maximumFractionDigits: 0
699 + }).format(valor);
700 + }
701 +
702 + function formatearPorcentaje(valor) {
703 + return `${valor.toFixed(1)}%`;
704 + }
705 +
706 + // Renderizar sparklines minimalistas
707 + function renderizarTimeseries() {
708 + if (timeserieFiltered.length === 0) return;
709 +
710 + const data = timeserieFiltered
711 + .map(d => ({
712 + ...d,
713 + fecha: new Date(d.fecha),
714 + diferencia: d.diferencia || 0
715 + }))
716 + .filter(d => !isNaN(d.fecha.getTime()))
717 + .sort((a, b) => a.fecha - b.fecha);
718 +
719 + // Colores dinámicos para el gráfico según tema
720 + const isDarkTheme = document.documentElement.classList.contains('dark');
721 + const colorTexto = isDarkTheme ? '#B8B5AD' : '#666666';
722 + const colorGrid = isDarkTheme ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)';
723 + const colorRule = isDarkTheme ? 'rgba(255,255,255,0.2)' : '#ccc';
724 +
725 + const chartConfig = {
726 + height: 200,
727 + marginTop: 20,
728 + marginBottom: 30,
729 + marginLeft: 50,
730 + marginRight: 20,
731 + style: {
732 + background: 'transparent',
733 + color: colorTexto
734 + },
735 + x: {
736 + label: null,
737 + tickFormat: d => d.toLocaleDateString('es-BO', { day: 'numeric', month: 'short' }),
738 + ticks: 5,
739 + tickSize: 0,
740 + line: false
741 + },
742 + y: {
743 + label: null,
744 + tickFormat: d => {
745 + if (d >= 1e9) return `${(d / 1e9).toFixed(0)}MM`;
746 + if (d >= 1e6) return `${(d / 1e6).toFixed(0)}M`;
747 + return d;
748 + },
749 + grid: true,
750 + gridColor: colorGrid,
751 + tickSize: 0,
752 + line: false
753 + }
754 + };
755 +
756 + // Calcular fecha de corte según el periodo seleccionado
757 + // Usar la última fecha de los datos como referencia, no la fecha de hoy
758 + const ultimaFecha = data.length > 0 ? data[data.length - 1].fecha : new Date();
759 + let fechaCorte = null;
760 + if (diasSeleccionados !== 'total') {
761 + fechaCorte = new Date(ultimaFecha);
762 + fechaCorte.setDate(fechaCorte.getDate() - parseInt(diasSeleccionados));
763 + }
764 +
765 + // Separar datos en "activos" (periodo seleccionado) y "fuera de foco"
766 + const dataActiva = fechaCorte
767 + ? data.filter(d => d.fecha >= fechaCorte)
768 + : data;
769 + const dataFuera = fechaCorte
770 + ? data.filter(d => d.fecha < fechaCorte)
771 + : [];
772 +
773 + // Colores para los gráficos (según tema)
774 + const isDark = document.documentElement.classList.contains('dark');
775 + const colorFuera = isDark ? '#555550' : '#d0d0d0';
776 + const colorFueraFill = isDark ? '#3a3a38' : '#e8e8e8';
777 + // Color de fill activo = mismo que las barras highlight
778 + const colorActivoFill = isDark ? '#C9A751' : '#F3EAD8';
779 + const colorActivoLinea = isDark ? '#888880' : '#999999';
780 +
781 + // Gráfico 1: Ejecución diaria (diferencia)
782 + if (chartDiariaContainer && data.length > 0) {
783 + chartDiariaContainer.innerHTML = '';
784 + const widthDiaria = chartDiariaContainer.clientWidth || 400;
785 +
786 + const marks = [
787 + Plot.ruleY([0], { stroke: colorRule, strokeWidth: 1 })
788 + ];
789 +
790 + // Primero la parte fuera de foco (si hay)
791 + if (dataFuera.length > 0) {
792 + marks.push(
793 + Plot.areaY(dataFuera, {
794 + x: 'fecha',
795 + y: 'diferencia',
796 + fill: colorFueraFill,
797 + fillOpacity: 0.5,
798 + curve: 'monotone-x'
799 + }),
800 + Plot.lineY(dataFuera, {
801 + x: 'fecha',
802 + y: 'diferencia',
803 + stroke: colorFuera,
804 + strokeWidth: 1.5,
805 + curve: 'monotone-x'
806 + })
807 + );
808 + }
809 +
810 + // Luego la parte activa
811 + if (dataActiva.length > 0) {
812 + marks.push(
813 + Plot.areaY(dataActiva, {
814 + x: 'fecha',
815 + y: 'diferencia',
816 + fill: colorActivoFill,
817 + fillOpacity: 0.6,
818 + curve: 'monotone-x'
819 + }),
820 + Plot.lineY(dataActiva, {
821 + x: 'fecha',
822 + y: 'diferencia',
823 + stroke: colorActivoLinea,
824 + strokeWidth: 1.5,
825 + curve: 'monotone-x'
826 + })
827 + );
828 + }
829 +
830 + const plotDiaria = Plot.plot({
831 + ...chartConfig,
832 + width: widthDiaria,
833 + marks
834 + });
835 +
836 + chartDiariaContainer.appendChild(plotDiaria);
837 + }
838 +
839 + // Gráfico 2: Ejecución acumulada (devengado)
840 + if (chartAcumuladaContainer && data.length > 0) {
841 + chartAcumuladaContainer.innerHTML = '';
842 +
843 + // Crear punto de inicio en 1 de enero del año de los datos
844 + const primerDato = data[0];
845 + const anioData = primerDato.fecha.getFullYear();
846 + const inicioAnio = new Date(anioData, 0, 1); // 1 de enero
847 +
848 + // Línea desde inicio del año (valor 0) hasta el primer dato
849 + const lineaInicio = [
850 + { fecha: inicioAnio, devengado: 0 },
851 + { fecha: primerDato.fecha, devengado: primerDato.devengado }
852 + ];
853 +
854 + const marksAcum = [
855 + Plot.ruleY([0], { stroke: colorRule, strokeWidth: 1 }),
856 + // Área y línea recta desde 1 de enero hasta primer dato
857 + Plot.areaY(lineaInicio, {
858 + x: 'fecha',
859 + y: 'devengado',
860 + fill: colorActivoFill,
861 + fillOpacity: 0.6
862 + }),
863 + Plot.line(lineaInicio, {
864 + x: 'fecha',
865 + y: 'devengado',
866 + stroke: colorActivoLinea,
867 + strokeWidth: 1.5
868 + })
869 + ];
870 +
871 + // Primero la parte fuera de foco (si hay)
872 + if (dataFuera.length > 0) {
873 + marksAcum.push(
874 + Plot.areaY(dataFuera, {
875 + x: 'fecha',
876 + y: 'devengado',
877 + fill: colorFueraFill,
878 + fillOpacity: 0.5,
879 + curve: 'monotone-x'
880 + }),
881 + Plot.lineY(dataFuera, {
882 + x: 'fecha',
883 + y: 'devengado',
884 + stroke: colorFuera,
885 + strokeWidth: 1.5,
886 + curve: 'monotone-x'
887 + })
888 + );
889 + }
890 +
891 + // Luego la parte activa
892 + if (dataActiva.length > 0) {
893 + marksAcum.push(
894 + Plot.areaY(dataActiva, {
895 + x: 'fecha',
896 + y: 'devengado',
897 + fill: colorActivoFill,
898 + fillOpacity: 0.6,
899 + curve: 'monotone-x'
900 + }),
901 + Plot.lineY(dataActiva, {
902 + x: 'fecha',
903 + y: 'devengado',
904 + stroke: colorActivoLinea,
905 + strokeWidth: 1.5,
906 + curve: 'monotone-x'
907 + })
908 + );
909 + }
910 +
911 + const plotAcumulada = Plot.plot({
912 + ...chartConfig,
913 + width: chartAcumuladaContainer.clientWidth || 400,
914 + marks: marksAcum
915 + });
916 +
917 + chartAcumuladaContainer.appendChild(plotAcumulada);
918 + }
919 + }
920 +
921 +
922 + // Reaccionar a cambios en controles y cuando los contenedores estén listos
923 + $effect(() => {
924 + // Dependencias explícitas
925 + const containers = [chartDiariaContainer, chartAcumuladaContainer];
926 + const filters = [diasSeleccionados, incluirTransferencias];
927 + const dataReady = timeserieFiltered.length > 0;
928 +
929 + if (!loading && chartDiariaContainer && chartAcumuladaContainer && dataReady) {
930 + setTimeout(() => {
931 + renderizarTimeseries();
932 + }, 100);
933 + }
934 + });
935 +
936 + // Calcular totales
937 + let totalDevengado = $derived.by(() => {
938 + if (timeserieFiltered.length === 0) return 0;
939 + return timeserieFiltered[timeserieFiltered.length - 1]?.devengado || 0;
940 + });
941 +
942 + let diferenciaDiaria = $derived.by(() => {
943 + if (timeserieFiltered.length === 0) return 0;
944 + return timeserieFiltered[timeserieFiltered.length - 1]?.diferencia || 0;
945 + });
946 +</script>
947 +
948 +<svelte:head>
949 + <title>Vista Diaria | Presupuesto Público</title>
950 + <link rel="preconnect" href="https://fonts.googleapis.com" />
951 + <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
952 +</svelte:head>
953 +
954 +<div class="vista-diaria" style="font-family: var(--font-sans);" bind:this={vistaContainer} onscroll={detectarSeccion}>
955 + <!-- Navegación interna fija -->
956 + <nav class="nav-interna">
957 + {#each secciones as seccion, i}
958 + <button
959 + class="nav-item"
960 + class:active={seccionActiva === i}
961 + onclick={() => irASeccion(i)}
962 + >
963 + <span class="nav-dot"></span>
964 + <span class="nav-label">{seccion.titulo}</span>
965 + </button>
966 + {/each}
967 + </nav>
968 +
969 + {#if loading}
970 + <div class="loading">
971 + <p>Cargando datos...</p>
972 + </div>
973 + {:else if error}
974 + <div class="error">
975 + <p>Error: {error}</p>
976 + </div>
977 + {:else}
978 + <!-- SECCIÓN 1: Clasificadores -->
979 + <section class="vista-seccion" id="clasificadores">
980 + <div class="vista-contenido">
981 + <div class="contenido-centrado">
982 + <!-- Título de sección -->
983 + <header class="seccion-header">
984 + <h1 class="seccion-titulo-grande">Gasto del Estado: quiénes, qué, dónde y cuánto</h1>
985 + </header>
986 +
987 + <!-- Contenido principal: barras clasificadores -->
988 + <div class="clasificadores-grid">
989 + {#each clasificadores as { key, label }}
990 + {@const datos = datosPorClasificador[key] || []}
991 + {@const displayItem = getDisplayItem(key)}
992 +
993 + {#if datos.length > 0}
994 + <div class="clasificador-row">
995 + <div class="clasificador-header">
996 + <div class="clasificador-info">
997 + <span class="clasificador-monto">{formatearNumeroCompleto(displayItem?.devengado || 0)}</span>
998 + <span class="bs-en">Bs. en</span>
999 + <span class="clasificador-hijo">{displayItem?.hijo || ''}</span>
1000 + <span class="clasificador-padre-annotation">({displayItem?.padre || ''})</span>
1001 + </div>
1002 + <span class="clasificador-tipo">{label}</span>
1003 + </div>
1004 + <div class="barra-d3-container" bind:this={barContainers[key]}></div>
1005 + </div>
1006 + {/if}
1007 + {/each}
1008 + </div>
1009 +
1010 + <!-- Controles centrados debajo -->
1011 + <div class="controles-footer">
1012 + {#each labelsPeriodo as { value, label }}
1013 + <button
1014 + class="periodo-btn"
1015 + class:active={diasSeleccionados === value}
1016 + onclick={() => diasSeleccionados = value}
1017 + >
1018 + {label}
1019 + </button>
1020 + {/each}
1021 + <label class="toggle-transferencias">
1022 + <input type="checkbox" bind:checked={incluirTransferencias} />
1023 + <span class="toggle-label">Incluir transferencias</span>
1024 + <span class="tooltip-trigger">
1025 + <svg class="tooltip-icon" viewBox="0 0 20 20" fill="currentColor">
1026 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
1027 + </svg>
1028 + <span class="tooltip-content">
1029 + Las transferencias son recursos que el gobierno central envía a gobiernos territoriales o al sector privado. Al desactivar esta opción se evita la doble contabilización, mostrando el gasto consolidado.
1030 + </span>
1031 + </span>
1032 + </label>
1033 + </div>
1034 + </div>
1035 + </div>
1036 + </section>
1037 +
1038 +
1039 + <!-- SECCIÓN 2: Entidades -->
1040 + <section class="vista-seccion" id="entidades">
1041 + <div class="vista-contenido">
1042 + <div class="contenido-centrado">
1043 + <!-- Título de sección -->
1044 + <header class="seccion-header">
1045 + <h1 class="seccion-titulo-grande">¿Quién ejecuta más?</h1>
1046 + </header>
1047 +
1048 + <!-- Info de entidad seleccionada -->
1049 + {#if displayEntidad}
1050 + <div class="entidad-info">
1051 + <span class="entidad-pct">{formatearPorcentaje(displayEntidad.ejecucion)}</span>
1052 + <span class="entidad-nombre">{displayEntidad.entidad_desc_entidad}</span>
1053 + <span class="entidad-area">{displayEntidad.entidad_desc_area}</span>
1054 + </div>
1055 + {/if}
1056 +
1057 + <!-- Barras de ejecución -->
1058 + <div class="entidades-bar-container" bind:this={entidadesBarContainer}></div>
1059 +
1060 + <!-- Paginación -->
1061 + <div class="entidades-paginacion">
1062 + <p class="entidades-nota">
1063 + Mostrando {entidadesFiltered.length} de {entidadesTodas.length} entidades
1064 + </p>
1065 + <div class="entidades-botones">
1066 + {#if entidadesVisibles > 10}
1067 + <button class="entidades-ver-mas" onclick={() => { entidadesVisibles = Math.max(10, entidadesVisibles - 10); }}>
1068 + Ver menos
1069 + </button>
1070 + {/if}
1071 + {#if entidadesFiltered.length < entidadesTodas.length}
1072 + <button class="entidades-ver-mas" onclick={() => { entidadesVisibles += 10; }}>
1073 + Ver {Math.min(10, entidadesTodas.length - entidadesFiltered.length)} más
1074 + </button>
1075 + {/if}
1076 + </div>
1077 + </div>
1078 +
1079 + <!-- Controles centrados debajo (igual que sección 1) -->
1080 + <div class="controles-footer">
1081 + {#each labelsPeriodo as { value, label }}
1082 + <button
1083 + class="periodo-btn"
1084 + class:active={diasSeleccionados === value}
1085 + onclick={() => diasSeleccionados = value}
1086 + >
1087 + {label}
1088 + </button>
1089 + {/each}
1090 + <label class="toggle-transferencias">
1091 + <input type="checkbox" bind:checked={incluirTransferencias} />
1092 + <span class="toggle-label">Incluir transferencias</span>
1093 + <span class="tooltip-trigger">
1094 + <svg class="tooltip-icon" viewBox="0 0 20 20" fill="currentColor">
1095 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
1096 + </svg>
1097 + <span class="tooltip-content">
1098 + Las transferencias son recursos que el gobierno central envía a gobiernos territoriales o al sector privado. Al desactivar esta opción se evita la doble contabilización, mostrando el gasto consolidado.
1099 + </span>
1100 + </span>
1101 + </label>
1102 + <div class="separador-control"></div>
1103 + <!-- Filtro de rango -->
1104 + <div class="rango-control-inline">
1105 + <label class="rango-label">Rango: {rangoEjecucionMin}% – {rangoEjecucionMax}%</label>
1106 + <div class="dual-slider dual-slider-inline" bind:this={sliderContainer}>
1107 + <div class="slider-track"></div>
1108 + <div
1109 + class="slider-range"
1110 + style="left: {rangoEjecucionMin}%; right: {100 - rangoEjecucionMax}%;"
1111 + onmousedown={handleRangeMouseDown}
1112 + role="slider"
1113 + tabindex="0"
1114 + ></div>
1115 + <input
1116 + type="range"
1117 + min="0"
1118 + max="100"
1119 + bind:value={rangoEjecucionMin}
1120 + class="slider-thumb slider-thumb-min"
1121 + oninput={() => {
1122 + if (rangoEjecucionMin > rangoEjecucionMax - 5) {
1123 + rangoEjecucionMin = rangoEjecucionMax - 5;
1124 + }
1125 + }}
1126 + />
1127 + <input
1128 + type="range"
1129 + min="0"
1130 + max="100"
1131 + bind:value={rangoEjecucionMax}
1132 + class="slider-thumb slider-thumb-max"
1133 + oninput={() => {
1134 + if (rangoEjecucionMax < rangoEjecucionMin + 5) {
1135 + rangoEjecucionMax = rangoEjecucionMin + 5;
1136 + }
1137 + }}
1138 + />
1139 + </div>
1140 + </div>
1141 + </div>
1142 + </div>
1143 + </div>
1144 + </section>
1145 +
1146 + <!-- SECCIÓN 3: Programas -->
1147 + <section class="vista-seccion" id="programas">
1148 + <div class="vista-contenido">
1149 + <div class="contenido-centrado">
1150 + <!-- Título de sección -->
1151 + <header class="seccion-header seccion-header-centrado">
1152 + <h1 class="seccion-titulo-grande">¿En qué programas?</h1>
1153 + <p class="seccion-descripcion">Detalle de programas y actividades ejecutadas en la última semana</p>
1154 + </header>
1155 +
1156 + <div class="timeline-search">
1157 + <svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
1158 + <path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
1159 + </svg>
1160 + <input
1161 + type="text"
1162 + placeholder="Buscar entidad..."
1163 + bind:value={busquedaTimeline}
1164 + class="timeline-search-input"
1165 + />
1166 + </div>
1167 +
1168 + <div class="timeline-cards">
1169 + {#each timelineByEntity as entidad}
1170 + <div class="timeline-card">
1171 + <div class="timeline-card-header">
1172 + <div class="timeline-card-entity">
1173 + <span class="timeline-card-area">{entidad.entidad_desc_area}</span>
1174 + <span class="timeline-card-nombre">{entidad.entidad_desc_entidad}</span>
1175 + </div>
1176 + <div class="timeline-card-total">
1177 + <span class="timeline-card-total-label">Ejecutado</span>
1178 + <span class="timeline-card-total-monto">Bs. {formatearNumeroCompleto(entidad.total)}</span>
1179 + </div>
1180 + </div>
1181 + <div class="timeline-card-body">
1182 + {#each cardsExpandidas[entidad.entidad] ? entidad.programas : entidad.programas.slice(0, 8) as prog}
1183 + <div class="timeline-prog-row">
1184 + <div class="timeline-prog-info">
1185 + <span class="timeline-prog-programa">{prog.programa}</span>
1186 + <span class="timeline-prog-actividad">{prog.actividad}</span>
1187 + </div>
1188 + <span class="timeline-prog-monto">Bs. {formatearNumeroCompleto(prog.devengado)}</span>
1189 + </div>
1190 + {/each}
1191 +
1192 + {#if entidad.programas.length > 8}
1193 + <button
1194 + class="timeline-ver-mas"
1195 + onclick={() => {
1196 + cardsExpandidas[entidad.entidad] = !cardsExpandidas[entidad.entidad];
1197 + cardsExpandidas = { ...cardsExpandidas };
1198 + }}
1199 + >
1200 + {cardsExpandidas[entidad.entidad] ? 'Ver menos' : `Ver ${entidad.programas.length - 8} más...`}
1201 + </button>
1202 + {/if}
1203 + </div>
1204 + </div>
1205 + {/each}
1206 + </div>
1207 + </div>
1208 + </div>
1209 + </section>
1210 +
1211 + <!-- SECCIÓN 4: Evolución temporal -->
1212 + <section class="vista-seccion" id="evolucion">
1213 + <div class="vista-contenido">
1214 + <div class="contenido-centrado">
1215 + <!-- Título de sección -->
1216 + <header class="seccion-header">
1217 + <h1 class="seccion-titulo-grande">¿Cómo evoluciona el gasto?</h1>
1218 + </header>
1219 +
1220 + <!-- Gráficos lado a lado -->
1221 + <div class="charts-grid">
1222 + <div class="chart-card">
1223 + <div class="chart-header">
1224 + <span class="chart-label">Ejecución diaria</span>
1225 + <span class="chart-value">{formatearMonto(diferenciaDiaria)}</span>
1226 + </div>
1227 + <div class="chart-container" bind:this={chartDiariaContainer}></div>
1228 + </div>
1229 + <div class="chart-card">
1230 + <div class="chart-header">
1231 + <span class="chart-label">Ejecución acumulada</span>
1232 + <span class="chart-value">{formatearMonto(totalDevengado)}</span>
1233 + </div>
1234 + <div class="chart-container" bind:this={chartAcumuladaContainer}></div>
1235 + </div>
1236 + </div>
1237 +
1238 + <!-- Controles centrados debajo -->
1239 + <div class="controles-footer">
1240 + {#each labelsPeriodo as { value, label }}
1241 + <button
1242 + class="periodo-btn"
1243 + class:active={diasSeleccionados === value}
1244 + onclick={() => diasSeleccionados = value}
1245 + >
1246 + {label}
1247 + </button>
1248 + {/each}
1249 + <label class="toggle-transferencias">
1250 + <input type="checkbox" bind:checked={incluirTransferencias} />
1251 + <span class="toggle-label">Incluir transferencias</span>
1252 + <span class="tooltip-trigger">
1253 + <svg class="tooltip-icon" viewBox="0 0 20 20" fill="currentColor">
1254 + <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
1255 + </svg>
1256 + <span class="tooltip-content">
1257 + Las transferencias son recursos que el gobierno central envía a gobiernos territoriales o al sector privado. Al desactivar esta opción se evita la doble contabilización, mostrando el gasto consolidado.
1258 + </span>
1259 + </span>
1260 + </label>
1261 + </div>
1262 + </div>
1263 + </div>
1264 + </section>
1265 + {/if}
1266 +</div>
1267 +
1268 +<style>
1269 + /* Variables de color para gráficos - Modo claro */
1270 + :global(:root) {
1271 + --chart-bar-default: #e0e0e0;
1272 + --chart-bar-highlight: #F3EAD8;
1273 + --chart-bar-stroke: #b0b0b0;
1274 + --chart-bar-stroke-default: white;
1275 + --chart-track-bg: #f5f5f5;
1276 + --chart-grid: #bbb;
1277 + --chart-label: #888;
1278 + --chart-card-header: #f5f0e6;
1279 + }
1280 +
1281 + /* Variables de color para gráficos - Modo oscuro */
1282 + :global(.dark) {
1283 + --chart-bar-default: #3a3a38;
1284 + --chart-bar-highlight: #C9A751;
1285 + --chart-bar-stroke: #5a5a55;
1286 + --chart-bar-stroke-default: #2a2a28;
1287 + --chart-track-bg: #2a2a28;
1288 + --chart-grid: #4a4a45;
1289 + --chart-label: #8a8a85;
1290 + --chart-card-header: #3a3835;
1291 + }
1292 +
1293 + .vista-diaria {
1294 + background: var(--theme-body);
1295 + color: var(--theme-titulo);
1296 + scroll-snap-type: y mandatory;
1297 + overflow-y: scroll;
1298 + height: 100vh;
1299 + height: 100dvh;
1300 + }
1301 +
1302 + /* Loading/Error */
1303 + .loading, .error {
1304 + height: 100vh;
1305 + display: flex;
1306 + align-items: center;
1307 + justify-content: center;
1308 + color: var(--theme-texto);
1309 + }
1310 +
1311 + .error {
1312 + color: #DB281C;
1313 + }
1314 +
1315 + /* Navegación interna fija */
1316 + .nav-interna {
1317 + position: fixed;
1318 + right: 1.5rem;
1319 + top: 50%;
1320 + transform: translateY(-50%);
1321 + display: flex;
1322 + flex-direction: column;
1323 + gap: 1rem;
1324 + z-index: 100;
1325 + }
1326 +
1327 + .nav-item {
1328 + display: flex;
1329 + align-items: center;
1330 + gap: 0.5rem;
1331 + background: none;
1332 + border: none;
1333 + cursor: pointer;
1334 + padding: 0.25rem;
1335 + flex-direction: row-reverse;
1336 + }
1337 +
1338 + .nav-dot {
1339 + width: 10px;
1340 + height: 10px;
1341 + border-radius: 50%;
1342 + background: rgba(0, 0, 0, 0.2);
1343 + transition: all 0.2s;
1344 + }
1345 +
1346 + :global(.dark) .nav-dot {
1347 + background: rgba(255, 255, 255, 0.2);
1348 + }
1349 +
1350 + .nav-item.active .nav-dot {
1351 + background: var(--theme-accent);
1352 + transform: scale(1.2);
1353 + }
1354 +
1355 + .nav-label {
1356 + font-size: 0.7rem;
1357 + color: var(--theme-texto);
1358 + opacity: 0;
1359 + transition: opacity 0.2s;
1360 + white-space: nowrap;
1361 + }
1362 +
1363 + .nav-item:hover .nav-label,
1364 + .nav-item.active .nav-label {
1365 + opacity: 1;
1366 + }
1367 +
1368 + /* Secciones de 100vh - el navbar tiene ~56px de alto */
1369 + .vista-seccion {
1370 + min-height: 100vh;
1371 + min-height: 100dvh;
1372 + padding-top: 56px;
1373 + box-sizing: border-box;
1374 + display: flex;
1375 + flex-direction: column;
1376 + border-bottom: 1px solid var(--theme-borde);
1377 + scroll-snap-align: start;
1378 + scroll-snap-stop: always;
1379 + }
1380 +
1381 +
1382 + .vista-contenido {
1383 + max-width: 1200px;
1384 + margin: 0 auto;
1385 + padding: 2rem;
1386 + width: 100%;
1387 + flex: 1;
1388 + display: flex;
1389 + flex-direction: column;
1390 + justify-content: center;
1391 + }
1392 +
1393 + .contenido-centrado {
1394 + width: 100%;
1395 + }
1396 +
1397 + /* Controles centrados debajo del gráfico */
1398 + .controles-footer {
1399 + display: flex;
1400 + flex-wrap: wrap;
1401 + justify-content: center;
1402 + align-items: center;
1403 + gap: 0.5rem;
1404 + margin-top: 2rem;
1405 + padding-top: 1.5rem;
1406 + border-top: 1px solid var(--theme-borde);
1407 + }
1408 +
1409 + .periodo-btn {
1410 + padding: 0.5rem 1rem;
1411 + font-family: 'Qanelas', var(--font-sans);
1412 + font-size: 0.8rem;
1413 + font-weight: 500;
1414 + border: 1px solid var(--theme-borde);
1415 + border-radius: 20px;
1416 + background: transparent;
1417 + color: var(--theme-texto);
1418 + cursor: pointer;
1419 + transition: all 0.2s ease;
1420 + }
1421 +
1422 + .periodo-btn:hover {
1423 + background: var(--theme-fill);
1424 + border-color: var(--theme-accent);
1425 + color: var(--theme-titulo);
1426 + }
1427 +
1428 + .periodo-btn.active {
1429 + background: var(--theme-titulo);
1430 + border-color: var(--theme-titulo);
1431 + color: var(--theme-body);
1432 + }
1433 +
1434 + /* Dark mode: botón activo con acento dorado */
1435 + :global(.dark) .periodo-btn.active {
1436 + background: var(--theme-accent);
1437 + border-color: var(--theme-accent);
1438 + color: var(--theme-body);
1439 + }
1440 +
1441 + .toggle-transferencias {
1442 + display: flex;
1443 + align-items: center;
1444 + gap: 0.5rem;
1445 + cursor: pointer;
1446 + margin-left: 0.5rem;
1447 + padding-left: 1rem;
1448 + border-left: 1px solid var(--theme-borde);
1449 + }
1450 +
1451 + .toggle-transferencias input[type="checkbox"] {
1452 + width: 16px;
1453 + height: 16px;
1454 + accent-color: var(--theme-accent);
1455 + cursor: pointer;
1456 + }
1457 +
1458 + .toggle-label {
1459 + font-size: 0.75rem;
1460 + color: var(--theme-texto);
1461 + }
1462 +
1463 + .separador-control {
1464 + width: 1px;
1465 + height: 24px;
1466 + background: var(--theme-borde);
1467 + margin: 0 0.5rem;
1468 + }
1469 +
1470 + .rango-control-inline {
1471 + display: flex;
1472 + flex-direction: column;
1473 + align-items: flex-start;
1474 + gap: 0.25rem;
1475 + flex: 1;
1476 + min-width: 0;
1477 + }
1478 +
1479 + .rango-control-inline .rango-label {
1480 + font-size: 0.7rem;
1481 + }
1482 +
1483 + .dual-slider-inline {
1484 + width: 100%;
1485 + }
1486 +
1487 + /* Tooltip */
1488 + .tooltip-trigger {
1489 + position: relative;
1490 + display: inline-flex;
1491 + align-items: center;
1492 + cursor: help;
1493 + }
1494 +
1495 + .tooltip-icon {
1496 + width: 14px;
1497 + height: 14px;
1498 + color: var(--theme-texto);
1499 + opacity: 0.5;
1500 + transition: opacity 0.2s;
1501 + }
1502 +
1503 + .tooltip-trigger:hover .tooltip-icon {
1504 + opacity: 1;
1505 + color: var(--theme-accent);
1506 + }
1507 +
1508 + .tooltip-content {
1509 + position: absolute;
1510 + bottom: calc(100% + 8px);
1511 + left: 50%;
1512 + transform: translateX(-50%);
1513 + width: 280px;
1514 + padding: 0.75rem 1rem;
1515 + background: var(--theme-titulo);
1516 + color: var(--theme-body);
1517 + font-size: 0.75rem;
1518 + line-height: 1.5;
1519 + border-radius: 8px;
1520 + opacity: 0;
1521 + visibility: hidden;
1522 + transition: all 0.2s ease;
1523 + z-index: 100;
1524 + pointer-events: none;
1525 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1526 + }
1527 +
1528 + .tooltip-content::after {
1529 + content: '';
1530 + position: absolute;
1531 + top: 100%;
1532 + left: 50%;
1533 + transform: translateX(-50%);
1534 + border: 6px solid transparent;
1535 + border-top-color: var(--theme-titulo);
1536 + }
1537 +
1538 + .tooltip-trigger:hover .tooltip-content {
1539 + opacity: 1;
1540 + visibility: visible;
1541 + }
1542 +
1543 + /* Header de sección */
1544 + .seccion-header {
1545 + margin-bottom: 2rem;
1546 + }
1547 +
1548 + .seccion-header-centrado {
1549 + text-align: center;
1550 + }
1551 +
1552 + .seccion-titulo-grande {
1553 + font-family: 'Qanelas', var(--font-sans);
1554 + font-size: 2rem;
1555 + font-weight: 600;
1556 + margin: 0 0 0.25rem;
1557 + color: var(--theme-titulo);
1558 + }
1559 +
1560 + .seccion-descripcion {
1561 + font-size: 0.875rem;
1562 + color: var(--theme-texto);
1563 + margin: 0;
1564 + }
1565 +
1566 + /* Clasificadores Grid */
1567 + .clasificadores-grid {
1568 + display: flex;
1569 + flex-direction: column;
1570 + gap: 1rem;
1571 + }
1572 +
1573 + .clasificador-row {
1574 + display: flex;
1575 + flex-direction: column;
1576 + gap: 0.35rem;
1577 + }
1578 +
1579 + .clasificador-header {
1580 + display: flex;
1581 + align-items: baseline;
1582 + justify-content: space-between;
1583 + gap: 1rem;
1584 + }
1585 +
1586 + .clasificador-info {
1587 + display: flex;
1588 + align-items: baseline;
1589 + gap: 0.3rem;
1590 + flex-wrap: wrap;
1591 + }
1592 +
1593 + .clasificador-monto {
1594 + font-family: var(--font-sans);
1595 + font-variant-numeric: tabular-nums;
1596 + font-size: 0.95rem;
1597 + font-weight: 600;
1598 + color: var(--theme-titulo);
1599 + }
1600 +
1601 + .bs-en {
1602 + font-size: 0.75rem;
1603 + color: var(--theme-texto);
1604 + opacity: 0.6;
1605 + }
1606 +
1607 + .clasificador-hijo {
1608 + font-size: 0.85rem;
1609 + font-weight: 500;
1610 + color: var(--theme-titulo);
1611 + }
1612 +
1613 + .clasificador-padre-annotation {
1614 + font-size: 0.7rem;
1615 + color: var(--theme-texto);
1616 + opacity: 0.5;
1617 + }
1618 +
1619 + .barra-d3-container {
1620 + width: 100%;
1621 + height: 52px;
1622 + border-radius: 4px;
1623 + overflow: hidden;
1624 + }
1625 +
1626 + .clasificador-tipo {
1627 + font-size: 0.65rem;
1628 + text-transform: uppercase;
1629 + letter-spacing: 0.05em;
1630 + color: var(--theme-texto);
1631 + opacity: 0.7;
1632 + flex-shrink: 0;
1633 + }
1634 +
1635 + /* Timeline Section - Cards */
1636 + .timeline-search {
1637 + margin: 0 auto 1rem auto;
1638 + position: relative;
1639 + width: 100%;
1640 + max-width: 400px;
1641 + }
1642 +
1643 + .search-icon {
1644 + position: absolute;
1645 + left: 0.75rem;
1646 + top: 50%;
1647 + transform: translateY(-50%);
1648 + width: 16px;
1649 + height: 16px;
1650 + color: var(--theme-texto);
1651 + opacity: 0.5;
1652 + pointer-events: none;
1653 + }
1654 +
1655 + .timeline-search-input {
1656 + width: 100%;
1657 + padding: 0.6rem 1rem 0.6rem 2.25rem;
1658 + font-size: 0.8rem;
1659 + font-family: inherit;
1660 + border: 1px solid var(--theme-borde);
1661 + border-radius: 20px;
1662 + background: var(--theme-body);
1663 + color: var(--theme-titulo);
1664 + outline: none;
1665 + transition: border-color 0.2s;
1666 + }
1667 +
1668 + .timeline-search-input:focus {
1669 + border-color: var(--theme-accent);
1670 + }
1671 +
1672 + .timeline-search-input:focus + .search-icon,
1673 + .timeline-search:focus-within .search-icon {
1674 + opacity: 1;
1675 + color: var(--theme-accent);
1676 + }
1677 +
1678 + .timeline-search-input::placeholder {
1679 + color: var(--theme-texto);
1680 + opacity: 0.5;
1681 + }
1682 +
1683 + .timeline-cards {
1684 + display: flex;
1685 + flex-direction: column;
1686 + gap: 0.75rem;
1687 + max-width: 80%;
1688 + margin: 0 auto;
1689 + }
1690 +
1691 + .timeline-card {
1692 + background: var(--theme-surface);
1693 + border: 1px solid rgba(0, 0, 0, 0.08);
1694 + border-radius: 6px;
1695 + overflow: hidden;
1696 + }
1697 +
1698 + :global(.dark) .timeline-card {
1699 + background: var(--theme-fill);
1700 + border-color: rgba(255, 255, 255, 0.1);
1701 + }
1702 +
1703 + .timeline-card-header {
1704 + background: var(--chart-card-header);
1705 + padding: 0.75rem 1rem;
1706 + display: flex;
1707 + justify-content: space-between;
1708 + align-items: flex-start;
1709 + gap: 1rem;
1710 + }
1711 +
1712 + .timeline-card-entity {
1713 + display: flex;
1714 + flex-direction: column;
1715 + gap: 0.15rem;
1716 + }
1717 +
1718 + .timeline-card-area {
1719 + font-size: 0.65rem;
1720 + text-transform: uppercase;
1721 + letter-spacing: 0.03em;
1722 + color: var(--theme-texto);
1723 + opacity: 0.6;
1724 + }
1725 +
1726 + .timeline-card-nombre {
1727 + font-size: 1rem;
1728 + font-weight: 600;
1729 + color: var(--theme-titulo);
1730 + line-height: 1.2;
1731 + }
1732 +
1733 + .timeline-card-total {
1734 + display: flex;
1735 + flex-direction: column;
1736 + align-items: flex-end;
1737 + gap: 0.1rem;
1738 + flex-shrink: 0;
1739 + }
1740 +
1741 + .timeline-card-total-label {
1742 + font-size: 0.65rem;
1743 + text-transform: uppercase;
1744 + letter-spacing: 0.03em;
1745 + color: var(--theme-texto);
1746 + opacity: 0.6;
1747 + }
1748 +
1749 + .timeline-card-total-monto {
1750 + font-family: var(--font-sans);
1751 + font-variant-numeric: tabular-nums;
1752 + font-size: 1rem;
1753 + font-weight: 500;
1754 + color: var(--theme-titulo);
1755 + }
1756 +
1757 + .timeline-card-body {
1758 + padding: 0.5rem 1rem;
1759 + text-align: center;
1760 + }
1761 +
1762 + .timeline-prog-row {
1763 + display: flex;
1764 + justify-content: space-between;
1765 + align-items: flex-start;
1766 + gap: 1rem;
1767 + padding: 0.4rem 0;
1768 + border-bottom: 1px dotted rgba(0, 0, 0, 0.2);
1769 + text-align: left;
1770 + }
1771 +
1772 + :global(.dark) .timeline-prog-row {
1773 + border-bottom-color: rgba(255, 255, 255, 0.2);
1774 + }
1775 +
1776 + .timeline-prog-row:last-child {
1777 + border-bottom: none;
1778 + }
1779 +
1780 + .timeline-prog-info {
1781 + display: flex;
1782 + flex-direction: column;
1783 + gap: 0.1rem;
1784 + min-width: 0;
1785 + flex: 1;
1786 + }
1787 +
1788 + .timeline-prog-programa {
1789 + font-size: 0.6rem;
1790 + color: var(--theme-texto);
1791 + opacity: 0.5;
1792 + line-height: 1.2;
1793 + white-space: nowrap;
1794 + overflow: hidden;
1795 + text-overflow: ellipsis;
1796 + }
1797 +
1798 + .timeline-prog-actividad {
1799 + font-size: 0.75rem;
1800 + font-weight: 500;
1801 + color: var(--theme-titulo);
1802 + line-height: 1.25;
1803 + }
1804 +
1805 + .timeline-prog-monto {
1806 + font-family: var(--font-sans);
1807 + font-variant-numeric: tabular-nums;
1808 + font-size: 0.75rem;
1809 + color: var(--theme-titulo);
1810 + flex-shrink: 0;
1811 + }
1812 +
1813 + .timeline-ver-mas {
1814 + display: inline;
1815 + padding: 0;
1816 + margin-top: 0.5rem;
1817 + background: transparent;
1818 + border: none;
1819 + border-bottom: 1px dotted var(--theme-texto);
1820 + color: var(--theme-texto);
1821 + font-size: 0.7rem;
1822 + font-family: inherit;
1823 + cursor: pointer;
1824 + transition: all 0.2s;
1825 + }
1826 +
1827 + .timeline-ver-mas:hover {
1828 + color: var(--theme-accent);
1829 + border-bottom-color: var(--theme-accent);
1830 + }
1831 +
1832 + /* Entidades Section */
1833 + .seccion-subtitulo {
1834 + font-size: 0.875rem;
1835 + color: var(--theme-texto);
1836 + margin: -1rem 0 1.5rem;
1837 + }
1838 +
1839 + .entidad-info {
1840 + margin-bottom: 1.5rem;
1841 + display: flex;
1842 + flex-direction: column;
1843 + gap: 0.15rem;
1844 + }
1845 +
1846 + .entidad-pct {
1847 + font-family: 'Qanelas', var(--font-sans);
1848 + font-size: 2.5rem;
1849 + font-weight: 600;
1850 + color: var(--theme-titulo);
1851 + line-height: 1;
1852 + }
1853 +
1854 + .entidad-nombre {
1855 + font-size: 1rem;
1856 + font-weight: 500;
1857 + color: var(--theme-titulo);
1858 + line-height: 1.2;
1859 + margin-top: 0.25rem;
1860 + }
1861 +
1862 + .entidad-area {
1863 + font-size: 0.7rem;
1864 + color: var(--theme-texto);
1865 + opacity: 0.5;
1866 + text-transform: uppercase;
1867 + letter-spacing: 0.02em;
1868 + line-height: 1.2;
1869 + }
1870 +
1871 + .entidades-bar-container {
1872 + width: 100%;
1873 + overflow: hidden;
1874 + border-radius: 4px;
1875 + }
1876 +
1877 + .entidades-filtros {
1878 + margin-bottom: 1.5rem;
1879 + }
1880 +
1881 + .rango-control {
1882 + display: flex;
1883 + flex-direction: column;
1884 + gap: 0.75rem;
1885 + max-width: 400px;
1886 + }
1887 +
1888 + .rango-label {
1889 + font-size: 0.75rem;
1890 + color: var(--theme-texto);
1891 + font-variant-numeric: tabular-nums;
1892 + }
1893 +
1894 + .dual-slider {
1895 + position: relative;
1896 + height: 18px;
1897 + width: 100%;
1898 + }
1899 +
1900 + .slider-track {
1901 + position: absolute;
1902 + top: 50%;
1903 + transform: translateY(-50%);
1904 + width: 100%;
1905 + height: 3px;
1906 + background: var(--theme-borde);
1907 + border-radius: 2px;
1908 + }
1909 +
1910 + .slider-range {
1911 + position: absolute;
1912 + top: 50%;
1913 + transform: translateY(-50%);
1914 + height: 3px;
1915 + background: var(--theme-titulo);
1916 + border-radius: 2px;
1917 + cursor: grab;
1918 + z-index: 1;
1919 + }
1920 +
1921 + .slider-range:active {
1922 + cursor: grabbing;
1923 + }
1924 +
1925 + .slider-thumb {
1926 + position: absolute;
1927 + top: 0;
1928 + width: 100%;
1929 + height: 100%;
1930 + -webkit-appearance: none;
1931 + appearance: none;
1932 + background: transparent;
1933 + pointer-events: none;
1934 + margin: 0;
1935 + }
1936 +
1937 + .slider-thumb::-webkit-slider-thumb {
1938 + -webkit-appearance: none;
1939 + appearance: none;
1940 + width: 12px;
1941 + height: 12px;
1942 + background: var(--theme-titulo);
1943 + border: none;
1944 + border-radius: 50%;
1945 + cursor: pointer;
1946 + pointer-events: auto;
1947 + box-shadow: 0 1px 3px rgba(0,0,0,0.15);
1948 + transition: transform 0.15s;
1949 + }
1950 +
1951 + .slider-thumb::-webkit-slider-thumb:hover {
1952 + transform: scale(1.15);
1953 + }
1954 +
1955 + .slider-thumb::-moz-range-thumb {
1956 + width: 12px;
1957 + height: 12px;
1958 + background: var(--theme-titulo);
1959 + border: none;
1960 + border-radius: 50%;
1961 + cursor: pointer;
1962 + pointer-events: auto;
1963 + box-shadow: 0 1px 3px rgba(0,0,0,0.15);
1964 + }
1965 +
1966 + .entidades-paginacion {
1967 + display: flex;
1968 + align-items: center;
1969 + justify-content: space-between;
1970 + margin-top: 1rem;
1971 + gap: 1rem;
1972 + flex-wrap: wrap;
1973 + }
1974 +
1975 + .entidades-botones {
1976 + display: flex;
1977 + gap: 0.5rem;
1978 + }
1979 +
1980 + .entidades-nota {
1981 + font-size: 0.75rem;
1982 + color: var(--theme-texto);
1983 + opacity: 0.6;
1984 + }
1985 +
1986 + .entidades-ver-mas {
1987 + padding: 0.5rem 1rem;
1988 + background: transparent;
1989 + border: 1px solid var(--theme-borde);
1990 + border-radius: 6px;
1991 + color: var(--theme-texto);
1992 + font-size: 0.8rem;
1993 + font-family: inherit;
1994 + cursor: pointer;
1995 + transition: all 0.2s;
1996 + }
1997 +
1998 + .entidades-ver-mas:hover {
1999 + background: var(--theme-fill);
2000 + border-color: var(--theme-accent);
2001 + color: var(--theme-titulo);
2002 + }
2003 +
2004 + /* Charts Grid - Sección 4 */
2005 + .charts-grid {
2006 + display: grid;
2007 + grid-template-columns: 1fr 1fr;
2008 + gap: 2rem;
2009 + margin-bottom: 1rem;
2010 + }
2011 +
2012 + .chart-card {
2013 + background: var(--theme-fill);
2014 + border: 1px solid var(--theme-borde);
2015 + border-radius: 8px;
2016 + padding: 1.25rem;
2017 + }
2018 +
2019 + :global(.dark) .chart-card {
2020 + background: var(--theme-surface);
2021 + }
2022 +
2023 + .chart-header {
2024 + display: flex;
2025 + justify-content: space-between;
2026 + align-items: baseline;
2027 + margin-bottom: 1rem;
2028 + }
2029 +
2030 + .chart-label {
2031 + font-size: 0.8rem;
2032 + color: var(--theme-texto);
2033 + }
2034 +
2035 + .chart-value {
2036 + font-family: 'Qanelas', var(--font-sans);
2037 + font-size: 1.25rem;
2038 + font-weight: 600;
2039 + color: var(--theme-titulo);
2040 + }
2041 +
2042 + .chart-container {
2043 + width: 100%;
2044 + height: 220px;
2045 + }
2046 +
2047 + /* Tipografía y colores para los gráficos Observable Plot */
2048 + .chart-container :global(svg) {
2049 + font-family: 'Qanelas', var(--font-sans), system-ui, sans-serif;
2050 + }
2051 +
2052 + .chart-container :global(svg text) {
2053 + font-family: 'Qanelas', var(--font-sans), system-ui, sans-serif;
2054 + fill: var(--theme-texto);
2055 + }
2056 +
2057 + .chart-container :global(svg [aria-label="y-grid"] line) {
2058 + stroke: rgba(0, 0, 0, 0.1);
2059 + }
2060 +
2061 + :global(.dark) .chart-container :global(svg [aria-label="y-grid"] line) {
2062 + stroke: rgba(255, 255, 255, 0.1);
2063 + }
2064 +
2065 + .chart-container :global(svg [aria-label="x-axis"] line),
2066 + .chart-container :global(svg [aria-label="y-axis"] line) {
2067 + stroke: var(--theme-borde);
2068 + }
2069 +
2070 + /* Responsive */
2071 + @media (max-width: 900px) {
2072 + .nav-interna {
2073 + right: 0.75rem;
2074 + }
2075 +
2076 + .nav-label {
2077 + display: none;
2078 + }
2079 + }
2080 +
2081 + @media (max-width: 768px) {
2082 + .vista-diaria {
2083 + scroll-snap-type: y proximity;
2084 + }
2085 +
2086 + .vista-seccion {
2087 + scroll-snap-stop: normal;
2088 + }
2089 +
2090 + .vista-contenido {
2091 + padding: 1rem;
2092 + }
2093 +
2094 + .seccion-titulo-grande {
2095 + font-size: 1.5rem;
2096 + }
2097 +
2098 + .clasificador-header {
2099 + flex-direction: column;
2100 + align-items: flex-start;
2101 + gap: 0.25rem;
2102 + }
2103 +
2104 + .clasificador-tipo {
2105 + order: -1;
2106 + }
2107 +
2108 + .periodo-btn {
2109 + padding: 0.4rem 0.75rem;
2110 + font-size: 0.75rem;
2111 + }
2112 +
2113 + .entidad-pct {
2114 + font-size: 2rem;
2115 + }
2116 +
2117 + .charts-grid {
2118 + grid-template-columns: 1fr;
2119 + gap: 1rem;
2120 + }
2121 +
2122 + .chart-container {
2123 + height: 140px;
2124 + }
2125 +
2126 + .timeline-cards {
2127 + max-width: 100%;
2128 + }
2129 + }
2130 +</style>
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type