Showing
9 changed files
with
737 additions
and
22 deletions
src/lib/components/documentacion/doc.md
0 → 100644
This diff is collapsed. Click to expand it.
src/lib/components/objeto/BarChart.svelte
0 → 100644
| 1 | +<script> | ||
| 2 | + import { scaleLinear, scaleBand } from 'd3-scale'; | ||
| 3 | + | ||
| 4 | + let { | ||
| 5 | + data = [], | ||
| 6 | + hoveredYear = $bindable(null), | ||
| 7 | + height = 220, | ||
| 8 | + fill = false, | ||
| 9 | + marginTop = 20, | ||
| 10 | + marginRight = 10, | ||
| 11 | + marginBottom = 30, | ||
| 12 | + marginLeft = 50 | ||
| 13 | + } = $props(); | ||
| 14 | + | ||
| 15 | + // Medir altura real del contenedor cuando fill=true | ||
| 16 | + let wrapperEl = $state(null); | ||
| 17 | + let measuredHeight = $state(height); | ||
| 18 | + | ||
| 19 | + $effect(() => { | ||
| 20 | + if (fill && wrapperEl) { | ||
| 21 | + const updateHeight = () => { | ||
| 22 | + const h = wrapperEl.clientHeight; | ||
| 23 | + if (h > 0) measuredHeight = h; | ||
| 24 | + }; | ||
| 25 | + updateHeight(); | ||
| 26 | + const observer = new ResizeObserver(updateHeight); | ||
| 27 | + observer.observe(wrapperEl); | ||
| 28 | + return () => observer.disconnect(); | ||
| 29 | + } else { | ||
| 30 | + measuredHeight = height; | ||
| 31 | + } | ||
| 32 | + }); | ||
| 33 | + | ||
| 34 | + // Dimensiones del área del gráfico | ||
| 35 | + let effectiveHeight = $derived(fill ? measuredHeight : height); | ||
| 36 | + let chartHeight = $derived(effectiveHeight - marginTop - marginBottom); | ||
| 37 | + | ||
| 38 | + // Escalas D3 | ||
| 39 | + let xScale = $derived( | ||
| 40 | + scaleBand() | ||
| 41 | + .domain(data.map(d => d.año)) | ||
| 42 | + .range([0, 100]) // porcentaje | ||
| 43 | + .padding(0.2) | ||
| 44 | + ); | ||
| 45 | + | ||
| 46 | + // Para CSS bottom positioning: 0 → 0%, max → 100% | ||
| 47 | + let yScale = $derived( | ||
| 48 | + scaleLinear() | ||
| 49 | + .domain([0, Math.max(...data.map(d => d.perCapita))]) | ||
| 50 | + .range([0, chartHeight]) | ||
| 51 | + .nice() | ||
| 52 | + ); | ||
| 53 | + | ||
| 54 | + // Ticks para el eje Y | ||
| 55 | + let yTicks = $derived(yScale.ticks(3)); | ||
| 56 | + | ||
| 57 | + // Índices para mostrar en eje X | ||
| 58 | + let midIndex = $derived(Math.floor(data.length / 2)); | ||
| 59 | + | ||
| 60 | + // Formato para valores del eje Y | ||
| 61 | + function formatValue(v) { | ||
| 62 | + if (v >= 1e6) return (v / 1e6).toLocaleString('es-BO', { maximumFractionDigits: 1 }) + ' M'; | ||
| 63 | + if (v >= 1e3) return Math.round(v / 1e3).toLocaleString('es-BO') + ' K'; | ||
| 64 | + return v.toLocaleString('es-BO'); | ||
| 65 | + } | ||
| 66 | +</script> | ||
| 67 | + | ||
| 68 | +<div class="chart-wrapper" bind:this={wrapperEl} style="height: {fill ? '100%' : height + 'px'}"> | ||
| 69 | + <!-- Contenedor principal con márgenes --> | ||
| 70 | + <div class="chart-inner" style="top: {marginTop}px; bottom: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px"> | ||
| 71 | + | ||
| 72 | + <!-- Y-axis labels (fuera del área de barras, a la izquierda) --> | ||
| 73 | + {#each yTicks as tick} | ||
| 74 | + {@const pct = (yScale(tick) / chartHeight) * 100} | ||
| 75 | + <span class="y-label" style="bottom: {pct}%; left: -{marginLeft}px"> | ||
| 76 | + Bs {formatValue(tick)} | ||
| 77 | + </span> | ||
| 78 | + {/each} | ||
| 79 | + | ||
| 80 | + <!-- Grid lines --> | ||
| 81 | + {#each yTicks as tick} | ||
| 82 | + {@const pct = (yScale(tick) / chartHeight) * 100} | ||
| 83 | + <div class="grid-line" style="bottom: {pct}%"></div> | ||
| 84 | + {/each} | ||
| 85 | + | ||
| 86 | + <!-- Barras --> | ||
| 87 | + <div class="bars" onmouseleave={() => hoveredYear = null} role="group"> | ||
| 88 | + {#each data as d} | ||
| 89 | + {@const barPct = (yScale(d.perCapita) / chartHeight) * 100} | ||
| 90 | + <div | ||
| 91 | + class="bar-container" | ||
| 92 | + onmouseenter={() => hoveredYear = d.año} | ||
| 93 | + role="button" | ||
| 94 | + tabindex="0" | ||
| 95 | + > | ||
| 96 | + <div | ||
| 97 | + class="bar" | ||
| 98 | + class:hovered={hoveredYear === d.año} | ||
| 99 | + style="height: {barPct}%" | ||
| 100 | + ></div> | ||
| 101 | + </div> | ||
| 102 | + {/each} | ||
| 103 | + </div> | ||
| 104 | + </div> | ||
| 105 | + | ||
| 106 | + <!-- Eje X --> | ||
| 107 | + <div class="x-axis" style="height: {marginBottom}px; left: {marginLeft}px; right: {marginRight}px"> | ||
| 108 | + {#each data as d, i} | ||
| 109 | + {@const isFirst = i === 0} | ||
| 110 | + {@const isLast = i === data.length - 1} | ||
| 111 | + {@const isMid = i === midIndex && data.length > 5} | ||
| 112 | + {@const showLabel = isFirst || isLast || isMid || hoveredYear === d.año} | ||
| 113 | + <span | ||
| 114 | + class="x-label" | ||
| 115 | + class:visible={showLabel} | ||
| 116 | + > | ||
| 117 | + {d.año} | ||
| 118 | + </span> | ||
| 119 | + {/each} | ||
| 120 | + </div> | ||
| 121 | +</div> | ||
| 122 | + | ||
| 123 | +<style> | ||
| 124 | + .chart-wrapper { | ||
| 125 | + position: relative; | ||
| 126 | + width: 100%; | ||
| 127 | + transition: height 0.3s ease; | ||
| 128 | + } | ||
| 129 | + | ||
| 130 | + .chart-inner { | ||
| 131 | + position: absolute; | ||
| 132 | + } | ||
| 133 | + | ||
| 134 | + .y-label { | ||
| 135 | + position: absolute; | ||
| 136 | + width: 45px; | ||
| 137 | + text-align: right; | ||
| 138 | + transform: translateY(50%); | ||
| 139 | + font-family: 'Qanelas', var(--font-sans); | ||
| 140 | + font-size: 0.6875rem; | ||
| 141 | + color: var(--theme-texto); | ||
| 142 | + white-space: nowrap; | ||
| 143 | + } | ||
| 144 | + | ||
| 145 | + .grid-line { | ||
| 146 | + position: absolute; | ||
| 147 | + left: 0; | ||
| 148 | + right: 0; | ||
| 149 | + border-top: 0.5px dotted var(--theme-texto); | ||
| 150 | + opacity: 0.15; | ||
| 151 | + pointer-events: none; | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + .bars { | ||
| 155 | + position: absolute; | ||
| 156 | + top: 0; | ||
| 157 | + left: 0; | ||
| 158 | + right: 0; | ||
| 159 | + bottom: 0; | ||
| 160 | + display: flex; | ||
| 161 | + align-items: flex-end; | ||
| 162 | + gap: 2px; | ||
| 163 | + } | ||
| 164 | + | ||
| 165 | + .bar-container { | ||
| 166 | + flex: 1; | ||
| 167 | + height: 100%; | ||
| 168 | + display: flex; | ||
| 169 | + align-items: flex-end; | ||
| 170 | + cursor: default; | ||
| 171 | + } | ||
| 172 | + | ||
| 173 | + .bar { | ||
| 174 | + width: 100%; | ||
| 175 | + background: #c4897d; | ||
| 176 | + border-radius: 4px 4px 0 0; | ||
| 177 | + transition: opacity 0.1s ease; | ||
| 178 | + min-height: 2px; | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + .bars:hover .bar { | ||
| 182 | + opacity: 0.35; | ||
| 183 | + } | ||
| 184 | + | ||
| 185 | + .bars:hover .bar.hovered { | ||
| 186 | + opacity: 1; | ||
| 187 | + } | ||
| 188 | + | ||
| 189 | + .x-axis { | ||
| 190 | + position: absolute; | ||
| 191 | + bottom: 0; | ||
| 192 | + display: flex; | ||
| 193 | + align-items: center; | ||
| 194 | + } | ||
| 195 | + | ||
| 196 | + .x-label { | ||
| 197 | + flex: 1; | ||
| 198 | + text-align: center; | ||
| 199 | + font-family: 'Qanelas', var(--font-sans); | ||
| 200 | + font-size: 0.6875rem; | ||
| 201 | + color: var(--theme-texto); | ||
| 202 | + opacity: 0; | ||
| 203 | + transition: opacity 0.2s ease; | ||
| 204 | + } | ||
| 205 | + | ||
| 206 | + .x-label.visible { | ||
| 207 | + opacity: 1; | ||
| 208 | + } | ||
| 209 | +</style> |
| 1 | +<script> | ||
| 2 | + let { | ||
| 3 | + entidades = [], | ||
| 4 | + selectedEntity = $bindable(null), | ||
| 5 | + nEntidades = 0 | ||
| 6 | + } = $props(); | ||
| 7 | + | ||
| 8 | + let dropdownOpen = $state(false); | ||
| 9 | + let searchQuery = $state(''); | ||
| 10 | + let entityLimit = $state(30); | ||
| 11 | + | ||
| 12 | + let filteredEntities = $derived.by(() => { | ||
| 13 | + if (!searchQuery) return entidades.slice(0, entityLimit); | ||
| 14 | + const q = searchQuery.toLowerCase(); | ||
| 15 | + return entidades | ||
| 16 | + .filter(e => | ||
| 17 | + e.entidad_desc?.toLowerCase().includes(q) || | ||
| 18 | + e.entidad?.toString().includes(q) | ||
| 19 | + ) | ||
| 20 | + .slice(0, entityLimit); | ||
| 21 | + }); | ||
| 22 | + | ||
| 23 | + function selectEntity(entity) { | ||
| 24 | + selectedEntity = entity; | ||
| 25 | + dropdownOpen = false; | ||
| 26 | + searchQuery = ''; | ||
| 27 | + } | ||
| 28 | + | ||
| 29 | + function clearEntity() { | ||
| 30 | + selectedEntity = null; | ||
| 31 | + dropdownOpen = false; | ||
| 32 | + searchQuery = ''; | ||
| 33 | + } | ||
| 34 | + | ||
| 35 | + function handleScroll(e) { | ||
| 36 | + const { scrollTop, scrollHeight, clientHeight } = e.target; | ||
| 37 | + if (scrollTop + clientHeight >= scrollHeight - 50) { | ||
| 38 | + entityLimit += 20; | ||
| 39 | + } | ||
| 40 | + } | ||
| 41 | +</script> | ||
| 42 | + | ||
| 43 | +<div class="entity-selector"> | ||
| 44 | + <button class="entity-btn" onclick={() => dropdownOpen = !dropdownOpen}> | ||
| 45 | + <span class="entity-label"> | ||
| 46 | + {#if selectedEntity} | ||
| 47 | + {selectedEntity.entidad_desc} | ||
| 48 | + {:else} | ||
| 49 | + Todo el sector público | ||
| 50 | + {/if} | ||
| 51 | + </span> | ||
| 52 | + <svg class="chevron" class:open={dropdownOpen} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||
| 53 | + <path d="M6 9l6 6 6-6"/> | ||
| 54 | + </svg> | ||
| 55 | + </button> | ||
| 56 | + | ||
| 57 | + {#if dropdownOpen} | ||
| 58 | + <div class="entity-dropdown"> | ||
| 59 | + <div class="dropdown-search"> | ||
| 60 | + <input | ||
| 61 | + type="text" | ||
| 62 | + bind:value={searchQuery} | ||
| 63 | + placeholder="Buscar entidad..." | ||
| 64 | + /> | ||
| 65 | + </div> | ||
| 66 | + <button | ||
| 67 | + class="dropdown-item" | ||
| 68 | + class:active={!selectedEntity} | ||
| 69 | + onclick={clearEntity} | ||
| 70 | + > | ||
| 71 | + Todo el sector público | ||
| 72 | + <span class="item-count">({nEntidades})</span> | ||
| 73 | + </button> | ||
| 74 | + <div class="dropdown-list" onscroll={handleScroll}> | ||
| 75 | + {#each filteredEntities as entity} | ||
| 76 | + <button | ||
| 77 | + class="dropdown-item" | ||
| 78 | + class:active={selectedEntity?.entidad === entity.entidad} | ||
| 79 | + onclick={() => selectEntity(entity)} | ||
| 80 | + > | ||
| 81 | + <span class="item-code">{entity.entidad}</span> | ||
| 82 | + <span class="item-name">{entity.entidad_desc}</span> | ||
| 83 | + </button> | ||
| 84 | + {/each} | ||
| 85 | + </div> | ||
| 86 | + </div> | ||
| 87 | + {/if} | ||
| 88 | +</div> | ||
| 89 | + | ||
| 90 | +<style> | ||
| 91 | + .entity-selector { | ||
| 92 | + position: relative; | ||
| 93 | + flex: 1; | ||
| 94 | + min-width: 0; | ||
| 95 | + } | ||
| 96 | + | ||
| 97 | + .entity-btn { | ||
| 98 | + display: inline-flex; | ||
| 99 | + align-items: center; | ||
| 100 | + gap: 0.375rem; | ||
| 101 | + padding: 0.125rem 0; | ||
| 102 | + background: transparent; | ||
| 103 | + border: none; | ||
| 104 | + border-bottom: 1px dotted #333333; | ||
| 105 | + color: var(--theme-titulo); | ||
| 106 | + font-size: 1rem; | ||
| 107 | + font-weight: 500; | ||
| 108 | + cursor: pointer; | ||
| 109 | + transition: border-color 0.2s; | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + .entity-btn:hover { | ||
| 113 | + border-bottom: 1px dotted #666666; | ||
| 114 | + } | ||
| 115 | + | ||
| 116 | + .entity-label { | ||
| 117 | + text-align: left; | ||
| 118 | + } | ||
| 119 | + | ||
| 120 | + .chevron { | ||
| 121 | + color: var(--theme-texto); | ||
| 122 | + opacity: 0.5; | ||
| 123 | + transition: transform 0.2s; | ||
| 124 | + flex-shrink: 0; | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + .chevron.open { | ||
| 128 | + transform: rotate(180deg); | ||
| 129 | + } | ||
| 130 | + | ||
| 131 | + .entity-dropdown { | ||
| 132 | + position: absolute; | ||
| 133 | + top: calc(100% + 4px); | ||
| 134 | + left: 0; | ||
| 135 | + min-width: 280px; | ||
| 136 | + background: var(--theme-surface); | ||
| 137 | + border: 1px solid var(--theme-borde); | ||
| 138 | + border-radius: 10px; | ||
| 139 | + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); | ||
| 140 | + z-index: 100; | ||
| 141 | + overflow: hidden; | ||
| 142 | + } | ||
| 143 | + | ||
| 144 | + .dropdown-search { | ||
| 145 | + padding: 0.5rem; | ||
| 146 | + border-bottom: 1px solid var(--theme-borde); | ||
| 147 | + } | ||
| 148 | + | ||
| 149 | + .dropdown-search input { | ||
| 150 | + width: 100%; | ||
| 151 | + padding: 0.5rem 0.75rem; | ||
| 152 | + border: 1px solid var(--theme-borde); | ||
| 153 | + border-radius: 6px; | ||
| 154 | + background: var(--theme-body); | ||
| 155 | + color: var(--theme-titulo); | ||
| 156 | + font-size: 0.8125rem; | ||
| 157 | + outline: none; | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + .dropdown-search input:focus { | ||
| 161 | + border-color: #6B9FD4; | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + .dropdown-item { | ||
| 165 | + display: flex; | ||
| 166 | + align-items: center; | ||
| 167 | + gap: 0.5rem; | ||
| 168 | + width: 100%; | ||
| 169 | + padding: 0.625rem 0.75rem; | ||
| 170 | + border: none; | ||
| 171 | + background: transparent; | ||
| 172 | + color: var(--theme-titulo); | ||
| 173 | + font-size: 0.8125rem; | ||
| 174 | + text-align: left; | ||
| 175 | + cursor: pointer; | ||
| 176 | + transition: background 0.15s; | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + .dropdown-item:hover { | ||
| 180 | + background: var(--theme-surface-hover); | ||
| 181 | + } | ||
| 182 | + | ||
| 183 | + .dropdown-item.active { | ||
| 184 | + background: rgba(107, 159, 212, 0.1); | ||
| 185 | + color: #6B9FD4; | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + .item-count { | ||
| 189 | + margin-left: auto; | ||
| 190 | + font-size: 0.75rem; | ||
| 191 | + color: var(--theme-texto); | ||
| 192 | + opacity: 0.6; | ||
| 193 | + } | ||
| 194 | + | ||
| 195 | + .item-code { | ||
| 196 | + font-family: 'DM Mono', monospace; | ||
| 197 | + font-size: 0.6875rem; | ||
| 198 | + color: var(--theme-texto); | ||
| 199 | + min-width: 2rem; | ||
| 200 | + } | ||
| 201 | + | ||
| 202 | + .item-name { | ||
| 203 | + flex: 1; | ||
| 204 | + overflow: hidden; | ||
| 205 | + text-overflow: ellipsis; | ||
| 206 | + white-space: nowrap; | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + .dropdown-list { | ||
| 210 | + max-height: 240px; | ||
| 211 | + overflow-y: auto; | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + :global(html.dark) .dropdown-item.active { | ||
| 215 | + background: rgba(201, 167, 81, 0.1); | ||
| 216 | + color: var(--theme-accent); | ||
| 217 | + } | ||
| 218 | +</style> |
| 1 | +<script> | ||
| 2 | + import { goto } from '$app/navigation'; | ||
| 3 | + import { | ||
| 4 | + query, | ||
| 5 | + results, | ||
| 6 | + isLoading as searchLoading, | ||
| 7 | + indexLoaded, | ||
| 8 | + selectedIndex, | ||
| 9 | + performSearch, | ||
| 10 | + clearSearch, | ||
| 11 | + navigateResults | ||
| 12 | + } from '$lib/stores/searchStore'; | ||
| 13 | + | ||
| 14 | + let searchInputRef = $state(null); | ||
| 15 | + let searchVal = $state(''); | ||
| 16 | + let searchFocused = $state(false); | ||
| 17 | + let debounceTimer; | ||
| 18 | + | ||
| 19 | + // Filtrar solo objeto_gasto | ||
| 20 | + let filteredSearchResults = $derived($results.filter(r => r.tipo === 'objeto_gasto')); | ||
| 21 | + | ||
| 22 | + function handleSearchInput(e) { | ||
| 23 | + const val = e.target.value; | ||
| 24 | + clearTimeout(debounceTimer); | ||
| 25 | + debounceTimer = setTimeout(() => { | ||
| 26 | + if (val.length >= 2) { | ||
| 27 | + performSearch(val); | ||
| 28 | + } else { | ||
| 29 | + clearSearch(); | ||
| 30 | + } | ||
| 31 | + }, 200); | ||
| 32 | + } | ||
| 33 | + | ||
| 34 | + function handleSearchKeydown(e) { | ||
| 35 | + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { | ||
| 36 | + e.preventDefault(); | ||
| 37 | + navigateResults(e.key === 'ArrowDown' ? 1 : -1, filteredSearchResults.length); | ||
| 38 | + } else if (e.key === 'Enter' && filteredSearchResults.length > 0) { | ||
| 39 | + e.preventDefault(); | ||
| 40 | + const selected = filteredSearchResults[$selectedIndex]; | ||
| 41 | + if (selected) handleSearchSelect(selected); | ||
| 42 | + } else if (e.key === 'Escape') { | ||
| 43 | + searchVal = ''; | ||
| 44 | + clearSearch(); | ||
| 45 | + searchInputRef?.blur(); | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + | ||
| 49 | + function handleSearchSelect(result) { | ||
| 50 | + goto(`/objeto/${result.codigo}`); | ||
| 51 | + searchVal = ''; | ||
| 52 | + clearSearch(); | ||
| 53 | + searchFocused = false; | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + function handleSearchBlur() { | ||
| 57 | + setTimeout(() => { | ||
| 58 | + searchFocused = false; | ||
| 59 | + }, 150); | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + function handleClear() { | ||
| 63 | + clearSearch(); | ||
| 64 | + searchVal = ''; | ||
| 65 | + } | ||
| 66 | +</script> | ||
| 67 | + | ||
| 68 | +<div class="objeto-search" class:focused={searchFocused}> | ||
| 69 | + <svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||
| 70 | + <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/> | ||
| 71 | + </svg> | ||
| 72 | + <input | ||
| 73 | + type="text" | ||
| 74 | + bind:this={searchInputRef} | ||
| 75 | + bind:value={searchVal} | ||
| 76 | + oninput={handleSearchInput} | ||
| 77 | + onkeydown={handleSearchKeydown} | ||
| 78 | + onfocus={() => searchFocused = true} | ||
| 79 | + onblur={handleSearchBlur} | ||
| 80 | + placeholder="Buscar objeto..." | ||
| 81 | + disabled={!$indexLoaded} | ||
| 82 | + /> | ||
| 83 | + {#if searchVal} | ||
| 84 | + <button class="search-clear" onclick={handleClear}> | ||
| 85 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||
| 86 | + <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> | ||
| 87 | + </svg> | ||
| 88 | + </button> | ||
| 89 | + {/if} | ||
| 90 | + {#if searchFocused && searchVal.length >= 2} | ||
| 91 | + <div class="search-dropdown"> | ||
| 92 | + {#if $searchLoading} | ||
| 93 | + <div class="search-msg">Buscando...</div> | ||
| 94 | + {:else if filteredSearchResults.length === 0} | ||
| 95 | + <div class="search-msg">Sin resultados</div> | ||
| 96 | + {:else} | ||
| 97 | + {#each filteredSearchResults as result, i} | ||
| 98 | + <button | ||
| 99 | + class="search-item" | ||
| 100 | + class:selected={$selectedIndex === i} | ||
| 101 | + onclick={() => handleSearchSelect(result)} | ||
| 102 | + onmouseenter={() => selectedIndex.set(i)} | ||
| 103 | + > | ||
| 104 | + <span class="item-code">{result.codigo}</span> | ||
| 105 | + <span class="item-name">{result.nombre}</span> | ||
| 106 | + </button> | ||
| 107 | + {/each} | ||
| 108 | + {/if} | ||
| 109 | + </div> | ||
| 110 | + {/if} | ||
| 111 | +</div> | ||
| 112 | + | ||
| 113 | +<style> | ||
| 114 | + .objeto-search { | ||
| 115 | + position: relative; | ||
| 116 | + display: flex; | ||
| 117 | + align-items: center; | ||
| 118 | + gap: 0.5rem; | ||
| 119 | + background: #f5f5f5; | ||
| 120 | + border: none; | ||
| 121 | + border-radius: 8px; | ||
| 122 | + padding: 0.625rem 1rem; | ||
| 123 | + transition: box-shadow 0.2s; | ||
| 124 | + width: 100%; | ||
| 125 | + } | ||
| 126 | + | ||
| 127 | + .objeto-search.focused { | ||
| 128 | + border-color: #6B9FD4; | ||
| 129 | + box-shadow: 0 0 0 3px rgba(107, 159, 212, 0.15); | ||
| 130 | + } | ||
| 131 | + | ||
| 132 | + .search-icon { | ||
| 133 | + color: var(--theme-texto); | ||
| 134 | + opacity: 0.5; | ||
| 135 | + flex-shrink: 0; | ||
| 136 | + } | ||
| 137 | + | ||
| 138 | + input { | ||
| 139 | + flex: 1; | ||
| 140 | + border: none; | ||
| 141 | + background: transparent; | ||
| 142 | + color: var(--theme-titulo); | ||
| 143 | + font-size: 0.8125rem; | ||
| 144 | + outline: none; | ||
| 145 | + min-width: 120px; | ||
| 146 | + } | ||
| 147 | + | ||
| 148 | + input::placeholder { | ||
| 149 | + color: var(--theme-texto); | ||
| 150 | + opacity: 0.5; | ||
| 151 | + } | ||
| 152 | + | ||
| 153 | + .search-clear { | ||
| 154 | + display: flex; | ||
| 155 | + align-items: center; | ||
| 156 | + justify-content: center; | ||
| 157 | + padding: 0.25rem; | ||
| 158 | + border-radius: 4px; | ||
| 159 | + border: none; | ||
| 160 | + background: transparent; | ||
| 161 | + color: var(--theme-texto); | ||
| 162 | + cursor: pointer; | ||
| 163 | + transition: background 0.2s; | ||
| 164 | + } | ||
| 165 | + | ||
| 166 | + .search-clear:hover { | ||
| 167 | + background: var(--theme-borde); | ||
| 168 | + } | ||
| 169 | + | ||
| 170 | + .search-dropdown { | ||
| 171 | + position: absolute; | ||
| 172 | + top: calc(100% + 4px); | ||
| 173 | + left: 0; | ||
| 174 | + right: 0; | ||
| 175 | + background: var(--theme-surface); | ||
| 176 | + border: 1px solid var(--theme-borde); | ||
| 177 | + border-radius: 8px; | ||
| 178 | + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); | ||
| 179 | + max-height: 280px; | ||
| 180 | + overflow-y: auto; | ||
| 181 | + z-index: 100; | ||
| 182 | + } | ||
| 183 | + | ||
| 184 | + .search-msg { | ||
| 185 | + padding: 0.75rem 1rem; | ||
| 186 | + font-size: 0.8125rem; | ||
| 187 | + color: var(--theme-texto); | ||
| 188 | + opacity: 0.7; | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + .search-item { | ||
| 192 | + display: flex; | ||
| 193 | + align-items: center; | ||
| 194 | + gap: 0.75rem; | ||
| 195 | + width: 100%; | ||
| 196 | + padding: 0.625rem 1rem; | ||
| 197 | + border: none; | ||
| 198 | + background: transparent; | ||
| 199 | + text-align: left; | ||
| 200 | + cursor: pointer; | ||
| 201 | + transition: background 0.15s; | ||
| 202 | + } | ||
| 203 | + | ||
| 204 | + .search-item:hover, | ||
| 205 | + .search-item.selected { | ||
| 206 | + background: var(--theme-surface-hover); | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + .item-code { | ||
| 210 | + font-family: 'DM Mono', monospace; | ||
| 211 | + font-size: 0.75rem; | ||
| 212 | + color: var(--theme-texto); | ||
| 213 | + min-width: 3.5rem; | ||
| 214 | + } | ||
| 215 | + | ||
| 216 | + .item-name { | ||
| 217 | + font-size: 0.8125rem; | ||
| 218 | + color: var(--theme-titulo); | ||
| 219 | + } | ||
| 220 | +</style> |
src/lib/components/objeto/VistaToggle.svelte
0 → 100644
| 1 | +<script> | ||
| 2 | + let { vista = $bindable('agregado') } = $props(); | ||
| 3 | + | ||
| 4 | + function toggle() { | ||
| 5 | + vista = vista === 'agregado' ? 'comparar' : 'agregado'; | ||
| 6 | + } | ||
| 7 | +</script> | ||
| 8 | + | ||
| 9 | +<button class="vista-btn" onclick={toggle}> | ||
| 10 | + {#if vista === 'agregado'} | ||
| 11 | + Comparar entre entidades | ||
| 12 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||
| 13 | + <path d="M5 12h14M12 5l7 7-7 7"/> | ||
| 14 | + </svg> | ||
| 15 | + {:else} | ||
| 16 | + <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | ||
| 17 | + <path d="M19 12H5M12 19l-7-7 7-7"/> | ||
| 18 | + </svg> | ||
| 19 | + Ver todo | ||
| 20 | + {/if} | ||
| 21 | +</button> | ||
| 22 | + | ||
| 23 | +<style> | ||
| 24 | + .vista-btn { | ||
| 25 | + display: inline-flex; | ||
| 26 | + align-items: center; | ||
| 27 | + gap: 0.375rem; | ||
| 28 | + font-family: 'Qanelas', var(--font-sans); | ||
| 29 | + font-size: 0.75rem; | ||
| 30 | + font-weight: 500; | ||
| 31 | + padding: 0; | ||
| 32 | + border: none; | ||
| 33 | + background: transparent; | ||
| 34 | + color: #666666; | ||
| 35 | + cursor: pointer; | ||
| 36 | + transition: color 0.15s ease; | ||
| 37 | + white-space: nowrap; | ||
| 38 | + text-decoration: underline dotted; | ||
| 39 | + text-underline-offset: 3px; | ||
| 40 | + text-decoration-color: #666666; | ||
| 41 | + } | ||
| 42 | + | ||
| 43 | + .vista-btn svg { | ||
| 44 | + flex-shrink: 0; | ||
| 45 | + opacity: 0.7; | ||
| 46 | + } | ||
| 47 | + | ||
| 48 | + .vista-btn:hover { | ||
| 49 | + color: #999999; | ||
| 50 | + text-decoration-color: #666666; | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + .vista-btn:hover svg { | ||
| 54 | + opacity: 1; | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + .vista-btn:active { | ||
| 58 | + color: #555555; | ||
| 59 | + } | ||
| 60 | +</style> |
| ... | @@ -60,13 +60,25 @@ | ... | @@ -60,13 +60,25 @@ |
| 60 | </div> | 60 | </div> |
| 61 | {/if} | 61 | {/if} |
| 62 | 62 | ||
| 63 | -<div class="min-h-screen" style="background-color: var(--theme-body);"> | 63 | +<div class="layout-wrapper"> |
| 64 | - <main> | 64 | + <main class="layout-main"> |
| 65 | {@render children()} | 65 | {@render children()} |
| 66 | </main> | 66 | </main> |
| 67 | </div> | 67 | </div> |
| 68 | 68 | ||
| 69 | <style> | 69 | <style> |
| 70 | + .layout-wrapper { | ||
| 71 | + min-height: 100vh; | ||
| 72 | + width: 100%; | ||
| 73 | + background-color: var(--theme-body); | ||
| 74 | + } | ||
| 75 | + | ||
| 76 | + .layout-main { | ||
| 77 | + width: 100%; | ||
| 78 | + max-width: 100%; | ||
| 79 | + overflow-x: hidden; | ||
| 80 | + } | ||
| 81 | + | ||
| 70 | .nav-loader { | 82 | .nav-loader { |
| 71 | position: fixed; | 83 | position: fixed; |
| 72 | top: 0; | 84 | top: 0; | ... | ... |
| ... | @@ -37,6 +37,7 @@ | ... | @@ -37,6 +37,7 @@ |
| 37 | let gearMenuOpen = false; | 37 | let gearMenuOpen = false; |
| 38 | let searchInput; | 38 | let searchInput; |
| 39 | let debounceTimer; | 39 | let debounceTimer; |
| 40 | + let autoFocused = false; | ||
| 40 | 41 | ||
| 41 | // Search filters - which types to include | 42 | // Search filters - which types to include |
| 42 | let searchFilters = { | 43 | let searchFilters = { |
| ... | @@ -68,6 +69,14 @@ | ... | @@ -68,6 +69,14 @@ |
| 68 | warmupSupabase(); | 69 | warmupSupabase(); |
| 69 | }); | 70 | }); |
| 70 | 71 | ||
| 72 | + // Auto-focus en el buscador cuando el índice esté listo (solo una vez) | ||
| 73 | + $: if ($indexLoaded && searchInput && !autoFocused) { | ||
| 74 | + autoFocused = true; | ||
| 75 | + setTimeout(() => { | ||
| 76 | + searchInput?.focus(); | ||
| 77 | + }, 50); | ||
| 78 | + } | ||
| 79 | + | ||
| 71 | function toggleMobileSettings() { | 80 | function toggleMobileSettings() { |
| 72 | mobileSettingsOpen = !mobileSettingsOpen; | 81 | mobileSettingsOpen = !mobileSettingsOpen; |
| 73 | } | 82 | } |
| ... | @@ -124,8 +133,12 @@ | ... | @@ -124,8 +133,12 @@ |
| 124 | break; | 133 | break; |
| 125 | case 'Enter': | 134 | case 'Enter': |
| 126 | e.preventDefault(); | 135 | e.preventDefault(); |
| 136 | + // Si hay un resultado seleccionado, navegar a él | ||
| 127 | if ($selectedIndex >= 0 && filteredResults[$selectedIndex]) { | 137 | if ($selectedIndex >= 0 && filteredResults[$selectedIndex]) { |
| 128 | handleSelect(filteredResults[$selectedIndex]); | 138 | handleSelect(filteredResults[$selectedIndex]); |
| 139 | + } else if (filteredResults.length > 0) { | ||
| 140 | + // Si no hay selección pero hay resultados, ir al primero | ||
| 141 | + handleSelect(filteredResults[0]); | ||
| 129 | } | 142 | } |
| 130 | break; | 143 | break; |
| 131 | case 'Escape': | 144 | case 'Escape': |
| ... | @@ -323,11 +336,11 @@ | ... | @@ -323,11 +336,11 @@ |
| 323 | on:blur={handleBlur} | 336 | on:blur={handleBlur} |
| 324 | disabled={!$indexLoaded} | 337 | disabled={!$indexLoaded} |
| 325 | /> | 338 | /> |
| 326 | - {#if !searchVal && !searchFocused} | 339 | + {#if !searchVal} |
| 327 | <div class="search-placeholder"> | 340 | <div class="search-placeholder"> |
| 328 | <span class="blink-cursor" style="background:{accent}"></span> | 341 | <span class="blink-cursor" style="background:{accent}"></span> |
| 329 | - <span class="placeholder-text placeholder-desktop">{$indexLoaded ? placeholder : 'Cargando...'}</span> | 342 | + <span class="placeholder-text placeholder-desktop">{placeholder}</span> |
| 330 | - <span class="placeholder-text placeholder-mobile">{$indexLoaded ? (isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...') : 'Cargando...'}</span> | 343 | + <span class="placeholder-text placeholder-mobile">{isD ? 'MEFP, Min. Salud, GAM...' : 'BOA, Publicidad, IDH...'}</span> |
| 331 | </div> | 344 | </div> |
| 332 | {/if} | 345 | {/if} |
| 333 | {#if searchVal && !$isLoading} | 346 | {#if searchVal && !$isLoading} | ... | ... |
| ... | @@ -16,19 +16,6 @@ | ... | @@ -16,19 +16,6 @@ |
| 16 | let highlightedItem = $state(null); | 16 | let highlightedItem = $state(null); |
| 17 | let sidebarOpen = $state(false); | 17 | let sidebarOpen = $state(false); |
| 18 | 18 | ||
| 19 | - // Modo embed (cuando está dentro de un iframe en /objeto/[codigo]) | ||
| 20 | - let isEmbed = $derived($page.url.searchParams.get('embed') === 'true'); | ||
| 21 | - let initialObjeto = $derived($page.url.searchParams.get('objeto')); | ||
| 22 | - | ||
| 23 | - // Función para navegar a un objeto (en modo embed envía mensaje al padre) | ||
| 24 | - function navigateToObjeto(codigo) { | ||
| 25 | - if (isEmbed && window.parent !== window) { | ||
| 26 | - window.parent.postMessage({ type: 'navigate-objeto', codigo }, '*'); | ||
| 27 | - } else { | ||
| 28 | - goto(`/objeto/${codigo}`); | ||
| 29 | - } | ||
| 30 | - } | ||
| 31 | - | ||
| 32 | // Modo de visualización desde URL | 19 | // Modo de visualización desde URL |
| 33 | let viewMode = $derived($page.url.searchParams.get('modo') || 'lista'); | 20 | let viewMode = $derived($page.url.searchParams.get('modo') || 'lista'); |
| 34 | 21 | ||
| ... | @@ -1459,7 +1446,6 @@ | ... | @@ -1459,7 +1446,6 @@ |
| 1459 | class="transition-colors hover:text-[var(--theme-accent)]" | 1446 | class="transition-colors hover:text-[var(--theme-accent)]" |
| 1460 | style="color: var(--theme-texto);" | 1447 | style="color: var(--theme-texto);" |
| 1461 | title="Ver detalle" | 1448 | title="Ver detalle" |
| 1462 | - onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(selectedGrupo.objeto); }}} | ||
| 1463 | > | 1449 | > |
| 1464 | <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | 1450 | <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 1465 | <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" /> | 1451 | <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" /> |
| ... | @@ -1510,7 +1496,6 @@ | ... | @@ -1510,7 +1496,6 @@ |
| 1510 | class="transition-colors hover:text-[var(--theme-accent)]" | 1496 | class="transition-colors hover:text-[var(--theme-accent)]" |
| 1511 | style="color: var(--theme-texto);" | 1497 | style="color: var(--theme-texto);" |
| 1512 | title="Ver página de detalle" | 1498 | title="Ver página de detalle" |
| 1513 | - onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(subgrupo.objeto); }}} | ||
| 1514 | > | 1499 | > |
| 1515 | <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | 1500 | <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 1516 | <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" /> | 1501 | <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" /> |
| ... | @@ -1557,7 +1542,6 @@ | ... | @@ -1557,7 +1542,6 @@ |
| 1557 | class="transition-colors hover:text-[var(--theme-accent)]" | 1542 | class="transition-colors hover:text-[var(--theme-accent)]" |
| 1558 | style="color: var(--theme-texto);" | 1543 | style="color: var(--theme-texto);" |
| 1559 | title="Ver página de detalle" | 1544 | title="Ver página de detalle" |
| 1560 | - onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(partida.objeto); }}} | ||
| 1561 | > | 1545 | > |
| 1562 | <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | 1546 | <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 1563 | <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" /> | 1547 | <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" /> |
| ... | @@ -1600,7 +1584,6 @@ | ... | @@ -1600,7 +1584,6 @@ |
| 1600 | class="transition-colors hover:text-[var(--theme-accent)]" | 1584 | class="transition-colors hover:text-[var(--theme-accent)]" |
| 1601 | style="color: var(--theme-texto);" | 1585 | style="color: var(--theme-texto);" |
| 1602 | title="Ver página de detalle" | 1586 | title="Ver página de detalle" |
| 1603 | - onclick={(e) => { if (isEmbed) { e.preventDefault(); navigateToObjeto(subpartida.objeto); }}} | ||
| 1604 | > | 1587 | > |
| 1605 | <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | 1588 | <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 1606 | <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" /> | 1589 | <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" /> | ... | ... |
This diff is collapsed. Click to expand it.
-
Please register or login to post a comment