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