Rafael Lopez

first commit

Showing 46 changed files with 1529 additions and 0 deletions
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Claude
claude.md
engine-strict=true
# Lógica del Buscador
## Resumen
Buscador instantáneo con Fuse.js en cliente. Busca en clasificadores presupuestarios y navega a vistas de detalle.
## Decisiones de Diseño
### Motor de búsqueda
- **Tecnología**: Fuse.js (cliente)
- **Razón**: ~2,000-4,000 registros totales, cabe en memoria, búsqueda instantánea sin latencia
### Tolerancia a errores
- Normalización de acentos ("andres" → "andrés")
- Case-insensitive ("UMSA" = "umsa")
- Fuzzy matching ("univercidad" → "universidad")
### Comportamiento
| Parámetro | Valor |
|-----------|-------|
| Debounce | 200-300ms |
| Mínimo caracteres | 2 |
| Máximo resultados | 10-30 |
| Navegación | Flechas ↑↓ + Enter + scroll de mouse y loq ue corresponda para mobiles|
## Estructura del Índice
### Índice unificado (futuro)
Todos los clasificadores en un solo array con `tipo`:
```javascript
// src/lib/data/index.js
const indice = [
{
tipo: 'entidad',
codigo: 139,
nombre: 'Universidad Mayor De San Andrés',
sigla: 'UMSA',
contexto: 'Universidades Públicas',
años: 20,
// campos normalizados para búsqueda
_nombre_normalizado: 'universidad mayor de san andres',
_sigla_normalizada: 'umsa'
},
{
tipo: 'objeto_gasto',
codigo: 25100,
nombre: 'Pasajes',
contexto: 'Servicios',
// ...
}
]
```
### Por ahora (MVP)
Solo clasificador de entidades (`clas_institucional`).
## Columnas de Búsqueda
### Entidades (clas_institucional)
| Columna | Buscar | Mostrar |
|---------|--------|---------|
| `desc_entidad` | ✓ | ✓ nombre principal |
| `sigla_entidad` | ✓ | ✓ entre paréntesis |
| `desc_area` | ✗ | ✓ contexto |
| `n_gestiones` | ✗ | ✓ "X años de datos" |
| `entidad` | ✗ | para URL |
### Objetos de gasto (futuro)
| Columna | Buscar | Mostrar |
|---------|--------|---------|
| `desc_objeto` | ✓ | ✓ nombre principal |
| `codigo_objeto` | ✓ | ✓ código |
| `desc_partida` | ✗ | ✓ contexto |
## Flujo de Búsqueda
```
1. CARGA INICIAL
App monta → descarga clasificadores → construye índice Fuse.js
2. USUARIO ESCRIBE
Input → debounce 250ms → si ≥2 chars → Fuse.search()
3. RESULTADOS
Fuse devuelve matches → renderizar lista con highlighting
4. SELECCIÓN
Click o Enter → navegar a /entidad/[codigo] o /gasto/[codigo]
```
## Estructura de URLs
```
/entidad/[codigo] → /entidad/139
/gasto/[codigo] → /gasto/25100 (futuro)
/area/[codigo] → /area/1.1.4 (futuro, opcional)
```
## Formato de Resultados
```
┌─────────────────────────────────────────────────────┐
│ 🔍 [buscar entidad, objeto de gasto...] │
├─────────────────────────────────────────────────────┤
│ Universidad Mayor De San Andrés (UMSA) │
│ Universidades Públicas · 20 años │
├─────────────────────────────────────────────────────┤
│ Universidad Pública De El Alto (UPEA) │
│ Universidades Públicas · 21 años │
├─────────────────────────────────────────────────────┤
│ ... │
└─────────────────────────────────────────────────────┘
```
## Estados del Buscador
| Estado | Qué mostrar |
|--------|-------------|
| Vacío (< 2 chars) | Placeholder o sugerencias |
| Cargando índice | Spinner / "Cargando..." |
| Buscando | Nada (es instantáneo) |
| Con resultados | Lista de resultados |
| Sin resultados | "No se encontraron resultados para 'xyz'" |
| Error | "Error al cargar datos" |
## Estructura de Archivos
```
src/lib/
├── components/
│ └── search/
│ ├── SearchBox.svelte ← Input + lógica de búsqueda
│ ├── SearchResults.svelte ← Lista de resultados
│ └── SearchResultItem.svelte ← Item individual
├── services/
│ ├── supabase.js ← Cliente Supabase
│ └── search.js ← Inicialización Fuse.js
├── stores/
│ └── searchStore.js ← Estado: query, results, loading
├── utils/
│ └── normalize.js ← Normalización de texto
└── data/
└── index.js ← Carga y construcción del índice
```
## Trabajo Futuro
### Índice normalizado
Cuando haya múltiples clasificadores, crear un proceso que:
1. Descargue todos los clasificadores
2. Normalice campos de búsqueda (acentos, minúsculas)
3. Genere un índice unificado optimizado
4. Posiblemente pre-computar en servidor y servir como JSON estático
### Migración a servidor
Si el índice supera ~20,000 registros:
1. Habilitar extensión `pg_trgm` en PostgreSQL
2. Crear índices GIN en columnas de búsqueda
3. Cambiar de Fuse.js a queries con `similarity()` o `%` operator
4. El componente de UI se mantiene igual, solo cambia el servicio
---
*Documento de referencia para la implementación del buscador*
# Reglas de Diseño - Presupuesto Público Bolivia
## Identidad Visual
Este proyecto sigue las directrices del Manual de Identidad Gráfica del MEFP, adaptadas para una experiencia web moderna y de primer nivel.
## Paleta de Colores
### Colores Principales (Institucionales)
| Nombre | Hex | Uso |
|--------|-----|-----|
| **Dorado** | `#C9A751` | Acentos, títulos destacados, líneas divisorias |
| **Rojo Institucional** | `#B91817` | Alertas, elementos de énfasis |
| **Grafito** | `#363534` | Texto principal, fondos oscuros |
### Colores de la Bandera Nacional
| Nombre | Hex | Uso |
|--------|-----|-----|
| **Rojo Bandera** | `#DB281C` | Decorativo, acentos nacionales |
| **Amarillo** | `#F4E410` | Highlights, estados activos |
| **Verde** | `#007C38` | Estados de éxito, indicadores positivos |
### Colores Secundarios / Neutros
| Nombre | Hex | Uso |
|--------|-----|-----|
| **Crema (Eggshell)** | `#F3EAD8` | Fondos suaves, cards, secciones alternadas |
| **Gris** | `#676767` | Texto secundario, iconos |
### Escalas de Grises UI
- Fondo página: `#FAFAFA` o `#F5F5F5`
- Bordes: `#E5E5E5`
- Texto muted: `#9CA3AF`
## Tipografía
### Tipografía Principal: Qanelas
- **Uso**: Toda la interfaz (headings, body, UI)
- **Pesos disponibles**: Light (300), Regular (400), Medium (500), SemiBold (600), Bold (700)
- **Fallback**: system-ui, -apple-system, sans-serif
### Tipografía Institucional: Archivo Condensed
- **Uso**: Logo del producto, elementos de marca específicos
- **Pesos disponibles**: SemiBold, Bold, Black
- **Nota**: Usar con moderación, principalmente para branding
### Escala Tipográfica
```
text-xs: 12px / 0.75rem
text-sm: 14px / 0.875rem
text-base: 16px / 1rem
text-lg: 18px / 1.125rem
text-xl: 20px / 1.25rem
text-2xl: 24px / 1.5rem
text-3xl: 30px / 1.875rem
text-4xl: 36px / 2.25rem
text-5xl: 48px / 3rem
```
## Logos
### Ubicación de archivos
```
static/
├── logos_claro/
│ ├── 04_Imagotipo MEFP espacio positivo.png ← Fondos claros
│ └── 06_Isologo MEFP escala de grises.png
└── logos_oscuro/
├── 05_Imago MEFP horizontal espacio negativo.png ← Fondos oscuros
└── 05_Imagotipo MEFP espacio negativo.png
```
### Uso del Logo
- **Navbar fondo claro**: Logo positivo (colores originales)
- **Footer fondo oscuro**: Logo negativo (blanco/dorado)
- **Tamaño mínimo navbar**: altura 40-48px
## Principios de Diseño
### Modernidad
- Espaciado generoso (padding, margins)
- Bordes redondeados suaves (rounded-lg, rounded-xl)
- Sombras sutiles (shadow-sm, shadow-md)
- Transiciones suaves (transition-all duration-200)
### Jerarquía Visual
- Dorado para elementos de jerarquía (títulos, líneas, acentos)
- Grafito para texto principal
- Gris para texto secundario
- Crema para secciones de fondo alternado
### Accesibilidad
- Contraste mínimo WCAG AA
- Tamaño de texto base 16px
- Áreas de toque mínimo 44x44px en móviles
- Focus visible en elementos interactivos
## Componentes Clave
### Navbar
- Fondo: blanco con borde dorado inferior sutil
- Logo MEFP a la izquierda
- Links con hover dorado
- Menú móvil con transición suave
### Buscador
- Input con borde redondeado
- Focus ring dorado
- Resultados con fondo crema al hover
### Cards
- Fondo blanco
- Borde sutil `border-gray-200`
- Hover con sombra elevada
- Padding generoso (p-6)
### Botones
- Primario: fondo dorado, texto grafito
- Secundario: borde dorado, fondo transparente
- Estados hover con oscurecimiento sutil
### Footer
- Fondo grafito (#363534)
- Texto claro
- Logo versión negativa
- Franja tricolor (rojo, amarillo, verde) arriba
## Responsividad
### Breakpoints
- `sm`: 640px
- `md`: 768px
- `lg`: 1024px
- `xl`: 1280px
### Mobile First
- Diseño base para móviles
- Mejoras progresivas en pantallas grandes
- Navegación colapsable en móviles
- Grids fluidos con `grid-cols-1 md:grid-cols-2 lg:grid-cols-3`
## Iconografía
- Usar Heroicons (outline) para consistencia
- Tamaño base: `h-5 w-5`
- Color: heredar del texto o usar gris
## Animaciones
- Duración estándar: 200ms
- Easing: `ease-in-out`
- Usar para: hover states, transiciones de página, aparición de modales
- Evitar: animaciones excesivas que distraigan
---
*Documento de referencia para mantener consistencia visual*
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv create --template minimal --no-types --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
import { createClient } from '@supabase/supabase-js';
import * as dotenv from 'dotenv';
dotenv.config();
const supabase = createClient(
process.env.PUBLIC_SUPABASE_URL,
process.env.PUBLIC_SUPABASE_ANON_KEY
);
const { count, error } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*', { count: 'exact', head: true });
console.log('Total registros:', count);
if (error) console.log('Error:', error);
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}
This diff is collapsed. Click to expand it.
{
"name": "ppto",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.2.1",
"svelte": "^5.51.0",
"tailwindcss": "^4.2.1",
"vite": "^7.3.1"
},
"dependencies": {
"@supabase/supabase-js": "^2.98.0",
"d3": "^7.9.0",
"fuse.js": "^7.1.0"
}
}
@import "tailwindcss";
/* ============================================
TIPOGRAFÍAS INSTITUCIONALES
============================================ */
@font-face {
font-family: 'Qanelas';
src: url('/tipografia/Qanelas-Light.ttf') format('truetype');
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Qanelas';
src: url('/tipografia/Qanelas-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Qanelas';
src: url('/tipografia/Qanelas-Medium.ttf') format('truetype');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Qanelas';
src: url('/tipografia/Qanelas-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Qanelas';
src: url('/tipografia/Qanelas-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Archivo Condensed';
src: url('/tipografia/Archivo_Condensed-SemiBold.ttf') format('truetype');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Archivo Condensed';
src: url('/tipografia/Archivo_Condensed-Bold.ttf') format('truetype');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Archivo Condensed';
src: url('/tipografia/Archivo_Condensed-Black.ttf') format('truetype');
font-weight: 900;
font-style: normal;
font-display: swap;
}
/* ============================================
TEMA PERSONALIZADO - TAILWIND v4
============================================ */
@theme {
/* Tipografía */
--font-sans: 'Qanelas', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-display: 'Archivo Condensed', 'Qanelas', system-ui, sans-serif;
/* Colores Institucionales MEFP */
--color-gold: #C9A751;
--color-gold-light: #D4B76A;
--color-gold-dark: #A88A3D;
--color-wine: #B91817;
--color-wine-light: #D42625;
--color-wine-dark: #8F1211;
--color-graphite: #363534;
--color-graphite-light: #4A4948;
--color-graphite-dark: #252423;
/* Colores Bandera Nacional */
--color-flag-red: #DB281C;
--color-flag-yellow: #F4E410;
--color-flag-green: #007C38;
/* Colores Secundarios */
--color-cream: #F3EAD8;
--color-cream-light: #FAF6EE;
--color-cream-dark: #E8DCC4;
--color-muted: #676767;
--color-muted-light: #8A8A8A;
--color-muted-dark: #4A4A4A;
}
/* ============================================
ESTILOS BASE
============================================ */
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
color: var(--color-graphite);
background-color: #FAFAFA;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Focus ring consistente con identidad */
*:focus-visible {
outline: 2px solid var(--color-gold);
outline-offset: 2px;
}
/* Selección de texto */
::selection {
background-color: var(--color-gold);
color: white;
}
/* ============================================
UTILIDADES PERSONALIZADAS
============================================ */
/* Gradiente de bandera (horizontal) */
.bg-flag-gradient {
background: linear-gradient(
to right,
var(--color-flag-red) 33.33%,
var(--color-flag-yellow) 33.33%,
var(--color-flag-yellow) 66.66%,
var(--color-flag-green) 66.66%
);
}
/* Línea decorativa dorada */
.border-gold-accent {
border-bottom: 3px solid var(--color-gold);
}
/* Texto con acento dorado */
.text-gold-accent {
color: var(--color-gold);
}
/* Fuente display (Archivo) */
.font-display {
font-family: var(--font-display);
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
\ No newline at end of file
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import SearchResults from './SearchResults.svelte';
import {
query,
results,
isLoading,
indexLoaded,
error,
selectedIndex,
showResults,
initIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
let inputRef;
let debounceTimer;
let isFocused = $state(false);
onMount(() => {
initIndex();
});
function handleInput(e) {
const value = e.target.value;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
performSearch(value);
}, 250);
}
function handleKeydown(e) {
if (!$showResults) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
navigateResults('down', $results.length);
break;
case 'ArrowUp':
e.preventDefault();
navigateResults('up', $results.length);
break;
case 'Enter':
e.preventDefault();
if ($selectedIndex >= 0 && $results[$selectedIndex]) {
const item = $results[$selectedIndex];
if (item.tipo === 'entidad') {
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
}
clearSearch();
inputRef.value = '';
}
break;
case 'Escape':
clearSearch();
inputRef.value = '';
inputRef.blur();
break;
}
}
function handleClickOutside(e) {
if (!e.target.closest('.search-container')) {
isFocused = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<div class="search-container relative">
<input
bind:this={inputRef}
type="text"
placeholder={$indexLoaded ? "Buscar entidad u objeto de gasto..." : "Cargando..."}
disabled={!$indexLoaded}
class="w-full p-2 border rounded"
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => isFocused = true}
/>
{#if $isLoading}
<span class="absolute right-2 top-2 text-sm text-gray-400">...</span>
{/if}
{#if isFocused && $showResults}
<SearchResults
results={$results}
query={$query}
selectedIndex={$selectedIndex}
/>
{/if}
{#if $error}
<p class="text-red-500 text-sm mt-1">{$error}</p>
{/if}
</div>
<script>
let { item, isSelected = false, onclick } = $props();
</script>
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {isSelected ? 'bg-gray-100' : ''}"
{onclick}
>
<div>{item.nombre} {item.sigla ? `(${item.sigla})` : ''}</div>
<div class="text-xs text-gray-500">{item.contexto}</div>
</button>
<script>
import { goto } from '$app/navigation';
import { clearSearch } from '$lib/stores/searchStore';
let { results = [], query = '', selectedIndex = -1 } = $props();
function handleSelect(item) {
if (item.tipo === 'entidad') {
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
}
clearSearch();
}
</script>
{#if results.length > 0}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg max-h-80 overflow-y-auto z-50">
{#each results as item, index}
<button
type="button"
class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 {index === selectedIndex ? 'bg-gray-100' : ''}"
onclick={() => handleSelect(item)}
>
<div class="flex items-center gap-2">
{#if item.tipo === 'objeto_gasto'}
<span class="font-mono text-xs text-slate-400">{item.codigo}</span>
{/if}
<span>{item.nombre}</span>
{#if item.sigla}
<span class="text-slate-400">({item.sigla})</span>
{/if}
</div>
<div class="text-xs text-gray-500">{item.contexto}</div>
</button>
{/each}
</div>
{:else if query.length >= 2}
<div class="absolute top-full left-0 right-0 mt-1 bg-white border rounded p-3 text-sm text-gray-500">
Sin resultados para "{query}"
</div>
{/if}
<script>
import { page } from '$app/stores';
</script>
<nav class="border-b p-4">
<div class="max-w-4xl mx-auto flex items-center justify-between">
<a href="/">Presupuesto Público Bolivia</a>
<div class="flex gap-4 text-sm">
<a href="/">Inicio</a>
<a href="/clasificadores">Clasificadores</a>
</div>
</div>
</nav>
// place files you want to import through the `$lib` alias in this folder.
import Fuse from 'fuse.js';
import { supabase } from '$lib/supabase';
import { normalizeText } from '$lib/utils/normalize';
let fuseInstance = null;
let searchData = [];
/**
* Configuración de Fuse.js
*/
const fuseOptions = {
keys: [
{ name: 'nombre_normalizado', weight: 0.6 },
{ name: 'sigla_normalizada', weight: 0.2 },
{ name: 'codigo_normalizado', weight: 0.2 }
],
threshold: 0.4, // 0 = exacto, 1 = muy fuzzy
distance: 100,
includeScore: true,
includeMatches: true,
minMatchCharLength: 2,
ignoreLocation: true
};
/**
* Carga las entidades y objetos de gasto desde Supabase y construye el índice
*/
export async function initSearchIndex() {
if (fuseInstance) return fuseInstance;
// Cargar entidades
const { data: entidades, error: errorEntidades } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('entidad, desc_entidad, sigla_entidad, desc_area, n_gestiones');
if (errorEntidades) {
console.error('Error cargando entidades:', errorEntidades);
throw errorEntidades;
}
// Cargar objetos de gasto
const { data: objetos, error: errorObjetos } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('objeto, desc_objeto, nivel');
if (errorObjetos) {
console.error('Error cargando objetos:', errorObjetos);
throw errorObjetos;
}
// Transformar entidades para el índice
const entidadesIndex = entidades.map(item => ({
tipo: 'entidad',
codigo: item.entidad,
nombre: item.desc_entidad,
sigla: item.sigla_entidad,
contexto: item.desc_area,
años: item.n_gestiones,
nombre_normalizado: normalizeText(item.desc_entidad),
sigla_normalizada: normalizeText(item.sigla_entidad),
codigo_normalizado: item.entidad?.toString() || ''
}));
// Transformar objetos de gasto para el índice
const nivelLabels = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
const objetosIndex = objetos.map(item => ({
tipo: 'objeto_gasto',
codigo: item.objeto,
nombre: item.desc_objeto,
sigla: null,
contexto: `Objeto del Gasto · ${nivelLabels[item.nivel] || item.nivel}`,
nivel: item.nivel,
nombre_normalizado: normalizeText(item.desc_objeto),
sigla_normalizada: '',
codigo_normalizado: item.objeto || ''
}));
// Combinar ambos conjuntos
searchData = [...entidadesIndex, ...objetosIndex];
fuseInstance = new Fuse(searchData, fuseOptions);
return fuseInstance;
}
/**
* Busca en el índice
* @param {string} query - Texto de búsqueda
* @param {number} limit - Máximo de resultados (default 30)
* @returns {Array} Resultados de búsqueda
*/
export function search(query, limit = 30) {
if (!fuseInstance) {
console.warn('Índice no inicializado. Llama initSearchIndex() primero.');
return [];
}
if (!query || query.length < 2) {
return [];
}
const normalizedQuery = normalizeText(query);
const results = fuseInstance.search(normalizedQuery, { limit });
return results.map(result => ({
...result.item,
score: result.score,
matches: result.matches
}));
}
/**
* Obtiene todas las entidades (sin búsqueda)
*/
export function getAllEntidades() {
return searchData.filter(item => item.tipo === 'entidad');
}
/**
* Obtiene todos los objetos de gasto (sin búsqueda)
*/
export function getAllObjetos() {
return searchData.filter(item => item.tipo === 'objeto_gasto');
}
/**
* Verifica si el índice está cargado
*/
export function isIndexReady() {
return fuseInstance !== null;
}
import { writable, derived } from 'svelte/store';
import { initSearchIndex, search, isIndexReady } from '$lib/services/search';
// Estado del buscador
export const query = writable('');
export const results = writable([]);
export const isLoading = writable(false);
export const indexLoaded = writable(false);
export const error = writable(null);
export const selectedIndex = writable(-1);
// Estado derivado: si está mostrando resultados
export const showResults = derived(
[query, results, indexLoaded],
([$query, $results, $indexLoaded]) => $indexLoaded && $query.length >= 2
);
/**
* Inicializa el índice de búsqueda
*/
export async function initIndex() {
if (isIndexReady()) {
indexLoaded.set(true);
return;
}
isLoading.set(true);
error.set(null);
try {
await initSearchIndex();
indexLoaded.set(true);
} catch (e) {
error.set('Error al cargar el índice de búsqueda');
console.error(e);
} finally {
isLoading.set(false);
}
}
/**
* Realiza la búsqueda
*/
export function performSearch(searchQuery) {
query.set(searchQuery);
selectedIndex.set(-1);
if (searchQuery.length < 2) {
results.set([]);
return;
}
const searchResults = search(searchQuery, 30);
results.set(searchResults);
}
/**
* Limpia la búsqueda
*/
export function clearSearch() {
query.set('');
results.set([]);
selectedIndex.set(-1);
}
/**
* Navegación por teclado
*/
export function navigateResults(direction, totalResults) {
selectedIndex.update(current => {
if (direction === 'down') {
return current < totalResults - 1 ? current + 1 : 0;
} else if (direction === 'up') {
return current > 0 ? current - 1 : totalResults - 1;
}
return current;
});
}
import { createClient } from '@supabase/supabase-js';
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
/**
* Normaliza texto para búsqueda:
* - Convierte a minúsculas
* - Elimina acentos/diacríticos
* - Elimina espacios extra
*/
export function normalizeText(text) {
if (!text) return '';
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '') // Elimina diacríticos
.replace(/\s+/g, ' ') // Espacios múltiples a uno
.trim();
}
/**
* Resalta coincidencias en el texto
* @param {string} text - Texto original
* @param {string} query - Búsqueda del usuario
* @returns {string} HTML con <mark> en coincidencias
*/
export function highlightMatch(text, query) {
if (!query || !text) return text;
const normalizedQuery = normalizeText(query);
const normalizedText = normalizeText(text);
// Encontrar posición en texto normalizado
const index = normalizedText.indexOf(normalizedQuery);
if (index === -1) return text;
// Aplicar highlight en texto original (misma posición)
const before = text.slice(0, index);
const match = text.slice(index, index + query.length);
const after = text.slice(index + query.length);
return `${before}<mark class="bg-yellow-200 rounded px-0.5">${match}</mark>${after}`;
}
<script>
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import Navbar from '$lib/components/ui/Navbar.svelte';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="min-h-screen flex flex-col">
<Navbar />
<main class="flex-1">
{@render children()}
</main>
</div>
<script>
import SearchBox from '$lib/components/search/SearchBox.svelte';
</script>
<svelte:head>
<title>Presupuesto Público Bolivia</title>
</svelte:head>
<div class="max-w-4xl mx-auto p-4">
<h1 class="text-2xl mb-2">Presupuesto Público de Bolivia</h1>
<p class="mb-6 text-gray-600">
20 años de datos fiscales · 600+ entidades · Actualización diaria
</p>
<div class="mb-8">
<SearchBox />
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
<a href="/clasificadores" class="p-4 border rounded hover:bg-gray-50">
<strong>Clasificadores</strong>
<p class="text-gray-600">Navegar por instituciones, objetos de gasto, etc.</p>
</a>
<div class="p-4 border rounded opacity-50">
<strong>Historias</strong>
<p class="text-gray-600">Próximamente</p>
</div>
</div>
</div>
import { supabase } from '$lib/supabase';
import { json } from '@sveltejs/kit';
export async function GET() {
const { data, error } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('*')
.limit(5);
if (error) {
return json({ error: error.message }, { status: 500 });
}
return json({
success: true,
count: data.length,
columns: data.length > 0 ? Object.keys(data[0]) : [],
sample: data
});
}
<script>
const clasificadores = [
{ id: 'institucional', nombre: 'Institucional', descripcion: 'Ministerios, universidades, municipios, empresas públicas', disponible: true },
{ id: 'objeto-gasto', nombre: 'Objeto del Gasto', descripcion: 'Servicios personales, bienes, inversiones', disponible: true },
{ id: 'fuente', nombre: 'Fuente de Financiamiento', descripcion: 'Tesoro, créditos externos, recursos propios', disponible: false },
{ id: 'organismo', nombre: 'Organismo Financiador', descripcion: 'BID, Banco Mundial, CAF', disponible: false },
{ id: 'geografico', nombre: 'Geográfico', descripcion: 'Departamentos, provincias, municipios', disponible: false },
{ id: 'funcional', nombre: 'Funcional', descripcion: 'Educación, salud, defensa', disponible: false },
{ id: 'economico', nombre: 'Económico', descripcion: 'Corrientes, de capital', disponible: false },
{ id: 'programatico', nombre: 'Programático', descripcion: 'Programas y proyectos', disponible: false },
];
</script>
<svelte:head>
<title>Clasificadores | Presupuesto Público</title>
</svelte:head>
<div class="max-w-4xl mx-auto p-4">
<p class="text-sm text-gray-500 mb-4">
<a href="/">Inicio</a> / Clasificadores
</p>
<h1 class="text-2xl mb-6">Clasificadores</h1>
<div class="space-y-2">
{#each clasificadores as item}
{#if item.disponible}
<a href="/clasificadores/{item.id}" class="block p-4 border rounded hover:bg-gray-50">
<strong>{item.nombre}</strong>
<p class="text-sm text-gray-600">{item.descripcion}</p>
</a>
{:else}
<div class="p-4 border rounded opacity-50">
<strong>{item.nombre}</strong>
<span class="text-xs text-gray-400 ml-2">(próximamente)</span>
<p class="text-sm text-gray-600">{item.descripcion}</p>
</div>
{/if}
{/each}
</div>
</div>
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const codigo = parseInt(params.codigo);
if (isNaN(codigo)) {
throw error(400, 'Código de entidad inválido');
}
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('*')
.eq('entidad', codigo)
.single();
if (dbError || !data) {
throw error(404, 'Entidad no encontrada');
}
return {
entidad: data
};
}
<script>
let { data } = $props();
const entidad = data.entidad;
const gestiones = entidad.gestiones ? entidad.gestiones.split(',').map(g => g.trim()) : [];
</script>
<svelte:head>
<title>{entidad.desc_entidad} | Presupuesto Público</title>
</svelte:head>
<div class="max-w-4xl mx-auto p-4">
<p class="text-sm text-gray-500 mb-4">
<a href="/">Inicio</a> / <a href="/clasificadores/institucional">Institucional</a> / {entidad.sigla_entidad || 'Entidad'}
</p>
<h1 class="text-2xl mb-1">{entidad.desc_entidad}</h1>
{#if entidad.sigla_entidad}
<p class="text-gray-600 mb-4">{entidad.sigla_entidad}</p>
{/if}
<p class="text-sm text-gray-500 mb-6">
Código: {entidad.entidad} · {entidad.n_gestiones} años de datos
</p>
<div class="grid md:grid-cols-2 gap-4">
<div class="border rounded p-4">
<h2 class="font-bold mb-2">Clasificación</h2>
<p class="text-sm"><span class="text-gray-500">Sector:</span> {entidad.desc_sector}</p>
<p class="text-sm"><span class="text-gray-500">Subsector:</span> {entidad.desc_subsector}</p>
<p class="text-sm"><span class="text-gray-500">Área:</span> {entidad.desc_area}</p>
{#if entidad.desc_subarea !== entidad.desc_area}
<p class="text-sm"><span class="text-gray-500">Subárea:</span> {entidad.desc_subarea}</p>
{/if}
</div>
<div class="border rounded p-4">
<h2 class="font-bold mb-2">Gestiones</h2>
<div class="flex flex-wrap gap-1">
{#each gestiones as gestion}
<span class="text-xs bg-gray-100 px-2 py-1 rounded">{gestion}</span>
{/each}
</div>
</div>
</div>
<div class="border rounded p-4 mt-4">
<h2 class="font-bold mb-2">Historial de Ingresos y Gastos</h2>
<p class="text-gray-500 text-sm">Visualizaciones próximamente</p>
</div>
</div>
import { supabase } from '$lib/supabase';
import { error } from '@sveltejs/kit';
export async function load({ params }) {
const { codigo } = params;
const { data, error: dbError } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('objeto', codigo);
if (dbError || !data || data.length === 0) {
throw error(404, 'Objeto no encontrado');
}
// Tomar el primer resultado (puede haber duplicados)
const objeto = data[0];
// Obtener jerarquía (padres e hijos)
let padres = [];
let hijos = [];
// Buscar padre según nivel
if (objeto.nivel === 'subpartida') {
// Padre es partida
const { data: padreData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo)
.eq('partida', objeto.partida);
if (padreData?.length) padres.push(padreData[0]);
}
if (objeto.nivel === 'partida' || objeto.nivel === 'subpartida') {
// Padre es subgrupo
const { data: padreData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo);
if (padreData?.length) padres.unshift(padreData[0]);
}
if (objeto.nivel !== 'grupo') {
// Padre es grupo
const grupoCodigo = objeto.grupo + '0000';
const { data: padreData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'grupo')
.eq('objeto', grupoCodigo);
if (padreData?.length) padres.unshift(padreData[0]);
}
// Buscar hijos según nivel
if (objeto.nivel === 'grupo') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subgrupo')
.eq('grupo', objeto.grupo)
.order('objeto');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.objeto === item.objeto)) acc.push(item);
return acc;
}, []) || [];
} else if (objeto.nivel === 'subgrupo') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'partida')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo)
.order('objeto');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.objeto === item.objeto)) acc.push(item);
return acc;
}, []) || [];
} else if (objeto.nivel === 'partida') {
const { data: hijosData } = await supabase
.schema('ppto')
.from('clas_objetos')
.select('*')
.eq('nivel', 'subpartida')
.eq('grupo', objeto.grupo)
.eq('subgrupo', objeto.subgrupo)
.eq('partida', objeto.partida)
.order('objeto');
hijos = hijosData?.reduce((acc, item) => {
if (!acc.find(h => h.objeto === item.objeto)) acc.push(item);
return acc;
}, []) || [];
}
return {
objeto,
padres,
hijos
};
}
<script>
let { data } = $props();
const objeto = data.objeto;
const padres = data.padres;
const hijos = data.hijos;
function parseDescripciones(descripcionesStr) {
if (!descripcionesStr) return [];
try {
const parsed = JSON.parse(descripcionesStr);
// Ordenar por año más reciente (extraer el máximo año de cada rango)
return parsed.sort((a, b) => {
const maxYearA = getMaxYear(a.rangos);
const maxYearB = getMaxYear(b.rangos);
return maxYearB - maxYearA; // Descendente, más reciente primero
});
} catch {
return [];
}
}
function getMaxYear(rangos) {
if (!rangos) return 0;
const years = rangos.match(/\d{4}/g);
if (!years) return 0;
return Math.max(...years.map(y => parseInt(y)));
}
function getNivelLabel(nivel) {
const labels = { grupo: 'Grupo', subgrupo: 'Subgrupo', partida: 'Partida', subpartida: 'Subpartida' };
return labels[nivel] || nivel;
}
const descripciones = parseDescripciones(objeto.descripciones);
</script>
<svelte:head>
<title>{objeto.desc_objeto} | Presupuesto Público</title>
</svelte:head>
<div class="min-h-screen bg-white">
<header class="border-b bg-slate-50">
<div class="max-w-4xl mx-auto px-6 py-4">
<nav class="text-sm text-slate-500 mb-2 flex flex-wrap items-center gap-1">
<a href="/" class="hover:text-slate-700">Inicio</a>
<span>/</span>
<a href="/clasificadores/objeto-gasto" class="hover:text-slate-700">Objeto del Gasto</a>
{#each padres as padre}
<span>/</span>
<a href="/objeto/{padre.objeto}" class="hover:text-slate-700">{padre.desc_objeto}</a>
{/each}
<span>/</span>
<span class="text-slate-900">{objeto.desc_objeto}</span>
</nav>
</div>
</header>
<main class="max-w-4xl mx-auto px-6 py-8">
<!-- Encabezado -->
<div class="mb-8">
<p class="text-xs text-slate-500 uppercase tracking-wide mb-1">{getNivelLabel(objeto.nivel)}</p>
<div class="flex items-baseline gap-4">
<span class="font-mono text-2xl text-slate-400">{objeto.objeto}</span>
<h1 class="text-2xl font-medium text-slate-900">{objeto.desc_objeto}</h1>
</div>
</div>
<!-- Descripciones -->
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{#if descripciones.length > 1}
Descripciones ({descripciones.length} variaciones)
{:else}
Descripción
{/if}
</h2>
<div class="space-y-4">
{#each descripciones as desc, i}
<div class="border-l-2 {i === 0 ? 'border-blue-500 bg-blue-50/50' : 'border-slate-200 bg-slate-50/50'} pl-4 py-3 rounded-r">
<p class="text-xs text-slate-500 mb-2">
{#if i === 0 && descripciones.length > 1}
<span class="text-blue-600 font-medium">Vigente</span>
<span class="mx-1">·</span>
{/if}
{desc.rangos}
</p>
<p class="text-slate-700 leading-relaxed">{desc.descripcion}</p>
</div>
{/each}
</div>
</section>
<!-- Jerarquía -->
{#if padres.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">Jerarquía</h2>
<div class="space-y-2">
{#each padres as padre, i}
<div class="flex items-center gap-2" style="margin-left: {i * 1.5}rem">
<span class="text-slate-300">└</span>
<a
href="/objeto/{padre.objeto}"
class="text-sm text-slate-600 hover:text-blue-600"
>
<span class="font-mono text-xs text-slate-400">{padre.objeto}</span>
{padre.desc_objeto}
</a>
</div>
{/each}
<div class="flex items-center gap-2" style="margin-left: {padres.length * 1.5}rem">
<span class="text-blue-500">└</span>
<span class="text-sm font-medium text-blue-600">
<span class="font-mono text-xs">{objeto.objeto}</span>
{objeto.desc_objeto}
</span>
</div>
</div>
</section>
{/if}
<!-- Hijos -->
{#if hijos.length > 0}
<section class="mb-10">
<h2 class="text-sm font-medium text-slate-700 mb-4">
{#if objeto.nivel === 'grupo'}
Subgrupos ({hijos.length})
{:else if objeto.nivel === 'subgrupo'}
Partidas ({hijos.length})
{:else if objeto.nivel === 'partida'}
Subpartidas ({hijos.length})
{/if}
</h2>
<div class="border rounded-lg divide-y">
{#each hijos as hijo}
{@const hijoDescs = parseDescripciones(hijo.descripciones)}
<a
href="/objeto/{hijo.objeto}"
class="block px-4 py-3 hover:bg-slate-50 transition-colors"
>
<div class="flex items-baseline gap-3">
<span class="font-mono text-sm text-slate-400">{hijo.objeto}</span>
<span class="text-slate-900">{hijo.desc_objeto}</span>
{#if hijo.n_variaciones > 1}
<span class="text-xs text-orange-500">({hijo.n_variaciones} var.)</span>
{/if}
</div>
{#if hijoDescs.length > 0}
<p class="text-sm text-slate-500 mt-1 ml-16">{hijoDescs[0].descripcion}</p>
{/if}
</a>
{/each}
</div>
</section>
{/if}
<!-- Volver -->
<div class="pt-6 border-t">
<a
href="/clasificadores/objeto-gasto"
class="text-sm text-blue-600 hover:underline"
>
Volver al clasificador
</a>
</div>
</main>
</div>
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
No preview for this file type
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});