Showing
9 changed files
with
976 additions
and
21 deletions
| ... | @@ -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,23 +9,47 @@ | ... | @@ -9,23 +9,47 @@ |
| 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} |
| 42 | + <div class="px-3 py-1.5 bg-slate-50 border-b"> | ||
| 43 | + <span class="text-xs font-medium text-slate-500 uppercase tracking-wide">Entidades</span> | ||
| 44 | + </div> | ||
| 45 | + {#each groupedResults().entidades as item, index} | ||
| 46 | + {@const globalIdx = getGlobalIndex('entidad', index)} | ||
| 20 | <button | 47 | <button |
| 21 | type="button" | 48 | type="button" |
| 22 | - class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {index === selectedIndex ? 'bg-gray-100' : ''}" | 49 | + class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {globalIdx === selectedIndex ? 'bg-blue-50' : ''}" |
| 23 | onclick={() => handleSelect(item)} | 50 | onclick={() => handleSelect(item)} |
| 24 | > | 51 | > |
| 25 | <div class="flex items-center gap-2"> | 52 | <div class="flex items-center gap-2"> |
| 26 | - {#if item.tipo === 'objeto_gasto'} | ||
| 27 | - <span class="font-mono text-xs text-slate-400">{item.codigo}</span> | ||
| 28 | - {/if} | ||
| 29 | <span>{item.nombre}</span> | 53 | <span>{item.nombre}</span> |
| 30 | {#if item.sigla} | 54 | {#if item.sigla} |
| 31 | <span class="text-slate-400">({item.sigla})</span> | 55 | <span class="text-slate-400">({item.sigla})</span> |
| ... | @@ -34,6 +58,47 @@ | ... | @@ -34,6 +58,47 @@ |
| 34 | <div class="text-xs text-gray-500">{item.contexto}</div> | 58 | <div class="text-xs text-gray-500">{item.contexto}</div> |
| 35 | </button> | 59 | </button> |
| 36 | {/each} | 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"> | ||
| 95 | + <span class="font-mono text-xs text-slate-400">{item.codigo}</span> | ||
| 96 | + <span>{item.nombre}</span> | ||
| 97 | + </div> | ||
| 98 | + <div class="text-xs text-gray-500">{item.contexto}</div> | ||
| 99 | + </button> | ||
| 100 | + {/each} | ||
| 101 | + {/if} | ||
| 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 | + × | ||
| 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} |
src/routes/rubro/[codigo]/+page.server.js
0 → 100644
| 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 | +} |
src/routes/rubro/[codigo]/+page.svelte
0 → 100644
| 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> |
-
Please register or login to post a comment