Showing
16 changed files
with
421 additions
and
152 deletions
| ... | @@ -36,9 +36,16 @@ | ... | @@ -36,9 +36,16 @@ |
| 36 | let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50)); | 36 | let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50)); |
| 37 | 37 | ||
| 38 | // Máximo del eje Y (con guard para data vacía o valores undefined) | 38 | // Máximo del eje Y (con guard para data vacía o valores undefined) |
| 39 | - let maxPerCapita = $derived( | 39 | + let maxPerCapita = $derived.by(() => { |
| 40 | - data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 1) : 1 | 40 | + if (data.length === 0) return 1; |
| 41 | - ); | 41 | + const vals = data.map(d => typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0); |
| 42 | + const max = Math.max(...vals); | ||
| 43 | + const min = Math.min(...vals.filter(v => v > 0)); | ||
| 44 | + // Si max es 0, usar 1 como fallback | ||
| 45 | + if (max <= 0) return 1; | ||
| 46 | + // Agregar 10% de margen arriba para que la barra más alta no toque el techo | ||
| 47 | + return max * 1.1; | ||
| 48 | + }); | ||
| 42 | 49 | ||
| 43 | // Escalas D3 | 50 | // Escalas D3 |
| 44 | let xScale = $derived( | 51 | let xScale = $derived( |
| ... | @@ -92,7 +99,8 @@ | ... | @@ -92,7 +99,8 @@ |
| 92 | <!-- Barras --> | 99 | <!-- Barras --> |
| 93 | <div class="bars" onmouseleave={() => hoveredYear = null} role="group"> | 100 | <div class="bars" onmouseleave={() => hoveredYear = null} role="group"> |
| 94 | {#each data as d} | 101 | {#each data as d} |
| 95 | - {@const barPct = (yScale(d.perCapita) / chartHeight) * 100} | 102 | + {@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0} |
| 103 | + {@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0} | ||
| 96 | <div | 104 | <div |
| 97 | class="bar-container" | 105 | class="bar-container" |
| 98 | onmouseenter={() => hoveredYear = d.año} | 106 | onmouseenter={() => hoveredYear = d.año} |
| ... | @@ -102,7 +110,7 @@ | ... | @@ -102,7 +110,7 @@ |
| 102 | <div | 110 | <div |
| 103 | class="bar" | 111 | class="bar" |
| 104 | class:hovered={hoveredYear === d.año} | 112 | class:hovered={hoveredYear === d.año} |
| 105 | - style="height: {barPct}%" | 113 | + style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}" |
| 106 | ></div> | 114 | ></div> |
| 107 | </div> | 115 | </div> |
| 108 | {/each} | 116 | {/each} | ... | ... |
| ... | @@ -109,7 +109,7 @@ | ... | @@ -109,7 +109,7 @@ |
| 109 | onkeydown={handleSearchKeydown} | 109 | onkeydown={handleSearchKeydown} |
| 110 | onfocus={() => searchFocused = true} | 110 | onfocus={() => searchFocused = true} |
| 111 | onblur={handleSearchBlur} | 111 | onblur={handleSearchBlur} |
| 112 | - placeholder="Buscar objeto..." | 112 | + placeholder="Buscar otro gasto..." |
| 113 | /> | 113 | /> |
| 114 | {#if searchVal} | 114 | {#if searchVal} |
| 115 | <button class="search-clear" onclick={handleClear}> | 115 | <button class="search-clear" onclick={handleClear}> | ... | ... |
| ... | @@ -108,7 +108,19 @@ | ... | @@ -108,7 +108,19 @@ |
| 108 | codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`; | 108 | codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`; |
| 109 | } | 109 | } |
| 110 | 110 | ||
| 111 | - return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion }; | 111 | + // Contexto extra para programas |
| 112 | + let contexto = ''; | ||
| 113 | + if (!isClass) { | ||
| 114 | + const ents = meta.entidades || []; | ||
| 115 | + const entNombre = ents.length === 1 ? ents[0].entidad_desc_entidad : ents.length > 1 ? `${ents.length} entidades` : ''; | ||
| 116 | + const prog = meta.programa_desc || ''; | ||
| 117 | + if (entNombre && prog) contexto = `${entNombre} · Programa: ${prog}`; | ||
| 118 | + else if (entNombre) contexto = entNombre; | ||
| 119 | + else if (prog) contexto = `Programa: ${prog}`; | ||
| 120 | + console.log('[SEARCH] programa contexto:', { isClass, tipo, nombre, entNombre, prog, contexto, metaKeys: Object.keys(meta) }); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion, contexto }; | ||
| 112 | }); | 124 | }); |
| 113 | selectedIdx = -1; | 125 | selectedIdx = -1; |
| 114 | } catch { | 126 | } catch { |
| ... | @@ -184,7 +196,6 @@ | ... | @@ -184,7 +196,6 @@ |
| 184 | } | 196 | } |
| 185 | 197 | ||
| 186 | function goToResult(item) { | 198 | function goToResult(item) { |
| 187 | - closeModal(); | ||
| 188 | const routes = { | 199 | const routes = { |
| 189 | entidad: '/entidad/', | 200 | entidad: '/entidad/', |
| 190 | objeto_gasto: '/objeto/', | 201 | objeto_gasto: '/objeto/', |
| ... | @@ -196,7 +207,12 @@ | ... | @@ -196,7 +207,12 @@ |
| 196 | programa: '/proyecto/' | 207 | programa: '/proyecto/' |
| 197 | }; | 208 | }; |
| 198 | const base = routes[item.tipo]; | 209 | const base = routes[item.tipo]; |
| 199 | - if (base) goto(`${base}${item.codigo}`); | 210 | + if (base) { |
| 211 | + const url = `${base}${item.codigo}`; | ||
| 212 | + console.log('[SEARCH] goToResult:', item.tipo, item.codigo, '→', url); | ||
| 213 | + open = false; | ||
| 214 | + window.location.href = url; | ||
| 215 | + } | ||
| 200 | } | 216 | } |
| 201 | 217 | ||
| 202 | function handleBackdropClick(e) { | 218 | function handleBackdropClick(e) { |
| ... | @@ -319,6 +335,9 @@ | ... | @@ -319,6 +335,9 @@ |
| 319 | {typeConfig[item.tipo]?.label || item.tipo} | 335 | {typeConfig[item.tipo]?.label || item.tipo} |
| 320 | </span> | 336 | </span> |
| 321 | <span class="result-name">{@html item.highlight}</span> | 337 | <span class="result-name">{@html item.highlight}</span> |
| 338 | + {#if item.contexto} | ||
| 339 | + <span class="result-contexto">{item.contexto}</span> | ||
| 340 | + {/if} | ||
| 322 | </div> | 341 | </div> |
| 323 | <span class="result-code">{item.codigo}</span> | 342 | <span class="result-code">{item.codigo}</span> |
| 324 | </button> | 343 | </button> |
| ... | @@ -646,6 +665,15 @@ | ... | @@ -646,6 +665,15 @@ |
| 646 | text-overflow: ellipsis; | 665 | text-overflow: ellipsis; |
| 647 | } | 666 | } |
| 648 | 667 | ||
| 668 | + .result-contexto { | ||
| 669 | + font-size: 0.75rem; | ||
| 670 | + color: var(--theme-texto); | ||
| 671 | + opacity: 0.6; | ||
| 672 | + white-space: nowrap; | ||
| 673 | + overflow: hidden; | ||
| 674 | + text-overflow: ellipsis; | ||
| 675 | + } | ||
| 676 | + | ||
| 649 | .result-desc { | 677 | .result-desc { |
| 650 | font-size: 0.8125rem; | 678 | font-size: 0.8125rem; |
| 651 | color: var(--theme-texto); | 679 | color: var(--theme-texto); | ... | ... |
| ... | @@ -1379,9 +1379,16 @@ | ... | @@ -1379,9 +1379,16 @@ |
| 1379 | </div> | 1379 | </div> |
| 1380 | <div class="result-meta"> | 1380 | <div class="result-meta"> |
| 1381 | <span class="result-monto">{formatMonto(hit.document.devengado)}</span> | 1381 | <span class="result-monto">{formatMonto(hit.document.devengado)}</span> |
| 1382 | - {#if meta.entidad_desc_entidad} | 1382 | + {#if meta.entidades?.length === 1} |
| 1383 | <span class="result-sep">·</span> | 1383 | <span class="result-sep">·</span> |
| 1384 | - <span class="result-entity">{meta.entidad_desc_entidad}</span> | 1384 | + <span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span> |
| 1385 | + {:else if meta.entidades?.length > 1} | ||
| 1386 | + <span class="result-sep">·</span> | ||
| 1387 | + <span class="result-entity">{meta.entidades.length} entidades</span> | ||
| 1388 | + {/if} | ||
| 1389 | + {#if meta.programa_desc} | ||
| 1390 | + <span class="result-sep">·</span> | ||
| 1391 | + <span class="result-entity">{meta.programa_desc}</span> | ||
| 1385 | {/if} | 1392 | {/if} |
| 1386 | </div> | 1393 | </div> |
| 1387 | </div> | 1394 | </div> |
| ... | @@ -1443,10 +1450,20 @@ | ... | @@ -1443,10 +1450,20 @@ |
| 1443 | <span class="result-year">{formatYears(meta.gestion)}</span> | 1450 | <span class="result-year">{formatYears(meta.gestion)}</span> |
| 1444 | <span class="result-sep">·</span> | 1451 | <span class="result-sep">·</span> |
| 1445 | <span class="result-monto">{formatMonto(hit.document.devengado)}</span> | 1452 | <span class="result-monto">{formatMonto(hit.document.devengado)}</span> |
| 1446 | - {#if meta.entidad_desc_entidad} | 1453 | + {#if meta.entidades?.length === 1} |
| 1454 | + <span class="result-sep">·</span> | ||
| 1455 | + <span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span> | ||
| 1456 | + {:else if meta.entidades?.length > 1} | ||
| 1457 | + <span class="result-sep">·</span> | ||
| 1458 | + <span class="result-entity">{meta.entidades.length} entidades</span> | ||
| 1459 | + {:else if meta.entidad_desc_entidad} | ||
| 1447 | <span class="result-sep">·</span> | 1460 | <span class="result-sep">·</span> |
| 1448 | <span class="result-entity">{meta.entidad_desc_entidad}</span> | 1461 | <span class="result-entity">{meta.entidad_desc_entidad}</span> |
| 1449 | {/if} | 1462 | {/if} |
| 1463 | + {#if meta.programa_desc} | ||
| 1464 | + <span class="result-sep">·</span> | ||
| 1465 | + <span class="result-entity">Programa: {meta.programa_desc}</span> | ||
| 1466 | + {/if} | ||
| 1450 | {/if} | 1467 | {/if} |
| 1451 | {#if isEntidadClass || isClassResult} | 1468 | {#if isEntidadClass || isClassResult} |
| 1452 | <span class="result-monto">{formatMonto(hit.document.devengado)}</span> | 1469 | <span class="result-monto">{formatMonto(hit.document.devengado)}</span> | ... | ... |
| 1 | +const API_BASE = 'http://136.112.29.74/api/entidad/clasificador'; | ||
| 2 | + | ||
| 3 | +export async function GET() { | ||
| 4 | + try { | ||
| 5 | + const response = await fetch(API_BASE); | ||
| 6 | + if (!response.ok) { | ||
| 7 | + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), { | ||
| 8 | + status: response.status, headers: { 'Content-Type': 'application/json' } | ||
| 9 | + }); | ||
| 10 | + } | ||
| 11 | + const data = await response.json(); | ||
| 12 | + return new Response(JSON.stringify(data), { | ||
| 13 | + headers: { 'Content-Type': 'application/json' } | ||
| 14 | + }); | ||
| 15 | + } catch { | ||
| 16 | + return new Response(JSON.stringify({ error: 'Failed to fetch' }), { | ||
| 17 | + status: 500, headers: { 'Content-Type': 'application/json' } | ||
| 18 | + }); | ||
| 19 | + } | ||
| 20 | +} |
| 1 | +const API_BASE = 'http://136.112.29.74/api/objeto/clasificador'; | ||
| 2 | + | ||
| 3 | +export async function GET() { | ||
| 4 | + try { | ||
| 5 | + const response = await fetch(API_BASE); | ||
| 6 | + if (!response.ok) { | ||
| 7 | + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), { | ||
| 8 | + status: response.status, headers: { 'Content-Type': 'application/json' } | ||
| 9 | + }); | ||
| 10 | + } | ||
| 11 | + const data = await response.json(); | ||
| 12 | + return new Response(JSON.stringify(data), { | ||
| 13 | + headers: { 'Content-Type': 'application/json' } | ||
| 14 | + }); | ||
| 15 | + } catch { | ||
| 16 | + return new Response(JSON.stringify({ error: 'Failed to fetch' }), { | ||
| 17 | + status: 500, headers: { 'Content-Type': 'application/json' } | ||
| 18 | + }); | ||
| 19 | + } | ||
| 20 | +} |
src/routes/api/objeto-data/+server.js
0 → 100644
| 1 | +const API_BASE = 'http://136.112.29.74/api/objeto'; | ||
| 2 | + | ||
| 3 | +export async function GET({ url }) { | ||
| 4 | + const codigo = url.searchParams.get('codigo') || ''; | ||
| 5 | + const tipo = url.searchParams.get('tipo') || 'clasificador'; | ||
| 6 | + const entidad = url.searchParams.get('entidad') || ''; | ||
| 7 | + const gestion = url.searchParams.get('gestion') || ''; | ||
| 8 | + | ||
| 9 | + if (!codigo) { | ||
| 10 | + return new Response(JSON.stringify({ error: 'Missing codigo' }), { | ||
| 11 | + status: 400, headers: { 'Content-Type': 'application/json' } | ||
| 12 | + }); | ||
| 13 | + } | ||
| 14 | + | ||
| 15 | + let apiUrl; | ||
| 16 | + if (tipo === 'clasificador') { | ||
| 17 | + apiUrl = `${API_BASE}/${codigo}`; | ||
| 18 | + } else if (tipo === 'entidades-lista') { | ||
| 19 | + apiUrl = `${API_BASE}/${codigo}/entidades/map`; | ||
| 20 | + } else if (tipo === 'entidad' && entidad) { | ||
| 21 | + apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`; | ||
| 22 | + } else if (tipo === 'entidades-año' && gestion) { | ||
| 23 | + apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`; | ||
| 24 | + } else { | ||
| 25 | + return new Response(JSON.stringify({ error: 'Invalid tipo' }), { | ||
| 26 | + status: 400, headers: { 'Content-Type': 'application/json' } | ||
| 27 | + }); | ||
| 28 | + } | ||
| 29 | + | ||
| 30 | + try { | ||
| 31 | + const response = await fetch(apiUrl); | ||
| 32 | + if (!response.ok) { | ||
| 33 | + return new Response(JSON.stringify({ error: `API error: ${response.status}` }), { | ||
| 34 | + status: response.status, headers: { 'Content-Type': 'application/json' } | ||
| 35 | + }); | ||
| 36 | + } | ||
| 37 | + const data = await response.json(); | ||
| 38 | + return new Response(JSON.stringify(data), { | ||
| 39 | + headers: { 'Content-Type': 'application/json' } | ||
| 40 | + }); | ||
| 41 | + } catch (err) { | ||
| 42 | + return new Response(JSON.stringify({ error: 'Failed to fetch' }), { | ||
| 43 | + status: 500, headers: { 'Content-Type': 'application/json' } | ||
| 44 | + }); | ||
| 45 | + } | ||
| 46 | +} |
| 1 | +const API_BASE = 'http://136.112.29.74/api/entidad/clasificador'; | ||
| 2 | + | ||
| 3 | +export async function GET() { | ||
| 4 | + try { | ||
| 5 | + const res = await fetch(API_BASE); | ||
| 6 | + if (!res.ok) throw new Error(); | ||
| 7 | + const data = await res.json(); | ||
| 8 | + // Mapear al formato que espera el treemap | ||
| 9 | + const mapped = data.map(d => ({ | ||
| 10 | + entidad: d.entidad, | ||
| 11 | + desc_entidad: d.desc_entidad, | ||
| 12 | + sigla_entidad: d.sigla_entidad || '' | ||
| 13 | + })); | ||
| 14 | + return new Response(JSON.stringify(mapped), { | ||
| 15 | + headers: { 'Content-Type': 'application/json' } | ||
| 16 | + }); | ||
| 17 | + } catch { | ||
| 18 | + return new Response(JSON.stringify({ error: 'Failed to fetch' }), { | ||
| 19 | + status: 500, headers: { 'Content-Type': 'application/json' } | ||
| 20 | + }); | ||
| 21 | + } | ||
| 22 | +} |
src/routes/api/objeto-treemap/+server.js
0 → 100644
| 1 | +const API_BASE = 'http://136.112.29.74/api/objeto'; | ||
| 2 | + | ||
| 3 | +// Cache en memoria del servidor | ||
| 4 | +let cachedClasificador = null; | ||
| 5 | +let cachedEstados = null; // Map: objeto → estados[] | ||
| 6 | +let cachedTimestamp = 0; | ||
| 7 | +const CACHE_TTL = 5 * 60 * 1000; // 5 minutos | ||
| 8 | + | ||
| 9 | +async function loadAndCacheAll() { | ||
| 10 | + const now = Date.now(); | ||
| 11 | + if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return; | ||
| 12 | + | ||
| 13 | + // 1. Cargar clasificador | ||
| 14 | + const clasRes = await fetch(`${API_BASE}/clasificador`); | ||
| 15 | + if (!clasRes.ok) throw new Error('Failed to fetch clasificador'); | ||
| 16 | + cachedClasificador = await clasRes.json(); | ||
| 17 | + | ||
| 18 | + // 2. Cargar todos los objetos en paralelo (lotes de 80) | ||
| 19 | + const estados = new Map(); | ||
| 20 | + const batchSize = 80; | ||
| 21 | + | ||
| 22 | + for (let i = 0; i < cachedClasificador.length; i += batchSize) { | ||
| 23 | + const batch = cachedClasificador.slice(i, i + batchSize); | ||
| 24 | + const results = await Promise.all(batch.map(async (item) => { | ||
| 25 | + try { | ||
| 26 | + const res = await fetch(`${API_BASE}/${item.objeto}`); | ||
| 27 | + if (!res.ok) return null; | ||
| 28 | + const data = await res.json(); | ||
| 29 | + return { objeto: item.objeto, estados: data.estados || [] }; | ||
| 30 | + } catch { return null; } | ||
| 31 | + })); | ||
| 32 | + results.filter(Boolean).forEach(r => estados.set(r.objeto, r.estados)); | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + cachedEstados = estados; | ||
| 36 | + cachedTimestamp = now; | ||
| 37 | +} | ||
| 38 | + | ||
| 39 | +export async function GET({ url }) { | ||
| 40 | + const gestion = parseInt(url.searchParams.get('gestion') || '2025'); | ||
| 41 | + const entidad = url.searchParams.get('entidad') || '0'; | ||
| 42 | + | ||
| 43 | + try { | ||
| 44 | + if (entidad === '0') { | ||
| 45 | + // Todo el estado — usar cache | ||
| 46 | + await loadAndCacheAll(); | ||
| 47 | + | ||
| 48 | + const results = []; | ||
| 49 | + for (const item of cachedClasificador) { | ||
| 50 | + const estados = cachedEstados.get(item.objeto); | ||
| 51 | + if (!estados) continue; | ||
| 52 | + const yearData = estados.find(d => d.gestion === gestion); | ||
| 53 | + if (!yearData || !yearData.total) continue; | ||
| 54 | + results.push({ | ||
| 55 | + gestion, | ||
| 56 | + nivel: item.nivel, | ||
| 57 | + objeto: item.objeto, | ||
| 58 | + desc_objeto: item.desc_objeto, | ||
| 59 | + parent: getParent(item.objeto, item.nivel), | ||
| 60 | + devengado: yearData.total | ||
| 61 | + }); | ||
| 62 | + } | ||
| 63 | + | ||
| 64 | + return new Response(JSON.stringify(results), { | ||
| 65 | + headers: { 'Content-Type': 'application/json' } | ||
| 66 | + }); | ||
| 67 | + } else { | ||
| 68 | + // Entidad específica — cargar por entidad (no cacheable eficientemente) | ||
| 69 | + if (!cachedClasificador) { | ||
| 70 | + const clasRes = await fetch(`${API_BASE}/clasificador`); | ||
| 71 | + cachedClasificador = await clasRes.json(); | ||
| 72 | + } | ||
| 73 | + | ||
| 74 | + const results = []; | ||
| 75 | + const batchSize = 80; | ||
| 76 | + | ||
| 77 | + for (let i = 0; i < cachedClasificador.length; i += batchSize) { | ||
| 78 | + const batch = cachedClasificador.slice(i, i + batchSize); | ||
| 79 | + const batchResults = await Promise.all(batch.map(async (item) => { | ||
| 80 | + try { | ||
| 81 | + const res = await fetch(`${API_BASE}/${item.objeto}/entidades/${entidad}`); | ||
| 82 | + if (!res.ok) return null; | ||
| 83 | + const data = await res.json(); | ||
| 84 | + const yearData = data.find(d => d.gestion === gestion); | ||
| 85 | + if (!yearData || !yearData.monto) return null; | ||
| 86 | + return { | ||
| 87 | + gestion, | ||
| 88 | + nivel: item.nivel, | ||
| 89 | + objeto: item.objeto, | ||
| 90 | + desc_objeto: item.desc_objeto, | ||
| 91 | + parent: getParent(item.objeto, item.nivel), | ||
| 92 | + devengado: yearData.monto | ||
| 93 | + }; | ||
| 94 | + } catch { return null; } | ||
| 95 | + })); | ||
| 96 | + results.push(...batchResults.filter(Boolean)); | ||
| 97 | + } | ||
| 98 | + | ||
| 99 | + return new Response(JSON.stringify(results), { | ||
| 100 | + headers: { 'Content-Type': 'application/json' } | ||
| 101 | + }); | ||
| 102 | + } | ||
| 103 | + } catch (err) { | ||
| 104 | + return new Response(JSON.stringify({ error: 'Failed to build treemap' }), { | ||
| 105 | + status: 500, headers: { 'Content-Type': 'application/json' } | ||
| 106 | + }); | ||
| 107 | + } | ||
| 108 | +} | ||
| 109 | + | ||
| 110 | +function getParent(objeto, nivel) { | ||
| 111 | + if (nivel === 'grupo') return null; | ||
| 112 | + if (nivel === 'subgrupo') return objeto.charAt(0) + '0000'; | ||
| 113 | + if (nivel === 'partida') return objeto.substring(0, 2) + '000'; | ||
| 114 | + if (nivel === 'subpartida') return objeto.substring(0, 3) + '00'; | ||
| 115 | + return null; | ||
| 116 | +} |
This diff is collapsed. Click to expand it.
| 1 | -import { supabase } from '$lib/supabase'; | ||
| 2 | import { error } from '@sveltejs/kit'; | 1 | import { error } from '@sveltejs/kit'; |
| 3 | 2 | ||
| 4 | -// Funciones para derivar jerarquía del código objeto | 3 | +const API_BASE = 'http://136.112.29.74/api/objeto'; |
| 5 | -function getGrupoCode(objeto) { | ||
| 6 | - return objeto.charAt(0) + '0000'; | ||
| 7 | -} | ||
| 8 | - | ||
| 9 | -function getSubgrupoCode(objeto) { | ||
| 10 | - return objeto.substring(0, 2) + '000'; | ||
| 11 | -} | ||
| 12 | - | ||
| 13 | -function getPartidaCode(objeto) { | ||
| 14 | - return objeto.substring(0, 3) + '00'; | ||
| 15 | -} | ||
| 16 | - | ||
| 17 | -function getNivel(objeto) { | ||
| 18 | - if (objeto.endsWith('0000')) return 'grupo'; | ||
| 19 | - if (objeto.endsWith('000')) return 'subgrupo'; | ||
| 20 | - if (objeto.endsWith('00')) return 'partida'; | ||
| 21 | - return 'subpartida'; | ||
| 22 | -} | ||
| 23 | 4 | ||
| 24 | export async function load({ params }) { | 5 | export async function load({ params }) { |
| 25 | - console.time('[SERVER] Total load objeto'); | ||
| 26 | const { codigo } = params; | 6 | const { codigo } = params; |
| 27 | 7 | ||
| 28 | - console.time('[SERVER] Query clas_objetos'); | 8 | + // Cargar clasificador desde la API |
| 29 | - const { data, error: dbError } = await supabase | 9 | + const res = await fetch(`${API_BASE}/${codigo}`); |
| 30 | - .schema('ppto') | 10 | + if (!res.ok) throw error(404, 'Objeto no encontrado'); |
| 31 | - .from('clas_objetos') | 11 | + const objeto = await res.json(); |
| 32 | - .select('*') | ||
| 33 | - .eq('objeto', codigo); | ||
| 34 | - console.timeEnd('[SERVER] Query clas_objetos'); | ||
| 35 | 12 | ||
| 36 | - if (dbError || !data || data.length === 0) { | 13 | + // Derivar padres e hijos del código |
| 37 | - throw error(404, 'Objeto no encontrado'); | 14 | + const nivel = objeto.nivel; |
| 38 | - } | 15 | + let padresCodes = []; |
| 39 | - | ||
| 40 | - // Tomar el primer resultado | ||
| 41 | - const objeto = data[0]; | ||
| 42 | - const nivel = objeto.nivel || getNivel(codigo); | ||
| 43 | - | ||
| 44 | - // Obtener jerarquía (padres e hijos) | ||
| 45 | - let padres = []; | ||
| 46 | - let hijos = []; | ||
| 47 | 16 | ||
| 48 | - console.time('[SERVER] Query padres'); | ||
| 49 | - | ||
| 50 | - // Buscar padres según nivel | ||
| 51 | if (nivel === 'subpartida') { | 17 | if (nivel === 'subpartida') { |
| 52 | - // Padres: grupo, subgrupo, partida | 18 | + padresCodes = [ |
| 53 | - const grupoCode = getGrupoCode(codigo); | 19 | + codigo.charAt(0) + '0000', |
| 54 | - const subgrupoCode = getSubgrupoCode(codigo); | 20 | + codigo.substring(0, 2) + '000', |
| 55 | - const partidaCode = getPartidaCode(codigo); | 21 | + codigo.substring(0, 3) + '00' |
| 56 | - | 22 | + ]; |
| 57 | - const { data: padresData } = await supabase | ||
| 58 | - .schema('ppto') | ||
| 59 | - .from('clas_objetos') | ||
| 60 | - .select('*') | ||
| 61 | - .in('objeto', [grupoCode, subgrupoCode, partidaCode]) | ||
| 62 | - .order('objeto'); | ||
| 63 | - | ||
| 64 | - if (padresData) padres = padresData; | ||
| 65 | } else if (nivel === 'partida') { | 23 | } else if (nivel === 'partida') { |
| 66 | - // Padres: grupo, subgrupo | 24 | + padresCodes = [ |
| 67 | - const grupoCode = getGrupoCode(codigo); | 25 | + codigo.charAt(0) + '0000', |
| 68 | - const subgrupoCode = getSubgrupoCode(codigo); | 26 | + codigo.substring(0, 2) + '000' |
| 69 | - | 27 | + ]; |
| 70 | - const { data: padresData } = await supabase | ||
| 71 | - .schema('ppto') | ||
| 72 | - .from('clas_objetos') | ||
| 73 | - .select('*') | ||
| 74 | - .in('objeto', [grupoCode, subgrupoCode]) | ||
| 75 | - .order('objeto'); | ||
| 76 | - | ||
| 77 | - if (padresData) padres = padresData; | ||
| 78 | } else if (nivel === 'subgrupo') { | 28 | } else if (nivel === 'subgrupo') { |
| 79 | - // Padre: grupo | 29 | + padresCodes = [codigo.charAt(0) + '0000']; |
| 80 | - const grupoCode = getGrupoCode(codigo); | ||
| 81 | - | ||
| 82 | - const { data: padresData } = await supabase | ||
| 83 | - .schema('ppto') | ||
| 84 | - .from('clas_objetos') | ||
| 85 | - .select('*') | ||
| 86 | - .eq('objeto', grupoCode); | ||
| 87 | - | ||
| 88 | - if (padresData) padres = padresData; | ||
| 89 | - } | ||
| 90 | - // grupo no tiene padres | ||
| 91 | - | ||
| 92 | - console.timeEnd('[SERVER] Query padres'); | ||
| 93 | - | ||
| 94 | - console.time('[SERVER] Query hijos'); | ||
| 95 | - | ||
| 96 | - // Buscar hijos según nivel | ||
| 97 | - if (nivel === 'grupo') { | ||
| 98 | - // Hijos: subgrupos que empiecen con el mismo dígito | ||
| 99 | - const prefix = codigo.charAt(0); | ||
| 100 | - const { data: hijosData } = await supabase | ||
| 101 | - .schema('ppto') | ||
| 102 | - .from('clas_objetos') | ||
| 103 | - .select('*') | ||
| 104 | - .eq('nivel', 'subgrupo') | ||
| 105 | - .like('objeto', `${prefix}%`) | ||
| 106 | - .order('objeto'); | ||
| 107 | - | ||
| 108 | - hijos = hijosData || []; | ||
| 109 | - } else if (nivel === 'subgrupo') { | ||
| 110 | - // Hijos: partidas que empiecen con los mismos 2 dígitos | ||
| 111 | - const prefix = codigo.substring(0, 2); | ||
| 112 | - const { data: hijosData } = await supabase | ||
| 113 | - .schema('ppto') | ||
| 114 | - .from('clas_objetos') | ||
| 115 | - .select('*') | ||
| 116 | - .eq('nivel', 'partida') | ||
| 117 | - .like('objeto', `${prefix}%`) | ||
| 118 | - .order('objeto'); | ||
| 119 | - | ||
| 120 | - hijos = hijosData || []; | ||
| 121 | - } else if (nivel === 'partida') { | ||
| 122 | - // Hijos: subpartidas que empiecen con los mismos 3 dígitos | ||
| 123 | - const prefix = codigo.substring(0, 3); | ||
| 124 | - const { data: hijosData } = await supabase | ||
| 125 | - .schema('ppto') | ||
| 126 | - .from('clas_objetos') | ||
| 127 | - .select('*') | ||
| 128 | - .eq('nivel', 'subpartida') | ||
| 129 | - .like('objeto', `${prefix}%`) | ||
| 130 | - .order('objeto'); | ||
| 131 | - | ||
| 132 | - hijos = hijosData || []; | ||
| 133 | } | 30 | } |
| 134 | - // subpartida no tiene hijos | ||
| 135 | 31 | ||
| 136 | - console.timeEnd('[SERVER] Query hijos'); | 32 | + // Cargar padres e hijos en paralelo desde la API |
| 33 | + const [padresResults, hijosResult] = await Promise.all([ | ||
| 34 | + Promise.all(padresCodes.map(async (c) => { | ||
| 35 | + try { | ||
| 36 | + const r = await fetch(`${API_BASE}/${c}`); | ||
| 37 | + if (r.ok) return await r.json(); | ||
| 38 | + } catch {} | ||
| 39 | + return null; | ||
| 40 | + })), | ||
| 41 | + (async () => { | ||
| 42 | + // Hijos: buscar en el clasificador | ||
| 43 | + try { | ||
| 44 | + const r = await fetch(`${API_BASE}/clasificador`); | ||
| 45 | + if (!r.ok) return []; | ||
| 46 | + const all = await r.json(); | ||
| 47 | + | ||
| 48 | + if (nivel === 'grupo') { | ||
| 49 | + return all.filter(o => o.nivel === 'subgrupo' && o.objeto.charAt(0) === codigo.charAt(0) && o.objeto !== codigo); | ||
| 50 | + } else if (nivel === 'subgrupo') { | ||
| 51 | + return all.filter(o => o.nivel === 'partida' && o.objeto.substring(0, 2) === codigo.substring(0, 2)); | ||
| 52 | + } else if (nivel === 'partida') { | ||
| 53 | + return all.filter(o => o.nivel === 'subpartida' && o.objeto.substring(0, 3) === codigo.substring(0, 3)); | ||
| 54 | + } | ||
| 55 | + return []; | ||
| 56 | + } catch { return []; } | ||
| 57 | + })() | ||
| 58 | + ]); | ||
| 59 | + | ||
| 60 | + const padres = padresResults.filter(Boolean).sort((a, b) => a.objeto.localeCompare(b.objeto)); | ||
| 61 | + const hijos = hijosResult.sort((a, b) => a.objeto.localeCompare(b.objeto)); | ||
| 137 | 62 | ||
| 138 | - console.timeEnd('[SERVER] Total load objeto'); | ||
| 139 | return { | 63 | return { |
| 140 | objeto, | 64 | objeto, |
| 141 | padres, | 65 | padres, |
| 142 | - hijos | 66 | + hijos, |
| 67 | + nEntidades: 0 | ||
| 143 | }; | 68 | }; |
| 144 | } | 69 | } | ... | ... |
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
| ... | @@ -25,6 +25,7 @@ | ... | @@ -25,6 +25,7 @@ |
| 25 | let clasificadorSeleccionado = $state('total'); // total, objeto, finfun, acteco | 25 | let clasificadorSeleccionado = $state('total'); // total, objeto, finfun, acteco |
| 26 | let subPartidaSearch = $state(''); | 26 | let subPartidaSearch = $state(''); |
| 27 | let subPartidaSeleccionada = $state(null); | 27 | let subPartidaSeleccionada = $state(null); |
| 28 | + let mapLinkCopied = $state(false); | ||
| 28 | 29 | ||
| 29 | const CLASIFICADORES_MAPA = [ | 30 | const CLASIFICADORES_MAPA = [ |
| 30 | { id: 'total', label: 'Gasto total', disponible: true }, | 31 | { id: 'total', label: 'Gasto total', disponible: true }, |
| ... | @@ -128,7 +129,7 @@ | ... | @@ -128,7 +129,7 @@ |
| 128 | const res = await fetch(`${apiEndpoint}?${params}`); | 129 | const res = await fetch(`${apiEndpoint}?${params}`); |
| 129 | const data = await res.json(); | 130 | const data = await res.json(); |
| 130 | const mapped = (data || []) | 131 | const mapped = (data || []) |
| 131 | - .filter(d => d.ubigeo !== '0.0.0') | 132 | + .filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo)) |
| 132 | .map(d => ({ | 133 | .map(d => ({ |
| 133 | codigo: d.ubigeo, | 134 | codigo: d.ubigeo, |
| 134 | desc: d.desc_ubigeo, | 135 | desc: d.desc_ubigeo, |
| ... | @@ -393,11 +394,19 @@ | ... | @@ -393,11 +394,19 @@ |
| 393 | goto(url.toString(), { replaceState: true, noScroll: true }); | 394 | goto(url.toString(), { replaceState: true, noScroll: true }); |
| 394 | } | 395 | } |
| 395 | 396 | ||
| 396 | - // Resolver nombre de partida por código desde Typesense | 397 | + // Resolver nombre de partida por código desde la API |
| 397 | async function resolverNombre(clas, codigo) { | 398 | async function resolverNombre(clas, codigo) { |
| 398 | - const cfg = CLAS_SEARCH_MAP[clas]; | ||
| 399 | - if (!cfg) return codigo; | ||
| 400 | try { | 399 | try { |
| 400 | + if (clas === 'objeto') { | ||
| 401 | + const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=clasificador`); | ||
| 402 | + if (res.ok) { | ||
| 403 | + const data = await res.json(); | ||
| 404 | + if (data?.desc_objeto) return data.desc_objeto; | ||
| 405 | + } | ||
| 406 | + } | ||
| 407 | + // Fallback: buscar en Typesense | ||
| 408 | + const cfg = CLAS_SEARCH_MAP[clas]; | ||
| 409 | + if (!cfg) return codigo; | ||
| 401 | const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ }); | 410 | const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ }); |
| 402 | const res = await fetch(`/api/search?${params}`); | 411 | const res = await fetch(`/api/search?${params}`); |
| 403 | if (!res.ok) return codigo; | 412 | if (!res.ok) return codigo; |
| ... | @@ -477,10 +486,34 @@ | ... | @@ -477,10 +486,34 @@ |
| 477 | <div class="mapa-fullscreen"> | 486 | <div class="mapa-fullscreen"> |
| 478 | <div class="mapa-top-controls"> | 487 | <div class="mapa-top-controls"> |
| 479 | <nav class="breadcrumb"> | 488 | <nav class="breadcrumb"> |
| 480 | - <button class="back-btn" onclick={() => { history.back(); }}>←</button> | 489 | + {#if clasSeleccionado && clasificadorSeleccionado === 'objeto'} |
| 481 | - <a href="/">Inicio</a> | 490 | + <a href="/objeto/{clasSeleccionado.codigo}" class="back-link-mapa"> |
| 482 | - <span class="sep">/</span> | 491 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> |
| 483 | - <span>Ubicación geográfica</span> | 492 | + Volver a {clasSeleccionado.nombre} |
| 493 | + </a> | ||
| 494 | + {:else if clasSeleccionado && clasificadorSeleccionado === 'finfun'} | ||
| 495 | + <a href="/finfun/{clasSeleccionado.codigo}" class="back-link-mapa"> | ||
| 496 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"/></svg> | ||
| 497 | + Volver a {clasSeleccionado.nombre} | ||
| 498 | + </a> | ||
| 499 | + {:else} | ||
| 500 | + <span class="mapa-location-label">Ubicación geográfica</span> | ||
| 501 | + {/if} | ||
| 502 | + <button class="copy-link-btn-mapa" onclick={() => { | ||
| 503 | + navigator.clipboard.writeText(window.location.href); | ||
| 504 | + mapLinkCopied = true; | ||
| 505 | + setTimeout(() => mapLinkCopied = false, 2000); | ||
| 506 | + }}> | ||
| 507 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||
| 508 | + {#if mapLinkCopied} | ||
| 509 | + <path d="M20 6L9 17l-5-5"/> | ||
| 510 | + {:else} | ||
| 511 | + <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/> | ||
| 512 | + <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/> | ||
| 513 | + {/if} | ||
| 514 | + </svg> | ||
| 515 | + {mapLinkCopied ? 'Copiado' : 'Compartir'} | ||
| 516 | + </button> | ||
| 484 | </nav> | 517 | </nav> |
| 485 | <!-- Fila 1: Dropdowns inline --> | 518 | <!-- Fila 1: Dropdowns inline --> |
| 486 | <div class="titulo-dropdowns"> | 519 | <div class="titulo-dropdowns"> |
| ... | @@ -527,7 +560,6 @@ | ... | @@ -527,7 +560,6 @@ |
| 527 | <div class="objeto-search-mapa"> | 560 | <div class="objeto-search-mapa"> |
| 528 | {#if clasSeleccionado} | 561 | {#if clasSeleccionado} |
| 529 | <div class="objeto-selected-pill"> | 562 | <div class="objeto-selected-pill"> |
| 530 | - <span class="objeto-pill-code">{clasSeleccionado.codigo}</span> | ||
| 531 | <span class="objeto-pill-name">{clasSeleccionado.nombre}</span> | 563 | <span class="objeto-pill-name">{clasSeleccionado.nombre}</span> |
| 532 | <button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button> | 564 | <button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button> |
| 533 | </div> | 565 | </div> |
| ... | @@ -769,12 +801,41 @@ | ... | @@ -769,12 +801,41 @@ |
| 769 | flex-shrink: 0; | 801 | flex-shrink: 0; |
| 770 | } | 802 | } |
| 771 | 803 | ||
| 772 | - .breadcrumb { font-size: 0.7rem; color: var(--theme-texto); opacity: 0.5; display: flex; align-items: center; gap: 0.3rem; } | 804 | + .breadcrumb { font-size: 0.7rem; color: var(--theme-texto); display: flex; align-items: center; gap: 0.75rem; } |
| 773 | - .back-btn { background: none; border: none; cursor: pointer; color: var(--theme-texto); font-size: 0.85rem; padding: 0; opacity: 0.6; transition: opacity 0.15s; } | 805 | + |
| 774 | - .back-btn:hover { opacity: 1; } | 806 | + .back-link-mapa { |
| 775 | - .breadcrumb a { color: var(--theme-texto); text-decoration: none; } | 807 | + display: inline-flex; |
| 776 | - .breadcrumb a:hover { text-decoration: underline; opacity: 1; } | 808 | + align-items: center; |
| 777 | - .breadcrumb .sep { margin: 0 0.3rem; } | 809 | + gap: 0.25rem; |
| 810 | + font-family: 'DM Mono', monospace; | ||
| 811 | + font-size: 0.6875rem; | ||
| 812 | + color: var(--theme-accent); | ||
| 813 | + text-decoration: none; | ||
| 814 | + transition: opacity 0.15s; | ||
| 815 | + } | ||
| 816 | + .back-link-mapa:hover { opacity: 0.7; } | ||
| 817 | + | ||
| 818 | + .mapa-location-label { | ||
| 819 | + font-size: 0.6875rem; | ||
| 820 | + color: var(--theme-texto); | ||
| 821 | + opacity: 0.5; | ||
| 822 | + } | ||
| 823 | + | ||
| 824 | + .copy-link-btn-mapa { | ||
| 825 | + display: flex; | ||
| 826 | + align-items: center; | ||
| 827 | + gap: 0.25rem; | ||
| 828 | + font-size: 0.6875rem; | ||
| 829 | + color: var(--theme-texto); | ||
| 830 | + opacity: 0.5; | ||
| 831 | + background: none; | ||
| 832 | + border: none; | ||
| 833 | + padding: 0; | ||
| 834 | + cursor: pointer; | ||
| 835 | + transition: opacity 0.15s; | ||
| 836 | + margin-left: auto; | ||
| 837 | + } | ||
| 838 | + .copy-link-btn-mapa:hover { opacity: 1; } | ||
| 778 | 839 | ||
| 779 | .titulo-dropdowns { | 840 | .titulo-dropdowns { |
| 780 | display: flex; | 841 | display: flex; | ... | ... |
-
Please register or login to post a comment