Toggle navigation
Toggle navigation
This project
Loading...
Sign in
Rafael Lopez
/
ppto
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
Rafael Lopez
2026-03-04 00:20:53 -0400
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
49557a7bc5fc9e51e43805d8ea2707fd193c08ac
49557a7b
1 parent
f315f66b
rubros
Show whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
976 additions
and
21 deletions
BUSQUEDA.md
src/lib/components/search/SearchBox.svelte
src/lib/components/search/SearchResults.svelte
src/lib/services/search.js
src/lib/stores/searchStore.js
src/routes/clasificadores/+page.svelte
src/routes/clasificadores/rubros/+page.svelte
src/routes/rubro/[codigo]/+page.server.js
src/routes/rubro/[codigo]/+page.svelte
BUSQUEDA.md
View file @
49557a7
...
...
@@ -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 objet
os), cabe en memoria, búsqueda instantánea sin latencia
-
**Razón**
: ~1,
400 registros totales (~600 entidades + ~450 objetos + ~380 rubr
os), 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 | 2
00-30
0ms |
| Debounce | 2
5
0ms |
| Mínimo caracteres | 2 |
| Máximo resultados | 1
0-30
|
| Navegación | Flechas ↑↓ + Enter + scroll de mouse
y loq ue corresponda para mobiles
|
| Máximo resultados | 1
5
|
| 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 | - |
...
...
src/lib/components/search/SearchBox.svelte
View file @
49557a7
...
...
@@ -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 gast
o..." : "Cargando..."}
placeholder={$indexLoaded ? "Buscar entidad
, objeto de gasto o rubr
o..." : "Cargando..."}
disabled={!$indexLoaded}
class="w-full p-2 border rounded"
oninput={handleInput}
...
...
src/lib/components/search/SearchResults.svelte
View file @
49557a7
...
...
@@ -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-10
0' : ''}"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-5
0' : ''}"
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">
...
...
src/lib/services/search.js
View file @
49557a7
...
...
@@ -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
nivelLabels
Objetos
=
{
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 ·
${
nivelLabels
Objetos
[
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
);
...
...
src/lib/stores/searchStore.js
View file @
49557a7
...
...
@@ -50,7 +50,7 @@ export function performSearch(searchQuery) {
return
;
}
const
searchResults
=
search
(
searchQuery
,
30
);
const
searchResults
=
search
(
searchQuery
,
15
);
results
.
set
(
searchResults
);
}
...
...
src/routes/clasificadores/+page.svelte
View file @
49557a7
...
...
@@ -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 },
...
...
src/routes/clasificadores/rubros/+page.svelte
0 → 100644
View file @
49557a7
<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"
>
×
</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}
src/routes/rubro/[codigo]/+page.server.js
0 → 100644
View file @
49557a7
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
};
}
src/routes/rubro/[codigo]/+page.svelte
0 → 100644
View file @
49557a7
<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>
Please
register
or
login
to post a comment