Rafael Lopez

rubros

......@@ -7,7 +7,7 @@ Buscador instantáneo con Fuse.js en cliente. Busca en clasificadores presupuest
### Motor de búsqueda
- **Tecnología**: Fuse.js (cliente)
- **Razón**: ~1,000 registros totales (~600 entidades + ~450 objetos), cabe en memoria, búsqueda instantánea sin latencia
- **Razón**: ~1,400 registros totales (~600 entidades + ~450 objetos + ~380 rubros), cabe en memoria, búsqueda instantánea sin latencia
### Configuración Fuse.js
```javascript
......@@ -34,10 +34,10 @@ const fuseOptions = {
### Comportamiento
| Parámetro | Valor |
|-----------|-------|
| Debounce | 200-300ms |
| Debounce | 250ms |
| Mínimo caracteres | 2 |
| Máximo resultados | 10-30 |
| Navegación | Flechas ↑↓ + Enter + scroll de mouse y loq ue corresponda para mobiles|
| Máximo resultados | 15 |
| Navegación | Flechas ↑↓ + Enter + scroll de mouse |
## Estructura del Índice
......@@ -70,6 +70,18 @@ const searchData = [
nombre_normalizado: 'pasajes',
sigla_normalizada: '',
codigo_normalizado: '25100'
},
// Rubros
{
tipo: 'rubro',
codigo: 13000,
nombre: 'INGRESOS POR IMPUESTOS',
sigla: null,
contexto: 'Rubro · Tipo',
nivel: 'tipo',
nombre_normalizado: 'ingresos por impuestos',
sigla_normalizada: '',
codigo_normalizado: '13000'
}
]
```
......@@ -77,6 +89,7 @@ const searchData = [
### Fuentes de datos
- **Entidades**: tabla `ppto.clas_institucional`
- **Objetos de gasto**: tabla `ppto.clas_objetos`
- **Rubros**: tabla `ppto.clas_rubros`
## Columnas de Búsqueda
......@@ -96,6 +109,13 @@ const searchData = [
| `objeto` | ✓ | ✓ código (monospace) |
| `nivel` | ✗ | ✓ contexto ("Objeto del Gasto · Partida") |
### Rubros (clas_rubros)
| Columna | Buscar | Mostrar |
|---------|--------|---------|
| `desc_rubros` | ✓ | ✓ nombre principal |
| `rubro` | ✓ | ✓ código (monospace) |
| `nivel` | ✗ | ✓ contexto ("Rubro · Tipo") |
## Flujo de Búsqueda
```
......@@ -109,7 +129,7 @@ const searchData = [
Fuse devuelve matches → renderizar lista con highlighting
4. SELECCIÓN
Click o Enter → navegar a /entidad/[codigo] o /gasto/[codigo]
Click o Enter → navegar a URL según tipo
```
## Estructura de URLs
......@@ -117,28 +137,38 @@ const searchData = [
```
/entidad/[codigo] → /entidad/139
/objeto/[codigo] → /objeto/25100
/area/[codigo] → /area/1.1.4 (futuro, opcional)
/rubro/[codigo] → /rubro/13000
```
## Formato de Resultados
Resultados agrupados por tipo de clasificador:
```
┌─────────────────────────────────────────────────────┐
│ 🔍 [Buscar entidad u objeto de gasto...] │
│ 🔍 [Buscar entidad, objeto de gasto o rubro...] │
├─────────────────────────────────────────────────────┤
│ ENTIDADES │
├─────────────────────────────────────────────────────┤
│ Universidad Mayor De San Andrés (UMSA) │ ← Entidad
│ Universidad Mayor De San Andrés (UMSA) │
│ Universidades Públicas │
├─────────────────────────────────────────────────────┤
│ 25100 Pasajes │ ← Objeto de gasto
│ OBJETOS DEL GASTO │
├─────────────────────────────────────────────────────┤
│ 25100 Pasajes │
│ Objeto del Gasto · Partida │
├─────────────────────────────────────────────────────┤
│ ... │
│ RUBROS │
├─────────────────────────────────────────────────────┤
│ 13000 INGRESOS POR IMPUESTOS │
│ Rubro · Tipo │
└─────────────────────────────────────────────────────┘
```
### Diferencias visuales
- **Entidades**: Nombre + (sigla) en primera línea
- **Objetos de gasto**: Código (monospace) + Nombre en primera línea
- **Rubros**: Código (monospace) + Nombre en primera línea
## Estados del Buscador
......@@ -177,6 +207,7 @@ src/lib/
|--------------|--------|-------|
| Entidades | ✅ Implementado | `ppto.clas_institucional` |
| Objetos de gasto | ✅ Implementado | `ppto.clas_objetos` |
| Rubros | ✅ Implementado | `ppto.clas_rubros` |
| Fuente de financiamiento | ⏳ Pendiente | - |
| Geográfico | ⏳ Pendiente | - |
......
......@@ -52,6 +52,8 @@
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
}
clearSearch();
inputRef.value = '';
......@@ -78,7 +80,7 @@
<input
bind:this={inputRef}
type="text"
placeholder={$indexLoaded ? "Buscar entidad u objeto de gasto..." : "Cargando..."}
placeholder={$indexLoaded ? "Buscar entidad, objeto de gasto o rubro..." : "Cargando..."}
disabled={!$indexLoaded}
class="w-full p-2 border rounded"
oninput={handleInput}
......
......@@ -9,23 +9,47 @@
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
}
clearSearch();
}
// Agrupar resultados por tipo
let groupedResults = $derived(() => {
const entidades = results.filter(r => r.tipo === 'entidad');
const objetos = results.filter(r => r.tipo === 'objeto_gasto');
const rubros = results.filter(r => r.tipo === 'rubro');
return { entidades, objetos, rubros };
});
// Calcular índice global para navegación con teclado
function getGlobalIndex(tipo, localIndex) {
const g = groupedResults();
if (tipo === 'entidad') {
return localIndex;
} else if (tipo === 'objeto_gasto') {
return g.entidades.length + localIndex;
} else {
return g.entidades.length + g.objetos.length + localIndex;
}
}
</script>
{#if results.length > 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg max-h-80 overflow-y-auto z-50">
{#each results as item, index}
{#if groupedResults().entidades.length > 0}
<div class="px-3 py-1.5 bg-slate-50 border-b">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Entidades</span>
</div>
{#each groupedResults().entidades as item, index}
{@const globalIdx = getGlobalIndex('entidad', index)}
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {index === selectedIndex ? 'bg-gray-100' : ''}"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
onclick={() => handleSelect(item)}
>
<div class="flex items-center gap-2">
{#if item.tipo === 'objeto_gasto'}
<span class="font-mono text-xs text-slate-400">{item.codigo}</span>
{/if}
<span>{item.nombre}</span>
{#if item.sigla}
<span class="text-slate-400">({item.sigla})</span>
......@@ -34,6 +58,47 @@
<div class="text-xs text-gray-500">{item.contexto}</div>
</button>
{/each}
{/if}
{#if groupedResults().objetos.length > 0}
<div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 ? 'border-t' : ''}">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Objetos del Gasto</span>
</div>
{#each groupedResults().objetos as item, index}
{@const globalIdx = getGlobalIndex('objeto_gasto', index)}
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
onclick={() => handleSelect(item)}
>
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-slate-400">{item.codigo}</span>
<span>{item.nombre}</span>
</div>
<div class="text-xs text-gray-500">{item.contexto}</div>
</button>
{/each}
{/if}
{#if groupedResults().rubros.length > 0}
<div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 || groupedResults().objetos.length > 0 ? 'border-t' : ''}">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Rubros</span>
</div>
{#each groupedResults().rubros as item, index}
{@const globalIdx = getGlobalIndex('rubro', index)}
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
onclick={() => handleSelect(item)}
>
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-slate-400">{item.codigo}</span>
<span>{item.nombre}</span>
</div>
<div class="text-xs text-gray-500">{item.contexto}</div>
</button>
{/each}
{/if}
</div>
{:else if query.length >= 2}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border rounded p-3 text-sm text-gray-500">
......
......@@ -50,6 +50,17 @@ export async function initSearchIndex() {
throw errorObjetos;
}
// Cargar rubros
const { data: rubros, error: errorRubros } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('rubro, desc_rubros, nivel');
if (errorRubros) {
console.error('Error cargando rubros:', errorRubros);
throw errorRubros;
}
// Transformar entidades para el índice
const entidadesIndex = entidades.map(item => ({
tipo: 'entidad',
......@@ -64,21 +75,35 @@ export async function initSearchIndex() {
}));
// Transformar objetos de gasto para el índice
const nivelLabels = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
const nivelLabelsObjetos = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
const objetosIndex = objetos.map(item => ({
tipo: 'objeto_gasto',
codigo: item.objeto,
nombre: item.desc_objeto,
sigla: null,
contexto: `Objeto del Gasto · ${nivelLabels[item.nivel] || item.nivel}`,
contexto: `Objeto del Gasto · ${nivelLabelsObjetos[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_objeto),
sigla_normalizada: '',
codigo_normalizado: item.objeto || ''
}));
// Combinar ambos conjuntos
searchData = [...entidadesIndex, ...objetosIndex];
// Transformar rubros para el índice
const nivelLabelsRubros = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
const rubrosIndex = rubros.map(item => ({
tipo: 'rubro',
codigo: item.rubro,
nombre: item.desc_rubros,
sigla: null,
contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_rubros),
sigla_normalizada: '',
codigo_normalizado: item.rubro?.toString() || ''
}));
// Combinar todos los conjuntos
searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex];
fuseInstance = new Fuse(searchData, fuseOptions);
......
......@@ -50,7 +50,7 @@ export function performSearch(searchQuery) {
return;
}
const searchResults = search(searchQuery, 30);
const searchResults = search(searchQuery, 15);
results.set(searchResults);
}
......
......@@ -2,6 +2,7 @@
const clasificadores = [
{ id: 'institucional', nombre: 'Institucional', descripcion: 'Ministerios, universidades, municipios, empresas públicas', disponible: true },
{ id: 'objeto-gasto', nombre: 'Objeto del Gasto', descripcion: 'Servicios personales, bienes, inversiones', disponible: true },
{ id: 'rubros', nombre: 'Rubros de Ingresos', descripcion: 'Ingresos tributarios, no tributarios, transferencias', disponible: true },
{ id: 'fuente', nombre: 'Fuente de Financiamiento', descripcion: 'Tesoro, créditos externos, recursos propios', disponible: false },
{ id: 'organismo', nombre: 'Organismo Financiador', descripcion: 'BID, Banco Mundial, CAF', disponible: false },
{ id: 'geografico', nombre: 'Geográfico', descripcion: 'Departamentos, provincias, municipios', disponible: false },
......
This diff is collapsed. Click to expand it.
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const { codigo } = params;
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('rubro', codigo);
if (dbError || !data || data.length === 0) {
throw error(404, 'Rubro no encontrado');
}
const rubro = data[0];
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
// Buscar padre según nivel
if (rubro.nivel === 'sub_cuenta') {
// Padre es cuenta
const { data: padreData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'cuenta')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase)
.eq('cuenta', rubro.cuenta);
if (padreData?.length) padres.push(padreData[0]);
}
if (rubro.nivel === 'cuenta' || rubro.nivel === 'sub_cuenta') {
// Padre es clase
const { data: padreData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'clase')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase);
if (padreData?.length) padres.unshift(padreData[0]);
}
if (rubro.nivel !== 'tipo') {
// Padre es tipo
const { data: padreData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'tipo')
.eq('tipo', rubro.tipo);
if (padreData?.length) padres.unshift(padreData[0]);
}
// Buscar hijos según nivel
if (rubro.nivel === 'tipo') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'clase')
.eq('tipo', rubro.tipo)
.order('rubro');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
return acc;
}, []) || [];
} else if (rubro.nivel === 'clase') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'cuenta')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase)
.order('rubro');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
return acc;
}, []) || [];
} else if (rubro.nivel === 'cuenta') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.eq('nivel', 'sub_cuenta')
.eq('tipo', rubro.tipo)
.eq('clase', rubro.clase)
.eq('cuenta', rubro.cuenta)
.order('rubro');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
return acc;
}, []) || [];
}
return {
rubro,
padres,
hijos
};
}
<script>
let { data } = $props();
const rubro = data.rubro;
const padres = data.padres;
const hijos = data.hijos;
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = JSON.parse(descripcionesStr);
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA;
});
} catch {
return [];
}
}
function getMaxYear(rangos) {
if (!rangos) return 0;
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
}
function getNivelLabel(nivel) {
const labels = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
return labels[nivel] || nivel;
}
// Obtener la descripción correcta según el nivel
function getDescripcion(item) {
if (item.nivel === 'tipo') return item.desc_tipo;
if (item.nivel === 'clase') return item.desc_clase;
if (item.nivel === 'cuenta') return item.desc_cuenta;
if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta;
return item.desc_rubros;
}
function getHijosLabel(nivel) {
const labels = { tipo: 'Clases', clase: 'Cuentas', cuenta: 'Subcuentas' };
return labels[nivel] || 'Hijos';
}
const descripciones = parseDescripciones(rubro.descripciones);
</script>
<svelte:head>
<title>{getDescripcion(rubro)} | Presupuesto Público</title>
</svelte:head>
<div class="min-h-screen bg-white">
<header class="border-b bg-slate-50">
<div class="max-w-4xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span>/</span>
<a href="/clasificadores/rubros" class="hover:text-slate-700">Rubros</a>
{#each padres as padre}
<span>/</span>
<a href="/rubro/{padre.rubro}" class="hover:text-slate-700">{getDescripcion(padre)}</a>
{/each}
<span>/</span>
<span class="text-slate-900">{getDescripcion(rubro)}</span>
</nav>
</div>
</header>
<main class="max-w-4xl mx-auto px-6 py-8">
<!-- Encabezado -->
<div class="mb-8">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(rubro.nivel)}</p>
<div class="flex items-baseline gap-4">
<span class="font-mono text-2xl text-slate-400">{rubro.rubro}</span>
<h1 class="text-2xl font-medium text-slate-900">{getDescripcion(rubro)}</h1>
</div>
</div>
<!-- Descripciones -->
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{#if descripciones.length > 1}
Descripciones ({descripciones.length} variaciones)
{:else}
Descripción
{/if}
</h2>
<div class="space-y-4">
{#each descripciones as desc, i}
<div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200 bg-slate-50/50'} pl-4 py-3 rounded-r">
<p class="text-xs text-slate-500 mb-2">
{#if i === 0 && descripciones.length > 1}
<span class="text-blue-600 font-medium">Vigente</span>
<span class="mx-1">·</span>
{/if}
{desc.rangos}
</p>
<p class="text-slate-700 leading-relaxed">{desc.descripcion}</p>
</div>
{/each}
</div>
</section>
<!-- Jerarquía -->
{#if padres.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2>
<div class="space-y-2">
{#each padres as padre, i}
<div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem">
<span class="text-slate-300">└</span>
<a
href="/rubro/{padre.rubro}"
class="text-sm text-slate-600 hover:text-blue-600"
>
<span class="font-mono text-xs text-slate-400">{padre.rubro}</span>
{getDescripcion(padre)}
</a>
</div>
{/each}
<div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem">
<span class="text-blue-500">└</span>
<span class="text-sm font-medium text-blue-600">
<span class="font-mono text-xs">{rubro.rubro}</span>
{getDescripcion(rubro)}
</span>
</div>
</div>
</section>
{/if}
<!-- Hijos -->
{#if hijos.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{getHijosLabel(rubro.nivel)} ({hijos.length})
</h2>
<div class="border rounded-lg divide-y">
{#each hijos as hijo}
{@const hijoDescs = parseDescripciones(hijo.descripciones)}
<a
href="/rubro/{hijo.rubro}"
class="block px-4 py-3 hover:bg-slate-50 transition-colors"
>
<div class="flex items-baseline gap-3">
<span class="font-mono text-sm text-slate-400">{hijo.rubro}</span>
<span class="text-slate-900">{getDescripcion(hijo)}</span>
{#if hijo.n_variaciones > 1}
<span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span>
{/if}
</div>
{#if hijoDescs.length > 0}
<p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}
<!-- Volver -->
<div class="pt-6 border-t">
<a
href="/clasificadores/rubros"
class="text-sm text-blue-600 hover:underline"
>
Volver al clasificador
</a>
</div>
</main>
</div>