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('');
This diff is collapsed. Click to expand it.
...@@ -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 }
......
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
...@@ -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*
......