Rafael Lopez

final

Showing 43 changed files with 1169 additions and 1218 deletions
......@@ -4,7 +4,7 @@
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''"
......
// ═══════════════════════════════════════════════════════════════
// CONFIGURACIÓN CENTRAL DE API
// Cambiar la IP aquí actualiza todas las llamadas del proyecto
// ═══════════════════════════════════════════════════════════════
export const API_HOST = 'https://abierto.economiayfinanzas.gob.bo/presupuesto';
export const DOWNLOAD_BASE = 'https://abierto.economiayfinanzas.gob.bo/presupuesto/descargas/';
<script>
import { onMount } from 'svelte';
import { landingSearchMode, landingSelectedClassifiers, landingSearchQuery } from '$lib/stores/landingSearchState';
import { openDrawer } from '$lib/stores/drawer.js';
// Theme
let isDark = true;
......@@ -971,7 +972,7 @@
$: isSearching = searchVal.length >= 3;
</script>
<svelte:window on:keydown={handleGlobalKeydown} />
<svelte:window onkeydown={handleGlobalKeydown} />
<svelte:head>
<title>Buscador | Presupuesto Abierto</title>
......@@ -980,18 +981,6 @@
<div class="page" class:mounted class:page-locked={allHits.length > 0}>
<!-- Dots de navegación -->
<nav class="section-dots" class:section-dots-hidden={currentSection === 'hero'}>
{#each SECTIONS_NAV as s}
<button
class="section-dot"
class:section-dot-active={currentSection === s.id}
on:click={() => scrollToSection(s.id)}
aria-label={s.label}
>
<span class="section-dot-label">{s.label}</span>
</button>
{/each}
</nav>
<!-- Hero -->
<main data-section="hero">
......@@ -1006,7 +995,7 @@
</div>
<div class="hero-topbar-right" class:nav-hidden={!heroInView}>
<button class="nav-theme-btn" on:click={toggleTheme} aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}>
<button class="nav-theme-btn" onclick={toggleTheme} aria-label={isDark ? 'Cambiar a modo claro' : 'Cambiar a modo oscuro'}>
{#if isDark}
<svg width="18" height="18" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"/>
......@@ -1017,7 +1006,7 @@
</svg>
{/if}
</button>
<button class="nav-menu-btn" aria-label="Menú">
<button class="nav-menu-btn" aria-label="Menú" onclick={openDrawer}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
......@@ -1037,7 +1026,7 @@
Presupuesto <span class="gold">Abierto</span>
</h1>
<p class="hero-sub">
Información histórica y diaria de todo el Estado Boliviano.
Información histórica de todo el Estado Boliviano.
</p>
<!-- Buscador -->
......@@ -1053,10 +1042,10 @@
type="text"
bind:this={searchInput}
bind:value={searchVal}
on:input={handleInput}
on:keydown={handleKeydown}
on:focus={() => searchFocused = true}
on:blur={() => searchFocused = false}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={() => searchFocused = true}
onblur={() => searchFocused = false}
placeholder=""
class:has-text={searchVal.length > 0}
/>
......@@ -1067,12 +1056,12 @@
</div>
{/if}
{#if searchVal && !isLoading}
<button class="search-clear" on:click={clearSearch}>
<button class="search-clear" onclick={clearSearch}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
<kbd class="search-esc" on:click={clearSearch}>Esc</kbd>
<kbd class="search-esc" onclick={clearSearch}>Esc</kbd>
{/if}
{#if isLoading}
<div class="search-loading"><span class="loading-dot"></span></div>
......@@ -1087,7 +1076,7 @@
<button
class="cat-filter-chip"
class:cat-filter-hidden={hiddenCategories.has(cat.id)}
on:click={() => toggleCategory(cat.id)}
onclick={() => toggleCategory(cat.id)}
style="--cat-color:{cat.color}"
>
<span class="cat-filter-dot"></span>
......@@ -1110,8 +1099,8 @@
<div
class="mode-card"
class:mode-card-active={searchMode === 'programas'}
on:click={() => setMode('programas')}
on:keydown={(e) => (e.key === 'Enter' || e.key === ' ') && setMode('programas')}
onclick={() => setMode('programas')}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && setMode('programas')}
role="button"
tabindex="0"
>
......@@ -1134,8 +1123,8 @@
{#each EXAMPLES_PROGRAMAS as ex}
<span
class="mode-pill"
on:click|stopPropagation={() => searchFromPill(ex.label)}
on:keydown={(e) => e.key === 'Enter' && searchFromPill(ex.label)}
onclick={() => searchFromPill(ex.label)}
onkeydown={(e) => e.key === 'Enter' && searchFromPill(ex.label)}
role="button"
tabindex="0"
>
......@@ -1152,8 +1141,8 @@
<div
class="mode-card"
class:mode-card-active={searchMode === 'clasificadores'}
on:click={() => setMode('clasificadores')}
on:keydown={(e) => (e.key === 'Enter' || e.key === ' ') && setMode('clasificadores')}
onclick={() => setMode('clasificadores')}
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && setMode('clasificadores')}
role="button"
tabindex="0"
>
......@@ -1176,7 +1165,7 @@
class="clf-chip"
class:clf-chip-active={selectedClassifiers.includes(clf.id)}
class:clf-chip-disabled={clf.disabled}
on:click|stopPropagation={() => { if (!clf.disabled) toggleClassifier(clf.id); }}
onclick={() => { if (!clf.disabled) toggleClassifier(clf.id); }}
disabled={clf.disabled}
>
<span class="clf-dot"></span>
......@@ -1191,8 +1180,8 @@
{:else}
<span
class="mode-pill"
on:click|stopPropagation={() => searchFromPill(ex.label, ex.classifier)}
on:keydown={(e) => e.key === 'Enter' && searchFromPill(ex.label, ex.classifier)}
onclick={() => searchFromPill(ex.label, ex.classifier)}
onkeydown={(e) => e.key === 'Enter' && searchFromPill(ex.label, ex.classifier)}
role="button"
tabindex="0"
>
......@@ -1207,26 +1196,6 @@
</div>
</div>
<!-- Sección: Ir a -->
<span class="section-label section-label-goto">Ir a</span>
<a href="/vista-diaria" class="daily-card">
<div class="daily-card-left">
<span class="daily-dot"></span>
<span class="daily-card-title">Vista diaria</span>
</div>
<div class="daily-card-content">
{#if !dailyLoading && dailyData}
<span class="daily-card-text">Ayer el Estado ejecutó <strong>{formatMonto(dailyData.monto)}</strong></span>
{:else if !dailyLoading}
<span class="daily-card-text">Ver el resumen de ejecución de ayer</span>
{:else}
<span class="daily-card-text">Cargando datos...</span>
{/if}
</div>
<svg class="daily-card-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14M12 5l7 7-7 7"/>
</svg>
</a>
</div>
<!-- Contenedor de resultados (overlay absoluto) -->
......@@ -1234,7 +1203,7 @@
<div
class="results-content results-content-filled"
class:results-content-visible={allHits.length > 0 || (results && results.hits?.length === 0)}
on:mousedown|preventDefault
onmousedown={(e) => e.preventDefault()}
>
{#if results && results.hits?.length === 0 && !isLoading}
<div class="results-message-inline">
......@@ -1247,7 +1216,7 @@
<div class="search-context-row">
<span class="search-context-title">{searchContextText}</span>
{#if searchMode === 'clasificadores' && selectedClassifiers.length > 0}
<button class="search-explain-link" on:click|stopPropagation={openExplain}>
<button class="search-explain-link" onclick={openExplain}>
¿Qué busco?
</button>
{/if}
......@@ -1277,7 +1246,7 @@
</div>
<div class="sort-buttons">
{#if !stats.isClassifier}
<button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} on:click={() => toggleSort('alpha')}>
<button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} onclick={() => toggleSort('alpha')}>
A-Z
{#if sortBy === 'alpha'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
......@@ -1287,7 +1256,7 @@
{/if}
</button>
{/if}
<button class="sort-btn" class:sort-btn-active={sortBy === 'amount'} on:click={() => toggleSort('amount')}>
<button class="sort-btn" class:sort-btn-active={sortBy === 'amount'} onclick={() => toggleSort('amount')}>
Monto
{#if sortBy === 'amount'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
......@@ -1297,7 +1266,7 @@
{/if}
</button>
{#if groupedHits}
<button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} on:click={() => toggleSort('alpha')}>
<button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} onclick={() => toggleSort('alpha')}>
A-Z
{#if sortBy === 'alpha'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
......@@ -1320,7 +1289,7 @@
{@const isCollapsed = collapsedGroups.includes(gi)}
{@const maxVisible = isCollapsed ? 0 : group.hits.length}
<div class="result-group">
<button class="result-group-header" on:click={() => {
<button class="result-group-header" onclick={() => {
if (isCollapsed) { collapsedGroups = collapsedGroups.filter(x => x !== gi); }
else { collapsedGroups = [...collapsedGroups, gi]; }
}}>
......@@ -1546,7 +1515,7 @@
{#if hasMoreResults}
<div class="load-more-container">
<button class="load-more-btn" on:click={loadMore} disabled={isLoadingMore}>
<button class="load-more-btn" onclick={loadMore} disabled={isLoadingMore}>
{#if isLoadingMore}
<span class="loading-dot"></span> Cargando...
{:else}
......@@ -1563,19 +1532,11 @@
</div>
<!-- Scroll hint -->
<div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching || allHits.length > 0}>
<svg class="scroll-icon-mouse" width="18" height="28" viewBox="0 0 18 28" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
<rect x="1" y="1" width="16" height="26" rx="8"/><line x1="9" y1="7" x2="9" y2="12" opacity="0.7"/>
</svg>
<svg class="scroll-icon-touch" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 9l6 6 6-6"/>
</svg>
<span>Explora la página</span>
</div>
</main>
<!-- ═══ PORTAL DIRECTORY ═══ -->
<!-- Secciones removidas - el landing es solo el buscador -->
<div style="display:none">
<section class="directory" data-section="datos">
<div class="directory-inner">
<!-- Left: Title -->
......@@ -1590,7 +1551,7 @@
<div class="directory-right">
<!-- Diario -->
<div use:reveal={{ id: 'door-d', delay: 60 }} class="reveal" class:revealed={revealStates['door-d']}>
<div class="door door-warm" class:door-hovered={hDiario} on:mouseenter={() => hDiario = true} on:mouseleave={() => hDiario = false} role="button" tabindex="0">
<div class="door door-warm" class:door-hovered={hDiario} onmouseenter={() => hDiario = true} onmouseleave={() => hDiario = false} role="button" tabindex="0">
<div class="door-content">
<div class="door-header">
<div class="door-icon" style="opacity:{hDiario ? 1 : 0.4}">
......@@ -1622,7 +1583,7 @@
<!-- Archivo -->
<div use:reveal={{ id: 'door-a', delay: 160 }} class="reveal" class:revealed={revealStates['door-a']}>
<div class="door door-warm" class:door-hovered={hArchivo} on:mouseenter={() => hArchivo = true} on:mouseleave={() => hArchivo = false} role="button" tabindex="0">
<div class="door door-warm" class:door-hovered={hArchivo} onmouseenter={() => hArchivo = true} onmouseleave={() => hArchivo = false} role="button" tabindex="0">
<div class="door-content">
<div class="door-header">
<div class="door-icon" style="opacity:{hArchivo ? 1 : 0.4}">
......@@ -1678,7 +1639,7 @@
<div class="clas-group-sub">La entidad que gasta o recibe</div>
</div>
</div>
<a href="/clasificadores/institucional" class="widget-card widget-h" class:widget-hover={hWidget === 'quien'} on:mouseenter={() => hWidget = 'quien'} on:mouseleave={() => hWidget = null}>
<a href="/clasificadores/institucional" class="widget-card widget-h" class:widget-hover={hWidget === 'quien'} onmouseenter={() => hWidget = 'quien'} onmouseleave={() => hWidget = null}>
<div class="widget-left">
<div class="widget-headline">
<span class="widget-num-big">756</span>
......@@ -1709,7 +1670,7 @@
</div>
<div class="widgets-col">
{#each WIDGETS_INGRESOS as w}
<a href={w.href} class="widget-card widget-grow" class:widget-hover={hWidget === w.id} on:mouseenter={() => hWidget = w.id} on:mouseleave={() => hWidget = null}>
<a href={w.href} class="widget-card widget-grow" class:widget-hover={hWidget === w.id} onmouseenter={() => hWidget = w.id} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-gold">{w.n}</span>
<span class="widget-label">{w.label}</span>
......@@ -1751,7 +1712,7 @@
</div>
<div class="widgets-row">
{#each WIDGETS_GASTOS_QUE as w}
<a href={w.href} class="widget-card widget-sm" class:widget-hover={hWidget === w.id} on:mouseenter={() => hWidget = w.id} on:mouseleave={() => hWidget = null}>
<a href={w.href} class="widget-card widget-sm" class:widget-hover={hWidget === w.id} onmouseenter={() => hWidget = w.id} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">{w.n}</span>
<span class="widget-label">{w.label}</span>
......@@ -1778,7 +1739,7 @@
<div class="clas-subgroup-label">¿PARA QUÉ?</div>
</div>
</div>
<a href="/clasificadores/funcional" class="widget-card widget-full" class:widget-hover={hWidget === 'finalidad'} on:mouseenter={() => hWidget = 'finalidad'} on:mouseleave={() => hWidget = null}>
<a href="/clasificadores/funcional" class="widget-card widget-full" class:widget-hover={hWidget === 'finalidad'} onmouseenter={() => hWidget = 'finalidad'} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">132</span>
<span class="widget-label">finalidades y funciones</span>
......@@ -1801,7 +1762,7 @@
<div class="clas-subgroup-label">¿DÓNDE?</div>
</div>
</div>
<a href="/clasificadores/geografico" class="widget-card widget-full" class:widget-hover={hWidget === 'donde'} on:mouseenter={() => hWidget = 'donde'} on:mouseleave={() => hWidget = null}>
<a href="/clasificadores/geografico" class="widget-card widget-full" class:widget-hover={hWidget === 'donde'} onmouseenter={() => hWidget = 'donde'} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">352</span>
<span class="widget-label">ubicaciones geográficas</span>
......@@ -1840,7 +1801,7 @@
<div class="hist-grid">
{#each HISTORIAS as h, i}
<div use:reveal={{ id: `hist-${i}`, delay: 60 + i * 80 }} class="reveal hist-cell" class:revealed={revealStates[`hist-${i}`]} class:hist-tall={h.tall}>
<div class="hist-card" class:hist-card-hovered={hHist === i} on:mouseenter={() => hHist = i} on:mouseleave={() => hHist = null} role="button" tabindex="0" style="--card-color:{h.color}">
<div class="hist-card" class:hist-card-hovered={hHist === i} onmouseenter={() => hHist = i} onmouseleave={() => hHist = null} role="button" tabindex="0" style="--card-color:{h.color}">
<div class="hist-accent" style="background:{h.color};transform:scaleX({hHist === i ? 1 : 0})"></div>
<div class="hist-content">
<span class="hist-tag" style="color:{h.color}">{h.tag}</span>
......@@ -1877,7 +1838,7 @@
{#each VISUALIZACIONES as v, i}
<div use:reveal={{ id: `vis-${i}`, delay: 60 + i * 80 }} class="reveal" class:revealed={revealStates[`vis-${i}`]}>
{#if v.href}
<a href={v.href} class="vis-card vis-card-link" class:vis-card-hovered={hVis === i} on:mouseenter={() => hVis = i} on:mouseleave={() => hVis = null}>
<a href={v.href} class="vis-card vis-card-link" class:vis-card-hovered={hVis === i} onmouseenter={() => hVis = i} onmouseleave={() => hVis = null}>
<div class="vis-accent" style="background:{v.color};transform:scaleX({hVis === i ? 1 : 0})"></div>
<div>
<span class="vis-type" style="background:color-mix(in srgb, {v.color} 12%, transparent);color:{v.color}">{v.type}</span>
......@@ -1889,7 +1850,7 @@
</div>
</a>
{:else}
<div class="vis-card vis-card-disabled" class:vis-card-hovered={hVis === i} on:mouseenter={() => hVis = i} on:mouseleave={() => hVis = null} role="button" tabindex="0">
<div class="vis-card vis-card-disabled" class:vis-card-hovered={hVis === i} onmouseenter={() => hVis = i} onmouseleave={() => hVis = null} role="button" tabindex="0">
<div class="vis-accent" style="background:{v.color};transform:scaleX({hVis === i ? 1 : 0})"></div>
<div>
<span class="vis-type" style="background:color-mix(in srgb, {v.color} 12%, transparent);color:{v.color}">{v.type}</span>
......@@ -1936,11 +1897,14 @@
</div>
</section>
</div>
<!-- fin secciones ocultas -->
<!-- ═══ FOOTER ═══ -->
<footer>
<div class="footer-inner">
<div class="footer-org">MINISTERIO DE ECONOMÍA Y FINANZAS PÚBLICAS</div>
<div class="footer-right">DATOS ABIERTOS · 2005 — 2026</div>
<div class="footer-right">DATOS ABIERTOS · 2016 — 2025</div>
</div>
</footer>
</div>
......@@ -1949,11 +1913,11 @@
{#if showExplainModal}
{@const activeContents = selectedClassifiers.map(id => EXPLAIN_CONTENT[id]).filter(Boolean)}
{@const isSingle = activeContents.length === 1}
<div class="explain-backdrop" on:click={() => { showExplainModal = false; }} role="dialog" aria-modal="true">
<div class="explain-modal" on:click|stopPropagation>
<div class="explain-backdrop" onclick={() => { showExplainModal = false; }} role="dialog" aria-modal="true">
<div class="explain-modal" onclick={(e) => e.stopPropagation()}>
<div class="explain-header">
<h2 class="explain-title">{isSingle ? activeContents[0].titulo : '¿Qué busco?'}</h2>
<button class="explain-close" on:click={() => { showExplainModal = false; }}>
<button class="explain-close" onclick={() => { showExplainModal = false; }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
......@@ -1973,7 +1937,7 @@
{/each}
</div>
{#if content.link}
<a href={content.link.href} class="explain-cta" on:click={() => { showExplainModal = false; }}>
<a href={content.link.href} class="explain-cta" onclick={() => { showExplainModal = false; }}>
{content.link.texto}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
......@@ -1997,7 +1961,7 @@
{/each}
</div>
{#if content.link}
<a href={content.link.href} class="explain-cta" on:click={() => { showExplainModal = false; }}>
<a href={content.link.href} class="explain-cta" onclick={() => { showExplainModal = false; }}>
{content.link.texto}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M5 12h14M12 5l7 7-7 7"/>
......@@ -2028,7 +1992,7 @@
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500&display=swap');
:global(*){margin:0;padding:0;box-sizing:border-box}
.page *{margin:0;padding:0;box-sizing:border-box}
:global(html){width:100%;height:100%;margin:0;padding:0;overflow-x:hidden;background:#1C1C1A;scroll-behavior:smooth;transition:background 0.3s}
:global(html:not(.dark)){background:#ffffff}
:global(body){width:100%;min-height:100%;margin:0;padding:0;overflow-x:hidden;background:#1C1C1A;transition:background 0.3s}
......@@ -2037,7 +2001,7 @@
:global(::-webkit-scrollbar){width:5px}
:global(::-webkit-scrollbar-track){background:#1C1C1A}
:global(::-webkit-scrollbar-thumb){background:#444;border-radius:3px}
:global(a){color:inherit;text-decoration:none}
.page a{color:inherit;text-decoration:none}
@keyframes pulse{0%,100%{opacity:0.35}50%{opacity:1}}
@keyframes blink{0%,100%{opacity:1}50%{opacity:0}}
......@@ -2276,7 +2240,7 @@
.pills-hint{font-family:var(--sans);font-size:10px;color:#7B7870;font-weight:500}
.hint-desktop{display:inline}
.hint-mobile{display:none}
.mode-pill{font-family:var(--sans);font-size:11px;font-weight:500;padding:5px 10px;border-radius:8px;cursor:pointer;transition:all 0.2s;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);color:#D0CCC4}
.mode-pill{font-family:var(--sans);font-size:11px;font-weight:500;padding:5px 10px;border-radius:8px;cursor:pointer;transition:all 0.2s;border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.06);color:#F5F0E8}
.mode-pill:hover{background:rgba(255,255,255,0.12);border-color:rgba(255,255,255,0.25);color:#F5F0E8}
.mode-card-radio{position:absolute;top:16px;right:16px}
......
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/acteco';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/acteco';
// Jerarquía acteco: códigos con punto "1" → "1.1" → "1.1.1"
// Todos tienen nivel "actividad" en la API, derivamos la profundidad del código
......
const API_BASE = 'http://136.112.29.74/api/acteco/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/acteco/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/acteco';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/acteco';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/acteco';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/acteco';
let cachedClasificador = null;
let cachedEstados = null;
......
const API_BASE = 'http://136.112.29.74/api/acteco';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/acteco';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/diario';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/diario';
export async function GET({ url }) {
const tipo = url.searchParams.get('tipo') || 'status';
......
const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/entidad/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/entidad';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/entidad';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/finfun/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/finfun/clasificador';
export async function GET() {
try {
const response = await fetch(API_BASE);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
if (!response.ok) throw new Error();
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
......
const API_BASE = 'http://136.112.29.74/api/finfun';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/finfun';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/finfun';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/finfun';
// Cache en memoria
let cachedClasificador = null;
let cachedEstados = null;
let cachedTimestamp = 0;
const CACHE_TTL = 5 * 60 * 1000;
function getParent(finfun) {
const idx = finfun.lastIndexOf('.');
if (idx === -1) return null;
return finfun.substring(0, idx);
function getParent(finfun, nivel) {
const code = String(finfun);
if (nivel === 'finalidad') return null;
if (nivel === 'grp_funcion') {
return code.startsWith('10') ? '10' : code.charAt(0);
}
if (nivel === 'funcion') {
if (code.startsWith('10')) return code.substring(0, 3);
return code.substring(0, 2);
}
return null;
}
async function loadAndCacheAll() {
......@@ -58,7 +67,7 @@ export async function GET({ url }) {
nivel: item.nivel,
finfun: item.finfun,
desc_finfun: item.desc_finfun,
parent: getParent(item.finfun),
parent: getParent(item.finfun, item.nivel),
devengado: yearData.total
});
}
......@@ -89,7 +98,7 @@ export async function GET({ url }) {
nivel: item.nivel,
finfun: item.finfun,
desc_finfun: item.desc_finfun,
parent: getParent(item.finfun),
parent: getParent(item.finfun, item.nivel),
devengado: yearData.monto
};
} catch { return null; }
......
const API_BASE = 'http://136.112.29.74/api/finfun';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/finfun';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/objeto/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/objeto/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/objeto';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/objeto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/entidad/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/entidad/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/objeto';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/objeto';
// Cache en memoria del servidor
let cachedClasificador = null;
......
const API_BASE = 'http://136.112.29.74/api/objeto';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/objeto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/organismo/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/organismo/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/organismo';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/organismo';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/organismo';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/organismo';
let cachedClasificador = null;
let cachedEstados = null;
......
const API_BASE = 'http://136.112.29.74/api/programa_proyecto';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/programa_proyecto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/rubro/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/rubro/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/rubro';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/rubro';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
const API_BASE = 'http://136.112.29.74/api/rubro';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/rubro';
let cachedClasificador = null;
let cachedEstados = null;
......
const API_BASE = 'http://136.112.29.74/api/rubro';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/rubro';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
// Proxy para evitar CORS
const API_BASE = 'http://136.112.29.74/api/search';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/search';
export async function GET({ url }) {
const q = url.searchParams.get('q') || '';
......@@ -37,8 +38,8 @@ export async function GET({ url }) {
headers: { 'Content-Type': 'application/json' }
});
} catch (err) {
console.error('Proxy error:', err);
return new Response(JSON.stringify({ error: 'Failed to fetch from API' }), {
console.error('Proxy error:', err.message, 'URL:', apiUrl.toString());
return new Response(JSON.stringify({ error: 'Failed to fetch from API', detail: err.message, url: apiUrl.toString() }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
......
const API_BASE = 'http://136.112.29.74/api/ubigeo/clasificador';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/ubigeo/clasificador';
export async function GET() {
try {
......
const API_BASE = 'http://136.112.29.74/api/ubigeo';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/ubigeo';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
......
<script>
import { onMount } from 'svelte';
import { fade, scale } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
let hovered = null;
let activeModal = null;
let mounted = false;
let mounted = $state(false);
let hWidget = $state(null);
onMount(() => { setTimeout(() => { mounted = true; }, 80); });
const MODALS = {
quien: {
title: '¿Quién gasta o recibe?', tech: 'CLASIFICADOR INSTITUCIONAL',
everyday: 'Es como preguntar "¿quién en la familia maneja plata?". Papá tiene su sueldo, mamá maneja el negocio, el abuelo cobra su renta. Cada uno es una "entidad" distinta con su propio presupuesto.',
budget: 'El clasificador institucional cataloga a todas las entidades del sector público: ministerios, gobernaciones, municipios, empresas públicas, universidades. Cada una tiene un código único y un presupuesto asignado.',
example: 'Ejemplo: El Ministerio de Educación (código 040) y el Gobierno Autónomo de La Paz (código 126) son entidades distintas, cada una con sus propios ingresos y gastos.',
color: 'var(--theme-titulo)',
},
rubros: {
title: '¿Qué tipo de ingreso es?', tech: 'CLASIFICADOR DE RECURSOS POR RUBROS',
everyday: 'María gana un sueldo de su trabajo, cobra alquiler de un cuartito, y un amigo le prestó plata. Son tres tipos distintos de ingreso: laboral, patrimonial, y crédito.',
budget: 'En el presupuesto, el Estado recibe ingresos tributarios (impuestos = el "sueldo"), regalías por explotación de recursos (= el "alquiler"), y créditos internacionales (= el "préstamo"). El rubro te dice qué naturaleza tiene cada ingreso.',
example: 'Ejemplo: Los Bs 2.400M que Bolivia recibe por IDH son un rubro distinto a los Bs 800M de crédito del BID, aunque ambos financien al mismo ministerio.',
color: 'var(--theme-accent)',
},
fuentes: {
title: '¿Quién pone la plata?', tech: 'CLASIFICADOR DE FUENTES DE FINANCIAMIENTO',
everyday: 'María sabe que su sueldo se lo paga la empresa TechCorp, el alquiler se lo paga su inquilino Juan, y el préstamo se lo hizo el Banco Nacional. Son las fuentes: quién firma el cheque.',
budget: 'Las fuentes son los bolsillos específicos: el TGN (Tesoro General de la Nación), recursos propios de cada entidad, crédito externo del BID o Banco Mundial, o donaciones de cooperación.',
example: 'Ejemplo: Un crédito del BID y uno del Banco Mundial son el mismo rubro (crédito externo) pero distinta fuente. La fuente dice quién exactamente pone la plata.',
color: 'var(--theme-accent)',
},
objeto: {
title: '¿En qué se gasta concretamente?', tech: 'CLASIFICADOR POR OBJETO DEL GASTO',
everyday: 'Cuando María gasta, compra cosas concretas: arroz, paga la luz, le paga al albañil. Cada compra es un "objeto" distinto — algo tangible que se puede listar.',
budget: 'El Objeto del Gasto es exactamente eso: sueldos, consultorías, materiales de escritorio, combustible, medicamentos. Es la lista del supermercado del Estado.',
example: 'Ejemplo: El grupo 10000 "Servicios Personales" incluye sueldos y aguinaldos. El 20000 "Servicios No Personales" incluye consultorías y alquileres.',
color: '#4A8B6E',
},
sector: {
title: '¿A qué sector de la economía va?', tech: 'CLASIFICADOR POR SECTORES ECONÓMICOS',
everyday: 'Si María contrata un albañil, esa plata va al sector construcción. Si compra medicina, va al sector salud. No importa qué compró — importa qué parte de la economía mueve.',
budget: 'Este clasificador mide el impacto económico: cuánto va a infraestructura, cuánto a servicios, cuánto a industria. Permite evaluar cómo las decisiones fiscales afectan a cada sector.',
example: 'Ejemplo: Un hospital nuevo es "construcción" en Objeto del Gasto pero "salud" en Sector Económico. Misma plata, dos lentes distintos.',
color: '#4A8B6E',
},
finalidad: {
title: '¿Para qué se gasta?', tech: 'CLASIFICADOR POR FINALIDAD Y FUNCIÓN',
everyday: 'María compró arroz (objeto), pero ¿para qué? Para alimentar a su familia. Compró cemento: para tener casa. El "para qué" es el objetivo final, no el insumo.',
budget: 'Este clasificador agrupa por propósito: educación, defensa, salud, protección social. No importa si se gastó en sueldos o en cemento — lo que importa es el fin.',
example: 'Ejemplo: El sueldo de un profesor y la construcción de una escuela son objetos de gasto distintos, pero ambos tienen la misma finalidad: Educación.',
color: '#4A8B6E',
},
donde: {
title: '¿Dónde se gasta?', tech: 'CLASIFICADOR GEOGRÁFICO',
everyday: 'María manda plata a su mamá en Cochabamba y paga el alquiler en La Paz. No es lo mismo gastar en la ciudad que en el área rural, en Santa Cruz que en Potosí.',
budget: 'La dimensión geográfica permite ver cómo se distribuyen los recursos en el territorio: por departamento, municipio o región. Revela las prioridades territoriales del Estado.',
example: 'Ejemplo: Del presupuesto de Educación, ¿cuánto va a La Paz vs. Beni? Este clasificador responde esa pregunta.',
color: '#4A8B6E',
},
};
onMount(() => { mounted = true; });
const WIDGETS_INGRESOS = [
{ id: 'rubros', q: '¿Qué tipo de ingreso?', d: 'Impuestos, regalías, ventas, créditos — la naturaleza de cada ingreso', t: 'Recursos por rubros', href: '/clasificadores/rubros' },
{ id: 'fuentes', q: '¿Quién pone la plata?', d: 'TGN, crédito externo, donaciones — de qué bolsillo sale', t: 'Fuentes de financiamiento', href: '/clasificadores/fuente' },
];
const WIDGETS_EN_QUE = [
{ id: 'objeto', q: 'Objeto del gasto', d: 'Sueldos, consultorías, materiales — la lista concreta', t: 'Objeto del gasto', href: '/clasificadores/objeto-gasto' },
{ id: 'sector', q: 'Sector económico', d: 'Industria, construcción, servicios — a qué sector va', t: 'Sectores económicos', href: '/clasificadores/sectores' },
{ id: 'rubros', label: 'tipos de ingreso', d: 'Impuestos, regalías, ventas, créditos', t: 'Recursos por rubros', n: '324', href: '/clasificadores/rubros' },
{ id: 'organismos', label: 'organismos financiadores', d: 'BID, Banco Mundial, cooperación', t: 'Organismos', n: '228', href: '/clasificadores/organismos' },
];
const WIDGETS_BOTTOM = [
{ id: 'finalidad', q: '¿Para qué se gasta?', d: 'Educación, salud, defensa — el objetivo final', t: 'Finalidad y función', href: '/clasificadores/funcional' },
{ id: 'donde', q: '¿Dónde se gasta?', d: 'Departamentos, municipios — distribución territorial', t: 'Geográfico', href: '/clasificadores/geografico' },
const WIDGETS_GASTOS_QUE = [
{ id: 'objeto', label: 'objetos de gasto', d: 'Sueldos, consultorías, materiales', t: 'Objeto del gasto', n: '324', href: '/clasificadores/objeto-gasto' },
{ id: 'sector', label: 'sectores económicos', d: 'Industria, construcción, servicios', t: 'Sectores', n: '488', href: '/clasificadores/sectores' },
];
$: modal = activeModal ? MODALS[activeModal] : null;
function openModal(e, id) {
e.preventDefault();
e.stopPropagation();
activeModal = id;
}
function closeModal() { activeModal = null; }
</script>
<svelte:head>
<title>Clasificadores — Presupuesto Bolivia</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
<title>Clasificadores | Presupuesto Público</title>
</svelte:head>
<div class="page" class:mounted>
<!-- Header -->
<header class="page-header">
<nav class="breadcrumb">
<a href="/" class="breadcrumb-link sm-hidden">← Inicio</a>
<div class="breadcrumb-full">
<a href="/">Inicio</a>
<span class="breadcrumb-sep">/</span>
<span class="breadcrumb-current">Clasificadores</span>
<div class="clas-container">
<!-- Header -->
<div class="clas-header-section">
<div class="clas-header-row">
<div>
<p class="section-tag">CLASIFICADORES</p>
<h2 class="section-title">Elige cómo explorar el presupuesto</h2>
<p class="clas-subtitle">Navega por cada una de las 2,626 piezas de los 8 clasificadores.</p>
</div>
</div>
</nav>
<div class="header-content anim" style="--d:0ms">
<h1>Elige cómo explorar el presupuesto</h1>
<p class="subtitulo">
Cada clasificador es una forma distinta de ver la misma información.
Haz clic en <span class="help-inline">?</span> para entender qué es cada uno.
</p>
</div>
</header>
<main>
<!-- ═══ ¿QUIÉN? ═══ -->
<div class="anim" style="--d:100ms">
<div class="group">
<div class="group-accent" style="--c: var(--theme-titulo)"></div>
<div class="group-header">
<div class="group-icon" style="--c: var(--theme-titulo)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div>
<div class="group-label">¿QUIÉN?</div>
<div class="group-sub">La entidad que gasta o recibe</div>
</div>
<!-- ¿Quién? -->
<div class="clas-group-card">
<div class="clas-group-header">
<div class="clas-group-icon" style="--c: var(--theme-titulo)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div>
<div class="clas-group-label">¿QUIÉN?</div>
<div class="clas-group-sub">La entidad que gasta o recibe</div>
</div>
<a
href="/clasificadores/institucional"
class="widget-card widget-h"
class:wh={hovered === 'quien'}
on:mouseenter={() => hovered = 'quien'}
on:mouseleave={() => hovered = null}
>
<div class="widget-h-left">
<div class="widget-q">Instituciones públicas</div>
<div class="widget-desc">Ministerios, gobernaciones, municipios, empresas públicas, universidades</div>
</div>
<div class="widget-h-right">
<span class="widget-tech">INSTITUCIONAL</span>
<button class="help-btn help-sm" on:click={(e) => openModal(e, 'quien')}>?</button>
<span class="widget-arrow" class:arrow-show={hovered === 'quien'}>→</span>
</div>
</a>
</div>
<a href="/clasificadores/institucional" class="widget-card widget-h" class:widget-hover={hWidget === 'quien'} onmouseenter={() => hWidget = 'quien'} onmouseleave={() => hWidget = null}>
<div class="widget-left">
<div class="widget-headline">
<span class="widget-num-big">756</span>
<span class="widget-label">instituciones públicas</span>
</div>
<div class="widget-desc">Ministerios, gobernaciones, municipios, empresas públicas, universidades</div>
</div>
<div class="widget-right">
<span class="widget-tech">INSTITUCIONAL</span>
<span class="widget-arrow" class:arrow-show={hWidget === 'quien'}>→</span>
</div>
</a>
</div>
<!-- ═══ INGRESOS + GASTOS ═══ -->
<div class="main-grid">
<!-- Grid: Ingresos + Gastos -->
<div class="clas-main-grid">
<!-- INGRESOS -->
<div class="anim col-stretch" style="--d:200ms">
<div class="group group-stretch">
<div class="group-accent" style="--c: var(--theme-accent)"></div>
<div class="group-header">
<div class="group-icon" style="--c: var(--theme-accent)">
<div class="clas-col">
<div class="clas-group-card">
<div class="clas-group-header">
<div class="clas-group-icon" style="--c:#C9A751">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>
</div>
<div>
<div class="group-label" style="color: var(--theme-accent)">INGRESOS</div>
<div class="group-sub">¿De dónde viene la plata?</div>
<div class="clas-group-label" style="color:#C9A751">INGRESOS</div>
</div>
</div>
<div class="widgets-stretch">
<div class="widgets-col">
{#each WIDGETS_INGRESOS as w}
<a
href={w.href}
class="widget-card widget-grow"
class:wh={hovered === w.id}
on:mouseenter={() => hovered = w.id}
on:mouseleave={() => hovered = null}
>
<div class="widget-top">
<h4 class="widget-q">{w.q}</h4>
<button class="help-btn" on:click={(e) => openModal(e, w.id)}>?</button>
<a href={w.href} class="widget-card widget-grow" class:widget-hover={hWidget === w.id} onmouseenter={() => hWidget = w.id} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-gold">{w.n}</span>
<span class="widget-label">{w.label}</span>
</div>
<p class="widget-desc widget-desc-grow">{w.d}</p>
<p class="widget-desc">{w.d}</p>
<div class="widget-footer">
<span class="widget-tech">{w.t}</span>
<span class="widget-arrow" class:arrow-show={hovered === w.id}>→</span>
<span class="widget-arrow" class:arrow-show={hWidget === w.id}>→</span>
</div>
<div class="widget-glow widget-glow-accent"></div>
</a>
{/each}
</div>
......@@ -191,703 +92,166 @@
</div>
<!-- GASTOS -->
<div class="anim col-stretch" style="--d:320ms">
<div class="group">
<div class="group-accent" style="--c: #4A8B6E"></div>
<div class="group-header">
<div class="group-icon" style="--c: #4A8B6E">
<div class="clas-col">
<div class="clas-group-card">
<div class="clas-group-header">
<div class="clas-group-icon" style="--c:#4A8B6E">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg>
</div>
<div>
<div class="group-label" style="color: #4A8B6E">GASTOS</div>
<div class="group-sub">¿Cómo se gasta la plata?</div>
<div class="clas-group-label" style="color:#4A8B6E">GASTOS</div>
</div>
</div>
<div class="group-widgets">
<!-- ¿En qué? sub-group -->
<div class="sub-group">
<div class="sub-group-accent" style="--c: #4A8B6E"></div>
<div class="sub-group-header">
<div class="sub-group-left">
<div class="sub-group-icon" style="--c: #4A8B6E">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
</div>
<div>
<div class="sub-group-label">¿En qué se gasta?</div>
<div class="sub-group-tag">DOS CLASIFICADORES</div>
</div>
<div class="gastos-content">
<!-- ¿En qué? -->
<div class="clas-subgroup">
<div class="clas-subgroup-header">
<div class="clas-subgroup-icon" style="--c:#4A8B6E">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
<div>
<div class="clas-subgroup-label">¿EN QUÉ?</div>
</div>
</div>
<div class="sub-grid">
{#each WIDGETS_EN_QUE as w}
<a
href={w.href}
class="widget-card sub-card"
class:wh={hovered === w.id}
on:mouseenter={() => hovered = w.id}
on:mouseleave={() => hovered = null}
>
<div class="widget-top">
<h4 class="widget-q sub-q">{w.q}</h4>
<button class="help-btn help-xs" on:click={(e) => openModal(e, w.id)}>?</button>
<div class="widgets-row">
{#each WIDGETS_GASTOS_QUE as w}
<a href={w.href} class="widget-card widget-sm" class:widget-hover={hWidget === w.id} onmouseenter={() => hWidget = w.id} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">{w.n}</span>
<span class="widget-label">{w.label}</span>
</div>
<p class="widget-desc sub-desc">{w.d}</p>
<div class="widget-footer sub-footer">
<span class="widget-tech sub-tech">{w.t}</span>
<span class="widget-arrow" class:arrow-show={hovered === w.id}>→</span>
<p class="widget-desc widget-desc-sm">{w.d}</p>
<div class="widget-footer">
<span class="widget-tech">{w.t}</span>
<span class="widget-arrow" class:arrow-show={hWidget === w.id}>→</span>
</div>
<div class="widget-glow widget-glow-green"></div>
</a>
{/each}
</div>
</div>
<!-- Bottom row -->
<div class="bottom-grid">
{#each WIDGETS_BOTTOM as w}
<a
href={w.href}
class="widget-card"
class:wh={hovered === w.id}
on:mouseenter={() => hovered = w.id}
on:mouseleave={() => hovered = null}
>
<div class="widget-top">
<h4 class="widget-q">{w.q}</h4>
<button class="help-btn" on:click={(e) => openModal(e, w.id)}>?</button>
<!-- Para qué + Dónde -->
<div class="widgets-row">
<div class="clas-subgroup">
<div class="clas-subgroup-header">
<div class="clas-subgroup-icon" style="--c:#4A8B6E">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
</div>
<div>
<div class="clas-subgroup-label">¿PARA QUÉ?</div>
</div>
</div>
<a href="/clasificadores/funcional" class="widget-card widget-full" class:widget-hover={hWidget === 'finalidad'} onmouseenter={() => hWidget = 'finalidad'} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">132</span>
<span class="widget-label">finalidades y funciones</span>
</div>
<p class="widget-desc">Educación, salud, defensa</p>
<div class="widget-footer">
<span class="widget-tech">Finalidad</span>
<span class="widget-arrow" class:arrow-show={hWidget === 'finalidad'}>→</span>
</div>
</a>
</div>
<div class="clas-subgroup">
<div class="clas-subgroup-header">
<div class="clas-subgroup-icon" style="--c:#4A8B6E">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>
</div>
<div>
<div class="clas-subgroup-label">¿DÓNDE?</div>
</div>
</div>
<a href="/ubicacion" class="widget-card widget-full" class:widget-hover={hWidget === 'donde'} onmouseenter={() => hWidget = 'donde'} onmouseleave={() => hWidget = null}>
<div class="widget-headline">
<span class="widget-num-big widget-num-green">352</span>
<span class="widget-label">ubicaciones geográficas</span>
</div>
<p class="widget-desc">{w.d}</p>
<p class="widget-desc">Departamentos, municipios</p>
<div class="widget-footer">
<span class="widget-tech">{w.t}</span>
<span class="widget-arrow" class:arrow-show={hovered === w.id}>→</span>
<span class="widget-tech">Geográfico</span>
<span class="widget-arrow" class:arrow-show={hWidget === 'donde'}>→</span>
</div>
<div class="widget-glow widget-glow-green"></div>
</a>
{/each}
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- ═══ MODAL ═══ -->
{#if modal}
<div
class="modal-overlay"
on:click={closeModal}
on:keydown={(e) => e.key === 'Escape' && closeModal()}
role="dialog"
aria-modal="true"
transition:fade={{ duration: 150 }}
>
<div
class="modal-box"
on:click|stopPropagation
role="document"
transition:scale={{ duration: 200, start: 0.95, easing: cubicOut }}
>
<button class="modal-close" on:click={closeModal}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
<div class="modal-badge">{modal.tech}</div>
<h2 class="modal-title">{modal.title}</h2>
<div class="modal-section">
<div class="modal-icon">👤</div>
<div>
<div class="modal-label">EN LA VIDA COTIDIANA</div>
<p class="modal-text">{modal.everyday}</p>
</div>
</div>
<div class="modal-divider"></div>
<div class="modal-section">
<div class="modal-icon">🏛️</div>
<div>
<div class="modal-label">EN EL PRESUPUESTO</div>
<p class="modal-text">{modal.budget}</p>
</div>
</div>
<div class="modal-example">
<p>{modal.example}</p>
</div>
</div>
</div>
{/if}
</div>
<style>
/* ── Base ── */
.page {
background: var(--theme-body);
color: var(--theme-titulo);
min-height: 100vh;
font-family: 'Instrument Sans', sans-serif;
transition: background-color 0.2s, color 0.2s;
}
/* ── Entrance animations ── */
.anim {
opacity: 0;
transform: translateY(24px);
transition: all 0.7s cubic-bezier(0.16,1,0.3,1);
transition-delay: var(--d, 0ms);
}
.mounted .anim {
opacity: 1;
transform: translateY(0);
}
/* ── Header ── */
.page-header {
border-bottom: 1px solid var(--theme-borde);
padding: 1.5rem 2rem 2rem;
max-width: 1120px;
margin: 0 auto;
}
.breadcrumb {
margin-bottom: 1rem;
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
}
.breadcrumb-link {
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-link:hover {
color: var(--theme-titulo);
}
.sm-hidden { display: block; }
.breadcrumb-full { display: none; }
@media (min-width: 640px) {
.sm-hidden { display: none; }
.breadcrumb-full {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--theme-texto);
}
.breadcrumb-full a {
color: var(--theme-texto);
text-decoration: none;
transition: color 0.2s;
}
.breadcrumb-full a:hover {
color: var(--theme-titulo);
}
.breadcrumb-sep {
opacity: 0.5;
}
.breadcrumb-current {
color: var(--theme-titulo);
}
}
.header-content h1 {
font-family: 'DM Serif Display', serif;
font-size: clamp(1.75rem, 4vw, 2rem);
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 0.5rem;
}
.subtitulo {
font-size: 0.95rem;
line-height: 1.5;
color: var(--theme-texto);
max-width: 600px;
}
.help-inline {
font-family: 'DM Serif Display', serif;
font-weight: 700;
color: var(--theme-texto);
border: 1px solid var(--theme-borde);
border-radius: 50%;
padding: 0 5px;
font-size: 0.7rem;
}
main {
max-width: 1120px;
margin: 0 auto;
padding: 2rem 2rem 4rem;
}
/* ── Group (level 1) ── */
.group {
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 1.5rem;
padding: 1.25rem 1.125rem 1.125rem;
position: relative;
overflow: hidden;
}
.group-stretch {
display: flex;
flex-direction: column;
height: 100%;
}
.group-accent {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--c) 30%, transparent), transparent);
border-radius: 1.5rem 1.5rem 0 0;
}
.group-header {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.875rem;
padding: 0 0.25rem;
}
.group-icon {
width: 2rem;
height: 2rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--c) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--c) 20%, transparent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--c);
}
.group-label {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
letter-spacing: 0.15em;
font-weight: 600;
line-height: 1;
color: var(--theme-titulo);
}
.group-sub {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.688rem;
color: var(--theme-texto);
margin-top: 0.25rem;
opacity: 0.8;
}
.group-widgets {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.widgets-stretch {
display: flex;
flex-direction: column;
gap: 0.625rem;
flex: 1;
}
/* ── Widget card (level 2) ── */
.widget-card {
display: block;
background: var(--theme-fill);
border: 1px solid var(--theme-borde);
border-radius: 1rem;
padding: 1.375rem;
position: relative;
overflow: hidden;
cursor: pointer;
text-decoration: none;
color: var(--theme-titulo);
transition: all 0.4s cubic-bezier(0.16,1,0.3,1);
}
.wh {
background: color-mix(in srgb, var(--theme-fill) 80%, var(--theme-titulo) 5%);
border-color: color-mix(in srgb, var(--theme-borde) 50%, var(--theme-texto) 20%);
transform: translateY(-1px);
}
.widget-grow {
flex: 1;
display: flex;
flex-direction: column;
}
/* Horizontal variant */
.widget-h {
display: flex;
justify-content: space-between;
align-items: center;
}
.widget-h-left { flex: 1; }
.widget-h-right {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.widget-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.625rem;
margin-bottom: 0.5rem;
position: relative;
z-index: 1;
}
.widget-q {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
font-family: 'Instrument Sans', sans-serif;
color: var(--theme-titulo);
}
.widget-desc {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.75rem;
color: var(--theme-texto);
line-height: 1.5;
position: relative;
z-index: 1;
}
.widget-desc-grow { flex: 1; }
.widget-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
position: relative;
z-index: 1;
}
.widget-tech {
font-family: 'DM Mono', monospace;
font-size: 0.5rem;
letter-spacing: 0.12em;
color: var(--theme-texto);
text-transform: uppercase;
}
.widget-arrow {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.813rem;
color: transparent;
transform: translateX(-6px);
transition: all 0.35s cubic-bezier(0.16,1,0.3,1);
}
.arrow-show {
background: var(--theme-body);
color: var(--theme-titulo);
transform: translateX(0);
}
.widget-glow {
position: absolute;
inset: 0;
pointer-events: none;
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.widget-glow-accent {
background: radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--theme-accent) 6%, transparent), transparent 70%);
}
.widget-glow-green {
background: radial-gradient(circle at 50% 50%, rgba(74,139,110,0.06), transparent 70%);
}
.wh > .widget-glow,
.wh .widget-glow { opacity: 1; }
/* ── Sub-group (¿En qué?) ── */
.sub-group {
background: color-mix(in srgb, var(--theme-surface) 60%, var(--theme-body) 40%);
border: 1px solid var(--theme-borde);
border-radius: 1.125rem;
padding: 1rem 0.875rem 0.875rem;
position: relative;
overflow: hidden;
}
.sub-group-accent {
position: absolute;
top: 0;
left: 15%;
right: 15%;
height: 1px;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--c) 25%, transparent), transparent);
}
.sub-group-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
padding: 0 0.25rem;
}
.sub-group-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.sub-group-icon {
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
background: color-mix(in srgb, var(--c) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--c) 18%, transparent);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--c);
}
.sub-group-label {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.75rem;
font-weight: 600;
color: var(--theme-texto);
line-height: 1.1;
}
.sub-group-tag {
font-family: 'DM Mono', monospace;
font-size: 0.438rem;
letter-spacing: 0.1em;
margin-top: 0.125rem;
color: #4A8B6E;
opacity: 0.7;
}
.sub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
/* ── Sub-widget (level 3) ── */
.sub-card {
background: var(--theme-fill);
border-color: var(--theme-borde);
padding: 1rem;
border-radius: 0.75rem;
}
.sub-card.wh {
background: color-mix(in srgb, var(--theme-fill) 80%, var(--theme-titulo) 5%);
border-color: color-mix(in srgb, var(--theme-borde) 50%, var(--theme-texto) 20%);
}
.sub-q {
font-size: 0.813rem;
font-weight: 500;
color: var(--theme-titulo);
}
.sub-desc {
font-size: 0.688rem;
opacity: 0.9;
}
.sub-footer { margin-top: 0.75rem; }
.sub-tech { font-size: 0.375rem; }
.bottom-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.625rem;
}
/* ── Main grid ── */
.main-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
margin-top: 1rem;
align-items: stretch;
}
.col-stretch {
display: flex;
flex-direction: column;
}
/* ── Help button ── */
.help-btn {
width: 1.625rem;
height: 1.625rem;
border-radius: 50%;
border: 1px solid var(--theme-borde);
background: transparent;
color: var(--theme-texto);
font-family: 'DM Serif Display', serif;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.help-btn:hover {
border-color: var(--theme-accent);
color: var(--theme-accent);
background: color-mix(in srgb, var(--theme-accent) 8%, transparent);
}
.help-sm { width: 1.375rem; height: 1.375rem; font-size: 0.625rem; }
.help-xs { width: 1.25rem; height: 1.25rem; font-size: 0.563rem; }
/* ── Modal ── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(12px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 2.5rem;
}
.modal-box {
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 1.5rem;
max-width: 520px;
width: 100%;
padding: 2rem 2.25rem;
position: relative;
max-height: 85vh;
overflow-y: auto;
}
.modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: transparent;
border: 1px solid var(--theme-borde);
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.2s;
}
.modal-close:hover {
border-color: var(--theme-texto);
color: var(--theme-titulo);
}
.modal-badge {
font-family: 'DM Mono', monospace;
font-size: 0.563rem;
letter-spacing: 0.15em;
border-radius: 0.375rem;
padding: 0.25rem 0.563rem;
display: inline-block;
margin-bottom: 0.875rem;
background: color-mix(in srgb, var(--theme-accent) 12%, transparent);
color: var(--theme-accent);
}
.modal-title {
font-family: 'DM Serif Display', serif;
font-size: 1.5rem;
font-weight: 400;
letter-spacing: -0.02em;
line-height: 1.15;
margin-bottom: 1.5rem;
color: var(--theme-titulo);
}
.modal-section {
display: flex;
gap: 0.875rem;
}
.modal-icon {
font-size: 1.125rem;
width: 2.25rem;
height: 2.25rem;
background: var(--theme-fill);
border-radius: 0.625rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border: 1px solid var(--theme-borde);
}
.modal-label {
font-family: 'DM Mono', monospace;
font-size: 0.563rem;
letter-spacing: 0.15em;
color: var(--theme-texto);
margin-bottom: 0.313rem;
}
.modal-text {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.813rem;
line-height: 1.65;
color: var(--theme-texto);
}
.modal-divider {
height: 1px;
background: var(--theme-borde);
margin: 1.125rem 0;
margin-left: 3.125rem;
}
.modal-example {
margin-top: 1.25rem;
margin-left: 3.125rem;
padding: 0.875rem 1.125rem;
border-left: 3px solid var(--theme-accent);
background: color-mix(in srgb, var(--theme-accent) 5%, transparent);
border-radius: 0 0.625rem 0.625rem 0;
}
.modal-example p {
font-family: 'Instrument Sans', sans-serif;
font-size: 0.75rem;
line-height: 1.6;
color: var(--theme-texto);
font-style: italic;
}
/* ── Responsive ── */
@media (max-width: 900px) {
.main-grid {
grid-template-columns: 1fr;
}
.page-header,
main {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
@media (max-width: 640px) {
.page-header,
main {
padding-left: 1rem;
padding-right: 1rem;
}
.sub-grid,
.bottom-grid {
grid-template-columns: 1fr;
}
.widget-h {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.widget-h-right {
width: 100%;
justify-content: space-between;
}
.modal-overlay {
padding: 1rem;
}
.modal-box {
padding: 1.5rem;
}
.modal-example,
.modal-divider {
margin-left: 0;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.clas-container { max-width: 1100px; margin: 0 auto; padding: 80px 2rem 2rem; }
.clas-header-section { margin-bottom: 1.5rem; }
.clas-header-row { display: flex; justify-content: space-between; align-items: flex-end; }
.section-tag { font-family: 'DM Mono', monospace; font-size: 0.6875rem; letter-spacing: 0.15em; color: var(--theme-texto); opacity: 0.5; margin: 0 0 0.25rem; }
.section-title { font-family: 'DM Serif Display', serif; font-size: 2rem; font-weight: 400; margin: 0 0 0.25rem; line-height: 1.15; }
.clas-subtitle { font-size: 0.875rem; color: var(--theme-texto); opacity: 0.6; margin: 0; }
.clas-group-card { background: var(--theme-surface); border: 1px solid var(--theme-borde); border-radius: 20px; padding: 16px; margin-bottom: 12px; display: flex; flex-direction: column; transition: all 0.3s; }
.clas-group-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.clas-group-icon { width: 36px; height: 36px; border-radius: 12px; background: color-mix(in srgb, var(--c) 10%, transparent); border: 1px solid color-mix(in srgb, var(--c) 15%, transparent); display: flex; align-items: center; justify-content: center; color: var(--c); flex-shrink: 0; }
.clas-group-label { font-family: 'DM Mono', monospace; font-size: 0.75rem; letter-spacing: 0.15em; font-weight: 600; color: var(--theme-titulo); transition: transform 0.3s; }
.clas-group-sub { font-size: 0.8125rem; color: var(--theme-texto); opacity: 0.6; }
.clas-main-grid { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; align-items: stretch; }
.clas-col .clas-group-card { flex: 1; height: 100%; }
.gastos-content { display: flex; flex-direction: column; gap: 10px; flex: 1; }
.gastos-content .clas-subgroup { flex: 1; margin-bottom: 0; }
.clas-subgroup { background: rgba(128, 128, 128, 0.05); border: 1px solid var(--theme-borde); border-radius: 14px; padding: 12px; display: flex; flex-direction: column; transition: all 0.3s; }
.clas-subgroup .widgets-row { flex: 1; }
.clas-subgroup-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.clas-subgroup-icon { width: 32px; height: 32px; border-radius: 10px; background: color-mix(in srgb, var(--c) 10%, transparent); border: 1px solid color-mix(in srgb, var(--c) 15%, transparent); display: flex; align-items: center; justify-content: center; color: var(--c); flex-shrink: 0; }
.clas-subgroup-label { font-family: 'DM Mono', monospace; font-size: 0.75rem; letter-spacing: 0.15em; font-weight: 600; color: var(--theme-titulo); }
.widget-card { display: block; background: var(--theme-surface); border: 1px solid var(--theme-borde); border-radius: 14px; padding: 14px; cursor: pointer; text-decoration: none; color: var(--theme-titulo); transition: all 0.35s cubic-bezier(0.16, 1, 0.3, 1); }
.widget-card:hover { border-color: var(--theme-accent); transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.widget-h { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
.widget-left { flex: 1; }
.widget-right { display: flex; flex-direction: column; align-items: flex-end; gap: 0.5rem; }
.widgets-col { display: flex; flex-direction: column; gap: 8px; flex: 1; }
.widgets-col .widget-card { flex: 1; }
.widgets-row { display: flex; gap: 8px; }
.widgets-row .widget-card { flex: 1; display: flex; flex-direction: column; justify-content: space-between; }
.widget-headline { display: flex; align-items: baseline; gap: 6px; margin-bottom: 4px; }
.widget-num-big { font-family: 'DM Mono', monospace; font-size: 1.5rem; font-weight: 600; color: var(--theme-titulo); line-height: 1; }
.widget-num-gold { color: #C9A751; }
.widget-num-green { color: #4A8B6E; }
.widget-label { font-size: 0.8125rem; color: var(--theme-texto); }
.widget-desc { font-size: 0.75rem; color: var(--theme-texto); opacity: 0.6; margin: 0; line-height: 1.4; }
.widget-desc-sm { font-size: 0.6875rem; }
.widget-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
.widget-tech { font-family: 'DM Mono', monospace; font-size: 0.5625rem; letter-spacing: 0.1em; text-transform: uppercase; color: var(--theme-texto); opacity: 0.4; }
.widget-arrow { font-size: 1.25rem; color: var(--theme-accent); opacity: 0; transition: all 0.3s; }
.arrow-show { opacity: 1; transform: translateX(3px); }
@media (max-width: 768px) {
.clas-container { padding: 70px 1rem 1rem; }
.section-title { font-size: 1.5rem; }
.clas-main-grid { grid-template-columns: 1fr; }
.widgets-row { flex-direction: column; }
.widget-h { flex-direction: column; align-items: flex-start; }
}
</style>
......
<script>
import { supabase } from '$lib/supabase';
import { onMount, untrack } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
......@@ -154,16 +153,11 @@
treemapNodes = [];
try {
const entidadFiltro = selectedEntity?.entidad ?? 0;
const { data, error: dbError } = await supabase
.schema('ppto')
.from('treemap_finfun')
.select('gestion, nivel, finfun, desc_funcion, parent, devengado')
.eq('gestion', selectedYear)
.eq('entidad', entidadFiltro)
.gt('devengado', 0);
if (dbError) {
console.error('Error fetching treemap data:', dbError.message);
const res = await fetch(`/api/finfun-treemap?gestion=${selectedYear}&entidad=${entidadFiltro}`);
const data = await res.json();
if (data.error) {
console.error('Error fetching treemap data:', data.error);
return;
}
......@@ -171,7 +165,7 @@
gestion: d.gestion,
nivel: d.nivel,
finfun: d.finfun,
desc_finfun: d.desc_funcion,
desc_finfun: d.desc_finfun,
parent: d.parent,
devengado: d.devengado || 0
}));
......@@ -186,15 +180,11 @@
async function loadEntitiesForYear(year) {
if (!year) return;
try {
const { data, error: dbError } = await supabase
.schema('ppto')
.from('entidades_treemap')
.select('entidad, desc_entidad, sigla_entidad')
.eq('gestion', year)
.order('desc_entidad');
if (dbError) {
console.error('Error loading entities:', dbError.message);
const res = await fetch('/api/objeto-treemap-entidades');
const data = await res.json();
if (data.error) {
console.error('Error loading entities:', data.error);
return;
}
entities = data || [];
......@@ -580,13 +570,10 @@
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_finfun')
.select('*')
.order('finfun');
const clasRes = await fetch('/api/finfun-clasificador');
const data = await clasRes.json();
if (!error && data) {
if (Array.isArray(data)) {
allItems = data;
// Extraer finalidades únicas (nivel más agregado)
......@@ -842,55 +829,6 @@
Jerarquía: Finalidad → Grupo Función → Función
</p>
<!-- Toggle de modos: Lista, Mapa, Comparar + Botón filtros móvil -->
<div class="flex items-center gap-3">
<div class="header-modes">
<button
class="header-mode-btn"
class:active={viewMode === 'lista'}
onclick={() => setMode('lista')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="header-mode-btn"
class:active={viewMode === 'mapa'}
onclick={() => setMode('mapa')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
Mapa
</button>
<button
class="header-mode-btn"
class:active={viewMode === 'comparar'}
onclick={() => setMode('comparar')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Comparar
</button>
</div>
<!-- Botón filtros para móvil en modo mapa -->
{#if viewMode === 'mapa'}
<button
class="lg:hidden flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm"
style="background-color: var(--theme-fill); color: var(--theme-titulo);"
onclick={() => mapaSidebarOpen = !mapaSidebarOpen}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
Filtros
</button>
{/if}
</div>
</div>
</div>
</header>
......
......@@ -1441,52 +1441,6 @@
<div class="mt-2">
<div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
{#if viewMode === 'lista'}
<div class="header-modes">
<button
class="header-mode-btn active"
onclick={() => setMode('lista')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="header-mode-btn"
onclick={() => setMode('mapa')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
Mapa
</button>
<button
class="header-mode-btn"
onclick={() => { setMode('comparar'); initCompareMode(); }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Comparar
</button>
</div>
<!-- Guía inline (solo para lista) -->
<div class="hidden sm:block text-sm border-l pl-4" style="color: var(--theme-texto); border-color: var(--theme-borde);">
<span class="flex items-center gap-1.5">
Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</span>
para ver el presupuesto histórico ·
<span class="underline decoration-dotted font-medium" style="color: var(--theme-accent);">N var.</span>
= número de cambios en la descripción oficial
</span>
</div>
{/if}
</div>
<div class="flex flex-wrap items-center gap-3 flex-shrink-0">
......
<script>
import { supabase } from '$lib/supabase';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
......@@ -33,15 +32,12 @@
document.body.offsetHeight; // Force reflow
});
const { data, error } = await supabase
.schema('ppto')
.from('clas_institucional')
.select('*')
.order('desc_area')
.order('desc_entidad');
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (!error && data) {
allEntidades = data;
if (Array.isArray(data)) {
allEntidades = data.sort((a, b) => (a.desc_area || '').localeCompare(b.desc_area || '') || (a.desc_entidad || '').localeCompare(b.desc_entidad || ''));
// Agrupar por área (segundo nivel)
const areasMap = {};
......@@ -74,6 +70,9 @@
selectedArea = areas[0];
}
}
} catch (err) {
console.error('Error loading clasificador:', err);
}
loading = false;
});
......@@ -238,49 +237,6 @@
<!-- Controles de visualización -->
<div class="mt-8">
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<!-- Toggle Vista -->
<div class="flex items-center gap-3">
<span class="text-sm font-medium" style="color: var(--theme-titulo);">Explorar como</span>
<nav class="inline-flex rounded-xl border-2 p-1 shadow-sm" style="border-color: var(--theme-titulo); background-color: var(--theme-surface);">
<a
href="?modo=lista"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'lista' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('lista'); }}
>
Lista
</a>
<a
href="?modo=mapa"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'mapa' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('mapa'); }}
>
Mapa
</a>
<a
href="?modo=comparar"
class="px-4 py-2 text-sm font-semibold rounded-lg transition-all"
style="{viewMode === 'comparar' ? `background-color: var(--theme-titulo); color: var(--theme-body);` : `color: var(--theme-texto);`}"
onclick={(e) => { e.preventDefault(); setMode('comparar'); }}
>
Comparar
</a>
</nav>
</div>
<!-- Guía inline -->
<div class="hidden sm:block text-sm border-l pl-4" style="color: var(--theme-texto); border-color: var(--theme-borde);">
{#if viewMode === 'lista'}
<span>Navega por áreas y subáreas</span>
{:else if viewMode === 'mapa'}
<span>Visualización geográfica del presupuesto</span>
{:else}
<span>Compara dos entidades o periodos</span>
{/if}
</div>
</div>
</div>
</div>
</header>
......
......@@ -1604,52 +1604,6 @@
<!-- Selector de modo + Guía inline (visible en sm+) -->
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<!-- Selector de modo (solo para lista, en mapa y comparar está en el sidebar) -->
{#if viewMode === 'lista'}
<div class="header-modes">
<button
class="header-mode-btn active"
onclick={() => setMode('lista')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="header-mode-btn"
onclick={() => setMode('mapa')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
Mapa
</button>
<button
class="header-mode-btn"
onclick={() => { setMode('comparar'); initCompareMode(); }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Comparar
</button>
</div>
<!-- Guía inline (solo para lista) -->
<div class="hidden sm:block text-sm border-l pl-4" style="color: var(--theme-texto); border-color: var(--theme-borde);">
<span class="flex items-center gap-1.5">
Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</span>
para ver el presupuesto histórico ·
<span class="underline decoration-dotted font-medium" style="color: var(--theme-accent);">N var.</span>
= número de cambios en la descripción oficial
</span>
</div>
{/if}
</div>
<!-- Filtros (botón móvil para abrir sidebar en mapa y comparar) -->
......
......@@ -1453,52 +1453,6 @@
<div class="mt-2">
<div class="flex flex-col md:flex-row gap-4 md:items-center md:justify-between">
<div class="flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
{#if viewMode === 'lista'}
<div class="header-modes">
<button
class="header-mode-btn active"
onclick={() => setMode('lista')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Lista
</button>
<button
class="header-mode-btn"
onclick={() => setMode('mapa')}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
Mapa
</button>
<button
class="header-mode-btn"
onclick={() => { setMode('comparar'); initCompareMode(); }}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Comparar
</button>
</div>
<!-- Guía inline (solo para lista) -->
<div class="hidden sm:block text-sm border-l pl-4" style="color: var(--theme-texto); border-color: var(--theme-borde);">
<span class="flex items-center gap-1.5">
Clic en
<span class="inline-flex items-center justify-center w-5 h-5 rounded" style="background-color: color-mix(in srgb, var(--theme-accent) 20%, transparent); color: var(--theme-accent);">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</span>
para ver el presupuesto histórico ·
<span class="underline decoration-dotted font-medium" style="color: var(--theme-accent);">N var.</span>
= número de cambios en la descripción oficial
</span>
</div>
{/if}
</div>
<!-- Filtros (botón móvil para abrir sidebar en mapa y comparar) -->
......
<script>
import { onMount } from 'svelte';
import { DOWNLOAD_BASE } from '$lib/api.js';
const YEARS = Array.from({ length: 10 }, (_, i) => 2016 + i);
let mounted = $state(false);
let dataset = $state('gastos');
let entities = $state([]);
let entitySearch = $state('');
let entityFocused = $state(false);
let selectedEntity = $state(null);
let selectedIdx = $state(-1);
let loadingEntities = $state(true);
let entityError = $state(false);
function normalizeText(val) {
return val.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim();
}
function buildUrl(partition, value) {
return `${DOWNLOAD_BASE}${dataset}/${partition}/${value}`;
}
let filteredEntities = $derived.by(() => {
if (!entitySearch || entitySearch.length < 2) return [];
const q = normalizeText(entitySearch);
return entities.filter(e => e.normalized.includes(q)).slice(0, 12);
});
function selectEntity(entity) {
selectedEntity = entity;
entitySearch = entity.desc_entidad;
entityFocused = false;
selectedIdx = -1;
}
function handleKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIdx = selectedIdx < filteredEntities.length - 1 ? selectedIdx + 1 : 0;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : filteredEntities.length - 1;
} else if (e.key === 'Enter' && selectedIdx >= 0) {
e.preventDefault();
selectEntity(filteredEntities[selectedIdx]);
} else if (e.key === 'Escape') {
entityFocused = false;
}
}
onMount(async () => {
mounted = true;
try {
const res = await fetch('/api/entidad-clasificador');
const data = await res.json();
if (Array.isArray(data)) {
entities = data
.filter(d => d.entidad && d.desc_entidad)
.map(d => ({ entidad: d.entidad, desc_entidad: d.desc_entidad, normalized: normalizeText(d.desc_entidad) }))
.sort((a, b) => a.desc_entidad.localeCompare(b.desc_entidad));
}
} catch { entityError = true; }
loadingEntities = false;
});
</script>
<svelte:head>
<title>Descargas | Presupuesto Público</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<div class="layout">
<h1 class="title">Descargas</h1>
<p class="subtitle">Descarga los datos del presupuesto público en formato CSV</p>
<!-- Dataset toggle -->
<div class="section">
<span class="section-label">Base</span>
<div class="toggle-group">
<button class="toggle-btn" class:active={dataset === 'gastos'} onclick={() => { dataset = 'gastos'; selectedEntity = null; }}>Gastos</button>
<button class="toggle-btn" class:active={dataset === 'ingresos'} onclick={() => { dataset = 'ingresos'; selectedEntity = null; }}>Ingresos</button>
</div>
</div>
<!-- Completo -->
<div class="section">
<h2 class="section-title">Completo</h2>
<a href={buildUrl('completo', 'ultimo')} class="download-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Descargar todo ({dataset})
</a>
</div>
<!-- Por gestión -->
<div class="section">
<h2 class="section-title">Por gestión</h2>
<div class="year-grid">
{#each YEARS as year}
<a href={buildUrl('gestion', year)} class="year-link">{year}</a>
{/each}
</div>
</div>
<!-- Por entidad -->
<div class="section">
<h2 class="section-title">Por entidad</h2>
<div class="entity-search">
<input
type="text"
bind:value={entitySearch}
onfocus={() => { entityFocused = true; selectedEntity = null; }}
onblur={() => setTimeout(() => entityFocused = false, 150)}
onkeydown={handleKeydown}
placeholder="Buscar entidad..."
class="search-input"
/>
{#if loadingEntities}
<span class="search-status">Cargando entidades...</span>
{:else if entityError}
<span class="search-status error">No se pudieron cargar las entidades</span>
{/if}
{#if entityFocused && filteredEntities.length > 0}
<div class="search-dropdown">
{#each filteredEntities as entity, i}
<button
class="search-item"
class:active={selectedIdx === i}
onmouseenter={() => selectedIdx = i}
onclick={() => selectEntity(entity)}
>
<span class="item-name">{entity.desc_entidad}</span>
<span class="item-code">Código {entity.entidad}</span>
</button>
{/each}
</div>
{/if}
{#if entityFocused && entitySearch.length >= 2 && filteredEntities.length === 0 && !loadingEntities}
<span class="search-status error">Sin resultados</span>
{/if}
</div>
{#if selectedEntity}
<div class="entity-result">
<span class="entity-name">{selectedEntity.desc_entidad}</span>
<a href={buildUrl('entidad', selectedEntity.entidad)} class="download-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Descargar
</a>
</div>
{/if}
</div>
</div>
</div>
<style>
.page {
min-height: 100vh;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 700px;
margin: 0 auto;
padding: 100px 2rem 3rem;
}
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.25rem;
}
.subtitle {
font-size: 0.875rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0 0 2rem;
}
.section { margin-bottom: 2rem; }
.section-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--theme-texto);
opacity: 0.5;
display: block;
margin-bottom: 0.5rem;
}
.section-title {
font-size: 1rem;
font-weight: 700;
margin: 0 0 0.75rem;
}
/* Toggle */
.toggle-group {
display: flex;
gap: 0.25rem;
background: var(--theme-surface);
border-radius: 8px;
padding: 3px;
width: fit-content;
}
.toggle-btn {
font-size: 0.8125rem;
font-weight: 500;
padding: 0.375rem 1rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn:hover { color: var(--theme-titulo); }
.toggle-btn.active {
background: var(--theme-accent);
color: white;
font-weight: 600;
}
/* Download link */
.download-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--theme-accent);
text-decoration: none;
border-bottom: 1px dotted rgba(201, 167, 81, 0.4);
padding-bottom: 1px;
transition: opacity 0.15s;
}
.download-link:hover { opacity: 0.7; }
/* Years */
.year-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.year-link {
font-family: 'DM Mono', monospace;
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
color: var(--theme-titulo);
text-decoration: none;
transition: all 0.15s;
}
.year-link:hover {
border-color: var(--theme-accent);
color: var(--theme-accent);
}
/* Entity search */
.entity-search {
position: relative;
}
.search-input {
width: 100%;
max-width: 400px;
padding: 0.625rem 1rem;
border: 1px solid var(--theme-borde);
border-radius: 8px;
background: var(--theme-surface);
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
transition: border-color 0.15s;
}
.search-input:focus { border-color: var(--theme-accent); }
.search-input::placeholder { color: var(--theme-texto); opacity: 0.5; }
.search-status {
display: block;
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
margin-top: 0.375rem;
}
.search-status.error { color: #b42318; opacity: 1; }
.search-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
max-width: 400px;
width: 100%;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
max-height: 240px;
overflow-y: auto;
z-index: 10;
}
.search-item {
display: flex;
flex-direction: column;
width: 100%;
padding: 0.625rem 1rem;
border: none;
border-top: 1px solid var(--theme-borde);
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.15s;
}
.search-item:first-child { border-top: none; }
.search-item:hover, .search-item.active { background: var(--theme-surface-hover, rgba(128,128,128,0.08)); }
.item-name { font-size: 0.8125rem; color: var(--theme-titulo); }
.item-code { font-family: 'DM Mono', monospace; font-size: 0.6875rem; color: var(--theme-texto); opacity: 0.5; }
.entity-result {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: var(--theme-surface);
border-radius: 8px;
max-width: 400px;
}
.entity-name {
font-size: 0.8125rem;
font-weight: 500;
color: var(--theme-titulo);
}
@media (max-width: 640px) {
.layout { padding: 80px 1rem 2rem; }
.title { font-size: 1.5rem; }
.search-input, .search-dropdown, .entity-result { max-width: 100%; }
}
</style>
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/finfun';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/finfun';
// Jerarquía finfun: Finalidad → Grupo Función → Función
// Códigos con punto: "1" (finalidad), "1.1" (grpfuncion), "1.1.1" (función)
......
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/objeto';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/objeto';
export async function load({ params }) {
const { codigo } = params;
......
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/organismo';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/organismo';
export async function load({ params }) {
const { codigo } = params;
......
import { error } from '@sveltejs/kit';
const API_BASE = 'http://136.112.29.74/api/rubro';
import { API_HOST } from '$lib/api.js';
const API_BASE = API_HOST + '/api/rubro';
export async function load({ params }) {
const { codigo } = params;
......
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Descargas presupuestarias</title>
<style>
:root {
--text: #1f1f1f;
--muted: #666;
--link: #0b57d0;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: sans-serif;
color: var(--text);
line-height: 1.5;
}
main {
max-width: 800px;
margin: 0 auto;
padding: 24px 16px 40px;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 16px;
}
h2 {
margin-bottom: 8px;
font-size: 1rem;
}
section {
margin-bottom: 20px;
}
label,
input,
button {
font: inherit;
}
input {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
background: #fff;
}
.field {
display: grid;
gap: 6px;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.radio-option {
display: inline-flex;
align-items: center;
gap: 8px;
}
.radio-option input {
width: auto;
padding: 0;
border: 0;
}
.links,
.results {
padding: 0;
margin: 0;
list-style: none;
}
.links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
}
.search-area {
position: relative;
}
.results {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 10;
max-height: 240px;
overflow-y: auto;
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
background: #fff;
}
.results[hidden],
.entity-downloads[hidden] {
display: none;
}
.results li + li {
border-top: 1px solid #ddd;
}
.result-button {
display: block;
width: 100%;
padding: 10px 0;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.result-button:hover,
.result-button.active,
.result-button:focus {
background: #f3f3f3;
}
.meta,
.status {
color: var(--muted);
}
.status {
min-height: 1.25rem;
margin: 6px 0 0;
font-size: 0.9rem;
}
.status.error {
color: #b42318;
}
.entity-downloads p {
margin-bottom: 8px;
}
a {
color: var(--link);
}
</style>
</head>
<body>
<main>
<h1>Descargas</h1>
<section>
<div class="field">
<span>Base</span>
<div class="radio-group">
<label class="radio-option">
<input type="radio" name="dataset" value="gastos" checked>
<span>Gastos</span>
</label>
<label class="radio-option">
<input type="radio" name="dataset" value="ingresos">
<span>Ingresos</span>
</label>
</div>
</div>
</section>
<section>
<h2>Completo</h2>
<ul class="links" id="full-download"></ul>
</section>
<section>
<h2>Por gestión</h2>
<ul class="links" id="year-downloads"></ul>
</section>
<section>
<h2>Por entidad</h2>
<div class="field search-area">
<label for="entity-search">Entidad</label>
<input
id="entity-search"
name="entity-search"
type="search"
placeholder="Busca entidades"
autocomplete="off"
>
<ul class="results" id="entity-results" hidden></ul>
</div>
<p class="status" id="entity-status">Cargando entidades...</p>
<div class="entity-downloads" id="entity-downloads" hidden>
<p id="entity-name"></p>
<ul class="links" id="entity-links"></ul>
</div>
</section>
</main>
<script>
const API_BASE = "http://136.112.29.74/api/";
const DOWNLOAD_BASE = "https://abierto.economiayfinanzas.gob.bo/presupuesto/descargas/";
const ENTITY_API = `${API_BASE}entidad/clasificador`;
const YEARS = Array.from({ length: 10 }, (_, index) => 2016 + index);
const MAX_ENTITY_RESULTS = 12;
let entities = [];
let activeResultIndex = -1;
function setStatus(message, type = "info") {
const status = document.getElementById("entity-status");
status.textContent = message;
status.classList.toggle("error", type === "error");
}
function currentDataset() {
return document.querySelector('input[name="dataset"]:checked').value;
}
function buildUrl(partition, value) {
return `${DOWNLOAD_BASE}${currentDataset()}/${partition}/${value}`;
}
function normalizeText(value) {
return value
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim();
}
function createLinkItem(label, href) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = href;
link.textContent = label;
item.appendChild(link);
return item;
}
function renderFullDownload() {
const container = document.getElementById("full-download");
container.innerHTML = "";
container.appendChild(createLinkItem("Descargar", buildUrl("completo", "ultimo")));
}
function renderYearDownloads() {
const container = document.getElementById("year-downloads");
container.innerHTML = "";
YEARS.forEach((year) => {
container.appendChild(createLinkItem(String(year), buildUrl("gestion", year)));
});
}
function renderEntityDownloads(entity) {
const wrapper = document.getElementById("entity-downloads");
const name = document.getElementById("entity-name");
const links = document.getElementById("entity-links");
name.textContent = entity.desc_entidad;
links.innerHTML = "";
links.appendChild(createLinkItem("Descargar", buildUrl("entidad", entity.entidad)));
wrapper.hidden = false;
}
function selectEntity(entity) {
document.getElementById("entity-search").value = entity.desc_entidad;
document.getElementById("entity-results").hidden = true;
activeResultIndex = -1;
renderEntityDownloads(entity);
}
function resultButtons() {
return Array.from(document.querySelectorAll("#entity-results .result-button"));
}
function setActiveResult(index) {
const buttons = resultButtons();
buttons.forEach((button, buttonIndex) => {
button.classList.toggle("active", buttonIndex === index);
});
activeResultIndex = index;
if (index >= 0 && buttons[index]) {
buttons[index].scrollIntoView({ block: "nearest" });
}
}
function renderEntityResults(matches) {
const results = document.getElementById("entity-results");
results.innerHTML = "";
activeResultIndex = -1;
if (!matches.length) {
results.hidden = true;
return;
}
matches.forEach((entity) => {
const item = document.createElement("li");
const button = document.createElement("button");
const meta = document.createElement("span");
button.type = "button";
button.className = "result-button";
button.textContent = entity.desc_entidad;
button.addEventListener("click", () => selectEntity(entity));
meta.className = "meta";
meta.textContent = `Código ${entity.entidad}`;
button.appendChild(document.createElement("br"));
button.appendChild(meta);
item.appendChild(button);
results.appendChild(item);
});
results.hidden = false;
}
function moveActiveResult(direction) {
const buttons = resultButtons();
if (!buttons.length) {
return;
}
if (activeResultIndex === -1) {
setActiveResult(direction > 0 ? 0 : buttons.length - 1);
return;
}
const nextIndex = (activeResultIndex + direction + buttons.length) % buttons.length;
setActiveResult(nextIndex);
}
function updateEntitySearch() {
const query = normalizeText(document.getElementById("entity-search").value);
document.getElementById("entity-downloads").hidden = true;
if (!entities.length) {
return;
}
if (!query) {
setStatus("");
document.getElementById("entity-results").hidden = true;
activeResultIndex = -1;
return;
}
const matches = entities
.filter((entity) => entity.normalizedName.includes(query))
.slice(0, MAX_ENTITY_RESULTS);
if (!matches.length) {
setStatus("Sin resultados.", "error");
document.getElementById("entity-results").hidden = true;
activeResultIndex = -1;
return;
}
setStatus(`${matches.length} resultado(s).`);
renderEntityResults(matches);
}
async function loadEntities() {
try {
const response = await fetch(ENTITY_API);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
entities = data
.filter((entity) => entity && entity.entidad && entity.desc_entidad)
.map((entity) => ({
entidad: entity.entidad,
desc_entidad: entity.desc_entidad,
normalizedName: normalizeText(entity.desc_entidad)
}));
setStatus("");
} catch (error) {
setStatus("No se pudieron cargar las entidades.", "error");
}
}
function rerenderDownloads() {
renderFullDownload();
renderYearDownloads();
const selectedName = document.getElementById("entity-search").value;
const selectedEntity = entities.find(
(entity) => entity.desc_entidad === selectedName
);
if (selectedEntity) {
renderEntityDownloads(selectedEntity);
}
}
document.querySelectorAll('input[name="dataset"]').forEach((input) => {
input.addEventListener("change", rerenderDownloads);
});
document.getElementById("entity-search").addEventListener("input", updateEntitySearch);
document.getElementById("entity-search").addEventListener("keydown", (event) => {
const results = document.getElementById("entity-results");
if (event.key === "ArrowDown") {
event.preventDefault();
if (!results.hidden) {
moveActiveResult(1);
}
}
if (event.key === "ArrowUp") {
event.preventDefault();
if (!results.hidden) {
moveActiveResult(-1);
}
}
if (event.key === "Enter") {
const buttons = resultButtons();
if (!results.hidden && activeResultIndex >= 0 && buttons[activeResultIndex]) {
event.preventDefault();
buttons[activeResultIndex].click();
}
}
if (event.key === "Escape") {
results.hidden = true;
activeResultIndex = -1;
}
});
renderFullDownload();
renderYearDownloads();
loadEntities();
</script>
</body>
</html>