Rafael Lopez

objetos

node_modules
API_ENDPOINTS.md
# Output
.output
......
......@@ -36,9 +36,16 @@
let chartHeight = $derived(Math.max(effectiveHeight - marginTop - marginBottom, 50));
// Máximo del eje Y (con guard para data vacía o valores undefined)
let maxPerCapita = $derived(
data.length > 0 ? Math.max(...data.map(d => d.perCapita || 0), 1) : 1
);
let maxPerCapita = $derived.by(() => {
if (data.length === 0) return 1;
const vals = data.map(d => typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0);
const max = Math.max(...vals);
const min = Math.min(...vals.filter(v => v > 0));
// Si max es 0, usar 1 como fallback
if (max <= 0) return 1;
// Agregar 10% de margen arriba para que la barra más alta no toque el techo
return max * 1.1;
});
// Escalas D3
let xScale = $derived(
......@@ -92,7 +99,8 @@
<!-- Barras -->
<div class="bars" onmouseleave={() => hoveredYear = null} role="group">
{#each data as d}
{@const barPct = (yScale(d.perCapita) / chartHeight) * 100}
{@const safeVal = typeof d.perCapita === 'number' && isFinite(d.perCapita) ? d.perCapita : 0}
{@const barPct = chartHeight > 0 ? (yScale(safeVal) / chartHeight) * 100 : 0}
<div
class="bar-container"
onmouseenter={() => hoveredYear = d.año}
......@@ -102,7 +110,7 @@
<div
class="bar"
class:hovered={hoveredYear === d.año}
style="height: {barPct}%"
style="height: {barPct}%; {safeVal > 0 ? 'min-height: 3px;' : ''}"
></div>
</div>
{/each}
......
......@@ -109,7 +109,7 @@
onkeydown={handleSearchKeydown}
onfocus={() => searchFocused = true}
onblur={handleSearchBlur}
placeholder="Buscar objeto..."
placeholder="Buscar otro gasto..."
/>
{#if searchVal}
<button class="search-clear" onclick={handleClear}>
......
......@@ -108,7 +108,19 @@
codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`;
}
return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion };
// Contexto extra para programas
let contexto = '';
if (!isClass) {
const ents = meta.entidades || [];
const entNombre = ents.length === 1 ? ents[0].entidad_desc_entidad : ents.length > 1 ? `${ents.length} entidades` : '';
const prog = meta.programa_desc || '';
if (entNombre && prog) contexto = `${entNombre} · Programa: ${prog}`;
else if (entNombre) contexto = entNombre;
else if (prog) contexto = `Programa: ${prog}`;
console.log('[SEARCH] programa contexto:', { isClass, tipo, nombre, entNombre, prog, contexto, metaKeys: Object.keys(meta) });
}
return { tipo, codigo, nombre, highlight, devengado: hit.document.devengado, gestion: hit.document.gestion, contexto };
});
selectedIdx = -1;
} catch {
......@@ -184,7 +196,6 @@
}
function goToResult(item) {
closeModal();
const routes = {
entidad: '/entidad/',
objeto_gasto: '/objeto/',
......@@ -196,7 +207,12 @@
programa: '/proyecto/'
};
const base = routes[item.tipo];
if (base) goto(`${base}${item.codigo}`);
if (base) {
const url = `${base}${item.codigo}`;
console.log('[SEARCH] goToResult:', item.tipo, item.codigo, '→', url);
open = false;
window.location.href = url;
}
}
function handleBackdropClick(e) {
......@@ -319,6 +335,9 @@
{typeConfig[item.tipo]?.label || item.tipo}
</span>
<span class="result-name">{@html item.highlight}</span>
{#if item.contexto}
<span class="result-contexto">{item.contexto}</span>
{/if}
</div>
<span class="result-code">{item.codigo}</span>
</button>
......@@ -646,6 +665,15 @@
text-overflow: ellipsis;
}
.result-contexto {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-desc {
font-size: 0.8125rem;
color: var(--theme-texto);
......
......@@ -1379,9 +1379,16 @@
</div>
<div class="result-meta">
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
{#if meta.entidad_desc_entidad}
{#if meta.entidades?.length === 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidad_desc_entidad}</span>
<span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span>
{:else if meta.entidades?.length > 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidades.length} entidades</span>
{/if}
{#if meta.programa_desc}
<span class="result-sep">·</span>
<span class="result-entity">{meta.programa_desc}</span>
{/if}
</div>
</div>
......@@ -1443,10 +1450,20 @@
<span class="result-year">{formatYears(meta.gestion)}</span>
<span class="result-sep">·</span>
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
{#if meta.entidad_desc_entidad}
{#if meta.entidades?.length === 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidades[0].entidad_desc_entidad}</span>
{:else if meta.entidades?.length > 1}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidades.length} entidades</span>
{:else if meta.entidad_desc_entidad}
<span class="result-sep">·</span>
<span class="result-entity">{meta.entidad_desc_entidad}</span>
{/if}
{#if meta.programa_desc}
<span class="result-sep">·</span>
<span class="result-entity">Programa: {meta.programa_desc}</span>
{/if}
{/if}
{#if isEntidadClass || isClassResult}
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
......
const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'clasificador';
const entidad = url.searchParams.get('entidad') || '';
const gestion = url.searchParams.get('gestion') || '';
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
let apiUrl;
if (tipo === 'clasificador') {
apiUrl = `${API_BASE}/${codigo}`;
} else if (tipo === 'entidades-lista') {
apiUrl = `${API_BASE}/${codigo}/entidades/map`;
} else if (tipo === 'entidad' && entidad) {
apiUrl = `${API_BASE}/${codigo}/entidades/${entidad}`;
} else if (tipo === 'entidades-año' && gestion) {
apiUrl = `${API_BASE}/${codigo}/entidades?gestion=${gestion}`;
} else {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
export async function GET() {
try {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error();
const data = await res.json();
// Mapear al formato que espera el treemap
const mapped = data.map(d => ({
entidad: d.entidad,
desc_entidad: d.desc_entidad,
sigla_entidad: d.sigla_entidad || ''
}));
return new Response(JSON.stringify(mapped), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/objeto';
// Cache en memoria del servidor
let cachedClasificador = null;
let cachedEstados = null; // Map: objeto → estados[]
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutos
async function loadAndCacheAll() {
const now = Date.now();
if (cachedEstados && (now - cachedTimestamp) < CACHE_TTL) return;
// 1. Cargar clasificador
const clasRes = await fetch(`${API_BASE}/clasificador`);
if (!clasRes.ok) throw new Error('Failed to fetch clasificador');
cachedClasificador = await clasRes.json();
// 2. Cargar todos los objetos en paralelo (lotes de 80)
const estados = new Map();
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const results = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.objeto}`);
if (!res.ok) return null;
const data = await res.json();
return { objeto: item.objeto, estados: data.estados || [] };
} catch { return null; }
}));
results.filter(Boolean).forEach(r => estados.set(r.objeto, r.estados));
}
cachedEstados = estados;
cachedTimestamp = now;
}
export async function GET({ url }) {
const gestion = parseInt(url.searchParams.get('gestion') || '2025');
const entidad = url.searchParams.get('entidad') || '0';
try {
if (entidad === '0') {
// Todo el estado — usar cache
await loadAndCacheAll();
const results = [];
for (const item of cachedClasificador) {
const estados = cachedEstados.get(item.objeto);
if (!estados) continue;
const yearData = estados.find(d => d.gestion === gestion);
if (!yearData || !yearData.total) continue;
results.push({
gestion,
nivel: item.nivel,
objeto: item.objeto,
desc_objeto: item.desc_objeto,
parent: getParent(item.objeto, item.nivel),
devengado: yearData.total
});
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
} else {
// Entidad específica — cargar por entidad (no cacheable eficientemente)
if (!cachedClasificador) {
const clasRes = await fetch(`${API_BASE}/clasificador`);
cachedClasificador = await clasRes.json();
}
const results = [];
const batchSize = 80;
for (let i = 0; i < cachedClasificador.length; i += batchSize) {
const batch = cachedClasificador.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(async (item) => {
try {
const res = await fetch(`${API_BASE}/${item.objeto}/entidades/${entidad}`);
if (!res.ok) return null;
const data = await res.json();
const yearData = data.find(d => d.gestion === gestion);
if (!yearData || !yearData.monto) return null;
return {
gestion,
nivel: item.nivel,
objeto: item.objeto,
desc_objeto: item.desc_objeto,
parent: getParent(item.objeto, item.nivel),
devengado: yearData.monto
};
} catch { return null; }
}));
results.push(...batchResults.filter(Boolean));
}
return new Response(JSON.stringify(results), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (err) {
return new Response(JSON.stringify({ error: 'Failed to build treemap' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
function getParent(objeto, nivel) {
if (nivel === 'grupo') return null;
if (nivel === 'subgrupo') return objeto.charAt(0) + '0000';
if (nivel === 'partida') return objeto.substring(0, 2) + '000';
if (nivel === 'subpartida') return objeto.substring(0, 3) + '00';
return null;
}
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
// Funciones para derivar jerarquía del código objeto
function getGrupoCode(objeto) {
return objeto.charAt(0) + '0000';
}
function getSubgrupoCode(objeto) {
return objeto.substring(0, 2) + '000';
}
function getPartidaCode(objeto) {
return objeto.substring(0, 3) + '00';
}
function getNivel(objeto) {
if (objeto.endsWith('0000')) return 'grupo';
if (objeto.endsWith('000')) return 'subgrupo';
if (objeto.endsWith('00')) return 'partida';
return 'subpartida';
}
const API_BASE = 'http://136.112.29.74/api/objeto';
export async function load({ params }) {
console.time('[SERVER] Total load objeto');
const { codigo } = params;
console.time('[SERVER] Query clas_objetos');
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', codigo);
console.timeEnd('[SERVER] Query clas_objetos');
if (dbError || !data || data.length === 0) {
throw error(404, 'Objeto no encontrado');
}
// Tomar el primer resultado
const objeto = data[0];
const nivel = objeto.nivel || getNivel(codigo);
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
// Cargar clasificador desde la API
const res = await fetch(`${API_BASE}/${codigo}`);
if (!res.ok) throw error(404, 'Objeto no encontrado');
const objeto = await res.json();
console.time('[SERVER] Query padres');
// Derivar padres e hijos del código
const nivel = objeto.nivel;
let padresCodes = [];
// Buscar padres según nivel
if (nivel === 'subpartida') {
// Padres: grupo, subgrupo, partida
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const partidaCode = getPartidaCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode, partidaCode])
.order('objeto');
if (padresData) padres = padresData;
padresCodes = [
codigo.charAt(0) + '0000',
codigo.substring(0, 2) + '000',
codigo.substring(0, 3) + '00'
];
} else if (nivel === 'partida') {
// Padres: grupo, subgrupo
const grupoCode = getGrupoCode(codigo);
const subgrupoCode = getSubgrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.in('objeto', [grupoCode, subgrupoCode])
.order('objeto');
if (padresData) padres = padresData;
padresCodes = [
codigo.charAt(0) + '0000',
codigo.substring(0, 2) + '000'
];
} else if (nivel === 'subgrupo') {
// Padre: grupo
const grupoCode = getGrupoCode(codigo);
const { data: padresData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', grupoCode);
if (padresData) padres = padresData;
padresCodes = [codigo.charAt(0) + '0000'];
}
// grupo no tiene padres
console.timeEnd('[SERVER] Query padres');
console.time('[SERVER] Query hijos');
// Cargar padres e hijos en paralelo desde la API
const [padresResults, hijosResult] = await Promise.all([
Promise.all(padresCodes.map(async (c) => {
try {
const r = await fetch(`${API_BASE}/${c}`);
if (r.ok) return await r.json();
} catch {}
return null;
})),
(async () => {
// Hijos: buscar en el clasificador
try {
const r = await fetch(`${API_BASE}/clasificador`);
if (!r.ok) return [];
const all = await r.json();
// Buscar hijos según nivel
if (nivel === 'grupo') {
// Hijos: subgrupos que empiecen con el mismo dígito
const prefix = codigo.charAt(0);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
return all.filter(o => o.nivel === 'subgrupo' && o.objeto.charAt(0) === codigo.charAt(0) && o.objeto !== codigo);
} else if (nivel === 'subgrupo') {
// Hijos: partidas que empiecen con los mismos 2 dígitos
const prefix = codigo.substring(0, 2);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
return all.filter(o => o.nivel === 'partida' && o.objeto.substring(0, 2) === codigo.substring(0, 2));
} else if (nivel === 'partida') {
// Hijos: subpartidas que empiecen con los mismos 3 dígitos
const prefix = codigo.substring(0, 3);
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subpartida')
.like('objeto', `${prefix}%`)
.order('objeto');
hijos = hijosData || [];
return all.filter(o => o.nivel === 'subpartida' && o.objeto.substring(0, 3) === codigo.substring(0, 3));
}
// subpartida no tiene hijos
return [];
} catch { return []; }
})()
]);
console.timeEnd('[SERVER] Query hijos');
const padres = padresResults.filter(Boolean).sort((a, b) => a.objeto.localeCompare(b.objeto));
const hijos = hijosResult.sort((a, b) => a.objeto.localeCompare(b.objeto));
console.timeEnd('[SERVER] Total load objeto');
return {
objeto,
padres,
hijos
hijos,
nEntidades: 0
};
}
......
This diff is collapsed. Click to expand it.
import { redirect } from '@sveltejs/kit';
export function load({ params }) {
throw redirect(301, `/objeto/${params.codigo}?entidad=${params.entidad}`);
}
......@@ -25,6 +25,7 @@
let clasificadorSeleccionado = $state('total'); // total, objeto, finfun, acteco
let subPartidaSearch = $state('');
let subPartidaSeleccionada = $state(null);
let mapLinkCopied = $state(false);
const CLASIFICADORES_MAPA = [
{ id: 'total', label: 'Gasto total', disponible: true },
......@@ -128,7 +129,7 @@
const res = await fetch(`${apiEndpoint}?${params}`);
const data = await res.json();
const mapped = (data || [])
.filter(d => d.ubigeo !== '0.0.0')
.filter(d => d.ubigeo !== '0.0.0' && !/multimunicipal/i.test(d.desc_ubigeo))
.map(d => ({
codigo: d.ubigeo,
desc: d.desc_ubigeo,
......@@ -393,11 +394,19 @@
goto(url.toString(), { replaceState: true, noScroll: true });
}
// Resolver nombre de partida por código desde Typesense
// Resolver nombre de partida por código desde la API
async function resolverNombre(clas, codigo) {
try {
if (clas === 'objeto') {
const res = await fetch(`/api/objeto-data?codigo=${codigo}&tipo=clasificador`);
if (res.ok) {
const data = await res.json();
if (data?.desc_objeto) return data.desc_objeto;
}
}
// Fallback: buscar en Typesense
const cfg = CLAS_SEARCH_MAP[clas];
if (!cfg) return codigo;
try {
const params = new URLSearchParams({ q: codigo, per_page: '5', is_class: 'true', class_: cfg.class_ });
const res = await fetch(`/api/search?${params}`);
if (!res.ok) return codigo;
......@@ -477,10 +486,34 @@
<div class="mapa-fullscreen">
<div class="mapa-top-controls">
<nav class="breadcrumb">
<button class="back-btn" onclick={() => { history.back(); }}>←</button>
<a href="/">Inicio</a>
<span class="sep">/</span>
<span>Ubicación geográfica</span>
{#if clasSeleccionado && clasificadorSeleccionado === 'objeto'}
<a href="/objeto/{clasSeleccionado.codigo}" class="back-link-mapa">
<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>
Volver a {clasSeleccionado.nombre}
</a>
{:else if clasSeleccionado && clasificadorSeleccionado === 'finfun'}
<a href="/finfun/{clasSeleccionado.codigo}" class="back-link-mapa">
<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>
Volver a {clasSeleccionado.nombre}
</a>
{:else}
<span class="mapa-location-label">Ubicación geográfica</span>
{/if}
<button class="copy-link-btn-mapa" onclick={() => {
navigator.clipboard.writeText(window.location.href);
mapLinkCopied = true;
setTimeout(() => mapLinkCopied = false, 2000);
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if mapLinkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
{mapLinkCopied ? 'Copiado' : 'Compartir'}
</button>
</nav>
<!-- Fila 1: Dropdowns inline -->
<div class="titulo-dropdowns">
......@@ -527,7 +560,6 @@
<div class="objeto-search-mapa">
{#if clasSeleccionado}
<div class="objeto-selected-pill">
<span class="objeto-pill-code">{clasSeleccionado.codigo}</span>
<span class="objeto-pill-name">{clasSeleccionado.nombre}</span>
<button class="objeto-pill-clear" onclick={() => { limpiarClasificador(); cargarGestion(gestionSeleccionada); }}>✕</button>
</div>
......@@ -769,12 +801,41 @@
flex-shrink: 0;
}
.breadcrumb { font-size: 0.7rem; color: var(--theme-texto); opacity: 0.5; display: flex; align-items: center; gap: 0.3rem; }
.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; }
.back-btn:hover { opacity: 1; }
.breadcrumb a { color: var(--theme-texto); text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; opacity: 1; }
.breadcrumb .sep { margin: 0 0.3rem; }
.breadcrumb { font-size: 0.7rem; color: var(--theme-texto); display: flex; align-items: center; gap: 0.75rem; }
.back-link-mapa {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-accent);
text-decoration: none;
transition: opacity 0.15s;
}
.back-link-mapa:hover { opacity: 0.7; }
.mapa-location-label {
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
}
.copy-link-btn-mapa {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.5;
background: none;
border: none;
padding: 0;
cursor: pointer;
transition: opacity 0.15s;
margin-left: auto;
}
.copy-link-btn-mapa:hover { opacity: 1; }
.titulo-dropdowns {
display: flex;
......