Rafael Lopez

entidades

...@@ -242,6 +242,8 @@ ...@@ -242,6 +242,8 @@
242 color: #B8B5AD; 242 color: #B8B5AD;
243 cursor: pointer; 243 cursor: pointer;
244 transition: all 0.2s ease; 244 transition: all 0.2s ease;
245 + backdrop-filter: blur(12px);
246 + -webkit-backdrop-filter: blur(12px);
245 } 247 }
246 248
247 .nav-search-btn:hover { 249 .nav-search-btn:hover {
...@@ -291,6 +293,8 @@ ...@@ -291,6 +293,8 @@
291 border: 1px solid rgba(255, 255, 255, 0.1); 293 border: 1px solid rgba(255, 255, 255, 0.1);
292 border-radius: 10px; 294 border-radius: 10px;
293 color: #B8B5AD; 295 color: #B8B5AD;
296 + backdrop-filter: blur(12px);
297 + -webkit-backdrop-filter: blur(12px);
294 cursor: pointer; 298 cursor: pointer;
295 transition: all 0.2s ease; 299 transition: all 0.2s ease;
296 } 300 }
...@@ -330,6 +334,8 @@ ...@@ -330,6 +334,8 @@
330 border: 1px solid rgba(255, 255, 255, 0.1); 334 border: 1px solid rgba(255, 255, 255, 0.1);
331 border-radius: 10px; 335 border-radius: 10px;
332 color: #B8B5AD; 336 color: #B8B5AD;
337 + backdrop-filter: blur(12px);
338 + -webkit-backdrop-filter: blur(12px);
333 cursor: pointer; 339 cursor: pointer;
334 transition: all 0.2s ease; 340 transition: all 0.2s ease;
335 text-decoration: none; 341 text-decoration: none;
...@@ -363,6 +369,8 @@ ...@@ -363,6 +369,8 @@
363 border: 1px solid rgba(255, 255, 255, 0.08); 369 border: 1px solid rgba(255, 255, 255, 0.08);
364 border-radius: 8px; 370 border-radius: 8px;
365 color: #B8B5AD; 371 color: #B8B5AD;
372 + backdrop-filter: blur(12px);
373 + -webkit-backdrop-filter: blur(12px);
366 cursor: pointer; 374 cursor: pointer;
367 transition: all 0.2s ease; 375 transition: all 0.2s ease;
368 } 376 }
......
1 <script> 1 <script>
2 - import { onMount, tick } from 'svelte'; 2 + import { tick } from 'svelte';
3 import { goto } from '$app/navigation'; 3 import { goto } from '$app/navigation';
4 - import { 4 + import { get } from 'svelte/store';
5 - query, 5 + import { landingSearchMode, landingSelectedClassifiers } from '$lib/stores/landingSearchState';
6 - results,
7 - isLoading,
8 - indexLoaded,
9 - error,
10 - selectedIndex,
11 - initIndex,
12 - performSearch,
13 - clearSearch,
14 - navigateResults
15 - } from '$lib/stores/searchStore';
16 6
17 let { open = $bindable(false) } = $props(); 7 let { open = $bindable(false) } = $props();
18 8
19 let searchInput = $state(null); 9 let searchInput = $state(null);
20 let searchVal = $state(''); 10 let searchVal = $state('');
21 - let isMac = $state(false); 11 + let isMac = $state(typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform));
22 - 12 + let searchLoading = $state(false);
23 - // Search filters 13 + let searchResults = $state([]);
24 - let searchFilters = $state({ 14 + let selectedIdx = $state(-1);
25 - entidad: true, 15 + let debounceTimer;
26 - objeto_gasto: true, 16 +
27 - rubro: true, 17 + // Mapeo de clasificadores landing → filtros modal
28 - finfun: true, 18 + const classifierToFilter = {
29 - organismo: true, 19 + entidad: 'entidad',
30 - fuente: true 20 + objeto: 'objeto',
31 - }); 21 + rubro: 'rubro',
22 + finfun: 'finfun',
23 + organismo: 'organismo',
24 + fuente: 'fuente'
25 + };
26 +
27 + function getInitialFilters() {
28 + const mode = get(landingSearchMode);
29 + const classifiers = get(landingSelectedClassifiers);
30 + if (mode === 'clasificadores' && classifiers.length > 0) {
31 + const filters = { entidad: false, objeto_gasto: false, rubro: false, finfun: false, organismo: false, fuente: false };
32 + classifiers.forEach(c => {
33 + const key = classifierToFilter[c];
34 + if (key === 'objeto') filters.objeto_gasto = true;
35 + else if (key && filters[key] !== undefined) filters[key] = true;
36 + });
37 + return filters;
38 + }
39 + return { entidad: true, objeto_gasto: true, rubro: true, finfun: true, organismo: true, fuente: true };
40 + }
32 41
33 - // Filtered results based on active filters 42 + let searchFilters = $state(getInitialFilters());
34 - let filteredResults = $derived(
35 - $results.filter(item => searchFilters[item.tipo])
36 - );
37 43
38 function toggleFilter(tipo) { 44 function toggleFilter(tipo) {
39 searchFilters[tipo] = !searchFilters[tipo]; 45 searchFilters[tipo] = !searchFilters[tipo];
46 + // Re-buscar con filtros actualizados
47 + if (searchVal.length >= 2) doSearch(searchVal);
40 } 48 }
41 49
42 - onMount(() => { 50 + // Filtros activos → class_ param para Typesense
43 - isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform); 51 + function getActiveClasses() {
44 - initIndex(); 52 + const map = { entidad: 'entidad', objeto_gasto: 'objeto', finfun: 'finfun' };
45 - }); 53 + return Object.entries(searchFilters)
54 + .filter(([_, v]) => v)
55 + .map(([k]) => map[k] || k)
56 + .filter(Boolean);
57 + }
58 +
59 + function parseMetadatos(meta) {
60 + if (!meta) return {};
61 + if (typeof meta === 'object') return meta;
62 + try { return JSON.parse(meta); } catch { return {}; }
63 + }
46 64
47 - // Focus input when modal opens 65 + async function doSearch(query) {
66 + searchLoading = true;
67 + try {
68 + const classes = getActiveClasses();
69 + const params = new URLSearchParams({
70 + q: query,
71 + is_class: 'true',
72 + per_page: '30'
73 + });
74 + if (classes.length > 0 && classes.length < 6) {
75 + params.set('class_', classes.join(','));
76 + }
77 + const res = await fetch(`/api/search?${params}`);
78 + if (!res.ok) throw new Error();
79 + const data = await res.json();
80 + searchResults = (data.hits || []).map(hit => {
81 + const meta = parseMetadatos(hit.document.metadatos);
82 + const cls = hit.document.class_;
83 + let tipo = cls;
84 + let codigo = '';
85 + let nombre = hit.document.texto;
86 + let highlight = hit.highlights?.[0]?.snippet || nombre;
87 +
88 + if (cls === 'entidad') {
89 + const esDA = !!meta.da;
90 + codigo = esDA ? `${meta.entidad}.${meta.da}` : String(meta.entidad);
91 + tipo = 'entidad';
92 + } else if (cls === 'objeto') {
93 + codigo = meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '';
94 + tipo = 'objeto_gasto';
95 + } else if (cls === 'finfun') {
96 + const fin = String(meta.finfun_finalidad || '');
97 + if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) {
98 + codigo = `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
99 + } else if (meta.finfun_grpfuncion !== undefined) {
100 + codigo = `${fin}.${meta.finfun_grpfuncion}`;
101 + } else {
102 + codigo = fin;
103 + }
104 + tipo = 'finfun';
105 + }
106 +
107 + return { tipo, codigo, nombre, highlight };
108 + });
109 + selectedIdx = -1;
110 + } catch {
111 + searchResults = [];
112 + }
113 + searchLoading = false;
114 + }
115 +
116 + let filteredResults = $derived(searchResults);
117 +
118 + // Focus input and sync filters when modal opens
48 $effect(() => { 119 $effect(() => {
49 if (open) { 120 if (open) {
121 + searchFilters = getInitialFilters();
50 tick().then(() => { 122 tick().then(() => {
51 searchInput?.focus(); 123 searchInput?.focus();
52 }); 124 });
53 } else { 125 } else {
54 - // Clear on close
55 searchVal = ''; 126 searchVal = '';
56 - clearSearch(); 127 + searchResults = [];
128 + selectedIdx = -1;
57 } 129 }
58 }); 130 });
59 131
...@@ -63,10 +135,11 @@ ...@@ -63,10 +135,11 @@
63 135
64 function handleInput(e) { 136 function handleInput(e) {
65 searchVal = e.target.value; 137 searchVal = e.target.value;
138 + clearTimeout(debounceTimer);
66 if (searchVal.length >= 2) { 139 if (searchVal.length >= 2) {
67 - performSearch(searchVal); 140 + debounceTimer = setTimeout(() => doSearch(searchVal), 200);
68 } else { 141 } else {
69 - clearSearch(); 142 + searchResults = [];
70 } 143 }
71 } 144 }
72 145
...@@ -75,13 +148,13 @@ ...@@ -75,13 +148,13 @@
75 closeModal(); 148 closeModal();
76 } else if (e.key === 'ArrowDown') { 149 } else if (e.key === 'ArrowDown') {
77 e.preventDefault(); 150 e.preventDefault();
78 - navigateResults('down', filteredResults.length); 151 + selectedIdx = selectedIdx < filteredResults.length - 1 ? selectedIdx + 1 : 0;
79 } else if (e.key === 'ArrowUp') { 152 } else if (e.key === 'ArrowUp') {
80 e.preventDefault(); 153 e.preventDefault();
81 - navigateResults('up', filteredResults.length); 154 + selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : filteredResults.length - 1;
82 - } else if (e.key === 'Enter' && $selectedIndex >= 0) { 155 + } else if (e.key === 'Enter' && selectedIdx >= 0) {
83 e.preventDefault(); 156 e.preventDefault();
84 - goToResult(filteredResults[$selectedIndex]); 157 + goToResult(filteredResults[selectedIdx]);
85 } 158 }
86 } 159 }
87 160
...@@ -91,10 +164,10 @@ ...@@ -91,10 +164,10 @@
91 goto(`/entidad/${item.codigo}`); 164 goto(`/entidad/${item.codigo}`);
92 } else if (item.tipo === 'objeto_gasto') { 165 } else if (item.tipo === 'objeto_gasto') {
93 goto(`/objeto/${item.codigo}`); 166 goto(`/objeto/${item.codigo}`);
94 - } else if (item.tipo === 'rubro') {
95 - goto(`/rubro/${item.codigo}`);
96 } else if (item.tipo === 'finfun') { 167 } else if (item.tipo === 'finfun') {
97 goto(`/finfun/${item.codigo}`); 168 goto(`/finfun/${item.codigo}`);
169 + } else if (item.tipo === 'rubro') {
170 + goto(`/rubro/${item.codigo}`);
98 } else if (item.tipo === 'organismo') { 171 } else if (item.tipo === 'organismo') {
99 goto(`/organismo/${item.codigo}`); 172 goto(`/organismo/${item.codigo}`);
100 } else if (item.tipo === 'fuente') { 173 } else if (item.tipo === 'fuente') {
...@@ -248,9 +321,7 @@ ...@@ -248,9 +321,7 @@
248 321
249 <!-- Results --> 322 <!-- Results -->
250 <div class="search-modal-results"> 323 <div class="search-modal-results">
251 - {#if !$indexLoaded} 324 + {#if searchLoading}
252 - <div class="search-modal-status">Cargando índice...</div>
253 - {:else if $isLoading}
254 <div class="search-modal-status">Buscando...</div> 325 <div class="search-modal-status">Buscando...</div>
255 {:else if searchVal.length < 2} 326 {:else if searchVal.length < 2}
256 <div class="search-modal-empty"></div> 327 <div class="search-modal-empty"></div>
...@@ -260,18 +331,15 @@ ...@@ -260,18 +331,15 @@
260 {#each filteredResults as item, i} 331 {#each filteredResults as item, i}
261 <button 332 <button
262 class="search-result-item" 333 class="search-result-item"
263 - class:result-selected={$selectedIndex === i} 334 + class:result-selected={selectedIdx === i}
264 onclick={() => goToResult(item)} 335 onclick={() => goToResult(item)}
265 - onmouseenter={() => selectedIndex.set(i)} 336 + onmouseenter={() => { selectedIdx = i; }}
266 > 337 >
267 <div class="result-main"> 338 <div class="result-main">
268 <span class="result-type" style="color:{typeConfig[item.tipo]?.color || '#888'}"> 339 <span class="result-type" style="color:{typeConfig[item.tipo]?.color || '#888'}">
269 {typeConfig[item.tipo]?.label || item.tipo} 340 {typeConfig[item.tipo]?.label || item.tipo}
270 </span> 341 </span>
271 - <span class="result-name">{item.nombre}</span> 342 + <span class="result-name">{@html item.highlight}</span>
272 - {#if item.descripcion}
273 - <span class="result-desc">{item.descripcion}</span>
274 - {/if}
275 </div> 343 </div>
276 <span class="result-code">{item.codigo}</span> 344 <span class="result-code">{item.codigo}</span>
277 </button> 345 </button>
......
1 +import { writable } from 'svelte/store';
2 +
3 +// Persiste el estado de búsqueda del landing entre navegaciones
4 +export const landingSearchMode = writable('programas'); // 'programas' | 'clasificadores'
5 +export const landingSelectedClassifiers = writable([]); // ['entidad', 'objeto', ...]
6 +export const landingSearchQuery = writable('');
1 <script> 1 <script>
2 import { onMount } from 'svelte'; 2 import { onMount } from 'svelte';
3 + import { landingSearchMode, landingSelectedClassifiers, landingSearchQuery } from '$lib/stores/landingSearchState';
3 4
4 // Estado base 5 // Estado base
5 let mounted = false; 6 let mounted = false;
7 + let heroInView = true;
6 let searchFocused = false; 8 let searchFocused = false;
7 - let searchVal = ''; 9 + let searchVal = $landingSearchQuery || '';
8 let searchInput; 10 let searchInput;
9 let isLoading = false; 11 let isLoading = false;
10 let results = null; 12 let results = null;
...@@ -58,48 +60,201 @@ ...@@ -58,48 +60,201 @@
58 } 60 }
59 61
60 $: sortedHits = allHits.length > 0 ? [...allHits].sort((a, b) => { 62 $: sortedHits = allHits.length > 0 ? [...allHits].sort((a, b) => {
61 - let valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0); 63 + const dir = sortOrder === 'desc' ? 1 : -1;
62 - let valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0); 64 + if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
63 - return sortOrder === 'desc' ? valB - valA : valA - valB; 65 + return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
64 }) : []; 66 }) : [];
65 67
66 - // ¿Es búsqueda de entidades? 68 + // Detectar tipos de clasificadores presentes en los resultados
67 - $: isEntidadSearchActive = results?.hits?.[0]?.document?.class_ === 'entidad'; 69 + $: activeClassTypes = (() => {
70 + const types = new Set();
71 + allHits.forEach(h => { if (h.document.class_) types.add(h.document.class_); });
72 + return types;
73 + })();
74 + $: isMixedClassSearch = activeClassTypes.size > 1;
75 + $: isEntidadSearchActive = !isMixedClassSearch && activeClassTypes.has('entidad');
76 + $: isObjetoSearchActive = !isMixedClassSearch && activeClassTypes.has('objeto');
77 + $: isFinfunSearchActive = !isMixedClassSearch && activeClassTypes.has('finfun');
78 +
79 + // Labels para tipos de clasificador
80 + const CLASS_LABELS = {
81 + entidad: 'Entidades',
82 + objeto: 'Objetos de Gasto',
83 + finfun: 'Finalidad y Función',
84 + };
85 +
86 + // Extraer código de finfun desde metadatos (formato con puntos: 1, 1.1, 1.1.3)
87 + function getFinfunCodigo(meta) {
88 + const fin = String(meta.finfun_finalidad || '');
89 + if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) {
90 + return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
91 + }
92 + if (meta.finfun_grpfuncion !== undefined) {
93 + return `${fin}.${meta.finfun_grpfuncion}`;
94 + }
95 + return fin;
96 + }
97 +
98 + function getFinfunNivel(meta) {
99 + if (meta.finfun_funcion !== undefined) return 'Función';
100 + if (meta.finfun_grpfuncion !== undefined) return 'Grupo función';
101 + return 'Finalidad';
102 + }
103 +
104 + // Extraer código de objeto desde metadatos (el nivel más bajo presente)
105 + function getObjetoCodigo(meta) {
106 + return meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '';
107 + }
68 108
69 - // Agrupar por subarea cuando es búsqueda de entidades 109 + // Agrupar resultados según tipo
70 $: groupedHits = (() => { 110 $: groupedHits = (() => {
71 - if (!isEntidadSearchActive || allHits.length === 0) return null; 111 + if (allHits.length === 0) return null;
72 - 112 +
73 - const groups = {}; 113 + // Búsqueda mixta: agrupar por tipo de clasificador
74 - allHits.forEach(hit => { 114 + if (isMixedClassSearch) {
75 - const m = parseMetadatos(hit.document.metadatos); 115 + // Filtrar duplicados de finfun
76 - const subarea = extractAfterHyphen(m.entidad_desc_subarea) || 'Otros'; 116 + const filtered = allHits.filter(hit => {
77 - if (!groups[subarea]) { 117 + if (hit.document.class_ !== 'finfun') return true;
78 - groups[subarea] = { name: subarea, hits: [], totalMonto: 0 }; 118 + const m = parseMetadatos(hit.document.metadatos);
119 + return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined);
120 + });
121 +
122 + const groups = {};
123 + filtered.forEach(hit => {
124 + const type = hit.document.class_ || 'otros';
125 + const groupName = CLASS_LABELS[type] || type;
126 + if (!groups[groupName]) {
127 + groups[groupName] = { name: groupName, hits: [], totalMonto: 0, classType: type };
128 + }
129 + groups[groupName].hits.push(hit);
130 + groups[groupName].totalMonto += hit.document.devengado || 0;
131 + });
132 +
133 + const dir = sortOrder === 'desc' ? 1 : -1;
134 + Object.values(groups).forEach(group => {
135 + group.hits.sort((a, b) => {
136 + if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
137 + return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
138 + });
139 + });
140 +
141 + const sortedGroups = Object.values(groups);
142 + if (sortBy === 'alpha') {
143 + sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
144 + } else {
145 + sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
146 + }
147 + return sortedGroups;
148 + }
149 +
150 + if (isEntidadSearchActive) {
151 + const groups = {};
152 + allHits.forEach(hit => {
153 + const m = parseMetadatos(hit.document.metadatos);
154 + const subarea = extractAfterHyphen(m.entidad_desc_subarea) || 'Otros';
155 + if (!groups[subarea]) {
156 + groups[subarea] = { name: subarea, hits: [], totalMonto: 0 };
157 + }
158 + groups[subarea].hits.push(hit);
159 + groups[subarea].totalMonto += hit.document.devengado || 0;
160 + });
161 +
162 + const dir = sortOrder === 'desc' ? 1 : -1;
163 + Object.values(groups).forEach(group => {
164 + group.hits.sort((a, b) => {
165 + if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
166 + const valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
167 + const valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
168 + return dir * (valB - valA);
169 + });
170 + });
171 +
172 + const sortedGroups = Object.values(groups);
173 + if (sortBy === 'alpha') {
174 + sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
175 + } else {
176 + sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
79 } 177 }
80 - groups[subarea].hits.push(hit); 178 + return sortedGroups;
81 - groups[subarea].totalMonto += hit.document.devengado || 0; 179 + }
82 - }); 180 +
83 - 181 + if (isObjetoSearchActive) {
84 - // Ordenar items dentro de cada grupo 182 + const groups = {};
85 - Object.values(groups).forEach(group => { 183 + allHits.forEach(hit => {
86 - group.hits.sort((a, b) => { 184 + const m = parseMetadatos(hit.document.metadatos);
87 - const valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0); 185 + const groupName = extractAfterHyphen(m.objeto_desc_grupo) || 'Otros';
88 - const valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0); 186 + if (!groups[groupName]) {
89 - return sortOrder === 'desc' ? valB - valA : valA - valB; 187 + groups[groupName] = { name: groupName, hits: [], totalMonto: 0 };
188 + }
189 + groups[groupName].hits.push(hit);
190 + groups[groupName].totalMonto += hit.document.devengado || 0;
191 + });
192 +
193 + const dir = sortOrder === 'desc' ? 1 : -1;
194 + Object.values(groups).forEach(group => {
195 + group.hits.sort((a, b) => {
196 + if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
197 + return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
198 + });
199 + });
200 +
201 + const sortedGroups = Object.values(groups);
202 + if (sortBy === 'alpha') {
203 + sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
204 + } else {
205 + sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
206 + }
207 + return sortedGroups;
208 + }
209 +
210 + if (isFinfunSearchActive) {
211 + // Filtrar duplicados: función 0 es idéntica a su grupo función padre
212 + const filtered = allHits.filter(hit => {
213 + const m = parseMetadatos(hit.document.metadatos);
214 + return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined);
215 + });
216 +
217 + const groups = {};
218 + filtered.forEach(hit => {
219 + const m = parseMetadatos(hit.document.metadatos);
220 + const groupName = m.finfun_desc_finalidad || hit.document.texto || 'Otros';
221 + if (!groups[groupName]) {
222 + groups[groupName] = { name: groupName, hits: [], totalMonto: 0 };
223 + }
224 + groups[groupName].hits.push(hit);
225 + groups[groupName].totalMonto += hit.document.devengado || 0;
226 + });
227 +
228 + const dir = sortOrder === 'desc' ? 1 : -1;
229 + Object.values(groups).forEach(group => {
230 + group.hits.sort((a, b) => {
231 + if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
232 + return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
233 + });
90 }); 234 });
91 - });
92 235
93 - // Convertir a array y ordenar grupos por monto total 236 + const sortedGroups = Object.values(groups);
94 - return Object.values(groups).sort((a, b) => b.totalMonto - a.totalMonto); 237 + if (sortBy === 'alpha') {
238 + sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
239 + } else {
240 + sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
241 + }
242 + return sortedGroups;
243 + }
244 +
245 + return null;
95 })(); 246 })();
96 247
248 + // Lista plana para navegación por teclado (funciona tanto con grupos como sin)
249 + $: flatHitsForNav = groupedHits
250 + ? groupedHits.flatMap(g => g.hits)
251 + : sortedHits;
252 +
97 $: hasMoreResults = results && allHits.length < results.found; 253 $: hasMoreResults = results && allHits.length < results.found;
98 254
99 - // Modo de búsqueda 255 + // Modo de búsqueda - restaurar desde store
100 - // Modo de búsqueda simplificado: 'programas' | 'clasificadores' 256 + let searchMode = $landingSearchMode;
101 - let searchMode = 'programas'; 257 + let selectedClassifiers = $landingSelectedClassifiers;
102 - let selectedClassifiers = [];
103 258
104 // Clasificadores unificados (transversales) 259 // Clasificadores unificados (transversales)
105 const CLASIFICADORES = [ 260 const CLASIFICADORES = [
...@@ -133,6 +288,11 @@ ...@@ -133,6 +288,11 @@
133 } 288 }
134 } 289 }
135 290
291 + // Persistir estado de búsqueda al store
292 + $: $landingSearchMode = searchMode;
293 + $: $landingSelectedClassifiers = selectedClassifiers;
294 + $: $landingSearchQuery = searchVal;
295 +
136 // Placeholder dinámico 296 // Placeholder dinámico
137 $: placeholderText = getPlaceholderText(searchMode, selectedClassifiers); 297 $: placeholderText = getPlaceholderText(searchMode, selectedClassifiers);
138 298
...@@ -275,6 +435,11 @@ ...@@ -275,6 +435,11 @@
275 const isMobile = window.innerWidth < 768; 435 const isMobile = window.innerWidth < 768;
276 if (!isMobile) setTimeout(() => searchInput?.focus(), 300); 436 if (!isMobile) setTimeout(() => searchInput?.focus(), 300);
277 437
438 + // Re-ejecutar búsqueda si volvemos con estado persistido
439 + if (searchVal && searchVal.length >= 2) {
440 + setTimeout(() => performSearch(searchVal), 100);
441 + }
442 +
278 // IntersectionObserver para secciones 443 // IntersectionObserver para secciones
279 const sections = document.querySelectorAll('[data-section]'); 444 const sections = document.querySelectorAll('[data-section]');
280 sectionObserver = new IntersectionObserver((entries) => { 445 sectionObserver = new IntersectionObserver((entries) => {
...@@ -286,7 +451,17 @@ ...@@ -286,7 +451,17 @@
286 }, { threshold: 0.3 }); 451 }, { threshold: 0.3 });
287 sections.forEach(section => sectionObserver.observe(section)); 452 sections.forEach(section => sectionObserver.observe(section));
288 453
289 - return () => sectionObserver?.disconnect(); 454 + // Observer separado para el hero: oculta logo/menú apenas el hero deja de ser mayoritario
455 + const heroEl = document.querySelector('[data-section="hero"]');
456 + if (heroEl) {
457 + const heroObserver = new IntersectionObserver((entries) => {
458 + heroInView = entries[0].isIntersecting;
459 + }, { threshold: 0.85 });
460 + heroObserver.observe(heroEl);
461 + var cleanupHero = () => heroObserver.disconnect();
462 + }
463 +
464 + return () => { sectionObserver?.disconnect(); cleanupHero?.(); };
290 }); 465 });
291 466
292 async function handleInput(e) { 467 async function handleInput(e) {
...@@ -372,6 +547,14 @@ ...@@ -372,6 +547,14 @@
372 if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1); 547 if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
373 } 548 }
374 549
550 + function handleGlobalKeydown(e) {
551 + if (e.key === 'Escape' && (searchVal || allHits.length > 0)) {
552 + clearSearch();
553 + searchInput?.blur();
554 + selectedResultIndex = -1;
555 + }
556 + }
557 +
375 function handleKeydown(e) { 558 function handleKeydown(e) {
376 if (e.key === 'Escape') { 559 if (e.key === 'Escape') {
377 clearSearch(); 560 clearSearch();
...@@ -380,10 +563,11 @@ ...@@ -380,10 +563,11 @@
380 } 563 }
381 564
382 // Navegación por resultados 565 // Navegación por resultados
383 - if (sortedHits.length > 0) { 566 + const navHits = flatHitsForNav;
567 + if (navHits.length > 0) {
384 if (e.key === 'ArrowDown') { 568 if (e.key === 'ArrowDown') {
385 e.preventDefault(); 569 e.preventDefault();
386 - selectedResultIndex = Math.min(selectedResultIndex + 1, sortedHits.length - 1); 570 + selectedResultIndex = Math.min(selectedResultIndex + 1, navHits.length - 1);
387 scrollToSelectedResult(); 571 scrollToSelectedResult();
388 } else if (e.key === 'ArrowUp') { 572 } else if (e.key === 'ArrowUp') {
389 e.preventDefault(); 573 e.preventDefault();
...@@ -391,7 +575,7 @@ ...@@ -391,7 +575,7 @@
391 scrollToSelectedResult(); 575 scrollToSelectedResult();
392 } else if (e.key === 'Enter' && selectedResultIndex >= 0) { 576 } else if (e.key === 'Enter' && selectedResultIndex >= 0) {
393 e.preventDefault(); 577 e.preventDefault();
394 - navigateToResult(sortedHits[selectedResultIndex]); 578 + navigateToResult(navHits[selectedResultIndex]);
395 } 579 }
396 } 580 }
397 } 581 }
...@@ -406,11 +590,19 @@ ...@@ -406,11 +590,19 @@
406 function navigateToResult(hit) { 590 function navigateToResult(hit) {
407 const meta = parseMetadatos(hit.document.metadatos); 591 const meta = parseMetadatos(hit.document.metadatos);
408 const isEntidadClass = hit.document.class_ === 'entidad'; 592 const isEntidadClass = hit.document.class_ === 'entidad';
593 + const isObjetoClass = hit.document.class_ === 'objeto';
409 const isClassResult = hit.document.is_class === true; 594 const isClassResult = hit.document.is_class === true;
410 595
411 let url; 596 let url;
597 + const isFinfunClass = hit.document.class_ === 'finfun';
598 +
412 if (isEntidadClass) { 599 if (isEntidadClass) {
413 - url = `/entidad/${meta.entidad}`; 600 + const entCodigo = meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad;
601 + url = `/entidad/${entCodigo}`;
602 + } else if (isObjetoClass) {
603 + url = `/objeto/${getObjetoCodigo(meta)}`;
604 + } else if (isFinfunClass) {
605 + url = `/finfun/${getFinfunCodigo(meta)}`;
414 } else if (isClassResult) { 606 } else if (isClassResult) {
415 url = `/clasificador/${hit.document.class_}/${hit.document.id}`; 607 url = `/clasificador/${hit.document.class_}/${hit.document.id}`;
416 } else { 608 } else {
...@@ -527,7 +719,10 @@ ...@@ -527,7 +719,10 @@
527 return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`; 719 return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`;
528 } 720 }
529 721
530 - $: stats = allHits.length > 0 ? getStats(allHits, results?.found || allHits.length) : null; 722 + $: stats = allHits.length > 0 ? getStats(
723 + (isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav : allHits,
724 + (isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav.length : (results?.found || allHits.length)
725 + ) : null;
531 726
532 // Texto contextual para resultados 727 // Texto contextual para resultados
533 $: searchContextText = (() => { 728 $: searchContextText = (() => {
...@@ -567,11 +762,13 @@ ...@@ -567,11 +762,13 @@
567 $: isSearching = searchVal.length >= 3; 762 $: isSearching = searchVal.length >= 3;
568 </script> 763 </script>
569 764
765 +<svelte:window on:keydown={handleGlobalKeydown} />
766 +
570 <svelte:head> 767 <svelte:head>
571 <title>Buscador | Presupuesto Abierto</title> 768 <title>Buscador | Presupuesto Abierto</title>
572 </svelte:head> 769 </svelte:head>
573 770
574 -<div class="page" class:mounted> 771 +<div class="page" class:mounted class:page-locked={allHits.length > 0}>
575 772
576 <!-- Dots de navegación --> 773 <!-- Dots de navegación -->
577 <nav class="section-dots" class:section-dots-hidden={currentSection === 'hero'}> 774 <nav class="section-dots" class:section-dots-hidden={currentSection === 'hero'}>
...@@ -590,17 +787,19 @@ ...@@ -590,17 +787,19 @@
590 <!-- Hero --> 787 <!-- Hero -->
591 <main data-section="hero"> 788 <main data-section="hero">
592 789
593 - <div class="nav-logo" class:nav-hidden={currentSection !== 'hero'}> 790 + <div class="hero-topbar">
594 - <img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" /> 791 + <div class="nav-logo" class:nav-hidden={!heroInView}>
595 - </div> 792 + <img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
793 + </div>
596 794
597 - <button class="nav-menu-btn" aria-label="Menú"> 795 + <button class="nav-menu-btn" class:nav-hidden={!heroInView} aria-label="Menú">
598 - <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"> 796 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
599 - <line x1="3" y1="6" x2="21" y2="6"/> 797 + <line x1="3" y1="6" x2="21" y2="6"/>
600 - <line x1="3" y1="12" x2="21" y2="12"/> 798 + <line x1="3" y1="12" x2="21" y2="12"/>
601 - <line x1="3" y1="18" x2="21" y2="18"/> 799 + <line x1="3" y1="18" x2="21" y2="18"/>
602 - </svg> 800 + </svg>
603 - </button> 801 + </button>
802 + </div>
604 803
605 <div class="search-container anim" style="--d:200ms"> 804 <div class="search-container anim" style="--d:200ms">
606 805
...@@ -826,9 +1025,7 @@ ...@@ -826,9 +1025,7 @@
826 <span class="summary-tag summary-tag-gold">{formatMonto(stats.montoTotal)}</span> 1025 <span class="summary-tag summary-tag-gold">{formatMonto(stats.montoTotal)}</span>
827 <span class="summary-sep">·</span> 1026 <span class="summary-sep">·</span>
828 {#if stats.isClassifier} 1027 {#if stats.isClassifier}
829 - {#if stats.isEntidad && groupedHits} 1028 + {#if groupedHits}
830 - <span class="summary-tag summary-tag-entities">{stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
831 - <span class="summary-sep">·</span>
832 <span class="summary-tag">{groupedHits.length} {groupedHits.length === 1 ? 'categoría' : 'categorías'}</span> 1029 <span class="summary-tag">{groupedHits.length} {groupedHits.length === 1 ? 'categoría' : 'categorías'}</span>
833 {/if} 1030 {/if}
834 {:else} 1031 {:else}
...@@ -842,9 +1039,9 @@ ...@@ -842,9 +1039,9 @@
842 </div> 1039 </div>
843 <div class="sort-buttons"> 1040 <div class="sort-buttons">
844 {#if !stats.isClassifier} 1041 {#if !stats.isClassifier}
845 - <button class="sort-btn" class:sort-btn-active={sortBy === 'year'} on:click={() => toggleSort('year')}> 1042 + <button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} on:click={() => toggleSort('alpha')}>
846 - Año 1043 + A-Z
847 - {#if sortBy === 'year'} 1044 + {#if sortBy === 'alpha'}
848 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1045 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
849 {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/> 1046 {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
850 {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if} 1047 {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
...@@ -861,6 +1058,17 @@ ...@@ -861,6 +1058,17 @@
861 </svg> 1058 </svg>
862 {/if} 1059 {/if}
863 </button> 1060 </button>
1061 + {#if groupedHits}
1062 + <button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} on:click={() => toggleSort('alpha')}>
1063 + A-Z
1064 + {#if sortBy === 'alpha'}
1065 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1066 + {#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
1067 + {:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
1068 + </svg>
1069 + {/if}
1070 + </button>
1071 + {/if}
864 </div> 1072 </div>
865 </div> 1073 </div>
866 </div> 1074 </div>
...@@ -868,7 +1076,7 @@ ...@@ -868,7 +1076,7 @@
868 1076
869 <div class="results-list"> 1077 <div class="results-list">
870 {#if groupedHits} 1078 {#if groupedHits}
871 - <!-- Vista agrupada para entidades --> 1079 + <!-- Vista agrupada para entidades u objetos -->
872 {#each groupedHits as group, gi} 1080 {#each groupedHits as group, gi}
873 <div class="result-group"> 1081 <div class="result-group">
874 <div class="result-group-header"> 1082 <div class="result-group-header">
...@@ -879,23 +1087,70 @@ ...@@ -879,23 +1087,70 @@
879 <div class="result-group-items"> 1087 <div class="result-group-items">
880 {#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))} 1088 {#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))}
881 {@const meta = parseMetadatos(hit.document.metadatos)} 1089 {@const meta = parseMetadatos(hit.document.metadatos)}
882 - {@const isDA = meta.da && meta.entidad_desc_entidad} 1090 + {@const globalIdx = flatHitsForNav.indexOf(hit)}
883 - {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null} 1091 + {@const hitClass = hit.document.class_}
884 - <a href="/entidad/{meta.entidad}" class="result-card result-card-grouped"> 1092 + {#if hitClass === 'entidad'}
885 - <div class="result-content"> 1093 + {@const isDA = meta.da && meta.entidad_desc_entidad}
886 - <div class="result-text"> 1094 + {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
887 - {#if hit.highlights?.[0]?.snippet} 1095 + {@const entidadCodigo = isDA ? `${meta.entidad}.${meta.da}` : meta.entidad}
888 - {@html hit.highlights[0].snippet} 1096 + <a href="/entidad/{entidadCodigo}" class="result-card result-card-grouped"
889 - {:else} 1097 + class:result-card-selected={selectedResultIndex === globalIdx}
890 - {hit.document.texto} 1098 + data-result-index={globalIdx}>
891 - {/if} 1099 + <div class="result-content">
1100 + <div class="result-text">
1101 + {#if hit.highlights?.[0]?.snippet}
1102 + {@html hit.highlights[0].snippet}
1103 + {:else}
1104 + {hit.document.texto}
1105 + {/if}
1106 + </div>
1107 + <div class="result-meta">
1108 + {#if isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if}
1109 + <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
1110 + </div>
892 </div> 1111 </div>
893 - <div class="result-meta"> 1112 + </a>
894 - {#if isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if} 1113 + {:else if hitClass === 'objeto'}
895 - <span class="result-monto">{formatMonto(hit.document.devengado)}</span> 1114 + {@const codigo = getObjetoCodigo(meta)}
1115 + {@const nivel = meta.objeto_subpartida ? 'Subpartida' : meta.objeto_partida ? 'Partida' : meta.objeto_subgrupo ? 'Subgrupo' : 'Grupo'}
1116 + <a href="/objeto/{codigo}" class="result-card result-card-grouped"
1117 + class:result-card-selected={selectedResultIndex === globalIdx}
1118 + data-result-index={globalIdx}>
1119 + <div class="result-content">
1120 + <div class="result-text">
1121 + {#if hit.highlights?.[0]?.snippet}
1122 + {@html hit.highlights[0].snippet}
1123 + {:else}
1124 + {hit.document.texto}
1125 + {/if}
1126 + </div>
1127 + <div class="result-meta">
1128 + <span class="result-objeto-nivel">{nivel} {codigo}</span>
1129 + <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
1130 + </div>
896 </div> 1131 </div>
897 - </div> 1132 + </a>
898 - </a> 1133 + {:else if hitClass === 'finfun'}
1134 + {@const codigo = getFinfunCodigo(meta)}
1135 + {@const nivel = getFinfunNivel(meta)}
1136 + <a href="/finfun/{codigo}" class="result-card result-card-grouped"
1137 + class:result-card-selected={selectedResultIndex === globalIdx}
1138 + data-result-index={globalIdx}>
1139 + <div class="result-content">
1140 + <div class="result-text">
1141 + {#if hit.highlights?.[0]?.snippet}
1142 + {@html hit.highlights[0].snippet}
1143 + {:else}
1144 + {hit.document.texto}
1145 + {/if}
1146 + </div>
1147 + <div class="result-meta">
1148 + <span class="result-objeto-nivel">{nivel} {codigo}</span>
1149 + <span class="result-monto">{formatMonto(hit.document.devengado)}</span>
1150 + </div>
1151 + </div>
1152 + </a>
1153 + {/if}
899 {/each} 1154 {/each}
900 </div> 1155 </div>
901 </div> 1156 </div>
...@@ -908,11 +1163,17 @@ ...@@ -908,11 +1163,17 @@
908 {@const isDA = isEntidadClass && meta.da && meta.entidad_desc_entidad} 1163 {@const isDA = isEntidadClass && meta.da && meta.entidad_desc_entidad}
909 {@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null} 1164 {@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
910 {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null} 1165 {@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
1166 + {@const isObjetoClass = hit.document.class_ === 'objeto'}
1167 + {@const isFinfunClass = hit.document.class_ === 'finfun'}
911 {@const isClassResult = hit.document.is_class === true} 1168 {@const isClassResult = hit.document.is_class === true}
912 <a 1169 <a
913 href={isEntidadClass 1170 href={isEntidadClass
914 - ? `/entidad/${meta.entidad}` 1171 + ? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}`
915 - : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)} 1172 + : isObjetoClass
1173 + ? `/objeto/${getObjetoCodigo(meta)}`
1174 + : isFinfunClass
1175 + ? `/finfun/${getFinfunCodigo(meta)}`
1176 + : (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
916 class="result-card" 1177 class="result-card"
917 class:result-card-selected={selectedResultIndex === i} 1178 class:result-card-selected={selectedResultIndex === i}
918 data-result-index={i} 1179 data-result-index={i}
...@@ -973,7 +1234,7 @@ ...@@ -973,7 +1234,7 @@
973 </div> 1234 </div>
974 1235
975 <!-- Scroll hint --> 1236 <!-- Scroll hint -->
976 - <div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching}> 1237 + <div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching || allHits.length > 0}>
977 <svg class="scroll-icon-mouse" width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"> 1238 <svg class="scroll-icon-mouse" width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
978 <rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/> 1239 <rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
979 </svg> 1240 </svg>
...@@ -1372,6 +1633,7 @@ ...@@ -1372,6 +1633,7 @@
1372 @keyframes heartbeat{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:48}} 1633 @keyframes heartbeat{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:48}}
1373 1634
1374 .page{--serif:'Qanelas',system-ui,sans-serif;--sans:'Qanelas',system-ui,sans-serif;--mono:'JetBrains Mono',monospace;font-family:var(--serif);color:#1C1C1A;width:100%;min-width:100%;overflow-x:hidden;scroll-snap-type:y mandatory;overflow-y:scroll;height:100vh} 1635 .page{--serif:'Qanelas',system-ui,sans-serif;--sans:'Qanelas',system-ui,sans-serif;--mono:'JetBrains Mono',monospace;font-family:var(--serif);color:#1C1C1A;width:100%;min-width:100%;overflow-x:hidden;scroll-snap-type:y mandatory;overflow-y:scroll;height:100vh}
1636 + .page-locked{overflow-y:hidden;scroll-snap-type:none}
1375 1637
1376 /* Section Navigation Dots */ 1638 /* Section Navigation Dots */
1377 .section-dots{position:fixed;right:24px;top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;gap:12px;background:rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:12px 8px;border-radius:20px;transition:opacity 0.4s ease,transform 0.4s ease} 1639 .section-dots{position:fixed;right:24px;top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;gap:12px;background:rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:12px 8px;border-radius:20px;transition:opacity 0.4s ease,transform 0.4s ease}
...@@ -1615,7 +1877,7 @@ ...@@ -1615,7 +1877,7 @@
1615 1877
1616 /* Results Content */ 1878 /* Results Content */
1617 .results-content{max-height:0;overflow:hidden;opacity:0;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)} 1879 .results-content{max-height:0;overflow:hidden;opacity:0;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)}
1618 - .results-content-visible{max-height:60vh;overflow-y:auto;opacity:1;background:#1C1C1A;border-radius:16px;border:none;padding:14px 10px;text-align:left;position:relative;z-index:10} 1880 + .results-content-visible{max-height:60vh;overflow-y:auto;opacity:1;background:#1C1C1A;border-radius:16px;border:none;padding:14px 10px;text-align:left;position:relative;z-index:10;overscroll-behavior:contain}
1619 1881
1620 /* Results Summary */ 1882 /* Results Summary */
1621 .results-summary{background:transparent;border-radius:0;padding:0 0 12px 0;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.08)} 1883 .results-summary{background:transparent;border-radius:0;padding:0 0 12px 0;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.08)}
...@@ -1659,7 +1921,8 @@ ...@@ -1659,7 +1921,8 @@
1659 .result-text{font-family:var(--sans);font-size:14px;color:#F5F0E8;line-height:1.4} 1921 .result-text{font-family:var(--sans);font-size:14px;color:#F5F0E8;line-height:1.4}
1660 .result-text :global(mark){background:rgba(255,213,79,0.9);color:#1C1C1A;border-radius:3px;padding:1px 5px;font-weight:600} 1922 .result-text :global(mark){background:rgba(255,213,79,0.9);color:#1C1C1A;border-radius:3px;padding:1px 5px;font-weight:600}
1661 .result-meta{display:flex;align-items:center;gap:10px;margin-top:6px;flex-wrap:wrap} 1923 .result-meta{display:flex;align-items:center;gap:10px;margin-top:6px;flex-wrap:wrap}
1662 - .result-parent,.result-subarea,.result-class-type{font-family:var(--mono);font-size:11px;color:#8B8880} 1924 + .result-parent,.result-subarea,.result-class-type,.result-objeto-nivel{font-family:var(--mono);font-size:11px;color:#8B8880}
1925 + .result-objeto-codigo{font-family:var(--mono);font-size:11px;color:#C9A751;opacity:0.7}
1663 .result-year{font-family:var(--mono);font-size:12px;color:#8B8880;background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:4px} 1926 .result-year{font-family:var(--mono);font-size:12px;color:#8B8880;background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:4px}
1664 .result-monto{font-family:var(--mono);font-size:12px;color:#5AAF8A} 1927 .result-monto{font-family:var(--mono);font-size:12px;color:#5AAF8A}
1665 .result-entity{font-family:var(--sans);font-size:12px;color:#9B9890} 1928 .result-entity{font-family:var(--sans);font-size:12px;color:#9B9890}
...@@ -1676,6 +1939,7 @@ ...@@ -1676,6 +1939,7 @@
1676 .search-loading{display:flex;align-items:center;justify-content:center} 1939 .search-loading{display:flex;align-items:center;justify-content:center}
1677 1940
1678 /* Nav elements in hero */ 1941 /* Nav elements in hero */
1942 + .hero-topbar{display:contents}
1679 main[data-section="hero"] .nav-logo{position:fixed;top:24px;left:24px;z-index:100;transition:opacity 0.4s ease,transform 0.4s ease} 1943 main[data-section="hero"] .nav-logo{position:fixed;top:24px;left:24px;z-index:100;transition:opacity 0.4s ease,transform 0.4s ease}
1680 .nav-menu-btn{position:fixed;top:24px;right:24px;z-index:100;background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.3s ease} 1944 .nav-menu-btn{position:fixed;top:24px;right:24px;z-index:100;background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.3s ease}
1681 .nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8} 1945 .nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
...@@ -1858,6 +2122,7 @@ ...@@ -1858,6 +2122,7 @@
1858 ══════════════════════════════════════════════════════════════════════════ */ 2122 ══════════════════════════════════════════════════════════════════════════ */
1859 @media(max-width:768px){ 2123 @media(max-width:768px){
1860 .page{scroll-snap-type:y proximity} 2124 .page{scroll-snap-type:y proximity}
2125 + main[data-section="hero"]{height:auto;min-height:100vh}
1861 main[data-section="hero"],.landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal} 2126 main[data-section="hero"],.landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal}
1862 2127
1863 /* Section dots - bottom horizontal bar */ 2128 /* Section dots - bottom horizontal bar */
...@@ -1868,7 +2133,9 @@ ...@@ -1868,7 +2133,9 @@
1868 2133
1869 /* Nav */ 2134 /* Nav */
1870 nav{padding:12px 24px} 2135 nav{padding:12px 24px}
1871 - .nav-logo{height:60px} 2136 + .hero-topbar{display:flex;justify-content:space-between;align-items:center;padding:16px 16px 0;width:100%;flex-shrink:0}
2137 + main[data-section="hero"] .nav-logo{position:static;height:52px;opacity:1;transform:none}
2138 + main[data-section="hero"] .nav-menu-btn{position:static;width:40px;height:40px}
1872 2139
1873 /* Hero */ 2140 /* Hero */
1874 .hero{padding:32px 24px} 2141 .hero{padding:32px 24px}
...@@ -1905,16 +2172,16 @@ ...@@ -1905,16 +2172,16 @@
1905 .scroll-icon-touch{display:block} 2172 .scroll-icon-touch{display:block}
1906 2173
1907 /* New search responsive */ 2174 /* New search responsive */
1908 - .search-container{padding:0 16px;justify-content:flex-start;padding-top:100px} 2175 + .search-container{padding:0 16px;justify-content:center;padding-top:0}
1909 .hero-title{font-size:clamp(38px,9vw,52px);margin-bottom:4px} 2176 .hero-title{font-size:clamp(38px,9vw,52px);margin-bottom:4px}
1910 .hero-sub{font-size:14px;margin-bottom:20px} 2177 .hero-sub{font-size:14px;margin-bottom:20px}
1911 .section-group{margin-top:16px} 2178 .section-group{margin-top:16px}
1912 .section-label{font-size:10px;margin-bottom:10px} 2179 .section-label{font-size:10px;margin-bottom:10px}
1913 .section-label-goto{margin-top:16px} 2180 .section-label-goto{margin-top:16px}
1914 .mode-cards{grid-template-columns:1fr;gap:12px} 2181 .mode-cards{grid-template-columns:1fr;gap:12px}
1915 - .mode-card{padding:18px;gap:10px;border-radius:16px} 2182 + .mode-card{padding:14px;gap:6px;border-radius:16px}
1916 - .mode-card-title{font-size:15px} 2183 + .mode-card-title{font-size:14px}
1917 - .mode-card-desc{font-size:12px} 2184 + .mode-card-desc{font-size:11px;line-height:1.3}
1918 .clf-chip{padding:5px 10px;font-size:11px} 2185 .clf-chip{padding:5px 10px;font-size:11px}
1919 .clf-check{width:12px;height:12px} 2186 .clf-check{width:12px;height:12px}
1920 .mode-pill{font-size:12px;padding:7px 12px} 2187 .mode-pill{font-size:12px;padding:7px 12px}
...@@ -1927,7 +2194,7 @@ ...@@ -1927,7 +2194,7 @@
1927 .results-area{margin-top:4px;min-height:40px} 2194 .results-area{margin-top:4px;min-height:40px}
1928 .results-content-visible{padding:2px 4px} 2195 .results-content-visible{padding:2px 4px}
1929 .search-wrap-with-results{padding:2px} 2196 .search-wrap-with-results{padding:2px}
1930 - .daily-card{padding:16px 18px;gap:12px;border-radius:14px} 2197 + .daily-card{padding:16px 18px;gap:12px;border-radius:14px;margin-bottom:24px}
1931 .daily-card-title{font-size:14px} 2198 .daily-card-title{font-size:14px}
1932 .daily-card-text{font-size:12px} 2199 .daily-card-text{font-size:12px}
1933 .results-summary{padding:4px 0} 2200 .results-summary{padding:4px 0}
...@@ -2036,18 +2303,18 @@ ...@@ -2036,18 +2303,18 @@
2036 .scroll-hint{display:none} 2303 .scroll-hint{display:none}
2037 2304
2038 /* New search 480px */ 2305 /* New search 480px */
2039 - .search-container{padding:0 12px;padding-top:80px} 2306 + .search-container{padding:0 12px;justify-content:center;padding-top:0}
2040 .search-input-wrap input{font-size:15px} 2307 .search-input-wrap input{font-size:15px}
2041 .placeholder-text{font-size:12px} 2308 .placeholder-text{font-size:12px}
2042 .hero-title{font-size:clamp(34px,10vw,44px)} 2309 .hero-title{font-size:clamp(34px,10vw,44px)}
2043 .hero-sub{font-size:13px;margin-bottom:16px} 2310 .hero-sub{font-size:13px;margin-bottom:16px}
2044 - main[data-section="hero"] .nav-logo{top:16px;left:16px;height:56px} 2311 + main[data-section="hero"] .nav-logo{height:44px}
2045 - .nav-menu-btn{top:16px;right:16px;width:38px;height:38px} 2312 + main[data-section="hero"] .nav-menu-btn{width:36px;height:36px}
2046 .mode-cards{gap:10px;margin-top:12px} 2313 .mode-cards{gap:10px;margin-top:12px}
2047 - .mode-card{padding:14px;gap:8px;border-radius:14px} 2314 + .mode-card{padding:12px;gap:5px;border-radius:14px}
2048 - .mode-card-icon svg{width:18px;height:18px} 2315 + .mode-card-icon svg{width:16px;height:16px}
2049 - .mode-card-title{font-size:14px} 2316 + .mode-card-title{font-size:13px}
2050 - .mode-card-desc{font-size:11px} 2317 + .mode-card-desc{font-size:10px;line-height:1.3}
2051 .mode-card-classifiers{gap:4px} 2318 .mode-card-classifiers{gap:4px}
2052 .clf-chip{padding:4px 8px;font-size:10px;gap:4px} 2319 .clf-chip{padding:4px 8px;font-size:10px;gap:4px}
2053 .clf-check{width:10px;height:10px} 2320 .clf-check{width:10px;height:10px}
......
...@@ -2,24 +2,64 @@ import { supabase } from '$lib/supabase'; ...@@ -2,24 +2,64 @@ import { supabase } from '$lib/supabase';
2 import { error } from '@sveltejs/kit'; 2 import { error } from '@sveltejs/kit';
3 3
4 export async function load({ params }) { 4 export async function load({ params }) {
5 - const codigo = parseInt(params.codigo); 5 + const codigo = params.codigo;
6 + const isDA = codigo.includes('.');
7 + const entidadCode = isDA ? codigo.split('.')[0] : codigo;
8 + const entidadNum = parseInt(entidadCode);
6 9
7 - if (isNaN(codigo)) { 10 + if (isNaN(entidadNum)) {
8 throw error(400, 'Código de entidad inválido'); 11 throw error(400, 'Código de entidad inválido');
9 } 12 }
10 13
11 - const { data, error: dbError } = await supabase 14 + // Cargar metadata y resumen en paralelo
12 - .schema('ppto') 15 + const [entidadRes, resumenRes] = await Promise.all([
13 - .from('clas_institucional') 16 + supabase
14 - .select('*') 17 + .schema('ppto')
15 - .eq('entidad', codigo) 18 + .from('clas_institucional')
16 - .single(); 19 + .select('*')
20 + .eq('entidad', entidadNum)
21 + .single(),
22 +
23 + supabase
24 + .schema('ppto')
25 + .from('entidad_resumen')
26 + .select('tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking')
27 + .eq('codigo', codigo)
28 + ]);
17 29
18 - if (dbError || !data) { 30 + if (entidadRes.error || !entidadRes.data) {
19 throw error(404, 'Entidad no encontrada'); 31 throw error(404, 'Entidad no encontrada');
20 } 32 }
21 33
34 + // Determinar última gestión disponible
35 + const gestiones = [...new Set((resumenRes.data || []).map(d => d.gestion))].sort((a, b) => b - a);
36 + const ultimaGestion = gestiones[0] || 2025;
37 +
38 + // Cargar distribuciones solo de la última gestión
39 + const distRes = await supabase
40 + .schema('ppto')
41 + .from('entidad_distribuciones')
42 + .select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
43 + .eq('codigo', codigo)
44 + .eq('gestion', ultimaGestion);
45 +
46 + // Resolver nombre de DA desde el resumen
47 + let nombreDA = null;
48 + let nombreEntidadMadre = null;
49 + if (isDA && resumenRes.data?.length > 0) {
50 + const firstRow = resumenRes.data[0];
51 + nombreDA = firstRow.desc || null;
52 + nombreEntidadMadre = firstRow.desc_padre || null;
53 + }
54 +
22 return { 55 return {
23 - entidad: data 56 + entidad: entidadRes.data,
57 + isDA,
58 + nombreDA,
59 + nombreEntidadMadre,
60 + codigoEntidadPadre: isDA ? entidadCode : null,
61 + resumenData: resumenRes.data || [],
62 + distribucionesData: distRes.data || [],
63 + gestionInicial: ultimaGestion
24 }; 64 };
25 } 65 }
......
1 <script> 1 <script>
2 - let { data } = $props(); 2 + import { onMount, tick } from 'svelte';
3 + import { page } from '$app/stores';
4 + import * as d3 from 'd3';
5 + import { supabase } from '$lib/supabase';
3 6
7 + let { data } = $props();
4 const entidad = data.entidad; 8 const entidad = data.entidad;
5 - const gestiones = entidad.gestiones ? entidad.gestiones.split(',').map(g => g.trim()) : []; 9 + const isDA = data.isDA;
10 + const nombreDA = data.nombreDA;
11 + const nombreEntidadMadre = data.nombreEntidadMadre;
12 + const codigoEntidadPadre = data.codigoEntidadPadre;
13 +
14 + // Datos desde el loader (Supabase)
15 + let codigoSeleccionado = $derived($page.params.codigo);
16 + let gestionSeleccionada = $state(data.gestionInicial);
17 + let resumenData = $state(data.resumenData);
18 + let distribucionesData = $state(data.distribucionesData);
19 + let cargando = $state(false);
20 + let distCache = $state({ [data.gestionInicial]: data.distribucionesData });
21 + let cargandoDist = $state(false);
22 +
23 + async function cargarDistribuciones(gestion) {
24 + if (distCache[gestion]) {
25 + distribucionesData = distCache[gestion];
26 + return;
27 + }
28 + cargandoDist = true;
29 + const { data: rows } = await supabase
30 + .schema('ppto')
31 + .from('entidad_distribuciones')
32 + .select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
33 + .eq('codigo', codigoSeleccionado)
34 + .eq('gestion', gestion);
35 + const result = rows || [];
36 + distCache[gestion] = result;
37 + distribucionesData = result;
38 + cargandoDist = false;
39 + }
40 +
41 + // Cargar distribuciones al cambiar gestión
42 + $effect(() => {
43 + cargarDistribuciones(gestionSeleccionada);
44 + });
45 +
46 + // Hover en gráficos de historia
47 + let hoveredIngresos = $state(null);
48 + let hoveredGastos = $state(null);
49 +
50 + // Dropdown de gestión
51 + let gestionDropdownOpen = $state(false);
52 +
53 + // Mini-buscador de entidades
54 + let searchQuery = $state('');
55 + let searchResults = $state([]);
56 + let searchLoading = $state(false);
57 + let searchOpen = $state(false);
58 + let searchSelectedIdx = $state(-1);
59 + let searchDebounce = null;
60 +
61 + function parseMetadatos(meta) {
62 + if (!meta) return {};
63 + if (typeof meta === 'object') return meta;
64 + try { return JSON.parse(meta); } catch { return {}; }
65 + }
66 +
67 + function onSearchInput() {
68 + clearTimeout(searchDebounce);
69 + if (searchQuery.length < 2) {
70 + searchResults = [];
71 + searchOpen = false;
72 + return;
73 + }
74 + searchDebounce = setTimeout(async () => {
75 + searchLoading = true;
76 + try {
77 + const params = new URLSearchParams({
78 + q: searchQuery,
79 + is_class: 'true',
80 + class_: 'entidad',
81 + per_page: '12'
82 + });
83 + const res = await fetch(`/api/search?${params}`);
84 + if (!res.ok) throw new Error();
85 + const json = await res.json();
86 + searchResults = (json.hits || []).map(hit => {
87 + const meta = parseMetadatos(hit.document.metadatos);
88 + const esDA = !!meta.da;
89 + return {
90 + nombre: hit.document.texto,
91 + codigo: esDA ? `${meta.entidad}.${meta.da}` : String(meta.entidad),
92 + esDA,
93 + entidadMadre: esDA ? (meta.entidad_desc_entidad || '').replace(/^[A-Z]+ - /, '') : null,
94 + highlight: hit.highlights?.[0]?.snippet || hit.document.texto
95 + };
96 + });
97 + searchOpen = searchResults.length > 0;
98 + searchSelectedIdx = -1;
99 + } catch {
100 + searchResults = [];
101 + }
102 + searchLoading = false;
103 + }, 250);
104 + }
105 +
106 + function navigateToEntity(item) {
107 + window.location.href = `/entidad/${item.codigo}`;
108 + }
109 +
110 + function onSearchKeydown(e) {
111 + if (!searchOpen) return;
112 + if (e.key === 'ArrowDown') {
113 + e.preventDefault();
114 + searchSelectedIdx = Math.min(searchSelectedIdx + 1, searchResults.length - 1);
115 + } else if (e.key === 'ArrowUp') {
116 + e.preventDefault();
117 + searchSelectedIdx = Math.max(searchSelectedIdx - 1, -1);
118 + } else if (e.key === 'Enter' && searchSelectedIdx >= 0) {
119 + e.preventDefault();
120 + navigateToEntity(searchResults[searchSelectedIdx]);
121 + } else if (e.key === 'Escape') {
122 + searchOpen = false;
123 + searchQuery = '';
124 + }
125 + }
126 +
127 + function closeSearch() {
128 + setTimeout(() => { searchOpen = false; }, 150);
129 + }
130 +
131 + // Datos derivados (ya filtrados por codigo desde el loader)
132 + let historiaGastos = $derived(
133 + resumenData
134 + .filter(d => d.tipo === 'gastos')
135 + .sort((a, b) => a.gestion - b.gestion)
136 + );
137 +
138 + let historiaIngresos = $derived(
139 + resumenData
140 + .filter(d => d.tipo === 'ingresos')
141 + .sort((a, b) => a.gestion - b.gestion)
142 + );
143 +
144 + let tieneIngresos = $derived(historiaIngresos.length > 0);
145 +
146 + let gestiones = $derived(() => {
147 + const years = [...new Set(resumenData.map(d => d.gestion))].sort();
148 + return years;
149 + });
150 +
151 + let rankingGastos = $derived(() => {
152 + const row = historiaGastos.find(d => d.gestion === gestionSeleccionada);
153 + return row ? { posicion: row.ranking, total: '~600' } : null;
154 + });
155 +
156 + let rankingIngresos = $derived(() => {
157 + const row = historiaIngresos.find(d => d.gestion === gestionSeleccionada);
158 + return row ? { posicion: row.ranking, total: '~600' } : null;
159 + });
160 +
161 + let distFiltradas = $derived(distribucionesData);
162 +
163 + function prepararSegmentos(data) {
164 + return data
165 + .map(d => ({
166 + codigo: d.hijo,
167 + nombre: d.desc_hijo,
168 + monto: d.devengado,
169 + padre: d.desc_padre
170 + }))
171 + .filter(d => d.monto > 0)
172 + .sort((a, b) => b.monto - a.monto);
173 + }
174 +
175 + let clasificadoresGasto = $derived.by(() => {
176 + const gastos = distFiltradas.filter(d => d.tipo === 'gastos');
177 + const dims = [
178 + { key: 'objeto', label: 'Objetos de gasto' },
179 + { key: 'finfun', label: 'Finalidad y función' },
180 + { key: 'acteco', label: 'Sectores económicos' }
181 + ];
182 + return dims
183 + .map(dim => ({
184 + ...dim,
185 + data: prepararSegmentos(gastos.filter(d => d.dimension === dim.key))
186 + }))
187 + .filter(dim => dim.data.length > 0);
188 + });
189 +
190 + let clasificadoresIngreso = $derived.by(() => {
191 + if (!tieneIngresos) return [];
192 + const ingresos = distFiltradas.filter(d => d.tipo === 'ingresos');
193 + const dims = [
194 + { key: 'rubro', label: 'Rubros de ingreso' },
195 + { key: 'organismo', label: 'Organismos financiadores' }
196 + ];
197 + return dims
198 + .map(dim => ({
199 + ...dim,
200 + data: prepararSegmentos(ingresos.filter(d => d.dimension === dim.key))
201 + }))
202 + .filter(dim => dim.data.length > 0);
203 + });
204 +
205 + let barContainers = $state({});
206 + let hoverData = $state({});
207 +
208 + function getTotal(data) {
209 + return data.reduce((sum, d) => sum + d.monto, 0);
210 + }
211 +
212 + function formatearMonto(valor) {
213 + if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)} mil millones`;
214 + if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)} millones`;
215 + if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)} mil`;
216 + return valor.toFixed(0);
217 + }
218 +
219 + function formatearMontoCorto(valor) {
220 + if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
221 + if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
222 + if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)}K`;
223 + return valor.toFixed(0);
224 + }
225 +
226 + function getChartColors() {
227 + const isDark = document.documentElement.classList.contains('dark');
228 + return {
229 + barDefault: isDark ? 'rgba(107, 159, 212, 0.2)' : 'rgba(196, 137, 125, 0.2)',
230 + barHighlight: isDark ? 'rgba(107, 159, 212, 0.85)' : '#c4897d',
231 + barStroke: isDark ? 'rgba(107, 159, 212, 0.5)' : 'rgba(196, 137, 125, 0.5)',
232 + barStrokeDefault: isDark ? 'transparent' : 'transparent'
233 + };
234 + }
235 +
236 + function renderizarBarras(key, data) {
237 + const container = barContainers[key];
238 + if (!container || data.length === 0) return;
239 +
240 + const total = getTotal(data);
241 + const height = 48;
242 + const radius = 4;
243 +
244 + d3.select(container).selectAll('*').remove();
245 +
246 + const containerRect = container.getBoundingClientRect();
247 + const width = containerRect.width || 800;
248 +
249 + const svg = d3.select(container)
250 + .append('svg')
251 + .attr('width', '100%')
252 + .attr('height', height)
253 + .attr('viewBox', `0 0 ${width} ${height}`)
254 + .attr('preserveAspectRatio', 'none')
255 + .style('display', 'block');
256 +
257 + let cumulative = 0;
258 + const segments = data.map(d => {
259 + const segWidth = (d.monto / total) * width;
260 + const segment = { ...d, x: cumulative, width: segWidth };
261 + cumulative += segWidth;
262 + return segment;
263 + });
264 +
265 + const chartColors = getChartColors();
266 + const colorDefault = chartColors.barDefault;
267 + const colorHover = chartColors.barHighlight;
268 + const colorFirst = chartColors.barHighlight;
269 +
270 + const bars = svg.selectAll('rect')
271 + .data(segments)
272 + .enter()
273 + .append('rect')
274 + .attr('x', d => d.x)
275 + .attr('y', 2)
276 + .attr('width', d => Math.max(d.width - 1, 1))
277 + .attr('height', height - 4)
278 + .attr('rx', (d, i) => {
279 + if (i === 0) return radius;
280 + if (i === segments.length - 1) return radius;
281 + return 0;
282 + })
283 + .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
284 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
285 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
286 + .style('cursor', 'pointer')
287 + .on('mouseenter', function(event, d) {
288 + bars
289 + .attr('fill', colorDefault)
290 + .attr('stroke', chartColors.barStrokeDefault)
291 + .attr('stroke-width', 0.5);
292 + d3.select(this)
293 + .attr('fill', colorHover)
294 + .attr('stroke', chartColors.barStroke)
295 + .attr('stroke-width', 1);
296 + hoverData[key] = d;
297 + hoverData = { ...hoverData };
298 + })
299 + .on('mouseleave', function() {
300 + bars
301 + .attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
302 + .attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
303 + .attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
304 + hoverData[key] = null;
305 + hoverData = { ...hoverData };
306 + });
307 + }
308 +
309 + function getDisplayItem(key, data) {
310 + const hover = hoverData[key];
311 + if (hover) return hover;
312 + return data[0] || null;
313 + }
314 +
315 + // Dimensiones del gráfico
316 + const chartMargin = { top: 10, right: 10, bottom: 24, left: 48 };
317 + const chartHeight = 180;
318 + let chartWidth = $state(600);
319 + let chartRefA = $state(null);
320 + let chartRefB = $state(null);
321 + let chartContainerEl = $derived(chartRefA || chartRefB);
322 +
323 + let innerW = $derived(Math.max(chartWidth - chartMargin.left - chartMargin.right, 0));
324 + let innerH = $derived(chartHeight - chartMargin.top - chartMargin.bottom);
325 +
326 + // Escalas D3 para gastos
327 + let xScaleGastos = $derived.by(() => {
328 + if (historiaGastos.length === 0) return null;
329 + return d3.scaleLinear()
330 + .domain(d3.extent(historiaGastos, d => d.gestion))
331 + .range([0, innerW]);
332 + });
333 +
334 + let yScaleGastos = $derived.by(() => {
335 + if (historiaGastos.length === 0) return null;
336 + return d3.scaleLinear()
337 + .domain([0, d3.max(historiaGastos, d => d.devengado) * 1.1])
338 + .range([innerH, 0])
339 + .nice();
340 + });
341 +
342 + let lineGastos = $derived.by(() => {
343 + if (!xScaleGastos || !yScaleGastos) return '';
344 + const gen = d3.line()
345 + .x(d => xScaleGastos(d.gestion))
346 + .y(d => yScaleGastos(d.devengado))
347 + .curve(d3.curveCatmullRom);
348 + return gen(historiaGastos) || '';
349 + });
350 +
351 + let areaGastos = $derived.by(() => {
352 + if (!xScaleGastos || !yScaleGastos) return '';
353 + const gen = d3.area()
354 + .x(d => xScaleGastos(d.gestion))
355 + .y0(innerH)
356 + .y1(d => yScaleGastos(d.devengado))
357 + .curve(d3.curveCatmullRom);
358 + return gen(historiaGastos) || '';
359 + });
360 +
361 + let yTicksGastos = $derived.by(() => {
362 + if (!yScaleGastos) return [];
363 + return yScaleGastos.ticks(4);
364 + });
365 +
366 + // Escalas D3 para ingresos
367 + let xScaleIngresos = $derived.by(() => {
368 + if (historiaIngresos.length === 0) return null;
369 + return d3.scaleLinear()
370 + .domain(d3.extent(historiaIngresos, d => d.gestion))
371 + .range([0, innerW]);
372 + });
373 +
374 + let yScaleIngresos = $derived.by(() => {
375 + if (historiaIngresos.length === 0) return null;
376 + return d3.scaleLinear()
377 + .domain([0, d3.max(historiaIngresos, d => d.devengado) * 1.1])
378 + .range([innerH, 0])
379 + .nice();
380 + });
381 +
382 + let lineIngresos = $derived.by(() => {
383 + if (!xScaleIngresos || !yScaleIngresos) return '';
384 + const gen = d3.line()
385 + .x(d => xScaleIngresos(d.gestion))
386 + .y(d => yScaleIngresos(d.devengado))
387 + .curve(d3.curveCatmullRom);
388 + return gen(historiaIngresos) || '';
389 + });
390 +
391 + let areaIngresos = $derived.by(() => {
392 + if (!xScaleIngresos || !yScaleIngresos) return '';
393 + const gen = d3.area()
394 + .x(d => xScaleIngresos(d.gestion))
395 + .y0(innerH)
396 + .y1(d => yScaleIngresos(d.devengado))
397 + .curve(d3.curveCatmullRom);
398 + return gen(historiaIngresos) || '';
399 + });
400 +
401 + let yTicksIngresos = $derived.by(() => {
402 + if (!yScaleIngresos) return [];
403 + return yScaleIngresos.ticks(4);
404 + });
405 +
406 + function renderizarTodasLasBarras() {
407 + setTimeout(() => {
408 + clasificadoresGasto.forEach(({ key, data }) => {
409 + if (barContainers[key]) renderizarBarras(key, data);
410 + });
411 + clasificadoresIngreso.forEach(({ key, data }) => {
412 + if (barContainers[key]) renderizarBarras(key, data);
413 + });
414 + }, 100);
415 + }
416 +
417 + $effect(() => {
418 + clasificadoresGasto;
419 + clasificadoresIngreso;
420 + tick().then(() => renderizarTodasLasBarras());
421 + });
422 +
423 + // Medir ancho del contenedor de chart
424 + $effect(() => {
425 + const el = chartContainerEl;
426 + if (!el) return;
427 + chartWidth = el.clientWidth - 48; // restar padding horizontal
428 + const ro = new ResizeObserver((entries) => {
429 + chartWidth = entries[0].contentRect.width;
430 + });
431 + ro.observe(el);
432 + return () => ro.disconnect();
433 + });
434 +
435 + onMount(() => {
436 + const observer = new MutationObserver(() => { renderizarTodasLasBarras(); });
437 + observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
438 +
439 + function handleClickOutside(e) {
440 + if (gestionDropdownOpen && !e.target.closest('.gestion-selector')) {
441 + gestionDropdownOpen = false;
442 + }
443 + }
444 + document.addEventListener('click', handleClickOutside);
445 +
446 + return () => { observer.disconnect(); document.removeEventListener('click', handleClickOutside); };
447 + });
6 </script> 448 </script>
7 449
8 <svelte:head> 450 <svelte:head>
9 - <title>{entidad.desc_entidad} | Presupuesto Público</title> 451 + <title>{isDA && nombreDA ? nombreDA : entidad.desc_entidad} | Presupuesto Público</title>
10 </svelte:head> 452 </svelte:head>
11 453
12 -<div class="max-w-4xl mx-auto p-4"> 454 +{#if cargando}
13 - <p class="text-sm text-gray-500 mb-4"> 455 + <div class="dashboard">
14 - <a href="/">Inicio</a> / <a href="/clasificadores/institucional">Institucional</a> / {entidad.sigla_entidad || 'Entidad'} 456 + <p>Cargando datos...</p>
15 - </p> 457 + </div>
458 +{:else}
459 +<div class="dashboard">
460 + <div class="nav-spacer"></div>
461 + <!-- Header sticky -->
462 + <header class="sticky-header">
463 + <div class="sticky-row">
464 + <div class="sticky-left">
465 + {#if isDA && nombreDA}
466 + <div class="da-titulo">
467 + <a href="/entidad/{codigoEntidadPadre}" class="entidad-madre-link">{entidad.desc_entidad}</a>
468 + <span class="da-separador">›</span>
469 + <span class="da-nombre">{nombreDA}</span>
470 + </div>
471 + {:else}
472 + <h1 class="entidad-nombre">{entidad.desc_entidad}</h1>
473 + {/if}
474 + <div class="entidad-search" class:focused={searchOpen || searchQuery.length > 0}>
475 + <svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
476 + <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
477 + </svg>
478 + <input
479 + type="text"
480 + placeholder="Buscar otra entidad..."
481 + bind:value={searchQuery}
482 + oninput={onSearchInput}
483 + onkeydown={onSearchKeydown}
484 + onblur={closeSearch}
485 + onfocus={() => { if (searchResults.length > 0) searchOpen = true; }}
486 + />
487 + {#if searchQuery}
488 + <button class="search-clear" onclick={() => { searchQuery = ''; searchResults = []; searchOpen = false; }}>
489 + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
490 + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
491 + </svg>
492 + </button>
493 + {/if}
494 + {#if searchOpen || (searchQuery.length >= 2 && searchLoading)}
495 + <div class="search-dropdown">
496 + {#if searchLoading}
497 + <div class="search-msg">Buscando...</div>
498 + {:else if searchResults.length === 0}
499 + <div class="search-msg">Sin resultados</div>
500 + {:else}
501 + {#each searchResults as item, i}
502 + <button
503 + class="search-item"
504 + class:selected={searchSelectedIdx === i}
505 + onmousedown={() => navigateToEntity(item)}
506 + onmouseenter={() => { searchSelectedIdx = i; }}
507 + >
508 + <span class="item-name">{@html item.highlight}</span>
509 + {#if item.esDA}
510 + <span class="item-meta">{item.entidadMadre}</span>
511 + {/if}
512 + </button>
513 + {/each}
514 + {/if}
515 + </div>
516 + {/if}
517 + </div>
518 + </div>
519 + </div>
520 + </header>
16 521
17 - <h1 class="text-2xl mb-1">{entidad.desc_entidad}</h1> 522 + <!-- Breadcrumb -->
18 - {#if entidad.sigla_entidad} 523 + <nav class="breadcrumb">
19 - <p class="text-gray-600 mb-4">{entidad.sigla_entidad}</p> 524 + <a href="/">Inicio</a>
20 - {/if} 525 + <span class="sep">/</span>
526 + <a href="/clasificadores/institucional">{entidad.desc_area}</a>
527 + <span class="sep">/</span>
528 + {#if isDA}
529 + <a href="/entidad/{codigoEntidadPadre}">{entidad.sigla_entidad || entidad.desc_entidad}</a>
530 + <span class="sep">/</span>
531 + <span>{nombreDA || codigoSeleccionado}</span>
532 + {:else}
533 + <span>{entidad.sigla_entidad || entidad.desc_entidad}</span>
534 + {/if}
535 + </nav>
21 536
22 - <p class="text-sm text-gray-500 mb-6"> 537 + <!-- Historia temporal (todos los años) -->
23 - Código: {entidad.entidad} · {entidad.n_gestiones} años de datos 538 + <section class="seccion">
24 - </p> 539 + <h2 class="seccion-titulo">Historia</h2>
25 - 540 + <div class="historia-grid" class:single={!tieneIngresos}>
26 - <div class="grid md:grid-cols-2 gap-4"> 541 + {#if tieneIngresos}
27 - <div class="border rounded p-4"> 542 + <div class="historia-card" bind:this={chartRefA}>
28 - <h2 class="font-bold mb-2">Clasificación</h2> 543 + <div class="historia-header">
29 - <p class="text-sm"><span class="text-gray-500">Sector:</span> {entidad.desc_sector}</p> 544 + <span class="historia-label">Ingresos</span>
30 - <p class="text-sm"><span class="text-gray-500">Subsector:</span> {entidad.desc_subsector}</p> 545 + {#if historiaIngresos.length > 0}
31 - <p class="text-sm"><span class="text-gray-500">Área:</span> {entidad.desc_area}</p> 546 + <span class="historia-monto ingresos">
32 - {#if entidad.desc_subarea !== entidad.desc_area} 547 + {#if hoveredIngresos}
33 - <p class="text-sm"><span class="text-gray-500">Subárea:</span> {entidad.desc_subarea}</p> 548 + {hoveredIngresos.gestion} · {formatearMonto(hoveredIngresos.devengado)} de Bolivianos
549 + {:else}
550 + {formatearMonto(historiaIngresos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos
551 + {/if}
552 + </span>
553 + {/if}
554 + </div>
555 + {#if xScaleIngresos && yScaleIngresos}
556 + <svg class="historia-svg" width="100%" height={chartHeight} viewBox="0 0 {chartWidth} {chartHeight}"
557 + onmouseleave={() => { hoveredIngresos = null; }}>
558 + <g transform="translate({chartMargin.left},{chartMargin.top})">
559 + {#each yTicksIngresos as tick}
560 + <line x1="0" x2={innerW} y1={yScaleIngresos(tick)} y2={yScaleIngresos(tick)} class="grid-line" />
561 + <text x="-8" y={yScaleIngresos(tick)} class="axis-label y-label">{formatearMontoCorto(tick)}</text>
562 + {/each}
563 + <path d={areaIngresos} class="area-path" />
564 + <path d={lineIngresos} class="line-path" fill="none" stroke-width="1.5" />
565 + {#each historiaIngresos as d}
566 + <circle cx={xScaleIngresos(d.gestion)} cy={yScaleIngresos(d.devengado)}
567 + r={hoveredIngresos?.gestion === d.gestion ? 4 : 2} class="dot" class:active={hoveredIngresos?.gestion === d.gestion} />
568 + {/each}
569 + {#each historiaIngresos as d}
570 + <rect x={xScaleIngresos(d.gestion) - innerW / historiaIngresos.length / 2} y="0"
571 + width={innerW / historiaIngresos.length} height={innerH}
572 + fill="transparent"
573 + onmouseenter={() => { hoveredIngresos = d; }}
574 + />
575 + {/each}
576 + {#if hoveredIngresos && xScaleIngresos}
577 + <line x1={xScaleIngresos(hoveredIngresos.gestion)} x2={xScaleIngresos(hoveredIngresos.gestion)}
578 + y1="0" y2={innerH} class="hover-line" />
579 + <text x={xScaleIngresos(hoveredIngresos.gestion)} y={innerH + 16} class="axis-label x-label active">{hoveredIngresos.gestion}</text>
580 + {:else}
581 + {#each historiaIngresos as d, i}
582 + {#if i === 0 || i === historiaIngresos.length - 1 || i === Math.floor(historiaIngresos.length / 2)}
583 + <text x={xScaleIngresos(d.gestion)} y={innerH + 16} class="axis-label x-label">{d.gestion}</text>
584 + {/if}
585 + {/each}
586 + {/if}
587 + </g>
588 + </svg>
589 + {/if}
590 + </div>
34 {/if} 591 {/if}
592 + <div class="historia-card" bind:this={chartRefB}>
593 + <div class="historia-header">
594 + <span class="historia-label">Gastos</span>
595 + {#if historiaGastos.length > 0}
596 + <span class="historia-monto gastos">
597 + {#if hoveredGastos}
598 + {hoveredGastos.gestion} · {formatearMonto(hoveredGastos.devengado)} de Bolivianos
599 + {:else}
600 + {formatearMonto(historiaGastos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos
601 + {/if}
602 + </span>
603 + {/if}
604 + </div>
605 + {#if xScaleGastos && yScaleGastos}
606 + <svg class="historia-svg" width="100%" height={chartHeight} viewBox="0 0 {chartWidth} {chartHeight}"
607 + onmouseleave={() => { hoveredGastos = null; }}>
608 + <g transform="translate({chartMargin.left},{chartMargin.top})">
609 + {#each yTicksGastos as tick}
610 + <line x1="0" x2={innerW} y1={yScaleGastos(tick)} y2={yScaleGastos(tick)} class="grid-line" />
611 + <text x="-8" y={yScaleGastos(tick)} class="axis-label y-label">{formatearMontoCorto(tick)}</text>
612 + {/each}
613 + <path d={areaGastos} class="area-path" />
614 + <path d={lineGastos} class="line-path" fill="none" stroke-width="1.5" />
615 + {#each historiaGastos as d}
616 + <circle cx={xScaleGastos(d.gestion)} cy={yScaleGastos(d.devengado)}
617 + r={hoveredGastos?.gestion === d.gestion ? 4 : 2} class="dot" class:active={hoveredGastos?.gestion === d.gestion} />
618 + {/each}
619 + {#each historiaGastos as d}
620 + <rect x={xScaleGastos(d.gestion) - innerW / historiaGastos.length / 2} y="0"
621 + width={innerW / historiaGastos.length} height={innerH}
622 + fill="transparent"
623 + onmouseenter={() => { hoveredGastos = d; }}
624 + />
625 + {/each}
626 + {#if hoveredGastos && xScaleGastos}
627 + <line x1={xScaleGastos(hoveredGastos.gestion)} x2={xScaleGastos(hoveredGastos.gestion)}
628 + y1="0" y2={innerH} class="hover-line" />
629 + <text x={xScaleGastos(hoveredGastos.gestion)} y={innerH + 16} class="axis-label x-label active">{hoveredGastos.gestion}</text>
630 + {:else}
631 + {#each historiaGastos as d, i}
632 + {#if i === 0 || i === historiaGastos.length - 1 || i === Math.floor(historiaGastos.length / 2)}
633 + <text x={xScaleGastos(d.gestion)} y={innerH + 16} class="axis-label x-label">{d.gestion}</text>
634 + {/if}
635 + {/each}
636 + {/if}
637 + </g>
638 + </svg>
639 + {/if}
640 + </div>
35 </div> 641 </div>
642 + </section>
36 643
37 - <div class="border rounded p-4"> 644 + <!-- Sección por año -->
38 - <h2 class="font-bold mb-2">Gestiones</h2> 645 + <section class="seccion seccion-anual">
39 - <div class="flex flex-wrap gap-1"> 646 + <h2 class="seccion-titulo">Gestión
40 - {#each gestiones as gestion} 647 + <div class="gestion-selector">
41 - <span class="text-xs bg-gray-100 px-2 py-1 rounded">{gestion}</span> 648 + <button class="gestion-btn" onclick={() => { gestionDropdownOpen = !gestionDropdownOpen; }}>
649 + <span>{gestionSeleccionada}</span>
650 + <svg class="gestion-chevron" class:open={gestionDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
651 + <path d="M6 9l6 6 6-6"/>
652 + </svg>
653 + </button>
654 + {#if gestionDropdownOpen}
655 + <div class="gestion-dropdown">
656 + {#each gestiones() as g}
657 + <button
658 + class="gestion-option"
659 + class:active={gestionSeleccionada === g}
660 + onclick={() => { gestionSeleccionada = g; gestionDropdownOpen = false; }}
661 + >
662 + {g}
663 + </button>
664 + {/each}
665 + </div>
666 + {/if}
667 + </div>
668 + </h2>
669 +
670 + <!-- Rankings -->
671 + <div class="ranking-grid" class:single={!tieneIngresos}>
672 + {#if rankingGastos()}
673 + {@const r = rankingGastos()}
674 + {@const total = parseInt(r.total) || 600}
675 + {@const barIdx = Math.max(0, Math.min(79, Math.round((r.posicion / total) * 80)))}
676 + <div class="ranking-card">
677 + <span class="ranking-card-label">Ranking en gasto</span>
678 + <div class="ranking-bars">
679 + {#each Array(80) as _, i}
680 + <div class="ranking-bar-tick"></div>
681 + {/each}
682 + <div class="ranking-marker" style="left: {barIdx * 1.25}%"></div>
683 + </div>
684 + <div class="ranking-caption">
685 + <span class="ranking-pos">#{r.posicion}</span> de {total} entidades
686 + </div>
687 + </div>
688 + {/if}
689 + {#if tieneIngresos && rankingIngresos()}
690 + {@const r = rankingIngresos()}
691 + {@const total = parseInt(r.total) || 600}
692 + {@const barIdx = Math.max(0, Math.min(79, Math.round((r.posicion / total) * 80)))}
693 + <div class="ranking-card">
694 + <span class="ranking-card-label">Ranking en ingresos</span>
695 + <div class="ranking-bars">
696 + {#each Array(80) as _, i}
697 + <div class="ranking-bar-tick"></div>
698 + {/each}
699 + <div class="ranking-marker" style="left: {barIdx * 1.25}%"></div>
700 + </div>
701 + <div class="ranking-caption">
702 + <span class="ranking-pos">#{r.posicion}</span> de {total} entidades
703 + </div>
704 + </div>
705 + {/if}
706 + </div>
707 +
708 + <!-- Composición del gasto -->
709 + {#if clasificadoresGasto.length > 0}
710 + <div class="seccion-sub">
711 + <h3 class="seccion-subtitulo">¿En qué gasta?</h3>
712 + <div class="clasificadores-grid">
713 + {#each clasificadoresGasto as { key, label, data }}
714 + {@const displayItem = getDisplayItem(key, data)}
715 + <div class="clasificador-card">
716 + <div class="clasificador-header">
717 + <div class="clasificador-info">
718 + <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span>
719 + <span class="clasificador-padre">{displayItem?.padre || ''}</span>
720 + <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
721 + </div>
722 + <span class="clasificador-label">{label}</span>
723 + </div>
724 + <div class="barra-container" bind:this={barContainers[key]}></div>
725 + </div>
42 {/each} 726 {/each}
43 </div> 727 </div>
44 </div> 728 </div>
45 - </div> 729 + {/if}
46 730
47 - <div class="border rounded p-4 mt-4"> 731 + <!-- Origen del dinero -->
48 - <h2 class="font-bold mb-2">Historial de Ingresos y Gastos</h2> 732 + {#if tieneIngresos && clasificadoresIngreso.length > 0}
49 - <p class="text-gray-500 text-sm">Visualizaciones próximamente</p> 733 + <div class="seccion-sub">
50 - </div> 734 + <h3 class="seccion-subtitulo">Fuentes de ingreso</h3>
735 + <div class="clasificadores-grid">
736 + {#each clasificadoresIngreso as { key, label, data }}
737 + {@const displayItem = getDisplayItem(key, data)}
738 + <div class="clasificador-card">
739 + <div class="clasificador-header">
740 + <div class="clasificador-info">
741 + <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span>
742 + <span class="clasificador-padre">{displayItem?.padre || ''}</span>
743 + <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
744 + </div>
745 + <span class="clasificador-label">{label}</span>
746 + </div>
747 + <div class="barra-container" bind:this={barContainers[key]}></div>
748 + </div>
749 + {/each}
750 + </div>
751 + </div>
752 + {/if}
753 +
754 + </section>
51 </div> 755 </div>
756 +{/if}
757 +
758 +<style>
759 + .dashboard {
760 + min-height: 100vh;
761 + background: var(--theme-body);
762 + color: var(--theme-texto);
763 + padding: 2rem;
764 + padding-top: 0;
765 + font-family: var(--font-sans);
766 + }
767 +
768 + :global(html:not(.dark)) .dashboard {
769 + background: #f5f5f7;
770 + }
771 +
772 + /* Spacer para empujar debajo del navbar fixed */
773 + .nav-spacer {
774 + height: 3.5rem;
775 + }
776 +
777 + .sticky-header {
778 + position: sticky;
779 + top: 0;
780 + z-index: 50;
781 + padding: 0.75rem 2rem;
782 + margin: 0 -2rem 1rem -2rem;
783 + background: var(--theme-body);
784 + border-bottom: 1px solid var(--theme-borde);
785 + }
786 +
787 + :global(html:not(.dark)) .sticky-header {
788 + background: #f5f5f7;
789 + }
790 +
791 + .sticky-row {
792 + display: flex;
793 + align-items: center;
794 + gap: 1rem;
795 + }
796 +
797 + .sticky-left {
798 + display: flex;
799 + align-items: center;
800 + gap: 0.75rem;
801 + min-width: 0;
802 + flex: 1;
803 + }
804 +
805 + .entidad-nombre {
806 + font-size: 1.1rem;
807 + font-weight: 700;
808 + color: var(--theme-titulo);
809 + margin: 0;
810 + line-height: 1.2;
811 + white-space: nowrap;
812 + overflow: hidden;
813 + text-overflow: ellipsis;
814 + }
815 +
816 + /* DA titulo jerárquico */
817 + .da-titulo {
818 + display: flex;
819 + align-items: baseline;
820 + gap: 0.4rem;
821 + min-width: 0;
822 + }
823 +
824 + .entidad-madre-link {
825 + font-size: 0.85rem;
826 + font-weight: 500;
827 + color: var(--theme-texto);
828 + text-decoration: none;
829 + opacity: 0.6;
830 + transition: opacity 0.15s;
831 + white-space: nowrap;
832 + }
833 +
834 + .entidad-madre-link:hover {
835 + opacity: 1;
836 + text-decoration: underline;
837 + }
838 +
839 + .da-separador {
840 + font-size: 0.85rem;
841 + color: var(--theme-texto);
842 + opacity: 0.35;
843 + }
844 +
845 + .da-nombre {
846 + font-size: 1.1rem;
847 + font-weight: 700;
848 + color: var(--theme-titulo);
849 + white-space: nowrap;
850 + overflow: hidden;
851 + text-overflow: ellipsis;
852 + }
853 +
854 + /* Buscador de entidades (estilo ObjetoSearch) */
855 + .entidad-search {
856 + position: relative;
857 + display: flex;
858 + align-items: center;
859 + gap: 0.5rem;
860 + background: var(--search-bg, #f5f5f5);
861 + border: 1px solid var(--theme-borde);
862 + border-radius: 8px;
863 + padding: 0.5rem 0.75rem;
864 + transition: box-shadow 0.2s, border-color 0.2s;
865 + width: 240px;
866 + flex-shrink: 0;
867 + }
868 +
869 + :global(html.dark) .entidad-search {
870 + --search-bg: rgba(255, 255, 255, 0.08);
871 + }
872 +
873 + .entidad-search.focused {
874 + box-shadow: 0 0 0 3px rgba(107, 159, 212, 0.15);
875 + }
876 +
877 + :global(html:not(.dark)) .entidad-search.focused {
878 + box-shadow: 0 0 0 3px rgba(196, 137, 125, 0.15);
879 + }
880 +
881 + .search-icon {
882 + color: var(--theme-texto);
883 + opacity: 0.5;
884 + flex-shrink: 0;
885 + }
886 +
887 + .entidad-search input {
888 + flex: 1;
889 + border: none;
890 + background: transparent;
891 + color: var(--theme-titulo);
892 + font-size: 0.8125rem;
893 + outline: none;
894 + min-width: 100px;
895 + font-family: var(--font-sans);
896 + }
897 +
898 + .entidad-search input::placeholder {
899 + color: var(--theme-texto);
900 + opacity: 0.5;
901 + }
902 +
903 + .search-clear {
904 + display: flex;
905 + align-items: center;
906 + justify-content: center;
907 + padding: 0.25rem;
908 + border-radius: 4px;
909 + border: none;
910 + background: transparent;
911 + color: var(--theme-texto);
912 + cursor: pointer;
913 + transition: background 0.2s;
914 + }
915 +
916 + .search-clear:hover {
917 + background: var(--theme-borde);
918 + }
919 +
920 + .search-dropdown {
921 + position: absolute;
922 + top: calc(100% + 4px);
923 + left: 0;
924 + right: 0;
925 + background: var(--theme-surface);
926 + border: 1px solid var(--theme-borde);
927 + border-radius: 8px;
928 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
929 + max-height: 280px;
930 + overflow-y: auto;
931 + z-index: 100;
932 + min-width: 320px;
933 + }
934 +
935 + .search-msg {
936 + padding: 0.75rem 1rem;
937 + font-size: 0.8125rem;
938 + color: var(--theme-texto);
939 + opacity: 0.7;
940 + }
941 +
942 + .search-item {
943 + display: flex;
944 + flex-direction: column;
945 + gap: 0.1rem;
946 + width: 100%;
947 + padding: 0.625rem 1rem;
948 + border: none;
949 + background: transparent;
950 + text-align: left;
951 + cursor: pointer;
952 + transition: background 0.15s;
953 + font-family: var(--font-sans);
954 + }
955 +
956 + .search-item:hover,
957 + .search-item.selected {
958 + background: var(--theme-surface-hover);
959 + }
960 +
961 + .item-name {
962 + font-size: 0.8125rem;
963 + color: var(--theme-titulo);
964 + line-height: 1.3;
965 + }
966 +
967 + .item-name :global(mark) {
968 + background: rgba(196, 137, 125, 0.25);
969 + color: inherit;
970 + border-radius: 2px;
971 + padding: 0 1px;
972 + }
973 +
974 + :global(html.dark) .item-name :global(mark) {
975 + background: rgba(107, 159, 212, 0.3);
976 + }
977 +
978 + .item-meta {
979 + font-size: 0.7rem;
980 + color: var(--theme-texto);
981 + opacity: 0.5;
982 + }
983 +
984 +
985 + /* Breadcrumb */
986 + .breadcrumb {
987 + font-size: 0.75rem;
988 + color: var(--theme-texto);
989 + opacity: 0.6;
990 + margin-bottom: 1.5rem;
991 + }
992 +
993 + .breadcrumb a {
994 + color: var(--theme-texto);
995 + text-decoration: none;
996 + }
997 +
998 + .breadcrumb a:hover {
999 + text-decoration: underline;
1000 + }
1001 +
1002 + .breadcrumb .sep {
1003 + margin: 0 0.35rem;
1004 + }
1005 +
1006 + /* Ranking visual - barritas con marcador animado */
1007 + .ranking-bars {
1008 + display: flex;
1009 + height: 18px;
1010 + position: relative;
1011 + gap: 0;
1012 + }
1013 +
1014 + .ranking-bar-tick {
1015 + flex: 1;
1016 + height: 100%;
1017 + background: var(--theme-borde);
1018 + opacity: 0.5;
1019 + border-right: 1px solid var(--theme-surface);
1020 + }
1021 +
1022 + :global(html:not(.dark)) .ranking-bar-tick {
1023 + background: #d0cec9;
1024 + opacity: 0.6;
1025 + }
1026 +
1027 + .ranking-marker {
1028 + position: absolute;
1029 + top: 0;
1030 + bottom: 0;
1031 + width: 1.25%;
1032 + background: #c4897d;
1033 + border-radius: 1px;
1034 + transition: left 0.5s cubic-bezier(0.4, 0, 0.2, 1);
1035 + pointer-events: none;
1036 + }
1037 +
1038 + :global(html.dark) .ranking-marker {
1039 + background: #D4A574;
1040 + }
1041 +
1042 + .ranking-caption {
1043 + margin-top: 0.35rem;
1044 + font-size: 0.7rem;
1045 + color: var(--theme-texto);
1046 + opacity: 0.6;
1047 + }
1048 +
1049 + .ranking-pos {
1050 + font-weight: 700;
1051 + color: var(--theme-titulo);
1052 + opacity: 1;
1053 + }
1054 +
1055 + /* Secciones */
1056 + .seccion {
1057 + margin-bottom: 2.5rem;
1058 + }
1059 +
1060 + .seccion-titulo {
1061 + font-size: 1.1rem;
1062 + font-weight: 600;
1063 + color: var(--theme-titulo);
1064 + margin: 0 0 1rem 0;
1065 + display: flex;
1066 + align-items: center;
1067 + gap: 0.75rem;
1068 + }
1069 +
1070 + /* Sección anual */
1071 + .seccion-anual {
1072 + border-top: 1px solid var(--theme-borde);
1073 + padding-top: 2rem;
1074 + }
1075 +
1076 + /* Dropdown de gestión */
1077 + .gestion-selector {
1078 + position: relative;
1079 + display: inline-flex;
1080 + }
1081 +
1082 + .gestion-btn {
1083 + display: inline-flex;
1084 + align-items: center;
1085 + gap: 0.375rem;
1086 + padding: 0.125rem 0;
1087 + background: transparent;
1088 + border: none;
1089 + border-bottom: 1.5px dotted var(--theme-texto);
1090 + color: var(--theme-titulo);
1091 + font-size: 1.1rem;
1092 + font-weight: 600;
1093 + cursor: pointer;
1094 + transition: border-color 0.2s;
1095 + font-family: var(--font-sans);
1096 + }
1097 +
1098 + .gestion-btn:hover {
1099 + border-bottom-color: var(--theme-accent);
1100 + }
1101 +
1102 + .gestion-chevron {
1103 + color: var(--theme-texto);
1104 + opacity: 0.5;
1105 + transition: transform 0.2s;
1106 + flex-shrink: 0;
1107 + }
1108 +
1109 + .gestion-chevron.open {
1110 + transform: rotate(180deg);
1111 + }
1112 +
1113 + .gestion-dropdown {
1114 + position: absolute;
1115 + top: calc(100% + 6px);
1116 + left: 0;
1117 + min-width: 100px;
1118 + max-height: 240px;
1119 + overflow-y: auto;
1120 + background: var(--theme-surface);
1121 + border: 1px solid var(--theme-borde);
1122 + border-radius: 10px;
1123 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
1124 + z-index: 100;
1125 + }
1126 +
1127 + .gestion-option {
1128 + display: block;
1129 + width: 100%;
1130 + padding: 0.5rem 0.75rem;
1131 + border: none;
1132 + background: transparent;
1133 + color: var(--theme-titulo);
1134 + font-size: 0.8125rem;
1135 + font-weight: 500;
1136 + text-align: left;
1137 + cursor: pointer;
1138 + transition: background 0.15s;
1139 + font-family: var(--font-sans);
1140 + }
1141 +
1142 + .gestion-option:hover {
1143 + background: var(--theme-surface-hover);
1144 + }
1145 +
1146 + .gestion-option.active {
1147 + background: rgba(107, 159, 212, 0.1);
1148 + color: #6B9FD4;
1149 + }
1150 +
1151 + :global(html:not(.dark)) .gestion-option.active {
1152 + background: rgba(90, 157, 191, 0.1);
1153 + color: #5A9DBF;
1154 + }
1155 +
1156 + .seccion-sub {
1157 + margin-top: 1.5rem;
1158 + }
1159 +
1160 + .seccion-subtitulo {
1161 + font-size: 0.9rem;
1162 + font-weight: 600;
1163 + color: var(--theme-titulo);
1164 + margin: 0 0 0.75rem 0;
1165 + opacity: 0.8;
1166 + }
1167 +
1168 + /* Rankings grid */
1169 + .ranking-grid {
1170 + display: grid;
1171 + grid-template-columns: repeat(2, 1fr);
1172 + gap: 1.5rem;
1173 + margin-bottom: 1.5rem;
1174 + }
1175 +
1176 + .ranking-grid.single {
1177 + grid-template-columns: 1fr;
1178 + max-width: 50%;
1179 + }
1180 +
1181 + .ranking-card {
1182 + background: var(--theme-surface);
1183 + border-radius: 16px;
1184 + padding: 1.25rem 1.5rem;
1185 + }
1186 +
1187 + .ranking-card-label {
1188 + font-size: 0.75rem;
1189 + font-weight: 500;
1190 + color: var(--theme-texto);
1191 + opacity: 0.7;
1192 + display: block;
1193 + margin-bottom: 0.5rem;
1194 + }
1195 +
1196 + /* Historia */
1197 + .historia-grid {
1198 + display: grid;
1199 + grid-template-columns: repeat(2, 1fr);
1200 + gap: 1.5rem;
1201 + }
1202 +
1203 + .historia-grid.single {
1204 + grid-template-columns: 1fr;
1205 + }
1206 +
1207 + .historia-card {
1208 + background: var(--theme-surface);
1209 + border-radius: 16px;
1210 + padding: 1.25rem 1.5rem;
1211 + }
1212 +
1213 + .historia-header {
1214 + display: flex;
1215 + justify-content: space-between;
1216 + align-items: baseline;
1217 + margin-bottom: 0.75rem;
1218 + }
1219 +
1220 + .historia-label {
1221 + font-size: 0.85rem;
1222 + font-weight: 500;
1223 + color: var(--theme-texto);
1224 + }
1225 +
1226 + .historia-monto {
1227 + font-size: 0.95rem;
1228 + font-weight: 600;
1229 + }
1230 +
1231 + .historia-monto.ingresos,
1232 + .historia-monto.gastos {
1233 + color: #5A9DBF;
1234 + }
1235 +
1236 + :global(html.dark) .historia-monto.ingresos,
1237 + :global(html.dark) .historia-monto.gastos {
1238 + color: #D4A574;
1239 + }
1240 +
1241 + /* SVG chart */
1242 + .historia-svg {
1243 + display: block;
1244 + overflow: visible;
1245 + cursor: default;
1246 + }
1247 +
1248 + .grid-line {
1249 + stroke: var(--theme-texto);
1250 + stroke-width: 0.5;
1251 + stroke-dasharray: 2 3;
1252 + opacity: 0.15;
1253 + }
1254 +
1255 + .axis-label {
1256 + font-size: 0.6875rem;
1257 + fill: var(--theme-texto);
1258 + opacity: 0.6;
1259 + font-family: var(--font-sans);
1260 + }
1261 +
1262 + .y-label {
1263 + text-anchor: end;
1264 + dominant-baseline: middle;
1265 + }
1266 +
1267 + .x-label {
1268 + text-anchor: middle;
1269 + dominant-baseline: hanging;
1270 + }
1271 +
1272 + .area-path {
1273 + fill: rgba(90, 157, 191, 0.12);
1274 + }
1275 +
1276 + .line-path {
1277 + stroke: #5A9DBF;
1278 + }
1279 +
1280 + .dot {
1281 + fill: #5A9DBF;
1282 + transition: r 0.15s;
1283 + }
1284 +
1285 + .dot.active {
1286 + fill: #5A9DBF;
1287 + }
1288 +
1289 + .hover-line {
1290 + stroke: var(--theme-texto);
1291 + stroke-width: 0.5;
1292 + stroke-dasharray: 3 3;
1293 + opacity: 0.3;
1294 + }
1295 +
1296 + .x-label.active {
1297 + font-weight: 600;
1298 + opacity: 1;
1299 + }
1300 +
1301 + :global(html.dark) .area-path {
1302 + fill: rgba(212, 165, 116, 0.12);
1303 + }
1304 +
1305 + :global(html.dark) .line-path {
1306 + stroke: #D4A574;
1307 + }
1308 +
1309 + :global(html.dark) .dot {
1310 + fill: #D4A574;
1311 + }
1312 +
1313 + :global(html.dark) .dot.active {
1314 + fill: #D4A574;
1315 + }
1316 +
1317 + /* Clasificadores */
1318 + .clasificadores-grid {
1319 + display: flex;
1320 + flex-direction: column;
1321 + gap: 1rem;
1322 + }
1323 +
1324 + .clasificador-card {
1325 + display: flex;
1326 + flex-direction: column;
1327 + gap: 0.5rem;
1328 + background: var(--theme-surface);
1329 + border-radius: 16px;
1330 + padding: 1.25rem 1.5rem;
1331 + }
1332 +
1333 + .clasificador-header {
1334 + display: flex;
1335 + align-items: baseline;
1336 + justify-content: space-between;
1337 + gap: 1rem;
1338 + }
1339 +
1340 + .clasificador-info {
1341 + display: flex;
1342 + align-items: baseline;
1343 + gap: 0.4rem;
1344 + min-width: 0;
1345 + overflow: hidden;
1346 + }
1347 +
1348 + .clasificador-monto {
1349 + font-size: 0.95rem;
1350 + font-weight: 600;
1351 + color: var(--theme-titulo);
1352 + white-space: nowrap;
1353 + flex-shrink: 0;
1354 + }
1355 +
1356 + .clasificador-padre {
1357 + font-size: 0.8rem;
1358 + font-weight: 400;
1359 + color: var(--theme-texto);
1360 + opacity: 0.5;
1361 + white-space: nowrap;
1362 + flex-shrink: 0;
1363 + }
1364 +
1365 + .clasificador-nombre {
1366 + font-size: 0.85rem;
1367 + font-weight: 500;
1368 + color: var(--theme-titulo);
1369 + white-space: nowrap;
1370 + overflow: hidden;
1371 + text-overflow: ellipsis;
1372 + }
1373 +
1374 + .clasificador-label {
1375 + font-size: 0.65rem;
1376 + text-transform: uppercase;
1377 + letter-spacing: 0.05em;
1378 + color: var(--theme-texto);
1379 + opacity: 0.6;
1380 + flex-shrink: 0;
1381 + }
1382 +
1383 + .barra-container {
1384 + width: 100%;
1385 + height: 48px;
1386 + border-radius: 4px;
1387 + overflow: hidden;
1388 + }
1389 +
1390 + /* Responsive */
1391 + @media (max-width: 768px) {
1392 + .dashboard {
1393 + padding: 1rem;
1394 + padding-top: 0.5rem;
1395 + }
1396 +
1397 + .nav-spacer {
1398 + height: 3rem;
1399 + }
1400 +
1401 + .sticky-header {
1402 + margin: 0 -1rem 0.75rem -1rem;
1403 + padding: 0.5rem 1rem;
1404 + }
1405 +
1406 + .sticky-row {
1407 + flex-direction: column;
1408 + align-items: flex-start;
1409 + gap: 0.5rem;
1410 + }
1411 +
1412 + .sticky-left {
1413 + flex-direction: column;
1414 + gap: 0.25rem;
1415 + }
1416 +
1417 + .da-titulo {
1418 + flex-direction: column;
1419 + gap: 0.15rem;
1420 + }
1421 +
1422 + .entidad-nombre {
1423 + font-size: 0.95rem;
1424 + }
1425 +
1426 + .da-nombre {
1427 + font-size: 0.95rem;
1428 + }
1429 +
1430 + .entidad-search {
1431 + width: 100%;
1432 + }
1433 +
1434 + .historia-grid {
1435 + grid-template-columns: 1fr;
1436 + }
1437 +
1438 + .ranking-grid {
1439 + grid-template-columns: 1fr;
1440 + }
1441 +
1442 + .ranking-grid.single {
1443 + max-width: 100%;
1444 + }
1445 + }
1446 +</style>
......
...@@ -3,97 +3,110 @@ ...@@ -3,97 +3,110 @@
3 import * as d3 from 'd3'; 3 import * as d3 from 'd3';
4 import * as Plot from '@observablehq/plot'; 4 import * as Plot from '@observablehq/plot';
5 5
6 - // Entidad de ejemplo 6 + // Estado reactivo
7 - const entidad = { 7 + let codigoSeleccionado = $state('1901');
8 - codigo: 139, 8 + let gestionSeleccionada = $state(2025);
9 - nombre: 'Universidad Mayor de San Andrés', 9 + let resumenData = $state([]);
10 - sigla: 'UMSA', 10 + let distribucionesData = $state([]);
11 - sector: 'Universidades Públicas' 11 + let codigosDisponibles = $state([]);
12 - }; 12 + let cargando = $state(true);
13 - 13 +
14 - // Estado 14 + // Datos derivados
15 - let gestionSeleccionada = $state(2024); 15 + let tipoCodigo = $derived.by(() => {
16 - const gestiones = [2020, 2021, 2022, 2023, 2024, 2025]; 16 + const row = resumenData.find(d => String(d.codigo) === codigoSeleccionado);
17 - 17 + return row?.tipo_codigo || 'entidad';
18 - // Datos mock - Historia temporal (20 años) 18 + });
19 - const historiaIngresos = Array.from({ length: 20 }, (_, i) => ({ 19 +
20 - gestion: 2005 + i, 20 + let esEntidad = $derived(tipoCodigo === 'entidad');
21 - monto: 800000000 + Math.random() * 400000000 + i * 50000000 21 +
22 - })); 22 + let resumenFiltrado = $derived(
23 - 23 + resumenData.filter(d => String(d.codigo) === codigoSeleccionado)
24 - const historiaGastos = Array.from({ length: 20 }, (_, i) => ({ 24 + );
25 - gestion: 2005 + i, 25 +
26 - monto: 750000000 + Math.random() * 350000000 + i * 45000000 26 + let historiaGastos = $derived(
27 - })); 27 + resumenFiltrado
28 - 28 + .filter(d => d.tipo === 'gastos')
29 - // Datos mock - Composición del gasto 29 + .sort((a, b) => a.gestion - b.gestion)
30 - const gastoObjetos = [ 30 + );
31 - { codigo: '10000', nombre: 'Servicios Personales', monto: 850000000, padre: 'Gasto Corriente' }, 31 +
32 - { codigo: '20000', nombre: 'Servicios No Personales', monto: 180000000, padre: 'Gasto Corriente' }, 32 + let historiaIngresos = $derived(
33 - { codigo: '30000', nombre: 'Materiales y Suministros', monto: 120000000, padre: 'Gasto Corriente' }, 33 + resumenFiltrado
34 - { codigo: '40000', nombre: 'Activos Reales', monto: 95000000, padre: 'Inversión' }, 34 + .filter(d => d.tipo === 'ingresos')
35 - { codigo: '70000', nombre: 'Transferencias', monto: 45000000, padre: 'Transferencias' }, 35 + .sort((a, b) => a.gestion - b.gestion)
36 - { codigo: '80000', nombre: 'Impuestos y Otros', monto: 25000000, padre: 'Otros' } 36 + );
37 - ]; 37 +
38 - 38 + let gestiones = $derived(() => {
39 - const gastoFinalidad = [ 39 + const years = [...new Set(resumenFiltrado.map(d => d.gestion))].sort();
40 - { codigo: '22', nombre: 'Educación Superior', monto: 920000000, padre: 'Educación' }, 40 + return years;
41 - { codigo: '23', nombre: 'Investigación', monto: 180000000, padre: 'Educación' }, 41 + });
42 - { codigo: '14', nombre: 'Administración', monto: 150000000, padre: 'Servicios Generales' }, 42 +
43 - { codigo: '31', nombre: 'Salud', monto: 45000000, padre: 'Servicios Sociales' }, 43 + let rankingGastos = $derived(() => {
44 - { codigo: '42', nombre: 'Extensión', monto: 20000000, padre: 'Cultura' } 44 + const row = historiaGastos.find(d => d.gestion === gestionSeleccionada);
45 - ]; 45 + return row ? { posicion: row.ranking, total: '~600' } : null;
46 - 46 + });
47 - const gastoSectores = [ 47 +
48 - { codigo: 'EDU', nombre: 'Educación', monto: 1050000000, padre: 'Social' }, 48 + let rankingIngresos = $derived(() => {
49 - { codigo: 'ADM', nombre: 'Administración Pública', monto: 180000000, padre: 'Gubernamental' }, 49 + const row = historiaIngresos.find(d => d.gestion === gestionSeleccionada);
50 - { codigo: 'SAL', nombre: 'Salud', monto: 55000000, padre: 'Social' }, 50 + return row ? { posicion: row.ranking, total: '~600' } : null;
51 - { codigo: 'CUL', nombre: 'Cultura y Deporte', monto: 30000000, padre: 'Social' } 51 + });
52 - ]; 52 +
53 - 53 + // Distribuciones filtradas por codigo y gestion
54 - // Datos mock - Origen del dinero 54 + let distFiltradas = $derived(
55 - const ingresoRubros = [ 55 + distribucionesData.filter(d =>
56 - { codigo: '1200', nombre: 'Transferencias TGN', monto: 650000000, padre: 'Transferencias' }, 56 + String(d.codigo) === codigoSeleccionado &&
57 - { codigo: '1400', nombre: 'Venta de Servicios', monto: 280000000, padre: 'Ingresos Propios' }, 57 + d.gestion === gestionSeleccionada
58 - { codigo: '1100', nombre: 'Matrículas y Aranceles', monto: 150000000, padre: 'Ingresos Propios' }, 58 + )
59 - { codigo: '1300', nombre: 'Regalías e IDH', monto: 95000000, padre: 'Coparticipación' }, 59 + );
60 - { codigo: '1900', nombre: 'Otros Ingresos', monto: 40000000, padre: 'Otros' } 60 +
61 - ]; 61 + // Clasificadores: cada hijo es un segmento de la barra
62 - 62 + function prepararSegmentos(data) {
63 - const ingresoOrganismos = [ 63 + return data
64 - { codigo: 'TGN', nombre: 'Tesoro General de la Nación', monto: 720000000, padre: 'Gobierno Central' }, 64 + .map(d => ({
65 - { codigo: 'PROP', nombre: 'Recursos Propios', monto: 350000000, padre: 'Autogestión' }, 65 + codigo: d.hijo,
66 - { codigo: 'IDH', nombre: 'Impuesto Directo a Hidrocarburos', monto: 95000000, padre: 'Coparticipación' }, 66 + nombre: d.desc_hijo,
67 - { codigo: 'COOP', nombre: 'Cooperación Internacional', monto: 50000000, padre: 'Externo' } 67 + monto: d.devengado,
68 - ]; 68 + padre: d.desc_padre
69 - 69 + }))
70 - // Ranking mock 70 + .filter(d => d.monto > 0)
71 - const ranking = { 71 + .sort((a, b) => b.monto - a.monto);
72 - gastos: { posicion: 23, total: 647 }, 72 + }
73 - ingresos: { posicion: 28, total: 647 } 73 +
74 - }; 74 + let clasificadoresGasto = $derived.by(() => {
75 - 75 + const gastos = distFiltradas.filter(d => d.tipo === 'gastos');
76 - // Contenedores para barras D3 76 + const dims = [
77 + { key: 'objeto', label: 'Objetos de gasto' },
78 + { key: 'finfun', label: 'Finalidad y función' },
79 + { key: 'acteco', label: 'Sectores económicos' }
80 + ];
81 + return dims
82 + .map(dim => ({
83 + ...dim,
84 + data: prepararSegmentos(gastos.filter(d => d.dimension === dim.key))
85 + }))
86 + .filter(dim => dim.data.length > 0);
87 + });
88 +
89 + let clasificadoresIngreso = $derived.by(() => {
90 + if (!esEntidad) return [];
91 + const ingresos = distFiltradas.filter(d => d.tipo === 'ingresos');
92 + const dims = [
93 + { key: 'rubro', label: 'Rubros de ingreso' },
94 + { key: 'organismo', label: 'Organismos financiadores' }
95 + ];
96 + return dims
97 + .map(dim => ({
98 + ...dim,
99 + data: prepararSegmentos(ingresos.filter(d => d.dimension === dim.key))
100 + }))
101 + .filter(dim => dim.data.length > 0);
102 + });
103 +
104 + // Contenedores D3
77 let barContainers = $state({}); 105 let barContainers = $state({});
78 let hoverData = $state({}); 106 let hoverData = $state({});
79 -
80 - // Contenedores para gráficos de historia
81 let chartIngresosContainer = $state(null); 107 let chartIngresosContainer = $state(null);
82 let chartGastosContainer = $state(null); 108 let chartGastosContainer = $state(null);
83 109
84 - // Clasificadores de gasto
85 - const clasificadoresGasto = [
86 - { key: 'objetos', label: 'Objetos de gasto', data: gastoObjetos },
87 - { key: 'finalidad', label: 'Finalidad y función', data: gastoFinalidad },
88 - { key: 'sectores', label: 'Sectores económicos', data: gastoSectores }
89 - ];
90 -
91 - // Clasificadores de ingreso
92 - const clasificadoresIngreso = [
93 - { key: 'rubros', label: 'Rubros de ingreso', data: ingresoRubros },
94 - { key: 'organismos', label: 'Organismos financiadores', data: ingresoOrganismos }
95 - ];
96 -
97 // Totales 110 // Totales
98 function getTotal(data) { 111 function getTotal(data) {
99 return data.reduce((sum, d) => sum + d.monto, 0); 112 return data.reduce((sum, d) => sum + d.monto, 0);
...@@ -129,7 +142,7 @@ ...@@ -129,7 +142,7 @@
129 // Renderizar barras D3 142 // Renderizar barras D3
130 function renderizarBarras(key, data) { 143 function renderizarBarras(key, data) {
131 const container = barContainers[key]; 144 const container = barContainers[key];
132 - if (!container) return; 145 + if (!container || data.length === 0) return;
133 146
134 const total = getTotal(data); 147 const total = getTotal(data);
135 const height = 48; 148 const height = 48;
...@@ -209,7 +222,7 @@ ...@@ -209,7 +222,7 @@
209 222
210 // Renderizar gráficos de historia 223 // Renderizar gráficos de historia
211 function renderizarHistoria() { 224 function renderizarHistoria() {
212 - if (chartIngresosContainer) { 225 + if (esEntidad && chartIngresosContainer && historiaIngresos.length > 0) {
213 chartIngresosContainer.innerHTML = ''; 226 chartIngresosContainer.innerHTML = '';
214 const plot = Plot.plot({ 227 const plot = Plot.plot({
215 width: chartIngresosContainer.clientWidth, 228 width: chartIngresosContainer.clientWidth,
...@@ -222,15 +235,15 @@ ...@@ -222,15 +235,15 @@
222 x: { label: null, tickFormat: d => d }, 235 x: { label: null, tickFormat: d => d },
223 y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true }, 236 y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
224 marks: [ 237 marks: [
225 - Plot.areaY(historiaIngresos, { x: 'gestion', y: 'monto', fill: '#4A8B6E', fillOpacity: 0.3 }), 238 + Plot.areaY(historiaIngresos, { x: 'gestion', y: 'devengado', fill: '#4A8B6E', fillOpacity: 0.3 }),
226 - Plot.lineY(historiaIngresos, { x: 'gestion', y: 'monto', stroke: '#4A8B6E', strokeWidth: 2 }), 239 + Plot.lineY(historiaIngresos, { x: 'gestion', y: 'devengado', stroke: '#4A8B6E', strokeWidth: 2 }),
227 - Plot.dot(historiaIngresos, { x: 'gestion', y: 'monto', fill: '#4A8B6E', r: 3 }) 240 + Plot.dot(historiaIngresos, { x: 'gestion', y: 'devengado', fill: '#4A8B6E', r: 3 })
228 ] 241 ]
229 }); 242 });
230 chartIngresosContainer.appendChild(plot); 243 chartIngresosContainer.appendChild(plot);
231 } 244 }
232 245
233 - if (chartGastosContainer) { 246 + if (chartGastosContainer && historiaGastos.length > 0) {
234 chartGastosContainer.innerHTML = ''; 247 chartGastosContainer.innerHTML = '';
235 const plot = Plot.plot({ 248 const plot = Plot.plot({
236 width: chartGastosContainer.clientWidth, 249 width: chartGastosContainer.clientWidth,
...@@ -243,17 +256,17 @@ ...@@ -243,17 +256,17 @@
243 x: { label: null, tickFormat: d => d }, 256 x: { label: null, tickFormat: d => d },
244 y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true }, 257 y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
245 marks: [ 258 marks: [
246 - Plot.areaY(historiaGastos, { x: 'gestion', y: 'monto', fill: '#C9A751', fillOpacity: 0.3 }), 259 + Plot.areaY(historiaGastos, { x: 'gestion', y: 'devengado', fill: '#C9A751', fillOpacity: 0.3 }),
247 - Plot.lineY(historiaGastos, { x: 'gestion', y: 'monto', stroke: '#C9A751', strokeWidth: 2 }), 260 + Plot.lineY(historiaGastos, { x: 'gestion', y: 'devengado', stroke: '#C9A751', strokeWidth: 2 }),
248 - Plot.dot(historiaGastos, { x: 'gestion', y: 'monto', fill: '#C9A751', r: 3 }) 261 + Plot.dot(historiaGastos, { x: 'gestion', y: 'devengado', fill: '#C9A751', r: 3 })
249 ] 262 ]
250 }); 263 });
251 chartGastosContainer.appendChild(plot); 264 chartGastosContainer.appendChild(plot);
252 } 265 }
253 } 266 }
254 267
255 - // Efectos 268 + // Renderizar todas las barras
256 - $effect(() => { 269 + function renderizarTodasLasBarras() {
257 setTimeout(() => { 270 setTimeout(() => {
258 clasificadoresGasto.forEach(({ key, data }) => { 271 clasificadoresGasto.forEach(({ key, data }) => {
259 if (barContainers[key]) renderizarBarras(key, data); 272 if (barContainers[key]) renderizarBarras(key, data);
...@@ -262,23 +275,65 @@ ...@@ -262,23 +275,65 @@
262 if (barContainers[key]) renderizarBarras(key, data); 275 if (barContainers[key]) renderizarBarras(key, data);
263 }); 276 });
264 }, 100); 277 }, 100);
278 + }
279 +
280 + // Efectos
281 + $effect(() => {
282 + // Trigger on data changes
283 + clasificadoresGasto;
284 + clasificadoresIngreso;
285 + renderizarTodasLasBarras();
265 }); 286 });
266 287
267 $effect(() => { 288 $effect(() => {
268 - if (chartIngresosContainer && chartGastosContainer) { 289 + if (chartGastosContainer) {
290 + // Trigger on history data changes
291 + historiaGastos;
292 + historiaIngresos;
293 + esEntidad;
269 setTimeout(renderizarHistoria, 150); 294 setTimeout(renderizarHistoria, 150);
270 } 295 }
271 }); 296 });
272 297
273 - onMount(() => { 298 + onMount(async () => {
299 + // Cargar CSVs
300 + const [resumenRaw, distRaw] = await Promise.all([
301 + fetch('/resumen.csv').then(r => r.text()),
302 + fetch('/distribuciones.csv').then(r => r.text())
303 + ]);
304 +
305 + resumenData = d3.csvParse(resumenRaw, d => ({
306 + tipo: d.tipo,
307 + codigo: d.codigo,
308 + gestion: +d.gestion,
309 + devengado: +d.devengado,
310 + ranking: +d.ranking,
311 + tipo_codigo: d.tipo_codigo
312 + }));
313 +
314 + distribucionesData = d3.csvParse(distRaw, d => ({
315 + tipo: d.tipo,
316 + codigo: d.codigo,
317 + dimension: d.dimension,
318 + gestion: +d.gestion,
319 + padre: d.padre,
320 + desc_padre: d.desc_padre,
321 + hijo: d.hijo,
322 + desc_hijo: d.desc_hijo,
323 + devengado: +d.devengado,
324 + tipo_codigo: d.tipo_codigo
325 + }));
326 +
327 + // Extraer codigos únicos
328 + codigosDisponibles = [...new Set(resumenData.map(d => d.codigo))].sort((a, b) => {
329 + return parseFloat(a) - parseFloat(b);
330 + });
331 +
332 + cargando = false;
333 +
274 // Observer para tema 334 // Observer para tema
275 const observer = new MutationObserver(() => { 335 const observer = new MutationObserver(() => {
276 - clasificadoresGasto.forEach(({ key, data }) => { 336 + renderizarTodasLasBarras();
277 - if (barContainers[key]) renderizarBarras(key, data);
278 - });
279 - clasificadoresIngreso.forEach(({ key, data }) => {
280 - if (barContainers[key]) renderizarBarras(key, data);
281 - });
282 }); 337 });
283 observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); 338 observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
284 339
...@@ -297,60 +352,80 @@ ...@@ -297,60 +352,80 @@
297 </script> 352 </script>
298 353
299 <svelte:head> 354 <svelte:head>
300 - <title>{entidad.nombre} - Radiografía</title> 355 + <title>Radiografía - {codigoSeleccionado}</title>
301 </svelte:head> 356 </svelte:head>
302 357
358 +{#if cargando}
359 + <div class="dashboard">
360 + <p>Cargando datos...</p>
361 + </div>
362 +{:else}
303 <div class="dashboard"> 363 <div class="dashboard">
304 - <!-- Header --> 364 + <!-- Header sticky -->
305 - <header class="dashboard-header"> 365 + <header class="sticky-header">
306 - <div class="header-info"> 366 + <div class="sticky-row">
307 - <h1 class="entidad-nombre">{entidad.nombre}</h1> 367 + <div class="sticky-left">
308 - <div class="entidad-meta"> 368 + <h1 class="entidad-nombre">Código: {codigoSeleccionado}</h1>
309 - <span class="entidad-sigla">{entidad.sigla}</span> 369 + <div class="entidad-meta">
310 - <span class="meta-sep">·</span> 370 + <span class="tipo-badge" class:da={!esEntidad}>{tipoCodigo}</span>
311 - <span class="entidad-sector">{entidad.sector}</span> 371 + <span class="entidad-sector">{esEntidad ? 'Gastos e Ingresos' : 'Solo Gastos'}</span>
372 + </div>
373 + </div>
374 + <div class="sticky-controls">
375 + <select class="control-select" bind:value={codigoSeleccionado}>
376 + {#each codigosDisponibles as cod}
377 + <option value={cod}>{cod}</option>
378 + {/each}
379 + </select>
380 + <select class="control-select" bind:value={gestionSeleccionada}>
381 + {#each gestiones() as g}
382 + <option value={g}>{g}</option>
383 + {/each}
384 + </select>
312 </div> 385 </div>
313 - </div>
314 - <div class="header-control">
315 - <label class="gestion-label">Gestión</label>
316 - <select class="gestion-select" bind:value={gestionSeleccionada}>
317 - {#each gestiones as g}
318 - <option value={g}>{g}</option>
319 - {/each}
320 - </select>
321 </div> 386 </div>
322 </header> 387 </header>
323 388
324 <!-- Ranking --> 389 <!-- Ranking -->
325 <div class="ranking-bar"> 390 <div class="ranking-bar">
326 - <div class="ranking-item"> 391 + {#if rankingGastos()}
327 - <span class="ranking-label">Ranking gastos</span> 392 + <div class="ranking-item">
328 - <span class="ranking-value">#{ranking.gastos.posicion}</span> 393 + <span class="ranking-label">Ranking gastos</span>
329 - <span class="ranking-total">de {ranking.gastos.total}</span> 394 + <span class="ranking-value">#{rankingGastos().posicion}</span>
330 - </div> 395 + <span class="ranking-total">de {rankingGastos().total}</span>
331 - <div class="ranking-sep"></div> 396 + </div>
332 - <div class="ranking-item"> 397 + {/if}
333 - <span class="ranking-label">Ranking ingresos</span> 398 + {#if esEntidad && rankingIngresos()}
334 - <span class="ranking-value">#{ranking.ingresos.posicion}</span> 399 + <div class="ranking-sep"></div>
335 - <span class="ranking-total">de {ranking.ingresos.total}</span> 400 + <div class="ranking-item">
336 - </div> 401 + <span class="ranking-label">Ranking ingresos</span>
402 + <span class="ranking-value">#{rankingIngresos().posicion}</span>
403 + <span class="ranking-total">de {rankingIngresos().total}</span>
404 + </div>
405 + {/if}
337 </div> 406 </div>
338 407
339 <!-- Historia temporal --> 408 <!-- Historia temporal -->
340 <section class="seccion"> 409 <section class="seccion">
341 - <h2 class="seccion-titulo">Historia 2005 – 2025</h2> 410 + <h2 class="seccion-titulo">{historiaGastos.length > 0 ? `${historiaGastos[0].gestion} – ${historiaGastos[historiaGastos.length - 1].gestion}` : ''}</h2>
342 - <div class="historia-grid"> 411 + <div class="historia-grid" class:single={!esEntidad}>
343 - <div class="historia-card"> 412 + {#if esEntidad}
344 - <div class="historia-header"> 413 + <div class="historia-card">
345 - <span class="historia-label">Ingresos</span> 414 + <div class="historia-header">
346 - <span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].monto)} Bs</span> 415 + <span class="historia-label">Ingresos</span>
416 + {#if historiaIngresos.length > 0}
417 + <span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].devengado)} Bs</span>
418 + {/if}
419 + </div>
420 + <div class="historia-chart" bind:this={chartIngresosContainer}></div>
347 </div> 421 </div>
348 - <div class="historia-chart" bind:this={chartIngresosContainer}></div> 422 + {/if}
349 - </div>
350 <div class="historia-card"> 423 <div class="historia-card">
351 <div class="historia-header"> 424 <div class="historia-header">
352 <span class="historia-label">Gastos</span> 425 <span class="historia-label">Gastos</span>
353 - <span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].monto)} Bs</span> 426 + {#if historiaGastos.length > 0}
427 + <span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].devengado)} Bs</span>
428 + {/if}
354 </div> 429 </div>
355 <div class="historia-chart" bind:this={chartGastosContainer}></div> 430 <div class="historia-chart" bind:this={chartGastosContainer}></div>
356 </div> 431 </div>
...@@ -358,49 +433,52 @@ ...@@ -358,49 +433,52 @@
358 </section> 433 </section>
359 434
360 <!-- Composición del gasto --> 435 <!-- Composición del gasto -->
361 - <section class="seccion"> 436 + {#if clasificadoresGasto.length > 0}
362 - <h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2> 437 + <section class="seccion">
363 - <div class="clasificadores-grid"> 438 + <h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
364 - {#each clasificadoresGasto as { key, label, data }} 439 + <div class="clasificadores-grid">
365 - {@const displayItem = getDisplayItem(key, data)} 440 + {#each clasificadoresGasto as { key, label, data }}
366 - {@const total = getTotal(data)} 441 + {@const displayItem = getDisplayItem(key, data)}
367 - <div class="clasificador-card"> 442 + <div class="clasificador-card">
368 - <div class="clasificador-header"> 443 + <div class="clasificador-header">
369 - <div class="clasificador-info"> 444 + <div class="clasificador-info">
370 - <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span> 445 + <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
371 - <span class="clasificador-nombre">{displayItem?.nombre || ''}</span> 446 + <span class="clasificador-padre">{displayItem?.padre || ''}</span>
372 - <span class="clasificador-padre">({displayItem?.padre || ''})</span> 447 + <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
448 + </div>
449 + <span class="clasificador-label">{label}</span>
373 </div> 450 </div>
374 - <span class="clasificador-label">{label}</span> 451 + <div class="barra-container" bind:this={barContainers[key]}></div>
375 </div> 452 </div>
376 - <div class="barra-container" bind:this={barContainers[key]}></div> 453 + {/each}
377 - </div> 454 + </div>
378 - {/each} 455 + </section>
379 - </div> 456 + {/if}
380 - </section> 457 +
381 - 458 + <!-- Origen del dinero (solo entidad) -->
382 - <!-- Origen del dinero --> 459 + {#if esEntidad && clasificadoresIngreso.length > 0}
383 - <section class="seccion"> 460 + <section class="seccion">
384 - <h2 class="seccion-titulo">¿De dónde viene la plata? <span class="gestion-badge">{gestionSeleccionada}</span></h2> 461 + <h2 class="seccion-titulo">Fuentes de ingreso y organismos financiadores <span class="gestion-badge">{gestionSeleccionada}</span></h2>
385 - <div class="clasificadores-grid"> 462 + <div class="clasificadores-grid">
386 - {#each clasificadoresIngreso as { key, label, data }} 463 + {#each clasificadoresIngreso as { key, label, data }}
387 - {@const displayItem = getDisplayItem(key, data)} 464 + {@const displayItem = getDisplayItem(key, data)}
388 - {@const total = getTotal(data)} 465 + <div class="clasificador-card">
389 - <div class="clasificador-card"> 466 + <div class="clasificador-header">
390 - <div class="clasificador-header"> 467 + <div class="clasificador-info">
391 - <div class="clasificador-info"> 468 + <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
392 - <span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span> 469 + <span class="clasificador-padre">{displayItem?.padre || ''}</span>
393 - <span class="clasificador-nombre">{displayItem?.nombre || ''}</span> 470 + <span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
394 - <span class="clasificador-padre">({displayItem?.padre || ''})</span> 471 + </div>
472 + <span class="clasificador-label">{label}</span>
395 </div> 473 </div>
396 - <span class="clasificador-label">{label}</span> 474 + <div class="barra-container" bind:this={barContainers[key]}></div>
397 </div> 475 </div>
398 - <div class="barra-container" bind:this={barContainers[key]}></div> 476 + {/each}
399 - </div> 477 + </div>
400 - {/each} 478 + </section>
401 - </div> 479 + {/if}
402 - </section>
403 </div> 480 </div>
481 +{/if}
404 482
405 <style> 483 <style>
406 .dashboard { 484 .dashboard {
...@@ -408,70 +486,99 @@ ...@@ -408,70 +486,99 @@
408 background: var(--theme-fondo); 486 background: var(--theme-fondo);
409 color: var(--theme-texto); 487 color: var(--theme-texto);
410 padding: 2rem; 488 padding: 2rem;
489 + padding-top: 2rem;
411 font-family: var(--font-sans); 490 font-family: var(--font-sans);
412 } 491 }
413 492
414 - /* Header */ 493 + /* Sticky header */
415 - .dashboard-header { 494 + .sticky-header {
416 - display: flex; 495 + position: sticky;
417 - justify-content: space-between; 496 + top: 0;
418 - align-items: flex-start; 497 + z-index: 50;
419 - gap: 2rem; 498 + padding: 0.75rem 2rem;
420 - margin-bottom: 1.5rem; 499 + margin: 0 -2rem 1.5rem -2rem;
421 - padding-bottom: 1.5rem; 500 + background: color-mix(in srgb, var(--theme-fondo) 90%, transparent);
422 border-bottom: 1px solid var(--theme-borde); 501 border-bottom: 1px solid var(--theme-borde);
502 + backdrop-filter: blur(12px);
503 + -webkit-backdrop-filter: blur(12px);
504 + }
505 +
506 + .sticky-row {
507 + display: flex;
508 + align-items: center;
509 + gap: 1rem;
510 + max-width: calc(100% - 220px); /* dejar espacio para el navbar fixed */
511 + }
512 +
513 + .sticky-left {
514 + display: flex;
515 + align-items: baseline;
516 + gap: 0.75rem;
517 + min-width: 0;
423 } 518 }
424 519
425 .entidad-nombre { 520 .entidad-nombre {
426 - font-size: 1.75rem; 521 + font-size: 1.1rem;
427 font-weight: 700; 522 font-weight: 700;
428 color: var(--theme-titulo); 523 color: var(--theme-titulo);
429 - margin: 0 0 0.5rem 0; 524 + margin: 0;
430 line-height: 1.2; 525 line-height: 1.2;
526 + white-space: nowrap;
527 + overflow: hidden;
528 + text-overflow: ellipsis;
431 } 529 }
432 530
433 .entidad-meta { 531 .entidad-meta {
434 display: flex; 532 display: flex;
435 align-items: center; 533 align-items: center;
436 - gap: 0.5rem; 534 + gap: 0.4rem;
437 - font-size: 0.9rem; 535 + flex-shrink: 0;
536 + }
537 +
538 + .entidad-sector {
539 + font-size: 0.7rem;
438 color: var(--theme-texto); 540 color: var(--theme-texto);
439 - opacity: 0.7; 541 + opacity: 0.6;
440 } 542 }
441 543
442 - .entidad-sigla { 544 + .tipo-badge {
545 + font-size: 0.6rem;
443 font-weight: 600; 546 font-weight: 600;
444 - color: var(--theme-titulo); 547 + padding: 0.15rem 0.45rem;
445 - opacity: 1; 548 + background: rgba(74, 139, 110, 0.15);
549 + color: #4A8B6E;
550 + border-radius: 4px;
551 + text-transform: uppercase;
552 + letter-spacing: 0.03em;
446 } 553 }
447 554
448 - .meta-sep { 555 + .tipo-badge.da {
449 - opacity: 0.4; 556 + background: rgba(201, 167, 81, 0.15);
557 + color: #C9A751;
450 } 558 }
451 559
452 - .header-control { 560 + .sticky-controls {
453 display: flex; 561 display: flex;
454 - flex-direction: column; 562 + align-items: center;
455 - gap: 0.25rem; 563 + gap: 0.4rem;
456 - } 564 + flex-shrink: 0;
457 -
458 - .gestion-label {
459 - font-size: 0.7rem;
460 - text-transform: uppercase;
461 - letter-spacing: 0.05em;
462 - color: var(--theme-texto);
463 - opacity: 0.6;
464 } 565 }
465 566
466 - .gestion-select { 567 + .control-select {
467 - padding: 0.5rem 1rem; 568 + padding: 0.35rem 0.6rem;
468 - font-size: 0.95rem; 569 + font-size: 0.8rem;
469 font-weight: 600; 570 font-weight: 600;
470 background: var(--theme-tarjeta); 571 background: var(--theme-tarjeta);
471 border: 1px solid var(--theme-borde); 572 border: 1px solid var(--theme-borde);
472 - border-radius: 8px; 573 + border-radius: 6px;
473 color: var(--theme-titulo); 574 color: var(--theme-titulo);
474 cursor: pointer; 575 cursor: pointer;
576 + appearance: none;
577 + -webkit-appearance: none;
578 + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
579 + background-repeat: no-repeat;
580 + background-position: right 0.4rem center;
581 + padding-right: 1.5rem;
475 } 582 }
476 583
477 /* Ranking */ 584 /* Ranking */
...@@ -546,6 +653,10 @@ ...@@ -546,6 +653,10 @@
546 gap: 1.5rem; 653 gap: 1.5rem;
547 } 654 }
548 655
656 + .historia-grid.single {
657 + grid-template-columns: 1fr;
658 + }
659 +
549 .historia-card { 660 .historia-card {
550 background: var(--theme-tarjeta); 661 background: var(--theme-tarjeta);
551 border-radius: 12px; 662 border-radius: 12px;
...@@ -616,18 +727,19 @@ ...@@ -616,18 +727,19 @@
616 color: var(--theme-titulo); 727 color: var(--theme-titulo);
617 } 728 }
618 729
730 + .clasificador-padre {
731 + font-size: 0.8rem;
732 + font-weight: 400;
733 + color: var(--theme-texto);
734 + opacity: 0.5;
735 + }
736 +
619 .clasificador-nombre { 737 .clasificador-nombre {
620 font-size: 0.85rem; 738 font-size: 0.85rem;
621 font-weight: 500; 739 font-weight: 500;
622 color: var(--theme-titulo); 740 color: var(--theme-titulo);
623 } 741 }
624 742
625 - .clasificador-padre {
626 - font-size: 0.7rem;
627 - color: var(--theme-texto);
628 - opacity: 0.5;
629 - }
630 -
631 .clasificador-label { 743 .clasificador-label {
632 font-size: 0.65rem; 744 font-size: 0.65rem;
633 text-transform: uppercase; 745 text-transform: uppercase;
...@@ -650,13 +762,33 @@ ...@@ -650,13 +762,33 @@
650 padding: 1rem; 762 padding: 1rem;
651 } 763 }
652 764
653 - .dashboard-header { 765 + .sticky-header {
766 + margin: 0 -1rem 1rem -1rem;
767 + padding: 0.5rem 1rem;
768 + }
769 +
770 + .sticky-row {
771 + flex-direction: column;
772 + align-items: flex-start;
773 + gap: 0.5rem;
774 + max-width: calc(100% - 160px);
775 + }
776 +
777 + .sticky-left {
654 flex-direction: column; 778 flex-direction: column;
655 - gap: 1rem; 779 + gap: 0.25rem;
656 } 780 }
657 781
658 .entidad-nombre { 782 .entidad-nombre {
659 - font-size: 1.35rem; 783 + font-size: 0.95rem;
784 + }
785 +
786 + .sticky-controls {
787 + width: 100%;
788 + }
789 +
790 + .control-select {
791 + flex: 1;
660 } 792 }
661 793
662 .historia-grid { 794 .historia-grid {
......
...@@ -377,6 +377,70 @@ Sector → Subsector → Área → Subárea → Entidad ...@@ -377,6 +377,70 @@ Sector → Subsector → Área → Subárea → Entidad
377 377
378 --- 378 ---
379 379
380 +## Tablas de Vista de Entidad
380 381
382 +### 13. `entidad_resumen` - Resumen histórico por entidad/DA
383 +
384 +Alimenta los gráficos de historia (area plots) y rankings en la vista de entidad (`/entidad/[codigo]`).
385 +
386 +| Columna | Tipo | Descripción |
387 +|---------|------|-------------|
388 +| tipo | TEXT | Tipo de flujo: 'gastos' o 'ingresos' |
389 +| tipo_codigo | TEXT | Tipo de código: 'entidad', 'entidad_da', 'municipio_ubigeo', etc. |
390 +| codigo | TEXT | Código de la entidad o DA (ej: "1901", "1901.6") |
391 +| desc | TEXT | Nombre de la entidad o DA |
392 +| desc_padre | TEXT | Nombre de la entidad madre (para DAs) |
393 +| gestion | INTEGER | Año fiscal |
394 +| devengado | NUMERIC | Monto devengado en Bs |
395 +| ranking | INTEGER | Posición en ranking por monto dentro de su tipo y gestión |
396 +
397 +**Primary key:** `(tipo, tipo_codigo, codigo, gestion)`
398 +
399 +**Índices:**
400 +```sql
401 +CREATE INDEX idx_entidad_resumen_codigo ON ppto.entidad_resumen (codigo);
402 +CREATE INDEX idx_entidad_resumen_gestion ON ppto.entidad_resumen (gestion);
403 +CREATE INDEX idx_entidad_resumen_tipo_codigo ON ppto.entidad_resumen (tipo_codigo);
404 +```
405 +
406 +**Notas:**
407 +- ~53K filas
408 +- `codigo` es TEXT para soportar entidades ("1901") y DAs ("1901.6")
409 +- `desc` y `desc_padre` permiten resolver nombres sin joins ni búsquedas externas
410 +- Una fila por combinación tipo + tipo_codigo + código + año
411 +
412 +---
413 +
414 +### 14. `entidad_distribuciones` - Distribución del gasto/ingreso por clasificador
415 +
416 +Alimenta las barras de composición (clasificadores de gasto e ingreso) en la vista de entidad.
417 +
418 +| Columna | Tipo | Descripción |
419 +|---------|------|-------------|
420 +| id | SERIAL (PK) | Identificador autoincremental |
421 +| tipo | TEXT | Tipo de flujo: 'gastos' o 'ingresos' |
422 +| tipo_codigo | TEXT | Tipo de código: 'entidad', 'entidad_da', etc. |
423 +| codigo | TEXT | Código de la entidad o DA |
424 +| dimension | TEXT | Clasificador: 'objeto', 'finfun', 'acteco', 'rubro', 'organismo' |
425 +| gestion | INTEGER | Año fiscal |
426 +| padre | TEXT | Código del grupo padre en la dimensión |
427 +| desc_padre | TEXT | Descripción del grupo padre |
428 +| hijo | TEXT | Código del ítem hijo |
429 +| desc_hijo | TEXT | Descripción del ítem hijo |
430 +| devengado | NUMERIC | Monto devengado en Bs |
431 +
432 +**Índices:**
433 +```sql
434 +CREATE INDEX idx_entidad_dist_codigo ON ppto.entidad_distribuciones (codigo);
435 +CREATE INDEX idx_entidad_dist_gestion ON ppto.entidad_distribuciones (gestion);
436 +CREATE INDEX idx_entidad_dist_codigo_gestion ON ppto.entidad_distribuciones (codigo, gestion);
437 +```
438 +
439 +**Notas:**
440 +- ~3.6M filas
441 +- Usa `id SERIAL` como PK porque un mismo `hijo` puede tener distintas descripciones dentro de la misma combinación padre/gestión
442 +- Cada fila es un segmento de las barras de composición: cuánto gastó/ingresó una entidad en un ítem específico de un clasificador en un año
443 +
444 +---
381 445
382 *Documento generado para el equipo de sistemas - Marzo 2026* 446 *Documento generado para el equipo de sistemas - Marzo 2026*
......