+page.svelte 10.3 KB
<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>