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,31 +9,96 @@
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}
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {index === selectedIndex ? 'bg-gray-100' : ''}"
onclick={() => handleSelect(item)}
>
<div class="flex items-center gap-2">
{#if item.tipo === 'objeto_gasto'}
{#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 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
onclick={() => handleSelect(item)}
>
<div class="flex items-center gap-2">
<span>{item.nombre}</span>
{#if item.sigla}
<span class="text-slate-400">({item.sigla})</span>
{/if}
</div>
<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>
{/if}
<span>{item.nombre}</span>
{#if item.sigla}
<span class="text-slate-400">({item.sigla})</span>
{/if}
</div>
<div class="text-xs text-gray-500">{item.contexto}</div>
</button>
{/each}
<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 },
......
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
let loading = $state(true);
let searchQuery = $state('');
let tipos = $state([]);
let allItems = $state([]);
let selectedTipo = $state(null);
let selectedItem = $state(null);
let highlightedItem = $state(null);
onMount(async () => {
const { data, error } = await supabase
.schema('ppto')
.from('clas_rubros')
.select('*')
.order('rubro');
if (!error && data) {
allItems = data;
// Extraer tipos únicos (nivel más agregado)
tipos = data
.filter(item => item.nivel === 'tipo')
.reduce((acc, item) => {
if (!acc.find(t => t.rubro === item.rubro)) {
acc.push(item);
}
return acc;
}, [])
.sort((a, b) => a.rubro - b.rubro);
if (tipos.length > 0) {
selectedTipo = tipos[0];
}
}
loading = false;
});
function getItemsForTipo(tipoCode) {
if (!tipoCode) return { clases: [] };
const tipoNum = Math.floor(tipoCode / 1000);
// Obtener todos los items de este tipo
const itemsDelTipo = allItems.filter(item => item.tipo === tipoNum && item.nivel !== 'tipo');
// Encontrar clases únicas desde los valores de la columna 'clase'
const clasesUnicas = [...new Set(itemsDelTipo.map(i => i.clase).filter(c => c != null))].sort((a, b) => a - b);
const clases = clasesUnicas.map(claseNum => {
// Buscar si existe una entrada de nivel 'clase' para esta clase
const claseEntry = allItems.find(item => item.nivel === 'clase' && item.tipo === tipoNum && item.clase === claseNum);
// Items de esta clase
const itemsDeClase = itemsDelTipo.filter(item => item.clase === claseNum);
// Encontrar cuentas únicas
const cuentasUnicas = [...new Set(itemsDeClase.map(i => i.cuenta).filter(c => c != null))].sort((a, b) => a - b);
const cuentas = cuentasUnicas.map(cuentaNum => {
// Buscar si existe una entrada de nivel 'cuenta' para esta cuenta
const cuentaEntry = allItems.find(item => item.nivel === 'cuenta' && item.tipo === tipoNum && item.clase === claseNum && item.cuenta === cuentaNum);
// Sub_cuentas de esta cuenta
const subcuentas = itemsDeClase
.filter(item => item.nivel === 'sub_cuenta' && item.cuenta === cuentaNum)
.reduce((acc, item) => {
if (!acc.find(s => s.rubro === item.rubro)) acc.push(item);
return acc;
}, [])
.sort((a, b) => a.rubro - b.rubro);
// Si existe entrada de cuenta, usarla; si no, crear una virtual
const cuentaData = cuentaEntry || {
rubro: tipoNum * 1000 + claseNum * 100 + cuentaNum * 10,
nivel: 'cuenta',
tipo: tipoNum,
clase: claseNum,
cuenta: cuentaNum,
desc_cuenta: subcuentas[0]?.desc_cuenta || `Cuenta ${cuentaNum}`,
desc_tipo: subcuentas[0]?.desc_tipo,
desc_clase: subcuentas[0]?.desc_clase,
n_variaciones: null,
descripciones: null
};
return { ...cuentaData, subcuentas };
});
// Si existe entrada de clase, usarla; si no, crear una virtual
const claseData = claseEntry || {
rubro: tipoNum * 1000 + claseNum * 100,
nivel: 'clase',
tipo: tipoNum,
clase: claseNum,
desc_clase: itemsDeClase[0]?.desc_clase || `Clase ${claseNum}`,
desc_tipo: itemsDeClase[0]?.desc_tipo,
n_variaciones: null,
descripciones: null
};
return { ...claseData, cuentas };
});
return { clases };
}
// Búsqueda global
function searchGlobal(query) {
if (!query || query.length < 2) return [];
const q = query.toLowerCase();
return allItems
.filter(item =>
item.rubro?.toString().includes(q) ||
item.desc_rubros?.toLowerCase().includes(q) ||
item.descripciones?.toLowerCase().includes(q)
)
.reduce((acc, item) => {
if (!acc.find(i => i.rubro === item.rubro)) {
acc.push(item);
}
return acc;
}, [])
.slice(0, 20);
}
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 selectTipo(tipo) {
selectedTipo = tipo;
selectedItem = null;
searchQuery = '';
highlightedItem = null;
}
function openDetail(item) {
selectedItem = item;
}
function closeDetail() {
selectedItem = null;
}
function goToSearchResult(item) {
const tipoNum = item.tipo;
const targetTipo = tipos.find(t => Math.floor(t.rubro / 1000) === tipoNum);
if (targetTipo) {
selectedTipo = targetTipo;
highlightedItem = item.rubro;
searchQuery = '';
setTimeout(() => {
const prefix = item.nivel === 'clase' ? 'cl' : item.nivel === 'cuenta' ? 'cu' : 'sc';
const element = document.getElementById(`${prefix}-${item.rubro}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 100);
}
}
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;
}
let tipoContent = $derived(selectedTipo ? getItemsForTipo(selectedTipo.rubro) : { clases: [] });
let searchResults = $derived(searchGlobal(searchQuery));
let isSearching = $derived(searchQuery.length >= 2);
</script>
<svelte:head>
<title>Rubros | Presupuesto Público</title>
</svelte:head>
<div class="min-h-screen bg-white">
<!-- Header -->
<header class="border-b bg-slate-50">
<div class="max-w-screen-2xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span class="mx-2">/</span>
<a href="/clasificadores" class="hover:text-slate-700">Clasificadores</a>
<span class="mx-2">/</span>
<span class="text-slate-900">Rubros</span>
</nav>
<div class="flex items-center justify-between">
<h1 class="text-2xl font-light text-slate-900">Clasificador por Rubros de Ingresos</h1>
</div>
</div>
</header>
{#if loading}
<div class="flex items-center justify-center py-20">
<p class="text-slate-500">Cargando clasificador...</p>
</div>
{:else}
<div class="max-w-screen-2xl mx-auto flex">
<!-- Sidebar izquierda: Tipos -->
<aside class="w-64 flex-shrink-0 border-r bg-slate-50/50">
<div class="sticky top-0 h-screen overflow-y-auto p-4">
<!-- Buscador -->
<div class="mb-6">
<input
type="text"
bind:value={searchQuery}
placeholder="Buscar..."
class="w-full px-3 py-2 text-sm border border-slate-200 rounded-md bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<!-- Resultados de búsqueda -->
{#if isSearching}
<div class="mb-4">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-2">
{searchResults.length} resultados
</p>
<div class="space-y-1">
{#each searchResults as result}
<button
class="w-full text-left px-2 py-2 text-sm rounded hover:bg-white hover:shadow-sm transition-all"
onclick={() => goToSearchResult(result)}
>
<span class="text-xs text-slate-400 block">{getNivelLabel(result.nivel)}</span>
<span class="font-mono text-xs text-blue-600">{result.rubro}</span>
<span class="text-slate-700 ml-1">{getDescripcion(result)}</span>
</button>
{/each}
{#if searchResults.length === 0}
<p class="text-sm text-slate-400 px-2">Sin resultados</p>
{/if}
</div>
</div>
{:else}
<!-- Lista de tipos -->
<nav>
<p class="text-xs text-slate-500 uppercase tracking-wide mb-3 px-2">Tipos</p>
<ul class="space-y-1">
{#each tipos as tipo}
<li>
<button
class="w-full text-left px-3 py-2 rounded-md text-sm transition-all
{selectedTipo?.rubro === tipo.rubro
? 'bg-blue-50 text-blue-900 font-medium border-l-2 border-blue-500'
: 'text-slate-600 hover:bg-white hover:text-slate-900'}"
onclick={() => selectTipo(tipo)}
>
<span class="font-mono text-xs text-slate-400 block">{tipo.rubro}</span>
{getDescripcion(tipo)}
</button>
</li>
{/each}
</ul>
</nav>
{/if}
</div>
</aside>
<!-- Contenido principal -->
<main class="flex-1 min-w-0">
<div class="px-8 py-6">
{#if selectedTipo}
<!-- Título del tipo -->
<div class="mb-8 pb-6 border-b">
<p class="text-sm font-mono text-slate-400 mb-1">{selectedTipo.rubro}</p>
<h2 class="text-xl font-medium text-slate-900 mb-2 flex items-center gap-3">
{getDescripcion(selectedTipo)}
<a
href="/rubro/{selectedTipo.rubro}"
class="text-slate-300 hover:text-blue-500 transition-colors"
title="Ver detalle"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h2>
{#if selectedTipo.descripciones}
{@const tipoDescs = parseDescripciones(selectedTipo.descripciones)}
{#if tipoDescs.length > 0}
<p class="text-slate-600 leading-relaxed">{tipoDescs[0].descripcion}</p>
{#if selectedTipo.n_variaciones > 1}
<button
class="text-sm text-orange-500 hover:text-orange-700 mt-2 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(selectedTipo)}
>
Ver {selectedTipo.n_variaciones} variaciones históricas
</button>
{/if}
{/if}
{/if}
</div>
<!-- Clases -->
<div class="space-y-8">
{#each tipoContent.clases as clase}
{@const claseDescs = parseDescripciones(clase.descripciones)}
<section
id="cl-{clase.rubro}"
class="scroll-mt-4 {highlightedItem === clase.rubro ? 'ring-2 ring-blue-200 rounded-lg' : ''}"
>
<div class="flex items-start gap-4 mb-3">
<span class="font-mono text-sm text-slate-400 pt-1">{clase.rubro}</span>
<div class="flex-1">
<h3 class="text-lg font-medium text-slate-900 flex items-center gap-2">
<span class="text-left">{getDescripcion(clase)}</span>
{#if clase.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(clase)}
title="Ver variaciones de descripción"
>
{clase.n_variaciones} var.
</button>
{/if}
<a
href="/rubro/{clase.rubro}"
class="text-slate-300 hover:text-blue-500 transition-colors"
title="Ver página de detalle"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h3>
{#if claseDescs.length > 0}
<p class="text-sm text-slate-600 mt-1 leading-relaxed">{claseDescs[0].descripcion}</p>
{/if}
</div>
</div>
<!-- Cuentas -->
{#if clase.cuentas?.length > 0}
<div class="ml-16 space-y-4 border-l-2 border-slate-100 pl-6">
{#each clase.cuentas as cuenta}
{@const cuentaDescs = parseDescripciones(cuenta.descripciones)}
<div
id="cu-{cuenta.rubro}"
class="scroll-mt-4 {highlightedItem === cuenta.rubro ? 'ring-2 ring-blue-200 rounded-lg p-2 -ml-2' : ''}"
>
<div class="flex items-start gap-3">
<span class="font-mono text-xs text-slate-400 pt-0.5">{cuenta.rubro}</span>
<div class="flex-1">
<h4 class="text-sm font-medium text-slate-800 flex items-center gap-2">
<span class="text-left">{getDescripcion(cuenta)}</span>
{#if cuenta.n_variaciones > 1}
<button
class="text-xs font-normal text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(cuenta)}
title="Ver variaciones de descripción"
>
{cuenta.n_variaciones} var.
</button>
{/if}
<a
href="/rubro/{cuenta.rubro}"
class="text-slate-300 hover:text-blue-500 transition-colors"
title="Ver página de detalle"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</h4>
{#if cuentaDescs.length > 0}
<p class="text-xs text-slate-500 mt-1 leading-relaxed">{cuentaDescs[0].descripcion}</p>
{/if}
</div>
</div>
<!-- Subcuentas -->
{#if cuenta.subcuentas?.length > 0}
<div class="ml-12 mt-3 space-y-2 border-l border-slate-100 pl-4">
{#each cuenta.subcuentas as subcuenta}
{@const subcuentaDescs = parseDescripciones(subcuenta.descripciones)}
<div
id="sc-{subcuenta.rubro}"
class="scroll-mt-4 {highlightedItem === subcuenta.rubro ? 'ring-2 ring-blue-200 rounded p-1 -ml-1' : ''}"
>
<div class="flex items-start gap-2">
<span class="font-mono text-xs text-slate-300">{subcuenta.rubro}</span>
<div class="flex-1">
<span class="flex items-center gap-2">
<span class="text-xs text-slate-700">{getDescripcion(subcuenta)}</span>
{#if subcuenta.n_variaciones > 1}
<button
class="text-xs text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
onclick={() => openDetail(subcuenta)}
title="Ver variaciones de descripción"
>
{subcuenta.n_variaciones} var.
</button>
{/if}
<a
href="/rubro/{subcuenta.rubro}"
class="text-slate-300 hover:text-blue-500 transition-colors"
title="Ver página de detalle"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</span>
{#if subcuentaDescs.length > 0}
<p class="text-xs text-slate-400 mt-0.5">{subcuentaDescs[0].descripcion}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{/if}
</section>
{/each}
</div>
{/if}
</div>
</main>
<!-- Sidebar derecha: En esta página -->
<aside class="w-56 flex-shrink-0 hidden xl:block">
<div class="sticky top-0 h-screen overflow-y-auto p-4 border-l">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-3">En esta página</p>
{#if selectedTipo && !isSearching}
<nav class="space-y-2">
{#each tipoContent.clases as clase}
<div>
<a
href="#cl-{clase.rubro}"
class="block text-sm text-slate-600 hover:text-blue-600 truncate"
title="{getDescripcion(clase)}"
>
{getDescripcion(clase)}
</a>
{#if clase.cuentas?.length > 0}
<div class="ml-3 mt-1 space-y-1 border-l border-slate-100 pl-2">
{#each clase.cuentas.slice(0, 5) as cuenta}
<a
href="#cu-{cuenta.rubro}"
class="block text-xs text-slate-400 hover:text-blue-600 truncate"
title="{getDescripcion(cuenta)}"
>
{getDescripcion(cuenta)}
</a>
{/each}
{#if clase.cuentas.length > 5}
<span class="text-xs text-slate-300">+{clase.cuentas.length - 5} más</span>
{/if}
</div>
{/if}
</div>
{/each}
</nav>
{/if}
</div>
</aside>
</div>
{/if}
</div>
<!-- Modal de detalle -->
{#if selectedItem}
<div class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onclick={closeDetail}>
<div
class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
onclick={(e) => e.stopPropagation()}
>
<div class="px-6 py-4 border-b bg-slate-50 flex justify-between items-start">
<div>
<p class="text-xs text-slate-500 uppercase tracking-wide">{getNivelLabel(selectedItem.nivel)}</p>
<h3 class="text-lg font-medium text-slate-900 mt-1">
<span class="font-mono text-slate-400">{selectedItem.rubro}</span>
<span class="mx-2">·</span>
{getDescripcion(selectedItem)}
</h3>
</div>
<button
onclick={closeDetail}
class="text-slate-400 hover:text-slate-600 text-2xl leading-none"
>
&times;
</button>
</div>
<div class="p-6 overflow-y-auto flex-1">
<h4 class="text-sm font-medium text-slate-700 mb-4">
{#if selectedItem.n_variaciones > 1}
Descripciones ({selectedItem.n_variaciones} variaciones)
{:else}
Descripción
{/if}
</h4>
<div class="space-y-4">
{#each parseDescripciones(selectedItem.descripciones) as desc, i}
<div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200'} pl-4 py-2 rounded-r">
<p class="text-xs text-slate-500 mb-2">
{#if i === 0 && selectedItem.n_variaciones > 1}
<span class="text-blue-600 font-medium">Vigente</span>
<span class="mx-1">·</span>
{/if}
{desc.rangos}
</p>
<p class="text-sm text-slate-700 leading-relaxed">{desc.descripcion}</p>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
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>