Rafael Lopez

rubros

...@@ -7,7 +7,7 @@ Buscador instantáneo con Fuse.js en cliente. Busca en clasificadores presupuest ...@@ -7,7 +7,7 @@ Buscador instantáneo con Fuse.js en cliente. Busca en clasificadores presupuest
7 7
8 ### Motor de búsqueda 8 ### Motor de búsqueda
9 - **Tecnología**: Fuse.js (cliente) 9 - **Tecnología**: Fuse.js (cliente)
10 -- **Razón**: ~1,000 registros totales (~600 entidades + ~450 objetos), cabe en memoria, búsqueda instantánea sin latencia 10 +- **Razón**: ~1,400 registros totales (~600 entidades + ~450 objetos + ~380 rubros), cabe en memoria, búsqueda instantánea sin latencia
11 11
12 ### Configuración Fuse.js 12 ### Configuración Fuse.js
13 ```javascript 13 ```javascript
...@@ -34,10 +34,10 @@ const fuseOptions = { ...@@ -34,10 +34,10 @@ const fuseOptions = {
34 ### Comportamiento 34 ### Comportamiento
35 | Parámetro | Valor | 35 | Parámetro | Valor |
36 |-----------|-------| 36 |-----------|-------|
37 -| Debounce | 200-300ms | 37 +| Debounce | 250ms |
38 | Mínimo caracteres | 2 | 38 | Mínimo caracteres | 2 |
39 -| Máximo resultados | 10-30 | 39 +| Máximo resultados | 15 |
40 -| Navegación | Flechas ↑↓ + Enter + scroll de mouse y loq ue corresponda para mobiles| 40 +| Navegación | Flechas ↑↓ + Enter + scroll de mouse |
41 41
42 ## Estructura del Índice 42 ## Estructura del Índice
43 43
...@@ -70,6 +70,18 @@ const searchData = [ ...@@ -70,6 +70,18 @@ const searchData = [
70 nombre_normalizado: 'pasajes', 70 nombre_normalizado: 'pasajes',
71 sigla_normalizada: '', 71 sigla_normalizada: '',
72 codigo_normalizado: '25100' 72 codigo_normalizado: '25100'
73 + },
74 + // Rubros
75 + {
76 + tipo: 'rubro',
77 + codigo: 13000,
78 + nombre: 'INGRESOS POR IMPUESTOS',
79 + sigla: null,
80 + contexto: 'Rubro · Tipo',
81 + nivel: 'tipo',
82 + nombre_normalizado: 'ingresos por impuestos',
83 + sigla_normalizada: '',
84 + codigo_normalizado: '13000'
73 } 85 }
74 ] 86 ]
75 ``` 87 ```
...@@ -77,6 +89,7 @@ const searchData = [ ...@@ -77,6 +89,7 @@ const searchData = [
77 ### Fuentes de datos 89 ### Fuentes de datos
78 - **Entidades**: tabla `ppto.clas_institucional` 90 - **Entidades**: tabla `ppto.clas_institucional`
79 - **Objetos de gasto**: tabla `ppto.clas_objetos` 91 - **Objetos de gasto**: tabla `ppto.clas_objetos`
92 +- **Rubros**: tabla `ppto.clas_rubros`
80 93
81 ## Columnas de Búsqueda 94 ## Columnas de Búsqueda
82 95
...@@ -96,6 +109,13 @@ const searchData = [ ...@@ -96,6 +109,13 @@ const searchData = [
96 | `objeto` | ✓ | ✓ código (monospace) | 109 | `objeto` | ✓ | ✓ código (monospace) |
97 | `nivel` | ✗ | ✓ contexto ("Objeto del Gasto · Partida") | 110 | `nivel` | ✗ | ✓ contexto ("Objeto del Gasto · Partida") |
98 111
112 +### Rubros (clas_rubros)
113 +| Columna | Buscar | Mostrar |
114 +|---------|--------|---------|
115 +| `desc_rubros` | ✓ | ✓ nombre principal |
116 +| `rubro` | ✓ | ✓ código (monospace) |
117 +| `nivel` | ✗ | ✓ contexto ("Rubro · Tipo") |
118 +
99 ## Flujo de Búsqueda 119 ## Flujo de Búsqueda
100 120
101 ``` 121 ```
...@@ -109,7 +129,7 @@ const searchData = [ ...@@ -109,7 +129,7 @@ const searchData = [
109 Fuse devuelve matches → renderizar lista con highlighting 129 Fuse devuelve matches → renderizar lista con highlighting
110 130
111 4. SELECCIÓN 131 4. SELECCIÓN
112 - Click o Enter → navegar a /entidad/[codigo] o /gasto/[codigo] 132 + Click o Enter → navegar a URL según tipo
113 ``` 133 ```
114 134
115 ## Estructura de URLs 135 ## Estructura de URLs
...@@ -117,28 +137,38 @@ const searchData = [ ...@@ -117,28 +137,38 @@ const searchData = [
117 ``` 137 ```
118 /entidad/[codigo] → /entidad/139 138 /entidad/[codigo] → /entidad/139
119 /objeto/[codigo] → /objeto/25100 139 /objeto/[codigo] → /objeto/25100
120 -/area/[codigo] → /area/1.1.4 (futuro, opcional) 140 +/rubro/[codigo] → /rubro/13000
121 ``` 141 ```
122 142
123 ## Formato de Resultados 143 ## Formato de Resultados
124 144
145 +Resultados agrupados por tipo de clasificador:
146 +
125 ``` 147 ```
126 ┌─────────────────────────────────────────────────────┐ 148 ┌─────────────────────────────────────────────────────┐
127 -│ 🔍 [Buscar entidad u objeto de gasto...] │ 149 +│ 🔍 [Buscar entidad, objeto de gasto o rubro...] │
150 +├─────────────────────────────────────────────────────┤
151 +│ ENTIDADES │
128 ├─────────────────────────────────────────────────────┤ 152 ├─────────────────────────────────────────────────────┤
129 -│ Universidad Mayor De San Andrés (UMSA) │ ← Entidad 153 +│ Universidad Mayor De San Andrés (UMSA) │
130 │ Universidades Públicas │ 154 │ Universidades Públicas │
131 ├─────────────────────────────────────────────────────┤ 155 ├─────────────────────────────────────────────────────┤
132 -│ 25100 Pasajes │ ← Objeto de gasto 156 +│ OBJETOS DEL GASTO │
157 +├─────────────────────────────────────────────────────┤
158 +│ 25100 Pasajes │
133 │ Objeto del Gasto · Partida │ 159 │ Objeto del Gasto · Partida │
134 ├─────────────────────────────────────────────────────┤ 160 ├─────────────────────────────────────────────────────┤
135 -│ ... │ 161 +│ RUBROS │
162 +├─────────────────────────────────────────────────────┤
163 +│ 13000 INGRESOS POR IMPUESTOS │
164 +│ Rubro · Tipo │
136 └─────────────────────────────────────────────────────┘ 165 └─────────────────────────────────────────────────────┘
137 ``` 166 ```
138 167
139 ### Diferencias visuales 168 ### Diferencias visuales
140 - **Entidades**: Nombre + (sigla) en primera línea 169 - **Entidades**: Nombre + (sigla) en primera línea
141 - **Objetos de gasto**: Código (monospace) + Nombre en primera línea 170 - **Objetos de gasto**: Código (monospace) + Nombre en primera línea
171 +- **Rubros**: Código (monospace) + Nombre en primera línea
142 172
143 ## Estados del Buscador 173 ## Estados del Buscador
144 174
...@@ -177,6 +207,7 @@ src/lib/ ...@@ -177,6 +207,7 @@ src/lib/
177 |--------------|--------|-------| 207 |--------------|--------|-------|
178 | Entidades | ✅ Implementado | `ppto.clas_institucional` | 208 | Entidades | ✅ Implementado | `ppto.clas_institucional` |
179 | Objetos de gasto | ✅ Implementado | `ppto.clas_objetos` | 209 | Objetos de gasto | ✅ Implementado | `ppto.clas_objetos` |
210 +| Rubros | ✅ Implementado | `ppto.clas_rubros` |
180 | Fuente de financiamiento | ⏳ Pendiente | - | 211 | Fuente de financiamiento | ⏳ Pendiente | - |
181 | Geográfico | ⏳ Pendiente | - | 212 | Geográfico | ⏳ Pendiente | - |
182 213
......
...@@ -52,6 +52,8 @@ ...@@ -52,6 +52,8 @@
52 goto(`/entidad/${item.codigo}`); 52 goto(`/entidad/${item.codigo}`);
53 } else if (item.tipo === 'objeto_gasto') { 53 } else if (item.tipo === 'objeto_gasto') {
54 goto(`/objeto/${item.codigo}`); 54 goto(`/objeto/${item.codigo}`);
55 + } else if (item.tipo === 'rubro') {
56 + goto(`/rubro/${item.codigo}`);
55 } 57 }
56 clearSearch(); 58 clearSearch();
57 inputRef.value = ''; 59 inputRef.value = '';
...@@ -78,7 +80,7 @@ ...@@ -78,7 +80,7 @@
78 <input 80 <input
79 bind:this={inputRef} 81 bind:this={inputRef}
80 type="text" 82 type="text"
81 - placeholder={$indexLoaded ? "Buscar entidad u objeto de gasto..." : "Cargando..."} 83 + placeholder={$indexLoaded ? "Buscar entidad, objeto de gasto o rubro..." : "Cargando..."}
82 disabled={!$indexLoaded} 84 disabled={!$indexLoaded}
83 class="w-full p-2 border rounded" 85 class="w-full p-2 border rounded"
84 oninput={handleInput} 86 oninput={handleInput}
......
...@@ -9,31 +9,96 @@ ...@@ -9,31 +9,96 @@
9 goto(`/entidad/${item.codigo}`); 9 goto(`/entidad/${item.codigo}`);
10 } else if (item.tipo === 'objeto_gasto') { 10 } else if (item.tipo === 'objeto_gasto') {
11 goto(`/objeto/${item.codigo}`); 11 goto(`/objeto/${item.codigo}`);
12 + } else if (item.tipo === 'rubro') {
13 + goto(`/rubro/${item.codigo}`);
12 } 14 }
13 clearSearch(); 15 clearSearch();
14 } 16 }
17 +
18 + // Agrupar resultados por tipo
19 + let groupedResults = $derived(() => {
20 + const entidades = results.filter(r => r.tipo === 'entidad');
21 + const objetos = results.filter(r => r.tipo === 'objeto_gasto');
22 + const rubros = results.filter(r => r.tipo === 'rubro');
23 + return { entidades, objetos, rubros };
24 + });
25 +
26 + // Calcular índice global para navegación con teclado
27 + function getGlobalIndex(tipo, localIndex) {
28 + const g = groupedResults();
29 + if (tipo === 'entidad') {
30 + return localIndex;
31 + } else if (tipo === 'objeto_gasto') {
32 + return g.entidades.length + localIndex;
33 + } else {
34 + return g.entidades.length + g.objetos.length + localIndex;
35 + }
36 + }
15 </script> 37 </script>
16 38
17 {#if results.length > 0} 39 {#if results.length > 0}
18 <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"> 40 <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">
19 - {#each results as item, index} 41 + {#if groupedResults().entidades.length > 0}
20 - <button 42 + <div class="px-3 py-1.5 bg-slate-50 border-b">
21 - type="button" 43 + <span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Entidades</span>
22 - class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {index === selectedIndex ? 'bg-gray-100' : ''}" 44 + </div>
23 - onclick={() => handleSelect(item)} 45 + {#each groupedResults().entidades as item, index}
24 - > 46 + {@const globalIdx = getGlobalIndex('entidad', index)}
25 - <div class="flex items-center gap-2"> 47 + <button
26 - {#if item.tipo === 'objeto_gasto'} 48 + type="button"
49 + class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
50 + onclick={() => handleSelect(item)}
51 + >
52 + <div class="flex items-center gap-2">
53 + <span>{item.nombre}</span>
54 + {#if item.sigla}
55 + <span class="text-slate-400">({item.sigla})</span>
56 + {/if}
57 + </div>
58 + <div class="text-xs text-gray-500">{item.contexto}</div>
59 + </button>
60 + {/each}
61 + {/if}
62 +
63 + {#if groupedResults().objetos.length > 0}
64 + <div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 ? 'border-t' : ''}">
65 + <span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Objetos del Gasto</span>
66 + </div>
67 + {#each groupedResults().objetos as item, index}
68 + {@const globalIdx = getGlobalIndex('objeto_gasto', index)}
69 + <button
70 + type="button"
71 + class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
72 + onclick={() => handleSelect(item)}
73 + >
74 + <div class="flex items-center gap-2">
75 + <span class="font-mono text-xs text-slate-400">{item.codigo}</span>
76 + <span>{item.nombre}</span>
77 + </div>
78 + <div class="text-xs text-gray-500">{item.contexto}</div>
79 + </button>
80 + {/each}
81 + {/if}
82 +
83 + {#if groupedResults().rubros.length > 0}
84 + <div class="px-3 py-1.5 bg-slate-50 border-b {groupedResults().entidades.length > 0 || groupedResults().objetos.length > 0 ? 'border-t' : ''}">
85 + <span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Rubros</span>
86 + </div>
87 + {#each groupedResults().rubros as item, index}
88 + {@const globalIdx = getGlobalIndex('rubro', index)}
89 + <button
90 + type="button"
91 + class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}"
92 + onclick={() => handleSelect(item)}
93 + >
94 + <div class="flex items-center gap-2">
27 <span class="font-mono text-xs text-slate-400">{item.codigo}</span> 95 <span class="font-mono text-xs text-slate-400">{item.codigo}</span>
28 - {/if} 96 + <span>{item.nombre}</span>
29 - <span>{item.nombre}</span> 97 + </div>
30 - {#if item.sigla} 98 + <div class="text-xs text-gray-500">{item.contexto}</div>
31 - <span class="text-slate-400">({item.sigla})</span> 99 + </button>
32 - {/if} 100 + {/each}
33 - </div> 101 + {/if}
34 - <div class="text-xs text-gray-500">{item.contexto}</div>
35 - </button>
36 - {/each}
37 </div> 102 </div>
38 {:else if query.length >= 2} 103 {:else if query.length >= 2}
39 <div class="absolute top-full left-0 right-0 mt-1 bg-white border rounded p-3 text-sm text-gray-500"> 104 <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() { ...@@ -50,6 +50,17 @@ export async function initSearchIndex() {
50 throw errorObjetos; 50 throw errorObjetos;
51 } 51 }
52 52
53 + // Cargar rubros
54 + const { data: rubros, error: errorRubros } = await supabase
55 + .schema('ppto')
56 + .from('clas_rubros')
57 + .select('rubro, desc_rubros, nivel');
58 +
59 + if (errorRubros) {
60 + console.error('Error cargando rubros:', errorRubros);
61 + throw errorRubros;
62 + }
63 +
53 // Transformar entidades para el índice 64 // Transformar entidades para el índice
54 const entidadesIndex = entidades.map(item => ({ 65 const entidadesIndex = entidades.map(item => ({
55 tipo: 'entidad', 66 tipo: 'entidad',
...@@ -64,21 +75,35 @@ export async function initSearchIndex() { ...@@ -64,21 +75,35 @@ export async function initSearchIndex() {
64 })); 75 }));
65 76
66 // Transformar objetos de gasto para el índice 77 // Transformar objetos de gasto para el índice
67 - const nivelLabels = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' }; 78 + const nivelLabelsObjetos = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
68 const objetosIndex = objetos.map(item => ({ 79 const objetosIndex = objetos.map(item => ({
69 tipo: 'objeto_gasto', 80 tipo: 'objeto_gasto',
70 codigo: item.objeto, 81 codigo: item.objeto,
71 nombre: item.desc_objeto, 82 nombre: item.desc_objeto,
72 sigla: null, 83 sigla: null,
73 - contexto: `Objeto del Gasto · ${nivelLabels[item.nivel] || item.nivel}`, 84 + contexto: `Objeto del Gasto · ${nivelLabelsObjetos[item.nivel] || item.nivel}`,
74 nivel: item.nivel, 85 nivel: item.nivel,
75 nombre_normalizado: normalizeText(item.desc_objeto), 86 nombre_normalizado: normalizeText(item.desc_objeto),
76 sigla_normalizada: '', 87 sigla_normalizada: '',
77 codigo_normalizado: item.objeto || '' 88 codigo_normalizado: item.objeto || ''
78 })); 89 }));
79 90
80 - // Combinar ambos conjuntos 91 + // Transformar rubros para el índice
81 - searchData = [...entidadesIndex, ...objetosIndex]; 92 + const nivelLabelsRubros = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
93 + const rubrosIndex = rubros.map(item => ({
94 + tipo: 'rubro',
95 + codigo: item.rubro,
96 + nombre: item.desc_rubros,
97 + sigla: null,
98 + contexto: `Rubro · ${nivelLabelsRubros[item.nivel] || item.nivel}`,
99 + nivel: item.nivel,
100 + nombre_normalizado: normalizeText(item.desc_rubros),
101 + sigla_normalizada: '',
102 + codigo_normalizado: item.rubro?.toString() || ''
103 + }));
104 +
105 + // Combinar todos los conjuntos
106 + searchData = [...entidadesIndex, ...objetosIndex, ...rubrosIndex];
82 107
83 fuseInstance = new Fuse(searchData, fuseOptions); 108 fuseInstance = new Fuse(searchData, fuseOptions);
84 109
......
...@@ -50,7 +50,7 @@ export function performSearch(searchQuery) { ...@@ -50,7 +50,7 @@ export function performSearch(searchQuery) {
50 return; 50 return;
51 } 51 }
52 52
53 - const searchResults = search(searchQuery, 30); 53 + const searchResults = search(searchQuery, 15);
54 results.set(searchResults); 54 results.set(searchResults);
55 } 55 }
56 56
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
2 const clasificadores = [ 2 const clasificadores = [
3 { id: 'institucional', nombre: 'Institucional', descripcion: 'Ministerios, universidades, municipios, empresas públicas', disponible: true }, 3 { id: 'institucional', nombre: 'Institucional', descripcion: 'Ministerios, universidades, municipios, empresas públicas', disponible: true },
4 { id: 'objeto-gasto', nombre: 'Objeto del Gasto', descripcion: 'Servicios personales, bienes, inversiones', disponible: true }, 4 { id: 'objeto-gasto', nombre: 'Objeto del Gasto', descripcion: 'Servicios personales, bienes, inversiones', disponible: true },
5 + { id: 'rubros', nombre: 'Rubros de Ingresos', descripcion: 'Ingresos tributarios, no tributarios, transferencias', disponible: true },
5 { id: 'fuente', nombre: 'Fuente de Financiamiento', descripcion: 'Tesoro, créditos externos, recursos propios', disponible: false }, 6 { id: 'fuente', nombre: 'Fuente de Financiamiento', descripcion: 'Tesoro, créditos externos, recursos propios', disponible: false },
6 { id: 'organismo', nombre: 'Organismo Financiador', descripcion: 'BID, Banco Mundial, CAF', disponible: false }, 7 { id: 'organismo', nombre: 'Organismo Financiador', descripcion: 'BID, Banco Mundial, CAF', disponible: false },
7 { id: 'geografico', nombre: 'Geográfico', descripcion: 'Departamentos, provincias, municipios', disponible: false }, 8 { id: 'geografico', nombre: 'Geográfico', descripcion: 'Departamentos, provincias, municipios', disponible: false },
......
1 +<script>
2 + import { supabase } from '$lib/supabase';
3 + import { onMount } from 'svelte';
4 +
5 + let loading = $state(true);
6 + let searchQuery = $state('');
7 + let tipos = $state([]);
8 + let allItems = $state([]);
9 + let selectedTipo = $state(null);
10 + let selectedItem = $state(null);
11 + let highlightedItem = $state(null);
12 +
13 + onMount(async () => {
14 + const { data, error } = await supabase
15 + .schema('ppto')
16 + .from('clas_rubros')
17 + .select('*')
18 + .order('rubro');
19 +
20 + if (!error && data) {
21 + allItems = data;
22 +
23 + // Extraer tipos únicos (nivel más agregado)
24 + tipos = data
25 + .filter(item => item.nivel === 'tipo')
26 + .reduce((acc, item) => {
27 + if (!acc.find(t => t.rubro === item.rubro)) {
28 + acc.push(item);
29 + }
30 + return acc;
31 + }, [])
32 + .sort((a, b) => a.rubro - b.rubro);
33 +
34 + if (tipos.length > 0) {
35 + selectedTipo = tipos[0];
36 + }
37 + }
38 +
39 + loading = false;
40 + });
41 +
42 + function getItemsForTipo(tipoCode) {
43 + if (!tipoCode) return { clases: [] };
44 +
45 + const tipoNum = Math.floor(tipoCode / 1000);
46 +
47 + // Obtener todos los items de este tipo
48 + const itemsDelTipo = allItems.filter(item => item.tipo === tipoNum && item.nivel !== 'tipo');
49 +
50 + // Encontrar clases únicas desde los valores de la columna 'clase'
51 + const clasesUnicas = [...new Set(itemsDelTipo.map(i => i.clase).filter(c => c != null))].sort((a, b) => a - b);
52 +
53 + const clases = clasesUnicas.map(claseNum => {
54 + // Buscar si existe una entrada de nivel 'clase' para esta clase
55 + const claseEntry = allItems.find(item => item.nivel === 'clase' && item.tipo === tipoNum && item.clase === claseNum);
56 +
57 + // Items de esta clase
58 + const itemsDeClase = itemsDelTipo.filter(item => item.clase === claseNum);
59 +
60 + // Encontrar cuentas únicas
61 + const cuentasUnicas = [...new Set(itemsDeClase.map(i => i.cuenta).filter(c => c != null))].sort((a, b) => a - b);
62 +
63 + const cuentas = cuentasUnicas.map(cuentaNum => {
64 + // Buscar si existe una entrada de nivel 'cuenta' para esta cuenta
65 + const cuentaEntry = allItems.find(item => item.nivel === 'cuenta' && item.tipo === tipoNum && item.clase === claseNum && item.cuenta === cuentaNum);
66 +
67 + // Sub_cuentas de esta cuenta
68 + const subcuentas = itemsDeClase
69 + .filter(item => item.nivel === 'sub_cuenta' && item.cuenta === cuentaNum)
70 + .reduce((acc, item) => {
71 + if (!acc.find(s => s.rubro === item.rubro)) acc.push(item);
72 + return acc;
73 + }, [])
74 + .sort((a, b) => a.rubro - b.rubro);
75 +
76 + // Si existe entrada de cuenta, usarla; si no, crear una virtual
77 + const cuentaData = cuentaEntry || {
78 + rubro: tipoNum * 1000 + claseNum * 100 + cuentaNum * 10,
79 + nivel: 'cuenta',
80 + tipo: tipoNum,
81 + clase: claseNum,
82 + cuenta: cuentaNum,
83 + desc_cuenta: subcuentas[0]?.desc_cuenta || `Cuenta ${cuentaNum}`,
84 + desc_tipo: subcuentas[0]?.desc_tipo,
85 + desc_clase: subcuentas[0]?.desc_clase,
86 + n_variaciones: null,
87 + descripciones: null
88 + };
89 +
90 + return { ...cuentaData, subcuentas };
91 + });
92 +
93 + // Si existe entrada de clase, usarla; si no, crear una virtual
94 + const claseData = claseEntry || {
95 + rubro: tipoNum * 1000 + claseNum * 100,
96 + nivel: 'clase',
97 + tipo: tipoNum,
98 + clase: claseNum,
99 + desc_clase: itemsDeClase[0]?.desc_clase || `Clase ${claseNum}`,
100 + desc_tipo: itemsDeClase[0]?.desc_tipo,
101 + n_variaciones: null,
102 + descripciones: null
103 + };
104 +
105 + return { ...claseData, cuentas };
106 + });
107 +
108 + return { clases };
109 + }
110 +
111 + // Búsqueda global
112 + function searchGlobal(query) {
113 + if (!query || query.length < 2) return [];
114 + const q = query.toLowerCase();
115 +
116 + return allItems
117 + .filter(item =>
118 + item.rubro?.toString().includes(q) ||
119 + item.desc_rubros?.toLowerCase().includes(q) ||
120 + item.descripciones?.toLowerCase().includes(q)
121 + )
122 + .reduce((acc, item) => {
123 + if (!acc.find(i => i.rubro === item.rubro)) {
124 + acc.push(item);
125 + }
126 + return acc;
127 + }, [])
128 + .slice(0, 20);
129 + }
130 +
131 + function parseDescripciones(descripcionesStr) {
132 + if (!descripcionesStr) return [];
133 + try {
134 + const parsed = JSON.parse(descripcionesStr);
135 + return parsed.sort((a, b) => {
136 + const maxYearA = getMaxYear(a.rangos);
137 + const maxYearB = getMaxYear(b.rangos);
138 + return maxYearB - maxYearA;
139 + });
140 + } catch {
141 + return [];
142 + }
143 + }
144 +
145 + function getMaxYear(rangos) {
146 + if (!rangos) return 0;
147 + const years = rangos.match(/\d{4}/g);
148 + if (!years) return 0;
149 + return Math.max(...years.map(y => parseInt(y)));
150 + }
151 +
152 + function selectTipo(tipo) {
153 + selectedTipo = tipo;
154 + selectedItem = null;
155 + searchQuery = '';
156 + highlightedItem = null;
157 + }
158 +
159 + function openDetail(item) {
160 + selectedItem = item;
161 + }
162 +
163 + function closeDetail() {
164 + selectedItem = null;
165 + }
166 +
167 + function goToSearchResult(item) {
168 + const tipoNum = item.tipo;
169 + const targetTipo = tipos.find(t => Math.floor(t.rubro / 1000) === tipoNum);
170 +
171 + if (targetTipo) {
172 + selectedTipo = targetTipo;
173 + highlightedItem = item.rubro;
174 + searchQuery = '';
175 +
176 + setTimeout(() => {
177 + const prefix = item.nivel === 'clase' ? 'cl' : item.nivel === 'cuenta' ? 'cu' : 'sc';
178 + const element = document.getElementById(`${prefix}-${item.rubro}`);
179 + if (element) {
180 + element.scrollIntoView({ behavior: 'smooth', block: 'center' });
181 + }
182 + }, 100);
183 + }
184 + }
185 +
186 + function getNivelLabel(nivel) {
187 + const labels = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
188 + return labels[nivel] || nivel;
189 + }
190 +
191 + // Obtener la descripción correcta según el nivel
192 + function getDescripcion(item) {
193 + if (item.nivel === 'tipo') return item.desc_tipo;
194 + if (item.nivel === 'clase') return item.desc_clase;
195 + if (item.nivel === 'cuenta') return item.desc_cuenta;
196 + if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta;
197 + return item.desc_rubros;
198 + }
199 +
200 + let tipoContent = $derived(selectedTipo ? getItemsForTipo(selectedTipo.rubro) : { clases: [] });
201 + let searchResults = $derived(searchGlobal(searchQuery));
202 + let isSearching = $derived(searchQuery.length >= 2);
203 +</script>
204 +
205 +<svelte:head>
206 + <title>Rubros | Presupuesto Público</title>
207 +</svelte:head>
208 +
209 +<div class="min-h-screen bg-white">
210 + <!-- Header -->
211 + <header class="border-b bg-slate-50">
212 + <div class="max-w-screen-2xl mx-auto px-6 py-4">
213 + <nav class="text-sm text-slate-500 mb-2">
214 + <a href="/" class="hover:text-slate-700">Inicio</a>
215 + <span class="mx-2">/</span>
216 + <a href="/clasificadores" class="hover:text-slate-700">Clasificadores</a>
217 + <span class="mx-2">/</span>
218 + <span class="text-slate-900">Rubros</span>
219 + </nav>
220 + <div class="flex items-center justify-between">
221 + <h1 class="text-2xl font-light text-slate-900">Clasificador por Rubros de Ingresos</h1>
222 + </div>
223 + </div>
224 + </header>
225 +
226 + {#if loading}
227 + <div class="flex items-center justify-center py-20">
228 + <p class="text-slate-500">Cargando clasificador...</p>
229 + </div>
230 + {:else}
231 + <div class="max-w-screen-2xl mx-auto flex">
232 + <!-- Sidebar izquierda: Tipos -->
233 + <aside class="w-64 flex-shrink-0 border-r bg-slate-50/50">
234 + <div class="sticky top-0 h-screen overflow-y-auto p-4">
235 + <!-- Buscador -->
236 + <div class="mb-6">
237 + <input
238 + type="text"
239 + bind:value={searchQuery}
240 + placeholder="Buscar..."
241 + 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"
242 + />
243 + </div>
244 +
245 + <!-- Resultados de búsqueda -->
246 + {#if isSearching}
247 + <div class="mb-4">
248 + <p class="text-xs text-slate-500 uppercase tracking-wide mb-2">
249 + {searchResults.length} resultados
250 + </p>
251 + <div class="space-y-1">
252 + {#each searchResults as result}
253 + <button
254 + class="w-full text-left px-2 py-2 text-sm rounded hover:bg-white hover:shadow-sm transition-all"
255 + onclick={() => goToSearchResult(result)}
256 + >
257 + <span class="text-xs text-slate-400 block">{getNivelLabel(result.nivel)}</span>
258 + <span class="font-mono text-xs text-blue-600">{result.rubro}</span>
259 + <span class="text-slate-700 ml-1">{getDescripcion(result)}</span>
260 + </button>
261 + {/each}
262 + {#if searchResults.length === 0}
263 + <p class="text-sm text-slate-400 px-2">Sin resultados</p>
264 + {/if}
265 + </div>
266 + </div>
267 + {:else}
268 + <!-- Lista de tipos -->
269 + <nav>
270 + <p class="text-xs text-slate-500 uppercase tracking-wide mb-3 px-2">Tipos</p>
271 + <ul class="space-y-1">
272 + {#each tipos as tipo}
273 + <li>
274 + <button
275 + class="w-full text-left px-3 py-2 rounded-md text-sm transition-all
276 + {selectedTipo?.rubro === tipo.rubro
277 + ? 'bg-blue-50 text-blue-900 font-medium border-l-2 border-blue-500'
278 + : 'text-slate-600 hover:bg-white hover:text-slate-900'}"
279 + onclick={() => selectTipo(tipo)}
280 + >
281 + <span class="font-mono text-xs text-slate-400 block">{tipo.rubro}</span>
282 + {getDescripcion(tipo)}
283 + </button>
284 + </li>
285 + {/each}
286 + </ul>
287 + </nav>
288 + {/if}
289 + </div>
290 + </aside>
291 +
292 + <!-- Contenido principal -->
293 + <main class="flex-1 min-w-0">
294 + <div class="px-8 py-6">
295 + {#if selectedTipo}
296 + <!-- Título del tipo -->
297 + <div class="mb-8 pb-6 border-b">
298 + <p class="text-sm font-mono text-slate-400 mb-1">{selectedTipo.rubro}</p>
299 + <h2 class="text-xl font-medium text-slate-900 mb-2 flex items-center gap-3">
300 + {getDescripcion(selectedTipo)}
301 + <a
302 + href="/rubro/{selectedTipo.rubro}"
303 + class="text-slate-300 hover:text-blue-500 transition-colors"
304 + title="Ver detalle"
305 + >
306 + <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
307 + <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" />
308 + </svg>
309 + </a>
310 + </h2>
311 + {#if selectedTipo.descripciones}
312 + {@const tipoDescs = parseDescripciones(selectedTipo.descripciones)}
313 + {#if tipoDescs.length > 0}
314 + <p class="text-slate-600 leading-relaxed">{tipoDescs[0].descripcion}</p>
315 + {#if selectedTipo.n_variaciones > 1}
316 + <button
317 + 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"
318 + onclick={() => openDetail(selectedTipo)}
319 + >
320 + Ver {selectedTipo.n_variaciones} variaciones históricas
321 + </button>
322 + {/if}
323 + {/if}
324 + {/if}
325 + </div>
326 +
327 + <!-- Clases -->
328 + <div class="space-y-8">
329 + {#each tipoContent.clases as clase}
330 + {@const claseDescs = parseDescripciones(clase.descripciones)}
331 + <section
332 + id="cl-{clase.rubro}"
333 + class="scroll-mt-4 {highlightedItem === clase.rubro ? 'ring-2 ring-blue-200 rounded-lg' : ''}"
334 + >
335 + <div class="flex items-start gap-4 mb-3">
336 + <span class="font-mono text-sm text-slate-400 pt-1">{clase.rubro}</span>
337 + <div class="flex-1">
338 + <h3 class="text-lg font-medium text-slate-900 flex items-center gap-2">
339 + <span class="text-left">{getDescripcion(clase)}</span>
340 + {#if clase.n_variaciones > 1}
341 + <button
342 + 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"
343 + onclick={() => openDetail(clase)}
344 + title="Ver variaciones de descripción"
345 + >
346 + {clase.n_variaciones} var.
347 + </button>
348 + {/if}
349 + <a
350 + href="/rubro/{clase.rubro}"
351 + class="text-slate-300 hover:text-blue-500 transition-colors"
352 + title="Ver página de detalle"
353 + >
354 + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
355 + <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" />
356 + </svg>
357 + </a>
358 + </h3>
359 + {#if claseDescs.length > 0}
360 + <p class="text-sm text-slate-600 mt-1 leading-relaxed">{claseDescs[0].descripcion}</p>
361 + {/if}
362 + </div>
363 + </div>
364 +
365 + <!-- Cuentas -->
366 + {#if clase.cuentas?.length > 0}
367 + <div class="ml-16 space-y-4 border-l-2 border-slate-100 pl-6">
368 + {#each clase.cuentas as cuenta}
369 + {@const cuentaDescs = parseDescripciones(cuenta.descripciones)}
370 + <div
371 + id="cu-{cuenta.rubro}"
372 + class="scroll-mt-4 {highlightedItem === cuenta.rubro ? 'ring-2 ring-blue-200 rounded-lg p-2 -ml-2' : ''}"
373 + >
374 + <div class="flex items-start gap-3">
375 + <span class="font-mono text-xs text-slate-400 pt-0.5">{cuenta.rubro}</span>
376 + <div class="flex-1">
377 + <h4 class="text-sm font-medium text-slate-800 flex items-center gap-2">
378 + <span class="text-left">{getDescripcion(cuenta)}</span>
379 + {#if cuenta.n_variaciones > 1}
380 + <button
381 + 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"
382 + onclick={() => openDetail(cuenta)}
383 + title="Ver variaciones de descripción"
384 + >
385 + {cuenta.n_variaciones} var.
386 + </button>
387 + {/if}
388 + <a
389 + href="/rubro/{cuenta.rubro}"
390 + class="text-slate-300 hover:text-blue-500 transition-colors"
391 + title="Ver página de detalle"
392 + >
393 + <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
394 + <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" />
395 + </svg>
396 + </a>
397 + </h4>
398 + {#if cuentaDescs.length > 0}
399 + <p class="text-xs text-slate-500 mt-1 leading-relaxed">{cuentaDescs[0].descripcion}</p>
400 + {/if}
401 + </div>
402 + </div>
403 +
404 + <!-- Subcuentas -->
405 + {#if cuenta.subcuentas?.length > 0}
406 + <div class="ml-12 mt-3 space-y-2 border-l border-slate-100 pl-4">
407 + {#each cuenta.subcuentas as subcuenta}
408 + {@const subcuentaDescs = parseDescripciones(subcuenta.descripciones)}
409 + <div
410 + id="sc-{subcuenta.rubro}"
411 + class="scroll-mt-4 {highlightedItem === subcuenta.rubro ? 'ring-2 ring-blue-200 rounded p-1 -ml-1' : ''}"
412 + >
413 + <div class="flex items-start gap-2">
414 + <span class="font-mono text-xs text-slate-300">{subcuenta.rubro}</span>
415 + <div class="flex-1">
416 + <span class="flex items-center gap-2">
417 + <span class="text-xs text-slate-700">{getDescripcion(subcuenta)}</span>
418 + {#if subcuenta.n_variaciones > 1}
419 + <button
420 + class="text-xs text-orange-500 hover:text-orange-700 transition-colors underline decoration-dotted decoration-orange-300 hover:decoration-orange-500 cursor-pointer"
421 + onclick={() => openDetail(subcuenta)}
422 + title="Ver variaciones de descripción"
423 + >
424 + {subcuenta.n_variaciones} var.
425 + </button>
426 + {/if}
427 + <a
428 + href="/rubro/{subcuenta.rubro}"
429 + class="text-slate-300 hover:text-blue-500 transition-colors"
430 + title="Ver página de detalle"
431 + >
432 + <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
433 + <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" />
434 + </svg>
435 + </a>
436 + </span>
437 + {#if subcuentaDescs.length > 0}
438 + <p class="text-xs text-slate-400 mt-0.5">{subcuentaDescs[0].descripcion}</p>
439 + {/if}
440 + </div>
441 + </div>
442 + </div>
443 + {/each}
444 + </div>
445 + {/if}
446 + </div>
447 + {/each}
448 + </div>
449 + {/if}
450 + </section>
451 + {/each}
452 + </div>
453 + {/if}
454 + </div>
455 + </main>
456 +
457 + <!-- Sidebar derecha: En esta página -->
458 + <aside class="w-56 flex-shrink-0 hidden xl:block">
459 + <div class="sticky top-0 h-screen overflow-y-auto p-4 border-l">
460 + <p class="text-xs text-slate-500 uppercase tracking-wide mb-3">En esta página</p>
461 + {#if selectedTipo && !isSearching}
462 + <nav class="space-y-2">
463 + {#each tipoContent.clases as clase}
464 + <div>
465 + <a
466 + href="#cl-{clase.rubro}"
467 + class="block text-sm text-slate-600 hover:text-blue-600 truncate"
468 + title="{getDescripcion(clase)}"
469 + >
470 + {getDescripcion(clase)}
471 + </a>
472 + {#if clase.cuentas?.length > 0}
473 + <div class="ml-3 mt-1 space-y-1 border-l border-slate-100 pl-2">
474 + {#each clase.cuentas.slice(0, 5) as cuenta}
475 + <a
476 + href="#cu-{cuenta.rubro}"
477 + class="block text-xs text-slate-400 hover:text-blue-600 truncate"
478 + title="{getDescripcion(cuenta)}"
479 + >
480 + {getDescripcion(cuenta)}
481 + </a>
482 + {/each}
483 + {#if clase.cuentas.length > 5}
484 + <span class="text-xs text-slate-300">+{clase.cuentas.length - 5} más</span>
485 + {/if}
486 + </div>
487 + {/if}
488 + </div>
489 + {/each}
490 + </nav>
491 + {/if}
492 + </div>
493 + </aside>
494 + </div>
495 + {/if}
496 +</div>
497 +
498 +<!-- Modal de detalle -->
499 +{#if selectedItem}
500 + <div class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50" onclick={closeDetail}>
501 + <div
502 + class="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[85vh] overflow-hidden flex flex-col"
503 + onclick={(e) => e.stopPropagation()}
504 + >
505 + <div class="px-6 py-4 border-b bg-slate-50 flex justify-between items-start">
506 + <div>
507 + <p class="text-xs text-slate-500 uppercase tracking-wide">{getNivelLabel(selectedItem.nivel)}</p>
508 + <h3 class="text-lg font-medium text-slate-900 mt-1">
509 + <span class="font-mono text-slate-400">{selectedItem.rubro}</span>
510 + <span class="mx-2">·</span>
511 + {getDescripcion(selectedItem)}
512 + </h3>
513 + </div>
514 + <button
515 + onclick={closeDetail}
516 + class="text-slate-400 hover:text-slate-600 text-2xl leading-none"
517 + >
518 + &times;
519 + </button>
520 + </div>
521 +
522 + <div class="p-6 overflow-y-auto flex-1">
523 + <h4 class="text-sm font-medium text-slate-700 mb-4">
524 + {#if selectedItem.n_variaciones > 1}
525 + Descripciones ({selectedItem.n_variaciones} variaciones)
526 + {:else}
527 + Descripción
528 + {/if}
529 + </h4>
530 +
531 + <div class="space-y-4">
532 + {#each parseDescripciones(selectedItem.descripciones) as desc, i}
533 + <div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200'} pl-4 py-2 rounded-r">
534 + <p class="text-xs text-slate-500 mb-2">
535 + {#if i === 0 && selectedItem.n_variaciones > 1}
536 + <span class="text-blue-600 font-medium">Vigente</span>
537 + <span class="mx-1">·</span>
538 + {/if}
539 + {desc.rangos}
540 + </p>
541 + <p class="text-sm text-slate-700 leading-relaxed">{desc.descripcion}</p>
542 + </div>
543 + {/each}
544 + </div>
545 + </div>
546 + </div>
547 + </div>
548 +{/if}
1 +import { supabase } from '$lib/supabase';
2 +import { error } from '@sveltejs/kit';
3 +
4 +export async function load({ params }) {
5 + const { codigo } = params;
6 +
7 + const { data, error: dbError } = await supabase
8 + .schema('ppto')
9 + .from('clas_rubros')
10 + .select('*')
11 + .eq('rubro', codigo);
12 +
13 + if (dbError || !data || data.length === 0) {
14 + throw error(404, 'Rubro no encontrado');
15 + }
16 +
17 + const rubro = data[0];
18 +
19 + // Obtener jerarquía (padres e hijos)
20 + let padres = [];
21 + let hijos = [];
22 +
23 + // Buscar padre según nivel
24 + if (rubro.nivel === 'sub_cuenta') {
25 + // Padre es cuenta
26 + const { data: padreData } = await supabase
27 + .schema('ppto')
28 + .from('clas_rubros')
29 + .select('*')
30 + .eq('nivel', 'cuenta')
31 + .eq('tipo', rubro.tipo)
32 + .eq('clase', rubro.clase)
33 + .eq('cuenta', rubro.cuenta);
34 + if (padreData?.length) padres.push(padreData[0]);
35 + }
36 +
37 + if (rubro.nivel === 'cuenta' || rubro.nivel === 'sub_cuenta') {
38 + // Padre es clase
39 + const { data: padreData } = await supabase
40 + .schema('ppto')
41 + .from('clas_rubros')
42 + .select('*')
43 + .eq('nivel', 'clase')
44 + .eq('tipo', rubro.tipo)
45 + .eq('clase', rubro.clase);
46 + if (padreData?.length) padres.unshift(padreData[0]);
47 + }
48 +
49 + if (rubro.nivel !== 'tipo') {
50 + // Padre es tipo
51 + const { data: padreData } = await supabase
52 + .schema('ppto')
53 + .from('clas_rubros')
54 + .select('*')
55 + .eq('nivel', 'tipo')
56 + .eq('tipo', rubro.tipo);
57 + if (padreData?.length) padres.unshift(padreData[0]);
58 + }
59 +
60 + // Buscar hijos según nivel
61 + if (rubro.nivel === 'tipo') {
62 + const { data: hijosData } = await supabase
63 + .schema('ppto')
64 + .from('clas_rubros')
65 + .select('*')
66 + .eq('nivel', 'clase')
67 + .eq('tipo', rubro.tipo)
68 + .order('rubro');
69 + hijos = hijosData?.reduce((acc, item) => {
70 + if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
71 + return acc;
72 + }, []) || [];
73 + } else if (rubro.nivel === 'clase') {
74 + const { data: hijosData } = await supabase
75 + .schema('ppto')
76 + .from('clas_rubros')
77 + .select('*')
78 + .eq('nivel', 'cuenta')
79 + .eq('tipo', rubro.tipo)
80 + .eq('clase', rubro.clase)
81 + .order('rubro');
82 + hijos = hijosData?.reduce((acc, item) => {
83 + if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
84 + return acc;
85 + }, []) || [];
86 + } else if (rubro.nivel === 'cuenta') {
87 + const { data: hijosData } = await supabase
88 + .schema('ppto')
89 + .from('clas_rubros')
90 + .select('*')
91 + .eq('nivel', 'sub_cuenta')
92 + .eq('tipo', rubro.tipo)
93 + .eq('clase', rubro.clase)
94 + .eq('cuenta', rubro.cuenta)
95 + .order('rubro');
96 + hijos = hijosData?.reduce((acc, item) => {
97 + if (!acc.find(h => h.rubro === item.rubro)) acc.push(item);
98 + return acc;
99 + }, []) || [];
100 + }
101 +
102 + return {
103 + rubro,
104 + padres,
105 + hijos
106 + };
107 +}
1 +<script>
2 + let { data } = $props();
3 +
4 + const rubro = data.rubro;
5 + const padres = data.padres;
6 + const hijos = data.hijos;
7 +
8 + function parseDescripciones(descripcionesStr) {
9 + if (!descripcionesStr) return [];
10 + try {
11 + const parsed = JSON.parse(descripcionesStr);
12 + return parsed.sort((a, b) => {
13 + const maxYearA = getMaxYear(a.rangos);
14 + const maxYearB = getMaxYear(b.rangos);
15 + return maxYearB - maxYearA;
16 + });
17 + } catch {
18 + return [];
19 + }
20 + }
21 +
22 + function getMaxYear(rangos) {
23 + if (!rangos) return 0;
24 + const years = rangos.match(/\d{4}/g);
25 + if (!years) return 0;
26 + return Math.max(...years.map(y => parseInt(y)));
27 + }
28 +
29 + function getNivelLabel(nivel) {
30 + const labels = { tipo: 'Tipo', clase: 'Clase', cuenta: 'Cuenta', sub_cuenta: 'Subcuenta' };
31 + return labels[nivel] || nivel;
32 + }
33 +
34 + // Obtener la descripción correcta según el nivel
35 + function getDescripcion(item) {
36 + if (item.nivel === 'tipo') return item.desc_tipo;
37 + if (item.nivel === 'clase') return item.desc_clase;
38 + if (item.nivel === 'cuenta') return item.desc_cuenta;
39 + if (item.nivel === 'sub_cuenta') return item.desc_sub_cuenta;
40 + return item.desc_rubros;
41 + }
42 +
43 + function getHijosLabel(nivel) {
44 + const labels = { tipo: 'Clases', clase: 'Cuentas', cuenta: 'Subcuentas' };
45 + return labels[nivel] || 'Hijos';
46 + }
47 +
48 + const descripciones = parseDescripciones(rubro.descripciones);
49 +</script>
50 +
51 +<svelte:head>
52 + <title>{getDescripcion(rubro)} | Presupuesto Público</title>
53 +</svelte:head>
54 +
55 +<div class="min-h-screen bg-white">
56 + <header class="border-b bg-slate-50">
57 + <div class="max-w-4xl mx-auto px-6 py-4">
58 + <nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1">
59 + <a href="/" class="hover:text-slate-700">Inicio</a>
60 + <span>/</span>
61 + <a href="/clasificadores/rubros" class="hover:text-slate-700">Rubros</a>
62 + {#each padres as padre}
63 + <span>/</span>
64 + <a href="/rubro/{padre.rubro}" class="hover:text-slate-700">{getDescripcion(padre)}</a>
65 + {/each}
66 + <span>/</span>
67 + <span class="text-slate-900">{getDescripcion(rubro)}</span>
68 + </nav>
69 + </div>
70 + </header>
71 +
72 + <main class="max-w-4xl mx-auto px-6 py-8">
73 + <!-- Encabezado -->
74 + <div class="mb-8">
75 + <p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(rubro.nivel)}</p>
76 + <div class="flex items-baseline gap-4">
77 + <span class="font-mono text-2xl text-slate-400">{rubro.rubro}</span>
78 + <h1 class="text-2xl font-medium text-slate-900">{getDescripcion(rubro)}</h1>
79 + </div>
80 + </div>
81 +
82 + <!-- Descripciones -->
83 + <section class="mb-10">
84 + <h2 class="text-sm font-medium text-slate-700 mb-4">
85 + {#if descripciones.length > 1}
86 + Descripciones ({descripciones.length} variaciones)
87 + {:else}
88 + Descripción
89 + {/if}
90 + </h2>
91 +
92 + <div class="space-y-4">
93 + {#each descripciones as desc, i}
94 + <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">
95 + <p class="text-xs text-slate-500 mb-2">
96 + {#if i === 0 && descripciones.length > 1}
97 + <span class="text-blue-600 font-medium">Vigente</span>
98 + <span class="mx-1">·</span>
99 + {/if}
100 + {desc.rangos}
101 + </p>
102 + <p class="text-slate-700 leading-relaxed">{desc.descripcion}</p>
103 + </div>
104 + {/each}
105 + </div>
106 + </section>
107 +
108 + <!-- Jerarquía -->
109 + {#if padres.length > 0}
110 + <section class="mb-10">
111 + <h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2>
112 + <div class="space-y-2">
113 + {#each padres as padre, i}
114 + <div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem">
115 + <span class="text-slate-300">└</span>
116 + <a
117 + href="/rubro/{padre.rubro}"
118 + class="text-sm text-slate-600 hover:text-blue-600"
119 + >
120 + <span class="font-mono text-xs text-slate-400">{padre.rubro}</span>
121 + {getDescripcion(padre)}
122 + </a>
123 + </div>
124 + {/each}
125 + <div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem">
126 + <span class="text-blue-500">└</span>
127 + <span class="text-sm font-medium text-blue-600">
128 + <span class="font-mono text-xs">{rubro.rubro}</span>
129 + {getDescripcion(rubro)}
130 + </span>
131 + </div>
132 + </div>
133 + </section>
134 + {/if}
135 +
136 + <!-- Hijos -->
137 + {#if hijos.length > 0}
138 + <section class="mb-10">
139 + <h2 class="text-sm font-medium text-slate-700 mb-4">
140 + {getHijosLabel(rubro.nivel)} ({hijos.length})
141 + </h2>
142 +
143 + <div class="border rounded-lg divide-y">
144 + {#each hijos as hijo}
145 + {@const hijoDescs = parseDescripciones(hijo.descripciones)}
146 + <a
147 + href="/rubro/{hijo.rubro}"
148 + class="block px-4 py-3 hover:bg-slate-50 transition-colors"
149 + >
150 + <div class="flex items-baseline gap-3">
151 + <span class="font-mono text-sm text-slate-400">{hijo.rubro}</span>
152 + <span class="text-slate-900">{getDescripcion(hijo)}</span>
153 + {#if hijo.n_variaciones > 1}
154 + <span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span>
155 + {/if}
156 + </div>
157 + {#if hijoDescs.length > 0}
158 + <p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p>
159 + {/if}
160 + </a>
161 + {/each}
162 + </div>
163 + </section>
164 + {/if}
165 +
166 + <!-- Volver -->
167 + <div class="pt-6 border-t">
168 + <a
169 + href="/clasificadores/rubros"
170 + class="text-sm text-blue-600 hover:underline"
171 + >
172 + Volver al clasificador
173 + </a>
174 + </div>
175 + </main>
176 +</div>