Toggle navigation
Toggle navigation
This project
Loading...
Sign in
Rafael Lopez
/
ppto
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Builds
Commits
Authored by
Rafael Lopez
2026-04-10 10:13:40 -0400
Browse Files
Options
Browse Files
Download
Email Patches
Plain Diff
Commit
2a1487188178d4d2c7882c1d3e6f659979923f0c
2a148718
1 parent
0794d1fb
entidades
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
2386 additions
and
406 deletions
src/lib/components/ui/Navbar.svelte
src/lib/components/ui/SearchModal.svelte
src/lib/stores/landingSearchState.js
src/routes/+page.svelte
src/routes/entidad/[codigo]/+page.js
src/routes/entidad/[codigo]/+page.svelte
src/routes/test/+page.svelte
static/screen.png
tablas.md
src/lib/components/ui/Navbar.svelte
View file @
2a14871
...
...
@@ -242,6 +242,8 @@
color: #B8B5AD;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.nav-search-btn:hover {
...
...
@@ -291,6 +293,8 @@
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #B8B5AD;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
cursor: pointer;
transition: all 0.2s ease;
}
...
...
@@ -330,6 +334,8 @@
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #B8B5AD;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
...
...
@@ -363,6 +369,8 @@
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: #B8B5AD;
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
cursor: pointer;
transition: all 0.2s ease;
}
...
...
src/lib/components/ui/SearchModal.svelte
View file @
2a14871
<script>
import {
onMount,
tick } from 'svelte';
import { tick } from 'svelte';
import { goto } from '$app/navigation';
import {
query,
results,
isLoading,
indexLoaded,
error,
selectedIndex,
initIndex,
performSearch,
clearSearch,
navigateResults
} from '$lib/stores/searchStore';
import { get } from 'svelte/store';
import { landingSearchMode, landingSelectedClassifiers } from '$lib/stores/landingSearchState';
let { open = $bindable(false) } = $props();
let searchInput = $state(null);
let searchVal = $state('');
let isMac = $state(false);
// Search filters
let searchFilters = $state({
entidad: true,
objeto_gasto: true,
rubro: true,
finfun: true,
organismo: true,
fuente: true
});
let isMac = $state(typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform));
let searchLoading = $state(false);
let searchResults = $state([]);
let selectedIdx = $state(-1);
let debounceTimer;
// Mapeo de clasificadores landing → filtros modal
const classifierToFilter = {
entidad: 'entidad',
objeto: 'objeto',
rubro: 'rubro',
finfun: 'finfun',
organismo: 'organismo',
fuente: 'fuente'
};
function getInitialFilters() {
const mode = get(landingSearchMode);
const classifiers = get(landingSelectedClassifiers);
if (mode === 'clasificadores' && classifiers.length > 0) {
const filters = { entidad: false, objeto_gasto: false, rubro: false, finfun: false, organismo: false, fuente: false };
classifiers.forEach(c => {
const key = classifierToFilter[c];
if (key === 'objeto') filters.objeto_gasto = true;
else if (key && filters[key] !== undefined) filters[key] = true;
});
return filters;
}
return { entidad: true, objeto_gasto: true, rubro: true, finfun: true, organismo: true, fuente: true };
}
// Filtered results based on active filters
let filteredResults = $derived(
$results.filter(item => searchFilters[item.tipo])
);
let searchFilters = $state(getInitialFilters());
function toggleFilter(tipo) {
searchFilters[tipo] = !searchFilters[tipo];
// Re-buscar con filtros actualizados
if (searchVal.length >= 2) doSearch(searchVal);
}
onMount(() => {
isMac = /Mac|iPod|iPhone|iPad/.test(navigator.platform);
initIndex();
});
// Filtros activos → class_ param para Typesense
function getActiveClasses() {
const map = { entidad: 'entidad', objeto_gasto: 'objeto', finfun: 'finfun' };
return Object.entries(searchFilters)
.filter(([_, v]) => v)
.map(([k]) => map[k] || k)
.filter(Boolean);
}
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
// Focus input when modal opens
async function doSearch(query) {
searchLoading = true;
try {
const classes = getActiveClasses();
const params = new URLSearchParams({
q: query,
is_class: 'true',
per_page: '30'
});
if (classes.length > 0 && classes.length < 6) {
params.set('class_', classes.join(','));
}
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error();
const data = await res.json();
searchResults = (data.hits || []).map(hit => {
const meta = parseMetadatos(hit.document.metadatos);
const cls = hit.document.class_;
let tipo = cls;
let codigo = '';
let nombre = hit.document.texto;
let highlight = hit.highlights?.[0]?.snippet || nombre;
if (cls === 'entidad') {
const esDA = !!meta.da;
codigo = esDA ? `${meta.entidad}.${meta.da}` : String(meta.entidad);
tipo = 'entidad';
} else if (cls === 'objeto') {
codigo = meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '';
tipo = 'objeto_gasto';
} else if (cls === 'finfun') {
const fin = String(meta.finfun_finalidad || '');
if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) {
codigo = `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
} else if (meta.finfun_grpfuncion !== undefined) {
codigo = `${fin}.${meta.finfun_grpfuncion}`;
} else {
codigo = fin;
}
tipo = 'finfun';
}
return { tipo, codigo, nombre, highlight };
});
selectedIdx = -1;
} catch {
searchResults = [];
}
searchLoading = false;
}
let filteredResults = $derived(searchResults);
// Focus input and sync filters when modal opens
$effect(() => {
if (open) {
searchFilters = getInitialFilters();
tick().then(() => {
searchInput?.focus();
});
} else {
// Clear on close
searchVal = '';
clearSearch();
searchResults = [];
selectedIdx = -1;
}
});
...
...
@@ -63,10 +135,11 @@
function handleInput(e) {
searchVal = e.target.value;
clearTimeout(debounceTimer);
if (searchVal.length >= 2) {
performSearch(searchVal
);
debounceTimer = setTimeout(() => doSearch(searchVal), 200
);
} else {
clearSearch()
;
searchResults = []
;
}
}
...
...
@@ -75,13 +148,13 @@
closeModal();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
navigateResults('down', filteredResults.length)
;
selectedIdx = selectedIdx < filteredResults.length - 1 ? selectedIdx + 1 : 0
;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
navigateResults('up', filteredResults.length)
;
} else if (e.key === 'Enter' &&
$selectedInde
x >= 0) {
selectedIdx = selectedIdx > 0 ? selectedIdx - 1 : filteredResults.length - 1
;
} else if (e.key === 'Enter' &&
selectedId
x >= 0) {
e.preventDefault();
goToResult(filteredResults[
$selectedInde
x]);
goToResult(filteredResults[
selectedId
x]);
}
}
...
...
@@ -91,10 +164,10 @@
goto(`/entidad/${item.codigo}`);
} else if (item.tipo === 'objeto_gasto') {
goto(`/objeto/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
} else if (item.tipo === 'finfun') {
goto(`/finfun/${item.codigo}`);
} else if (item.tipo === 'rubro') {
goto(`/rubro/${item.codigo}`);
} else if (item.tipo === 'organismo') {
goto(`/organismo/${item.codigo}`);
} else if (item.tipo === 'fuente') {
...
...
@@ -248,9 +321,7 @@
<!-- Results -->
<div class="search-modal-results">
{#if !$indexLoaded}
<div class="search-modal-status">Cargando índice...</div>
{:else if $isLoading}
{#if searchLoading}
<div class="search-modal-status">Buscando...</div>
{:else if searchVal.length < 2}
<div class="search-modal-empty"></div>
...
...
@@ -260,18 +331,15 @@
{#each filteredResults as item, i}
<button
class="search-result-item"
class:result-selected={
$selectedInde
x === i}
class:result-selected={
selectedId
x === i}
onclick={() => goToResult(item)}
onmouseenter={() =>
selectedIndex.set(i)
}
onmouseenter={() =>
{ selectedIdx = i; }
}
>
<div class="result-main">
<span class="result-type" style="color:{typeConfig[item.tipo]?.color || '#888'}">
{typeConfig[item.tipo]?.label || item.tipo}
</span>
<span class="result-name">{item.nombre}</span>
{#if item.descripcion}
<span class="result-desc">{item.descripcion}</span>
{/if}
<span class="result-name">{@html item.highlight}</span>
</div>
<span class="result-code">{item.codigo}</span>
</button>
...
...
src/lib/stores/landingSearchState.js
0 → 100644
View file @
2a14871
import
{
writable
}
from
'svelte/store'
;
// Persiste el estado de búsqueda del landing entre navegaciones
export
const
landingSearchMode
=
writable
(
'programas'
);
// 'programas' | 'clasificadores'
export
const
landingSelectedClassifiers
=
writable
([]);
// ['entidad', 'objeto', ...]
export
const
landingSearchQuery
=
writable
(
''
);
src/routes/+page.svelte
View file @
2a14871
<script>
import { onMount } from 'svelte';
import { landingSearchMode, landingSelectedClassifiers, landingSearchQuery } from '$lib/stores/landingSearchState';
// Estado base
let mounted = false;
let heroInView = true;
let searchFocused = false;
let searchVal = '';
let searchVal =
$landingSearchQuery ||
'';
let searchInput;
let isLoading = false;
let results = null;
...
...
@@ -58,48 +60,201 @@
}
$: sortedHits = allHits.length > 0 ? [...allHits].sort((a, b) => {
let valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0)
;
let valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0
);
return
sortOrder === 'desc' ? valB - valA : valA - valB
;
const dir = sortOrder === 'desc' ? 1 : -1
;
if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || ''
);
return
dir * ((b.document.devengado || 0) - (a.document.devengado || 0))
;
}) : [];
// ¿Es búsqueda de entidades?
$: isEntidadSearchActive = results?.hits?.[0]?.document?.class_ === 'entidad';
// Detectar tipos de clasificadores presentes en los resultados
$: activeClassTypes = (() => {
const types = new Set();
allHits.forEach(h => { if (h.document.class_) types.add(h.document.class_); });
return types;
})();
$: isMixedClassSearch = activeClassTypes.size > 1;
$: isEntidadSearchActive = !isMixedClassSearch && activeClassTypes.has('entidad');
$: isObjetoSearchActive = !isMixedClassSearch && activeClassTypes.has('objeto');
$: isFinfunSearchActive = !isMixedClassSearch && activeClassTypes.has('finfun');
// Labels para tipos de clasificador
const CLASS_LABELS = {
entidad: 'Entidades',
objeto: 'Objetos de Gasto',
finfun: 'Finalidad y Función',
};
// Extraer código de finfun desde metadatos (formato con puntos: 1, 1.1, 1.1.3)
function getFinfunCodigo(meta) {
const fin = String(meta.finfun_finalidad || '');
if (meta.finfun_funcion !== undefined && meta.finfun_grpfuncion !== undefined) {
return `${fin}.${meta.finfun_grpfuncion}.${meta.finfun_funcion}`;
}
if (meta.finfun_grpfuncion !== undefined) {
return `${fin}.${meta.finfun_grpfuncion}`;
}
return fin;
}
function getFinfunNivel(meta) {
if (meta.finfun_funcion !== undefined) return 'Función';
if (meta.finfun_grpfuncion !== undefined) return 'Grupo función';
return 'Finalidad';
}
// Extraer código de objeto desde metadatos (el nivel más bajo presente)
function getObjetoCodigo(meta) {
return meta.objeto_subpartida || meta.objeto_partida || meta.objeto_subgrupo || meta.objeto_grupo || '';
}
// Agrupar
por subarea cuando es búsqueda de entidades
// Agrupar
resultados según tipo
$: groupedHits = (() => {
if (!isEntidadSearchActive || allHits.length === 0) return null;
const groups = {};
allHits.forEach(hit => {
const m = parseMetadatos(hit.document.metadatos);
const subarea = extractAfterHyphen(m.entidad_desc_subarea) || 'Otros';
if (!groups[subarea]) {
groups[subarea] = { name: subarea, hits: [], totalMonto: 0 };
if (allHits.length === 0) return null;
// Búsqueda mixta: agrupar por tipo de clasificador
if (isMixedClassSearch) {
// Filtrar duplicados de finfun
const filtered = allHits.filter(hit => {
if (hit.document.class_ !== 'finfun') return true;
const m = parseMetadatos(hit.document.metadatos);
return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined);
});
const groups = {};
filtered.forEach(hit => {
const type = hit.document.class_ || 'otros';
const groupName = CLASS_LABELS[type] || type;
if (!groups[groupName]) {
groups[groupName] = { name: groupName, hits: [], totalMonto: 0, classType: type };
}
groups[groupName].hits.push(hit);
groups[groupName].totalMonto += hit.document.devengado || 0;
});
const dir = sortOrder === 'desc' ? 1 : -1;
Object.values(groups).forEach(group => {
group.hits.sort((a, b) => {
if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
});
});
const sortedGroups = Object.values(groups);
if (sortBy === 'alpha') {
sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
} else {
sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
}
return sortedGroups;
}
if (isEntidadSearchActive) {
const groups = {};
allHits.forEach(hit => {
const m = parseMetadatos(hit.document.metadatos);
const subarea = extractAfterHyphen(m.entidad_desc_subarea) || 'Otros';
if (!groups[subarea]) {
groups[subarea] = { name: subarea, hits: [], totalMonto: 0 };
}
groups[subarea].hits.push(hit);
groups[subarea].totalMonto += hit.document.devengado || 0;
});
const dir = sortOrder === 'desc' ? 1 : -1;
Object.values(groups).forEach(group => {
group.hits.sort((a, b) => {
if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
const valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
const valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
return dir * (valB - valA);
});
});
const sortedGroups = Object.values(groups);
if (sortBy === 'alpha') {
sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
} else {
sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
}
groups[subarea].hits.push(hit);
groups[subarea].totalMonto += hit.document.devengado || 0;
});
// Ordenar items dentro de cada grupo
Object.values(groups).forEach(group => {
group.hits.sort((a, b) => {
const valA = sortBy === 'year' ? (a.document.gestion || 0) : (a.document.devengado || 0);
const valB = sortBy === 'year' ? (b.document.gestion || 0) : (b.document.devengado || 0);
return sortOrder === 'desc' ? valB - valA : valA - valB;
return sortedGroups;
}
if (isObjetoSearchActive) {
const groups = {};
allHits.forEach(hit => {
const m = parseMetadatos(hit.document.metadatos);
const groupName = extractAfterHyphen(m.objeto_desc_grupo) || 'Otros';
if (!groups[groupName]) {
groups[groupName] = { name: groupName, hits: [], totalMonto: 0 };
}
groups[groupName].hits.push(hit);
groups[groupName].totalMonto += hit.document.devengado || 0;
});
const dir = sortOrder === 'desc' ? 1 : -1;
Object.values(groups).forEach(group => {
group.hits.sort((a, b) => {
if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
});
});
const sortedGroups = Object.values(groups);
if (sortBy === 'alpha') {
sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
} else {
sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
}
return sortedGroups;
}
if (isFinfunSearchActive) {
// Filtrar duplicados: función 0 es idéntica a su grupo función padre
const filtered = allHits.filter(hit => {
const m = parseMetadatos(hit.document.metadatos);
return !(m.finfun_funcion === 0 && m.finfun_grpfuncion !== undefined);
});
const groups = {};
filtered.forEach(hit => {
const m = parseMetadatos(hit.document.metadatos);
const groupName = m.finfun_desc_finalidad || hit.document.texto || 'Otros';
if (!groups[groupName]) {
groups[groupName] = { name: groupName, hits: [], totalMonto: 0 };
}
groups[groupName].hits.push(hit);
groups[groupName].totalMonto += hit.document.devengado || 0;
});
const dir = sortOrder === 'desc' ? 1 : -1;
Object.values(groups).forEach(group => {
group.hits.sort((a, b) => {
if (sortBy === 'alpha') return dir * (a.document.texto || '').localeCompare(b.document.texto || '');
return dir * ((b.document.devengado || 0) - (a.document.devengado || 0));
});
});
});
// Convertir a array y ordenar grupos por monto total
return Object.values(groups).sort((a, b) => b.totalMonto - a.totalMonto);
const sortedGroups = Object.values(groups);
if (sortBy === 'alpha') {
sortedGroups.sort((a, b) => dir * a.name.localeCompare(b.name));
} else {
sortedGroups.sort((a, b) => dir * (b.totalMonto - a.totalMonto));
}
return sortedGroups;
}
return null;
})();
// Lista plana para navegación por teclado (funciona tanto con grupos como sin)
$: flatHitsForNav = groupedHits
? groupedHits.flatMap(g => g.hits)
: sortedHits;
$: hasMoreResults = results && allHits.length < results.found;
// Modo de búsqueda
// Modo de búsqueda simplificado: 'programas' | 'clasificadores'
let searchMode = 'programas';
let selectedClassifiers = [];
// Modo de búsqueda - restaurar desde store
let searchMode = $landingSearchMode;
let selectedClassifiers = $landingSelectedClassifiers;
// Clasificadores unificados (transversales)
const CLASIFICADORES = [
...
...
@@ -133,6 +288,11 @@
}
}
// Persistir estado de búsqueda al store
$: $landingSearchMode = searchMode;
$: $landingSelectedClassifiers = selectedClassifiers;
$: $landingSearchQuery = searchVal;
// Placeholder dinámico
$: placeholderText = getPlaceholderText(searchMode, selectedClassifiers);
...
...
@@ -275,6 +435,11 @@
const isMobile = window.innerWidth < 768;
if (!isMobile) setTimeout(() => searchInput?.focus(), 300);
// Re-ejecutar búsqueda si volvemos con estado persistido
if (searchVal && searchVal.length >= 2) {
setTimeout(() => performSearch(searchVal), 100);
}
// IntersectionObserver para secciones
const sections = document.querySelectorAll('[data-section]');
sectionObserver = new IntersectionObserver((entries) => {
...
...
@@ -286,7 +451,17 @@
}, { threshold: 0.3 });
sections.forEach(section => sectionObserver.observe(section));
return () => sectionObserver?.disconnect();
// Observer separado para el hero: oculta logo/menú apenas el hero deja de ser mayoritario
const heroEl = document.querySelector('[data-section="hero"]');
if (heroEl) {
const heroObserver = new IntersectionObserver((entries) => {
heroInView = entries[0].isIntersecting;
}, { threshold: 0.85 });
heroObserver.observe(heroEl);
var cleanupHero = () => heroObserver.disconnect();
}
return () => { sectionObserver?.disconnect(); cleanupHero?.(); };
});
async function handleInput(e) {
...
...
@@ -372,6 +547,14 @@
if (hasMoreResults && !isLoadingMore) performSearch(searchVal, currentPage + 1);
}
function handleGlobalKeydown(e) {
if (e.key === 'Escape' && (searchVal || allHits.length > 0)) {
clearSearch();
searchInput?.blur();
selectedResultIndex = -1;
}
}
function handleKeydown(e) {
if (e.key === 'Escape') {
clearSearch();
...
...
@@ -380,10 +563,11 @@
}
// Navegación por resultados
if (sortedHits.length > 0) {
const navHits = flatHitsForNav;
if (navHits.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedResultIndex = Math.min(selectedResultIndex + 1,
sorted
Hits.length - 1);
selectedResultIndex = Math.min(selectedResultIndex + 1,
nav
Hits.length - 1);
scrollToSelectedResult();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
...
...
@@ -391,7 +575,7 @@
scrollToSelectedResult();
} else if (e.key === 'Enter' && selectedResultIndex >= 0) {
e.preventDefault();
navigateToResult(
sorted
Hits[selectedResultIndex]);
navigateToResult(
nav
Hits[selectedResultIndex]);
}
}
}
...
...
@@ -406,11 +590,19 @@
function navigateToResult(hit) {
const meta = parseMetadatos(hit.document.metadatos);
const isEntidadClass = hit.document.class_ === 'entidad';
const isObjetoClass = hit.document.class_ === 'objeto';
const isClassResult = hit.document.is_class === true;
let url;
const isFinfunClass = hit.document.class_ === 'finfun';
if (isEntidadClass) {
url = `/entidad/${meta.entidad}`;
const entCodigo = meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad;
url = `/entidad/${entCodigo}`;
} else if (isObjetoClass) {
url = `/objeto/${getObjetoCodigo(meta)}`;
} else if (isFinfunClass) {
url = `/finfun/${getFinfunCodigo(meta)}`;
} else if (isClassResult) {
url = `/clasificador/${hit.document.class_}/${hit.document.id}`;
} else {
...
...
@@ -527,7 +719,10 @@
return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`;
}
$: stats = allHits.length > 0 ? getStats(allHits, results?.found || allHits.length) : null;
$: stats = allHits.length > 0 ? getStats(
(isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav : allHits,
(isFinfunSearchActive || isMixedClassSearch) ? flatHitsForNav.length : (results?.found || allHits.length)
) : null;
// Texto contextual para resultados
$: searchContextText = (() => {
...
...
@@ -567,11 +762,13 @@
$: isSearching = searchVal.length >= 3;
</script>
<svelte:window on:keydown={handleGlobalKeydown} />
<svelte:head>
<title>Buscador | Presupuesto Abierto</title>
</svelte:head>
<div class="page" class:mounted>
<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'}>
...
...
@@ -590,17 +787,19 @@
<!-- Hero -->
<main data-section="hero">
<div class="nav-logo" class:nav-hidden={currentSection !== 'hero'}>
<img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
</div>
<div class="hero-topbar">
<div class="nav-logo" class:nav-hidden={!heroInView}>
<img src="/logos_oscuro/05_Imago MEFP horizontal espacio negativo.png" alt="MEFP" />
</div>
<button class="nav-menu-btn" aria-label="Menú">
<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"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<button class="nav-menu-btn" class:nav-hidden={!heroInView} aria-label="Menú">
<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"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
</div>
<div class="search-container anim" style="--d:200ms">
...
...
@@ -826,9 +1025,7 @@
<span class="summary-tag summary-tag-gold">{formatMonto(stats.montoTotal)}</span>
<span class="summary-sep">·</span>
{#if stats.isClassifier}
{#if stats.isEntidad && groupedHits}
<span class="summary-tag summary-tag-entities">{stats.numEntidades} {stats.numEntidades === 1 ? 'entidad' : 'entidades'}</span>
<span class="summary-sep">·</span>
{#if groupedHits}
<span class="summary-tag">{groupedHits.length} {groupedHits.length === 1 ? 'categoría' : 'categorías'}</span>
{/if}
{:else}
...
...
@@ -842,9 +1039,9 @@
</div>
<div class="sort-buttons">
{#if !stats.isClassifier}
<button class="sort-btn" class:sort-btn-active={sortBy === '
year'} on:click={() => toggleSort('year
')}>
A
ño
{#if sortBy === '
year
'}
<button class="sort-btn" class:sort-btn-active={sortBy === '
alpha'} on:click={() => toggleSort('alpha
')}>
A
-Z
{#if sortBy === '
alpha
'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
{:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
...
...
@@ -861,6 +1058,17 @@
</svg>
{/if}
</button>
{#if groupedHits}
<button class="sort-btn" class:sort-btn-active={sortBy === 'alpha'} on:click={() => toggleSort('alpha')}>
A-Z
{#if sortBy === 'alpha'}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if sortOrder === 'desc'}<path d="M12 5v14M5 12l7 7 7-7"/>
{:else}<path d="M12 19V5M5 12l7-7 7 7"/>{/if}
</svg>
{/if}
</button>
{/if}
</div>
</div>
</div>
...
...
@@ -868,7 +1076,7 @@
<div class="results-list">
{#if groupedHits}
<!-- Vista agrupada para entidades -->
<!-- Vista agrupada para entidades
u objetos
-->
{#each groupedHits as group, gi}
<div class="result-group">
<div class="result-group-header">
...
...
@@ -879,23 +1087,70 @@
<div class="result-group-items">
{#each group.hits as hit, i (getHitKey(hit, gi * 1000 + i))}
{@const meta = parseMetadatos(hit.document.metadatos)}
{@const isDA = meta.da && meta.entidad_desc_entidad}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
<a href="/entidad/{meta.entidad}" class="result-card result-card-grouped">
<div class="result-content">
<div class="result-text">
{#if hit.highlights?.[0]?.snippet}
{@html hit.highlights[0].snippet}
{:else}
{hit.document.texto}
{/if}
{@const globalIdx = flatHitsForNav.indexOf(hit)}
{@const hitClass = hit.document.class_}
{#if hitClass === 'entidad'}
{@const isDA = meta.da && meta.entidad_desc_entidad}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
{@const entidadCodigo = isDA ? `${meta.entidad}.${meta.da}` : meta.entidad}
<a href="/entidad/{entidadCodigo}" class="result-card result-card-grouped"
class:result-card-selected={selectedResultIndex === globalIdx}
data-result-index={globalIdx}>
<div class="result-content">
<div class="result-text">
{#if hit.highlights?.[0]?.snippet}
{@html hit.highlights[0].snippet}
{:else}
{hit.document.texto}
{/if}
</div>
<div class="result-meta">
{#if isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if}
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
</div>
</div>
<div class="result-meta">
{#if isDA}<span class="result-parent">Dependiente de {parentEntity}</span>{/if}
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
</a>
{:else if hitClass === 'objeto'}
{@const codigo = getObjetoCodigo(meta)}
{@const nivel = meta.objeto_subpartida ? 'Subpartida' : meta.objeto_partida ? 'Partida' : meta.objeto_subgrupo ? 'Subgrupo' : 'Grupo'}
<a href="/objeto/{codigo}" class="result-card result-card-grouped"
class:result-card-selected={selectedResultIndex === globalIdx}
data-result-index={globalIdx}>
<div class="result-content">
<div class="result-text">
{#if hit.highlights?.[0]?.snippet}
{@html hit.highlights[0].snippet}
{:else}
{hit.document.texto}
{/if}
</div>
<div class="result-meta">
<span class="result-objeto-nivel">{nivel} {codigo}</span>
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
</div>
</div>
</div>
</a>
</a>
{:else if hitClass === 'finfun'}
{@const codigo = getFinfunCodigo(meta)}
{@const nivel = getFinfunNivel(meta)}
<a href="/finfun/{codigo}" class="result-card result-card-grouped"
class:result-card-selected={selectedResultIndex === globalIdx}
data-result-index={globalIdx}>
<div class="result-content">
<div class="result-text">
{#if hit.highlights?.[0]?.snippet}
{@html hit.highlights[0].snippet}
{:else}
{hit.document.texto}
{/if}
</div>
<div class="result-meta">
<span class="result-objeto-nivel">{nivel} {codigo}</span>
<span class="result-monto">{formatMonto(hit.document.devengado)}</span>
</div>
</div>
</a>
{/if}
{/each}
</div>
</div>
...
...
@@ -908,11 +1163,17 @@
{@const isDA = isEntidadClass && meta.da && meta.entidad_desc_entidad}
{@const subarea = isEntidadClass ? extractAfterHyphen(meta.entidad_desc_subarea) : null}
{@const parentEntity = isDA ? extractAfterHyphen(meta.entidad_desc_entidad) : null}
{@const isObjetoClass = hit.document.class_ === 'objeto'}
{@const isFinfunClass = hit.document.class_ === 'finfun'}
{@const isClassResult = hit.document.is_class === true}
<a
href={isEntidadClass
? `/entidad/${meta.entidad}`
: (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
? `/entidad/${meta.da ? `${meta.entidad}.${meta.da}` : meta.entidad}`
: isObjetoClass
? `/objeto/${getObjetoCodigo(meta)}`
: isFinfunClass
? `/finfun/${getFinfunCodigo(meta)}`
: (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
class="result-card"
class:result-card-selected={selectedResultIndex === i}
data-result-index={i}
...
...
@@ -973,7 +1234,7 @@
</div>
<!-- Scroll hint -->
<div class="scroll-hint anim" style="--d:800ms" class:scroll-hint-hidden={isSearching}>
<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>
...
...
@@ -1372,6 +1633,7 @@
@keyframes heartbeat{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:48}}
.page{--serif:'Qanelas',system-ui,sans-serif;--sans:'Qanelas',system-ui,sans-serif;--mono:'JetBrains Mono',monospace;font-family:var(--serif);color:#1C1C1A;width:100%;min-width:100%;overflow-x:hidden;scroll-snap-type:y mandatory;overflow-y:scroll;height:100vh}
.page-locked{overflow-y:hidden;scroll-snap-type:none}
/* Section Navigation Dots */
.section-dots{position:fixed;right:24px;top:50%;transform:translateY(-50%);z-index:100;display:flex;flex-direction:column;gap:12px;background:rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:12px 8px;border-radius:20px;transition:opacity 0.4s ease,transform 0.4s ease}
...
...
@@ -1615,7 +1877,7 @@
/* Results Content */
.results-content{max-height:0;overflow:hidden;opacity:0;transition:all 0.4s cubic-bezier(0.16,1,0.3,1)}
.results-content-visible{max-height:60vh;overflow-y:auto;opacity:1;background:#1C1C1A;border-radius:16px;border:none;padding:14px 10px;text-align:left;position:relative;z-index:10}
.results-content-visible{max-height:60vh;overflow-y:auto;opacity:1;background:#1C1C1A;border-radius:16px;border:none;padding:14px 10px;text-align:left;position:relative;z-index:10
;overscroll-behavior:contain
}
/* Results Summary */
.results-summary{background:transparent;border-radius:0;padding:0 0 12px 0;margin-bottom:12px;border-bottom:1px solid rgba(255,255,255,0.08)}
...
...
@@ -1659,7 +1921,8 @@
.result-text{font-family:var(--sans);font-size:14px;color:#F5F0E8;line-height:1.4}
.result-text :global(mark){background:rgba(255,213,79,0.9);color:#1C1C1A;border-radius:3px;padding:1px 5px;font-weight:600}
.result-meta{display:flex;align-items:center;gap:10px;margin-top:6px;flex-wrap:wrap}
.result-parent,.result-subarea,.result-class-type{font-family:var(--mono);font-size:11px;color:#8B8880}
.result-parent,.result-subarea,.result-class-type,.result-objeto-nivel{font-family:var(--mono);font-size:11px;color:#8B8880}
.result-objeto-codigo{font-family:var(--mono);font-size:11px;color:#C9A751;opacity:0.7}
.result-year{font-family:var(--mono);font-size:12px;color:#8B8880;background:rgba(255,255,255,0.06);padding:2px 8px;border-radius:4px}
.result-monto{font-family:var(--mono);font-size:12px;color:#5AAF8A}
.result-entity{font-family:var(--sans);font-size:12px;color:#9B9890}
...
...
@@ -1676,6 +1939,7 @@
.search-loading{display:flex;align-items:center;justify-content:center}
/* Nav elements in hero */
.hero-topbar{display:contents}
main[data-section="hero"] .nav-logo{position:fixed;top:24px;left:24px;z-index:100;transition:opacity 0.4s ease,transform 0.4s ease}
.nav-menu-btn{position:fixed;top:24px;right:24px;z-index:100;background:rgba(255,255,255,0.06);border:none;border-radius:10px;width:44px;height:44px;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#B8B5AD;transition:all 0.3s ease}
.nav-menu-btn:hover{background:rgba(255,255,255,0.1);color:#F5F0E8}
...
...
@@ -1858,6 +2122,7 @@
══════════════════════════════════════════════════════════════════════════ */
@media(max-width:768px){
.page{scroll-snap-type:y proximity}
main[data-section="hero"]{height:auto;min-height:100vh}
main[data-section="hero"],.landing-screen,.directory,.clasificadores,.historias,.visualizaciones,.manifiesto,.descargas{scroll-snap-stop:normal}
/* Section dots - bottom horizontal bar */
...
...
@@ -1868,7 +2133,9 @@
/* Nav */
nav{padding:12px 24px}
.nav-logo{height:60px}
.hero-topbar{display:flex;justify-content:space-between;align-items:center;padding:16px 16px 0;width:100%;flex-shrink:0}
main[data-section="hero"] .nav-logo{position:static;height:52px;opacity:1;transform:none}
main[data-section="hero"] .nav-menu-btn{position:static;width:40px;height:40px}
/* Hero */
.hero{padding:32px 24px}
...
...
@@ -1905,16 +2172,16 @@
.scroll-icon-touch{display:block}
/* New search responsive */
.search-container{padding:0 16px;justify-content:
flex-start;padding-top:100px
}
.search-container{padding:0 16px;justify-content:
center;padding-top:0
}
.hero-title{font-size:clamp(38px,9vw,52px);margin-bottom:4px}
.hero-sub{font-size:14px;margin-bottom:20px}
.section-group{margin-top:16px}
.section-label{font-size:10px;margin-bottom:10px}
.section-label-goto{margin-top:16px}
.mode-cards{grid-template-columns:1fr;gap:12px}
.mode-card{padding:1
8px;gap:10
px;border-radius:16px}
.mode-card-title{font-size:1
5
px}
.mode-card-desc{font-size:1
2px
}
.mode-card{padding:1
4px;gap:6
px;border-radius:16px}
.mode-card-title{font-size:1
4
px}
.mode-card-desc{font-size:1
1px;line-height:1.3
}
.clf-chip{padding:5px 10px;font-size:11px}
.clf-check{width:12px;height:12px}
.mode-pill{font-size:12px;padding:7px 12px}
...
...
@@ -1927,7 +2194,7 @@
.results-area{margin-top:4px;min-height:40px}
.results-content-visible{padding:2px 4px}
.search-wrap-with-results{padding:2px}
.daily-card{padding:16px 18px;gap:12px;border-radius:14px}
.daily-card{padding:16px 18px;gap:12px;border-radius:14px
;margin-bottom:24px
}
.daily-card-title{font-size:14px}
.daily-card-text{font-size:12px}
.results-summary{padding:4px 0}
...
...
@@ -2036,18 +2303,18 @@
.scroll-hint{display:none}
/* New search 480px */
.search-container{padding:0 12px;
padding-top:80px
}
.search-container{padding:0 12px;
justify-content:center;padding-top:0
}
.search-input-wrap input{font-size:15px}
.placeholder-text{font-size:12px}
.hero-title{font-size:clamp(34px,10vw,44px)}
.hero-sub{font-size:13px;margin-bottom:16px}
main[data-section="hero"] .nav-logo{
top:16px;left:16px;height:56
px}
.nav-menu-btn{top:16px;right:16px;width:38px;height:38
px}
main[data-section="hero"] .nav-logo{
height:44
px}
main[data-section="hero"] .nav-menu-btn{width:36px;height:36
px}
.mode-cards{gap:10px;margin-top:12px}
.mode-card{padding:1
4px;gap:8
px;border-radius:14px}
.mode-card-icon svg{width:1
8px;height:18
px}
.mode-card-title{font-size:1
4
px}
.mode-card-desc{font-size:1
1px
}
.mode-card{padding:1
2px;gap:5
px;border-radius:14px}
.mode-card-icon svg{width:1
6px;height:16
px}
.mode-card-title{font-size:1
3
px}
.mode-card-desc{font-size:1
0px;line-height:1.3
}
.mode-card-classifiers{gap:4px}
.clf-chip{padding:4px 8px;font-size:10px;gap:4px}
.clf-check{width:10px;height:10px}
...
...
src/routes/entidad/[codigo]/+page.js
View file @
2a14871
...
...
@@ -2,24 +2,64 @@ import { supabase } from '$lib/supabase';
import
{
error
}
from
'@sveltejs/kit'
;
export
async
function
load
({
params
})
{
const
codigo
=
parseInt
(
params
.
codigo
);
const
codigo
=
params
.
codigo
;
const
isDA
=
codigo
.
includes
(
'.'
);
const
entidadCode
=
isDA
?
codigo
.
split
(
'.'
)[
0
]
:
codigo
;
const
entidadNum
=
parseInt
(
entidadCode
);
if
(
isNaN
(
codigo
))
{
if
(
isNaN
(
entidadNum
))
{
throw
error
(
400
,
'Código de entidad inválido'
);
}
const
{
data
,
error
:
dbError
}
=
await
supabase
.
schema
(
'ppto'
)
.
from
(
'clas_institucional'
)
.
select
(
'*'
)
.
eq
(
'entidad'
,
codigo
)
.
single
();
// Cargar metadata y resumen en paralelo
const
[
entidadRes
,
resumenRes
]
=
await
Promise
.
all
([
supabase
.
schema
(
'ppto'
)
.
from
(
'clas_institucional'
)
.
select
(
'*'
)
.
eq
(
'entidad'
,
entidadNum
)
.
single
(),
supabase
.
schema
(
'ppto'
)
.
from
(
'entidad_resumen'
)
.
select
(
'tipo, tipo_codigo, codigo, desc, desc_padre, gestion, devengado, ranking'
)
.
eq
(
'codigo'
,
codigo
)
]);
if
(
dbError
||
!
data
)
{
if
(
entidadRes
.
error
||
!
entidadRes
.
data
)
{
throw
error
(
404
,
'Entidad no encontrada'
);
}
// Determinar última gestión disponible
const
gestiones
=
[...
new
Set
((
resumenRes
.
data
||
[]).
map
(
d
=>
d
.
gestion
))].
sort
((
a
,
b
)
=>
b
-
a
);
const
ultimaGestion
=
gestiones
[
0
]
||
2025
;
// Cargar distribuciones solo de la última gestión
const
distRes
=
await
supabase
.
schema
(
'ppto'
)
.
from
(
'entidad_distribuciones'
)
.
select
(
'tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado'
)
.
eq
(
'codigo'
,
codigo
)
.
eq
(
'gestion'
,
ultimaGestion
);
// Resolver nombre de DA desde el resumen
let
nombreDA
=
null
;
let
nombreEntidadMadre
=
null
;
if
(
isDA
&&
resumenRes
.
data
?.
length
>
0
)
{
const
firstRow
=
resumenRes
.
data
[
0
];
nombreDA
=
firstRow
.
desc
||
null
;
nombreEntidadMadre
=
firstRow
.
desc_padre
||
null
;
}
return
{
entidad
:
data
entidad
:
entidadRes
.
data
,
isDA
,
nombreDA
,
nombreEntidadMadre
,
codigoEntidadPadre
:
isDA
?
entidadCode
:
null
,
resumenData
:
resumenRes
.
data
||
[],
distribucionesData
:
distRes
.
data
||
[],
gestionInicial
:
ultimaGestion
};
}
...
...
src/routes/entidad/[codigo]/+page.svelte
View file @
2a14871
<script>
let { data } = $props();
import { onMount, tick } from 'svelte';
import { page } from '$app/stores';
import * as d3 from 'd3';
import { supabase } from '$lib/supabase';
let { data } = $props();
const entidad = data.entidad;
const gestiones = entidad.gestiones ? entidad.gestiones.split(',').map(g => g.trim()) : [];
const isDA = data.isDA;
const nombreDA = data.nombreDA;
const nombreEntidadMadre = data.nombreEntidadMadre;
const codigoEntidadPadre = data.codigoEntidadPadre;
// Datos desde el loader (Supabase)
let codigoSeleccionado = $derived($page.params.codigo);
let gestionSeleccionada = $state(data.gestionInicial);
let resumenData = $state(data.resumenData);
let distribucionesData = $state(data.distribucionesData);
let cargando = $state(false);
let distCache = $state({ [data.gestionInicial]: data.distribucionesData });
let cargandoDist = $state(false);
async function cargarDistribuciones(gestion) {
if (distCache[gestion]) {
distribucionesData = distCache[gestion];
return;
}
cargandoDist = true;
const { data: rows } = await supabase
.schema('ppto')
.from('entidad_distribuciones')
.select('tipo, dimension, gestion, padre, desc_padre, hijo, desc_hijo, devengado')
.eq('codigo', codigoSeleccionado)
.eq('gestion', gestion);
const result = rows || [];
distCache[gestion] = result;
distribucionesData = result;
cargandoDist = false;
}
// Cargar distribuciones al cambiar gestión
$effect(() => {
cargarDistribuciones(gestionSeleccionada);
});
// Hover en gráficos de historia
let hoveredIngresos = $state(null);
let hoveredGastos = $state(null);
// Dropdown de gestión
let gestionDropdownOpen = $state(false);
// Mini-buscador de entidades
let searchQuery = $state('');
let searchResults = $state([]);
let searchLoading = $state(false);
let searchOpen = $state(false);
let searchSelectedIdx = $state(-1);
let searchDebounce = null;
function parseMetadatos(meta) {
if (!meta) return {};
if (typeof meta === 'object') return meta;
try { return JSON.parse(meta); } catch { return {}; }
}
function onSearchInput() {
clearTimeout(searchDebounce);
if (searchQuery.length < 2) {
searchResults = [];
searchOpen = false;
return;
}
searchDebounce = setTimeout(async () => {
searchLoading = true;
try {
const params = new URLSearchParams({
q: searchQuery,
is_class: 'true',
class_: 'entidad',
per_page: '12'
});
const res = await fetch(`/api/search?${params}`);
if (!res.ok) throw new Error();
const json = await res.json();
searchResults = (json.hits || []).map(hit => {
const meta = parseMetadatos(hit.document.metadatos);
const esDA = !!meta.da;
return {
nombre: hit.document.texto,
codigo: esDA ? `${meta.entidad}.${meta.da}` : String(meta.entidad),
esDA,
entidadMadre: esDA ? (meta.entidad_desc_entidad || '').replace(/^[A-Z]+ - /, '') : null,
highlight: hit.highlights?.[0]?.snippet || hit.document.texto
};
});
searchOpen = searchResults.length > 0;
searchSelectedIdx = -1;
} catch {
searchResults = [];
}
searchLoading = false;
}, 250);
}
function navigateToEntity(item) {
window.location.href = `/entidad/${item.codigo}`;
}
function onSearchKeydown(e) {
if (!searchOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
searchSelectedIdx = Math.min(searchSelectedIdx + 1, searchResults.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
searchSelectedIdx = Math.max(searchSelectedIdx - 1, -1);
} else if (e.key === 'Enter' && searchSelectedIdx >= 0) {
e.preventDefault();
navigateToEntity(searchResults[searchSelectedIdx]);
} else if (e.key === 'Escape') {
searchOpen = false;
searchQuery = '';
}
}
function closeSearch() {
setTimeout(() => { searchOpen = false; }, 150);
}
// Datos derivados (ya filtrados por codigo desde el loader)
let historiaGastos = $derived(
resumenData
.filter(d => d.tipo === 'gastos')
.sort((a, b) => a.gestion - b.gestion)
);
let historiaIngresos = $derived(
resumenData
.filter(d => d.tipo === 'ingresos')
.sort((a, b) => a.gestion - b.gestion)
);
let tieneIngresos = $derived(historiaIngresos.length > 0);
let gestiones = $derived(() => {
const years = [...new Set(resumenData.map(d => d.gestion))].sort();
return years;
});
let rankingGastos = $derived(() => {
const row = historiaGastos.find(d => d.gestion === gestionSeleccionada);
return row ? { posicion: row.ranking, total: '~600' } : null;
});
let rankingIngresos = $derived(() => {
const row = historiaIngresos.find(d => d.gestion === gestionSeleccionada);
return row ? { posicion: row.ranking, total: '~600' } : null;
});
let distFiltradas = $derived(distribucionesData);
function prepararSegmentos(data) {
return data
.map(d => ({
codigo: d.hijo,
nombre: d.desc_hijo,
monto: d.devengado,
padre: d.desc_padre
}))
.filter(d => d.monto > 0)
.sort((a, b) => b.monto - a.monto);
}
let clasificadoresGasto = $derived.by(() => {
const gastos = distFiltradas.filter(d => d.tipo === 'gastos');
const dims = [
{ key: 'objeto', label: 'Objetos de gasto' },
{ key: 'finfun', label: 'Finalidad y función' },
{ key: 'acteco', label: 'Sectores económicos' }
];
return dims
.map(dim => ({
...dim,
data: prepararSegmentos(gastos.filter(d => d.dimension === dim.key))
}))
.filter(dim => dim.data.length > 0);
});
let clasificadoresIngreso = $derived.by(() => {
if (!tieneIngresos) return [];
const ingresos = distFiltradas.filter(d => d.tipo === 'ingresos');
const dims = [
{ key: 'rubro', label: 'Rubros de ingreso' },
{ key: 'organismo', label: 'Organismos financiadores' }
];
return dims
.map(dim => ({
...dim,
data: prepararSegmentos(ingresos.filter(d => d.dimension === dim.key))
}))
.filter(dim => dim.data.length > 0);
});
let barContainers = $state({});
let hoverData = $state({});
function getTotal(data) {
return data.reduce((sum, d) => sum + d.monto, 0);
}
function formatearMonto(valor) {
if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)} mil millones`;
if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)} millones`;
if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)} mil`;
return valor.toFixed(0);
}
function formatearMontoCorto(valor) {
if (valor >= 1e9) return `${(valor / 1e9).toFixed(1)}B`;
if (valor >= 1e6) return `${(valor / 1e6).toFixed(0)}M`;
if (valor >= 1e3) return `${(valor / 1e3).toFixed(0)}K`;
return valor.toFixed(0);
}
function getChartColors() {
const isDark = document.documentElement.classList.contains('dark');
return {
barDefault: isDark ? 'rgba(107, 159, 212, 0.2)' : 'rgba(196, 137, 125, 0.2)',
barHighlight: isDark ? 'rgba(107, 159, 212, 0.85)' : '#c4897d',
barStroke: isDark ? 'rgba(107, 159, 212, 0.5)' : 'rgba(196, 137, 125, 0.5)',
barStrokeDefault: isDark ? 'transparent' : 'transparent'
};
}
function renderizarBarras(key, data) {
const container = barContainers[key];
if (!container || data.length === 0) return;
const total = getTotal(data);
const height = 48;
const radius = 4;
d3.select(container).selectAll('*').remove();
const containerRect = container.getBoundingClientRect();
const width = containerRect.width || 800;
const svg = d3.select(container)
.append('svg')
.attr('width', '100%')
.attr('height', height)
.attr('viewBox', `0 0 ${width} ${height}`)
.attr('preserveAspectRatio', 'none')
.style('display', 'block');
let cumulative = 0;
const segments = data.map(d => {
const segWidth = (d.monto / total) * width;
const segment = { ...d, x: cumulative, width: segWidth };
cumulative += segWidth;
return segment;
});
const chartColors = getChartColors();
const colorDefault = chartColors.barDefault;
const colorHover = chartColors.barHighlight;
const colorFirst = chartColors.barHighlight;
const bars = svg.selectAll('rect')
.data(segments)
.enter()
.append('rect')
.attr('x', d => d.x)
.attr('y', 2)
.attr('width', d => Math.max(d.width - 1, 1))
.attr('height', height - 4)
.attr('rx', (d, i) => {
if (i === 0) return radius;
if (i === segments.length - 1) return radius;
return 0;
})
.attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5)
.style('cursor', 'pointer')
.on('mouseenter', function(event, d) {
bars
.attr('fill', colorDefault)
.attr('stroke', chartColors.barStrokeDefault)
.attr('stroke-width', 0.5);
d3.select(this)
.attr('fill', colorHover)
.attr('stroke', chartColors.barStroke)
.attr('stroke-width', 1);
hoverData[key] = d;
hoverData = { ...hoverData };
})
.on('mouseleave', function() {
bars
.attr('fill', (d, i) => i === 0 ? colorFirst : colorDefault)
.attr('stroke', (d, i) => i === 0 ? chartColors.barStroke : chartColors.barStrokeDefault)
.attr('stroke-width', (d, i) => i === 0 ? 1 : 0.5);
hoverData[key] = null;
hoverData = { ...hoverData };
});
}
function getDisplayItem(key, data) {
const hover = hoverData[key];
if (hover) return hover;
return data[0] || null;
}
// Dimensiones del gráfico
const chartMargin = { top: 10, right: 10, bottom: 24, left: 48 };
const chartHeight = 180;
let chartWidth = $state(600);
let chartRefA = $state(null);
let chartRefB = $state(null);
let chartContainerEl = $derived(chartRefA || chartRefB);
let innerW = $derived(Math.max(chartWidth - chartMargin.left - chartMargin.right, 0));
let innerH = $derived(chartHeight - chartMargin.top - chartMargin.bottom);
// Escalas D3 para gastos
let xScaleGastos = $derived.by(() => {
if (historiaGastos.length === 0) return null;
return d3.scaleLinear()
.domain(d3.extent(historiaGastos, d => d.gestion))
.range([0, innerW]);
});
let yScaleGastos = $derived.by(() => {
if (historiaGastos.length === 0) return null;
return d3.scaleLinear()
.domain([0, d3.max(historiaGastos, d => d.devengado) * 1.1])
.range([innerH, 0])
.nice();
});
let lineGastos = $derived.by(() => {
if (!xScaleGastos || !yScaleGastos) return '';
const gen = d3.line()
.x(d => xScaleGastos(d.gestion))
.y(d => yScaleGastos(d.devengado))
.curve(d3.curveCatmullRom);
return gen(historiaGastos) || '';
});
let areaGastos = $derived.by(() => {
if (!xScaleGastos || !yScaleGastos) return '';
const gen = d3.area()
.x(d => xScaleGastos(d.gestion))
.y0(innerH)
.y1(d => yScaleGastos(d.devengado))
.curve(d3.curveCatmullRom);
return gen(historiaGastos) || '';
});
let yTicksGastos = $derived.by(() => {
if (!yScaleGastos) return [];
return yScaleGastos.ticks(4);
});
// Escalas D3 para ingresos
let xScaleIngresos = $derived.by(() => {
if (historiaIngresos.length === 0) return null;
return d3.scaleLinear()
.domain(d3.extent(historiaIngresos, d => d.gestion))
.range([0, innerW]);
});
let yScaleIngresos = $derived.by(() => {
if (historiaIngresos.length === 0) return null;
return d3.scaleLinear()
.domain([0, d3.max(historiaIngresos, d => d.devengado) * 1.1])
.range([innerH, 0])
.nice();
});
let lineIngresos = $derived.by(() => {
if (!xScaleIngresos || !yScaleIngresos) return '';
const gen = d3.line()
.x(d => xScaleIngresos(d.gestion))
.y(d => yScaleIngresos(d.devengado))
.curve(d3.curveCatmullRom);
return gen(historiaIngresos) || '';
});
let areaIngresos = $derived.by(() => {
if (!xScaleIngresos || !yScaleIngresos) return '';
const gen = d3.area()
.x(d => xScaleIngresos(d.gestion))
.y0(innerH)
.y1(d => yScaleIngresos(d.devengado))
.curve(d3.curveCatmullRom);
return gen(historiaIngresos) || '';
});
let yTicksIngresos = $derived.by(() => {
if (!yScaleIngresos) return [];
return yScaleIngresos.ticks(4);
});
function renderizarTodasLasBarras() {
setTimeout(() => {
clasificadoresGasto.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
clasificadoresIngreso.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
}, 100);
}
$effect(() => {
clasificadoresGasto;
clasificadoresIngreso;
tick().then(() => renderizarTodasLasBarras());
});
// Medir ancho del contenedor de chart
$effect(() => {
const el = chartContainerEl;
if (!el) return;
chartWidth = el.clientWidth - 48; // restar padding horizontal
const ro = new ResizeObserver((entries) => {
chartWidth = entries[0].contentRect.width;
});
ro.observe(el);
return () => ro.disconnect();
});
onMount(() => {
const observer = new MutationObserver(() => { renderizarTodasLasBarras(); });
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
function handleClickOutside(e) {
if (gestionDropdownOpen && !e.target.closest('.gestion-selector')) {
gestionDropdownOpen = false;
}
}
document.addEventListener('click', handleClickOutside);
return () => { observer.disconnect(); document.removeEventListener('click', handleClickOutside); };
});
</script>
<svelte:head>
<title>{entidad.desc_entidad} | Presupuesto Público</title>
<title>{
isDA && nombreDA ? nombreDA :
entidad.desc_entidad} | Presupuesto Público</title>
</svelte:head>
<div class="max-w-4xl mx-auto p-4">
<p class="text-sm text-gray-500 mb-4">
<a href="/">Inicio</a> / <a href="/clasificadores/institucional">Institucional</a> / {entidad.sigla_entidad || 'Entidad'}
</p>
{#if cargando}
<div class="dashboard">
<p>Cargando datos...</p>
</div>
{:else}
<div class="dashboard">
<div class="nav-spacer"></div>
<!-- Header sticky -->
<header class="sticky-header">
<div class="sticky-row">
<div class="sticky-left">
{#if isDA && nombreDA}
<div class="da-titulo">
<a href="/entidad/{codigoEntidadPadre}" class="entidad-madre-link">{entidad.desc_entidad}</a>
<span class="da-separador">›</span>
<span class="da-nombre">{nombreDA}</span>
</div>
{:else}
<h1 class="entidad-nombre">{entidad.desc_entidad}</h1>
{/if}
<div class="entidad-search" class:focused={searchOpen || searchQuery.length > 0}>
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
</svg>
<input
type="text"
placeholder="Buscar otra entidad..."
bind:value={searchQuery}
oninput={onSearchInput}
onkeydown={onSearchKeydown}
onblur={closeSearch}
onfocus={() => { if (searchResults.length > 0) searchOpen = true; }}
/>
{#if searchQuery}
<button class="search-clear" onclick={() => { searchQuery = ''; searchResults = []; searchOpen = false; }}>
<svg width="12" height="12" 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>
</button>
{/if}
{#if searchOpen || (searchQuery.length >= 2 && searchLoading)}
<div class="search-dropdown">
{#if searchLoading}
<div class="search-msg">Buscando...</div>
{:else if searchResults.length === 0}
<div class="search-msg">Sin resultados</div>
{:else}
{#each searchResults as item, i}
<button
class="search-item"
class:selected={searchSelectedIdx === i}
onmousedown={() => navigateToEntity(item)}
onmouseenter={() => { searchSelectedIdx = i; }}
>
<span class="item-name">{@html item.highlight}</span>
{#if item.esDA}
<span class="item-meta">{item.entidadMadre}</span>
{/if}
</button>
{/each}
{/if}
</div>
{/if}
</div>
</div>
</div>
</header>
<h1 class="text-2xl mb-1">{entidad.desc_entidad}</h1>
{#if entidad.sigla_entidad}
<p class="text-gray-600 mb-4">{entidad.sigla_entidad}</p>
{/if}
<!-- Breadcrumb -->
<nav class="breadcrumb">
<a href="/">Inicio</a>
<span class="sep">/</span>
<a href="/clasificadores/institucional">{entidad.desc_area}</a>
<span class="sep">/</span>
{#if isDA}
<a href="/entidad/{codigoEntidadPadre}">{entidad.sigla_entidad || entidad.desc_entidad}</a>
<span class="sep">/</span>
<span>{nombreDA || codigoSeleccionado}</span>
{:else}
<span>{entidad.sigla_entidad || entidad.desc_entidad}</span>
{/if}
</nav>
<p class="text-sm text-gray-500 mb-6">
Código: {entidad.entidad} · {entidad.n_gestiones} años de datos
</p>
<div class="grid md:grid-cols-2 gap-4">
<div class="border rounded p-4">
<h2 class="font-bold mb-2">Clasificación</h2>
<p class="text-sm"><span class="text-gray-500">Sector:</span> {entidad.desc_sector}</p>
<p class="text-sm"><span class="text-gray-500">Subsector:</span> {entidad.desc_subsector}</p>
<p class="text-sm"><span class="text-gray-500">Área:</span> {entidad.desc_area}</p>
{#if entidad.desc_subarea !== entidad.desc_area}
<p class="text-sm"><span class="text-gray-500">Subárea:</span> {entidad.desc_subarea}</p>
<!-- Historia temporal (todos los años) -->
<section class="seccion">
<h2 class="seccion-titulo">Historia</h2>
<div class="historia-grid" class:single={!tieneIngresos}>
{#if tieneIngresos}
<div class="historia-card" bind:this={chartRefA}>
<div class="historia-header">
<span class="historia-label">Ingresos</span>
{#if historiaIngresos.length > 0}
<span class="historia-monto ingresos">
{#if hoveredIngresos}
{hoveredIngresos.gestion} · {formatearMonto(hoveredIngresos.devengado)} de Bolivianos
{:else}
{formatearMonto(historiaIngresos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos
{/if}
</span>
{/if}
</div>
{#if xScaleIngresos && yScaleIngresos}
<svg class="historia-svg" width="100%" height={chartHeight} viewBox="0 0 {chartWidth} {chartHeight}"
onmouseleave={() => { hoveredIngresos = null; }}>
<g transform="translate({chartMargin.left},{chartMargin.top})">
{#each yTicksIngresos as tick}
<line x1="0" x2={innerW} y1={yScaleIngresos(tick)} y2={yScaleIngresos(tick)} class="grid-line" />
<text x="-8" y={yScaleIngresos(tick)} class="axis-label y-label">{formatearMontoCorto(tick)}</text>
{/each}
<path d={areaIngresos} class="area-path" />
<path d={lineIngresos} class="line-path" fill="none" stroke-width="1.5" />
{#each historiaIngresos as d}
<circle cx={xScaleIngresos(d.gestion)} cy={yScaleIngresos(d.devengado)}
r={hoveredIngresos?.gestion === d.gestion ? 4 : 2} class="dot" class:active={hoveredIngresos?.gestion === d.gestion} />
{/each}
{#each historiaIngresos as d}
<rect x={xScaleIngresos(d.gestion) - innerW / historiaIngresos.length / 2} y="0"
width={innerW / historiaIngresos.length} height={innerH}
fill="transparent"
onmouseenter={() => { hoveredIngresos = d; }}
/>
{/each}
{#if hoveredIngresos && xScaleIngresos}
<line x1={xScaleIngresos(hoveredIngresos.gestion)} x2={xScaleIngresos(hoveredIngresos.gestion)}
y1="0" y2={innerH} class="hover-line" />
<text x={xScaleIngresos(hoveredIngresos.gestion)} y={innerH + 16} class="axis-label x-label active">{hoveredIngresos.gestion}</text>
{:else}
{#each historiaIngresos as d, i}
{#if i === 0 || i === historiaIngresos.length - 1 || i === Math.floor(historiaIngresos.length / 2)}
<text x={xScaleIngresos(d.gestion)} y={innerH + 16} class="axis-label x-label">{d.gestion}</text>
{/if}
{/each}
{/if}
</g>
</svg>
{/if}
</div>
{/if}
<div class="historia-card" bind:this={chartRefB}>
<div class="historia-header">
<span class="historia-label">Gastos</span>
{#if historiaGastos.length > 0}
<span class="historia-monto gastos">
{#if hoveredGastos}
{hoveredGastos.gestion} · {formatearMonto(hoveredGastos.devengado)} de Bolivianos
{:else}
{formatearMonto(historiaGastos.reduce((s, d) => s + d.devengado, 0))} de Bolivianos
{/if}
</span>
{/if}
</div>
{#if xScaleGastos && yScaleGastos}
<svg class="historia-svg" width="100%" height={chartHeight} viewBox="0 0 {chartWidth} {chartHeight}"
onmouseleave={() => { hoveredGastos = null; }}>
<g transform="translate({chartMargin.left},{chartMargin.top})">
{#each yTicksGastos as tick}
<line x1="0" x2={innerW} y1={yScaleGastos(tick)} y2={yScaleGastos(tick)} class="grid-line" />
<text x="-8" y={yScaleGastos(tick)} class="axis-label y-label">{formatearMontoCorto(tick)}</text>
{/each}
<path d={areaGastos} class="area-path" />
<path d={lineGastos} class="line-path" fill="none" stroke-width="1.5" />
{#each historiaGastos as d}
<circle cx={xScaleGastos(d.gestion)} cy={yScaleGastos(d.devengado)}
r={hoveredGastos?.gestion === d.gestion ? 4 : 2} class="dot" class:active={hoveredGastos?.gestion === d.gestion} />
{/each}
{#each historiaGastos as d}
<rect x={xScaleGastos(d.gestion) - innerW / historiaGastos.length / 2} y="0"
width={innerW / historiaGastos.length} height={innerH}
fill="transparent"
onmouseenter={() => { hoveredGastos = d; }}
/>
{/each}
{#if hoveredGastos && xScaleGastos}
<line x1={xScaleGastos(hoveredGastos.gestion)} x2={xScaleGastos(hoveredGastos.gestion)}
y1="0" y2={innerH} class="hover-line" />
<text x={xScaleGastos(hoveredGastos.gestion)} y={innerH + 16} class="axis-label x-label active">{hoveredGastos.gestion}</text>
{:else}
{#each historiaGastos as d, i}
{#if i === 0 || i === historiaGastos.length - 1 || i === Math.floor(historiaGastos.length / 2)}
<text x={xScaleGastos(d.gestion)} y={innerH + 16} class="axis-label x-label">{d.gestion}</text>
{/if}
{/each}
{/if}
</g>
</svg>
{/if}
</div>
</div>
</section>
<div class="border rounded p-4">
<h2 class="font-bold mb-2">Gestiones</h2>
<div class="flex flex-wrap gap-1">
{#each gestiones as gestion}
<span class="text-xs bg-gray-100 px-2 py-1 rounded">{gestion}</span>
<!-- Sección por año -->
<section class="seccion seccion-anual">
<h2 class="seccion-titulo">Gestión
<div class="gestion-selector">
<button class="gestion-btn" onclick={() => { gestionDropdownOpen = !gestionDropdownOpen; }}>
<span>{gestionSeleccionada}</span>
<svg class="gestion-chevron" class:open={gestionDropdownOpen} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9l6 6 6-6"/>
</svg>
</button>
{#if gestionDropdownOpen}
<div class="gestion-dropdown">
{#each gestiones() as g}
<button
class="gestion-option"
class:active={gestionSeleccionada === g}
onclick={() => { gestionSeleccionada = g; gestionDropdownOpen = false; }}
>
{g}
</button>
{/each}
</div>
{/if}
</div>
</h2>
<!-- Rankings -->
<div class="ranking-grid" class:single={!tieneIngresos}>
{#if rankingGastos()}
{@const r = rankingGastos()}
{@const total = parseInt(r.total) || 600}
{@const barIdx = Math.max(0, Math.min(79, Math.round((r.posicion / total) * 80)))}
<div class="ranking-card">
<span class="ranking-card-label">Ranking en gasto</span>
<div class="ranking-bars">
{#each Array(80) as _, i}
<div class="ranking-bar-tick"></div>
{/each}
<div class="ranking-marker" style="left: {barIdx * 1.25}%"></div>
</div>
<div class="ranking-caption">
<span class="ranking-pos">#{r.posicion}</span> de {total} entidades
</div>
</div>
{/if}
{#if tieneIngresos && rankingIngresos()}
{@const r = rankingIngresos()}
{@const total = parseInt(r.total) || 600}
{@const barIdx = Math.max(0, Math.min(79, Math.round((r.posicion / total) * 80)))}
<div class="ranking-card">
<span class="ranking-card-label">Ranking en ingresos</span>
<div class="ranking-bars">
{#each Array(80) as _, i}
<div class="ranking-bar-tick"></div>
{/each}
<div class="ranking-marker" style="left: {barIdx * 1.25}%"></div>
</div>
<div class="ranking-caption">
<span class="ranking-pos">#{r.posicion}</span> de {total} entidades
</div>
</div>
{/if}
</div>
<!-- Composición del gasto -->
{#if clasificadoresGasto.length > 0}
<div class="seccion-sub">
<h3 class="seccion-subtitulo">¿En qué gasta?</h3>
<div class="clasificadores-grid">
{#each clasificadoresGasto as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span>
<span class="clasificador-padre">{displayItem?.padre || ''}</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
</div>
</div>
{/if}
<div class="border rounded p-4 mt-4">
<h2 class="font-bold mb-2">Historial de Ingresos y Gastos</h2>
<p class="text-gray-500 text-sm">Visualizaciones próximamente</p>
</div>
<!-- Origen del dinero -->
{#if tieneIngresos && clasificadoresIngreso.length > 0}
<div class="seccion-sub">
<h3 class="seccion-subtitulo">Fuentes de ingreso</h3>
<div class="clasificadores-grid">
{#each clasificadoresIngreso as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} de Bolivianos</span>
<span class="clasificador-padre">{displayItem?.padre || ''}</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
</div>
{/if}
</section>
</div>
{/if}
<style>
.dashboard {
min-height: 100vh;
background: var(--theme-body);
color: var(--theme-texto);
padding: 2rem;
padding-top: 0;
font-family: var(--font-sans);
}
:global(html:not(.dark)) .dashboard {
background: #f5f5f7;
}
/* Spacer para empujar debajo del navbar fixed */
.nav-spacer {
height: 3.5rem;
}
.sticky-header {
position: sticky;
top: 0;
z-index: 50;
padding: 0.75rem 2rem;
margin: 0 -2rem 1rem -2rem;
background: var(--theme-body);
border-bottom: 1px solid var(--theme-borde);
}
:global(html:not(.dark)) .sticky-header {
background: #f5f5f7;
}
.sticky-row {
display: flex;
align-items: center;
gap: 1rem;
}
.sticky-left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
flex: 1;
}
.entidad-nombre {
font-size: 1.1rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* DA titulo jerárquico */
.da-titulo {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
}
.entidad-madre-link {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-texto);
text-decoration: none;
opacity: 0.6;
transition: opacity 0.15s;
white-space: nowrap;
}
.entidad-madre-link:hover {
opacity: 1;
text-decoration: underline;
}
.da-separador {
font-size: 0.85rem;
color: var(--theme-texto);
opacity: 0.35;
}
.da-nombre {
font-size: 1.1rem;
font-weight: 700;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Buscador de entidades (estilo ObjetoSearch) */
.entidad-search {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
background: var(--search-bg, #f5f5f5);
border: 1px solid var(--theme-borde);
border-radius: 8px;
padding: 0.5rem 0.75rem;
transition: box-shadow 0.2s, border-color 0.2s;
width: 240px;
flex-shrink: 0;
}
:global(html.dark) .entidad-search {
--search-bg: rgba(255, 255, 255, 0.08);
}
.entidad-search.focused {
box-shadow: 0 0 0 3px rgba(107, 159, 212, 0.15);
}
:global(html:not(.dark)) .entidad-search.focused {
box-shadow: 0 0 0 3px rgba(196, 137, 125, 0.15);
}
.search-icon {
color: var(--theme-texto);
opacity: 0.5;
flex-shrink: 0;
}
.entidad-search input {
flex: 1;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
outline: none;
min-width: 100px;
font-family: var(--font-sans);
}
.entidad-search input::placeholder {
color: var(--theme-texto);
opacity: 0.5;
}
.search-clear {
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: 4px;
border: none;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
transition: background 0.2s;
}
.search-clear:hover {
background: var(--theme-borde);
}
.search-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
max-height: 280px;
overflow-y: auto;
z-index: 100;
min-width: 320px;
}
.search-msg {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.7;
}
.search-item {
display: flex;
flex-direction: column;
gap: 0.1rem;
width: 100%;
padding: 0.625rem 1rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
transition: background 0.15s;
font-family: var(--font-sans);
}
.search-item:hover,
.search-item.selected {
background: var(--theme-surface-hover);
}
.item-name {
font-size: 0.8125rem;
color: var(--theme-titulo);
line-height: 1.3;
}
.item-name :global(mark) {
background: rgba(196, 137, 125, 0.25);
color: inherit;
border-radius: 2px;
padding: 0 1px;
}
:global(html.dark) .item-name :global(mark) {
background: rgba(107, 159, 212, 0.3);
}
.item-meta {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.5;
}
/* Breadcrumb */
.breadcrumb {
font-size: 0.75rem;
color: var(--theme-texto);
opacity: 0.6;
margin-bottom: 1.5rem;
}
.breadcrumb a {
color: var(--theme-texto);
text-decoration: none;
}
.breadcrumb a:hover {
text-decoration: underline;
}
.breadcrumb .sep {
margin: 0 0.35rem;
}
/* Ranking visual - barritas con marcador animado */
.ranking-bars {
display: flex;
height: 18px;
position: relative;
gap: 0;
}
.ranking-bar-tick {
flex: 1;
height: 100%;
background: var(--theme-borde);
opacity: 0.5;
border-right: 1px solid var(--theme-surface);
}
:global(html:not(.dark)) .ranking-bar-tick {
background: #d0cec9;
opacity: 0.6;
}
.ranking-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1.25%;
background: #c4897d;
border-radius: 1px;
transition: left 0.5s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
:global(html.dark) .ranking-marker {
background: #D4A574;
}
.ranking-caption {
margin-top: 0.35rem;
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.6;
}
.ranking-pos {
font-weight: 700;
color: var(--theme-titulo);
opacity: 1;
}
/* Secciones */
.seccion {
margin-bottom: 2.5rem;
}
.seccion-titulo {
font-size: 1.1rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 1rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Sección anual */
.seccion-anual {
border-top: 1px solid var(--theme-borde);
padding-top: 2rem;
}
/* Dropdown de gestión */
.gestion-selector {
position: relative;
display: inline-flex;
}
.gestion-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0;
background: transparent;
border: none;
border-bottom: 1.5px dotted var(--theme-texto);
color: var(--theme-titulo);
font-size: 1.1rem;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s;
font-family: var(--font-sans);
}
.gestion-btn:hover {
border-bottom-color: var(--theme-accent);
}
.gestion-chevron {
color: var(--theme-texto);
opacity: 0.5;
transition: transform 0.2s;
flex-shrink: 0;
}
.gestion-chevron.open {
transform: rotate(180deg);
}
.gestion-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 100px;
max-height: 240px;
overflow-y: auto;
background: var(--theme-surface);
border: 1px solid var(--theme-borde);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 100;
}
.gestion-option {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
background: transparent;
color: var(--theme-titulo);
font-size: 0.8125rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background 0.15s;
font-family: var(--font-sans);
}
.gestion-option:hover {
background: var(--theme-surface-hover);
}
.gestion-option.active {
background: rgba(107, 159, 212, 0.1);
color: #6B9FD4;
}
:global(html:not(.dark)) .gestion-option.active {
background: rgba(90, 157, 191, 0.1);
color: #5A9DBF;
}
.seccion-sub {
margin-top: 1.5rem;
}
.seccion-subtitulo {
font-size: 0.9rem;
font-weight: 600;
color: var(--theme-titulo);
margin: 0 0 0.75rem 0;
opacity: 0.8;
}
/* Rankings grid */
.ranking-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
margin-bottom: 1.5rem;
}
.ranking-grid.single {
grid-template-columns: 1fr;
max-width: 50%;
}
.ranking-card {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.ranking-card-label {
font-size: 0.75rem;
font-weight: 500;
color: var(--theme-texto);
opacity: 0.7;
display: block;
margin-bottom: 0.5rem;
}
/* Historia */
.historia-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1.5rem;
}
.historia-grid.single {
grid-template-columns: 1fr;
}
.historia-card {
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.historia-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.75rem;
}
.historia-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-texto);
}
.historia-monto {
font-size: 0.95rem;
font-weight: 600;
}
.historia-monto.ingresos,
.historia-monto.gastos {
color: #5A9DBF;
}
:global(html.dark) .historia-monto.ingresos,
:global(html.dark) .historia-monto.gastos {
color: #D4A574;
}
/* SVG chart */
.historia-svg {
display: block;
overflow: visible;
cursor: default;
}
.grid-line {
stroke: var(--theme-texto);
stroke-width: 0.5;
stroke-dasharray: 2 3;
opacity: 0.15;
}
.axis-label {
font-size: 0.6875rem;
fill: var(--theme-texto);
opacity: 0.6;
font-family: var(--font-sans);
}
.y-label {
text-anchor: end;
dominant-baseline: middle;
}
.x-label {
text-anchor: middle;
dominant-baseline: hanging;
}
.area-path {
fill: rgba(90, 157, 191, 0.12);
}
.line-path {
stroke: #5A9DBF;
}
.dot {
fill: #5A9DBF;
transition: r 0.15s;
}
.dot.active {
fill: #5A9DBF;
}
.hover-line {
stroke: var(--theme-texto);
stroke-width: 0.5;
stroke-dasharray: 3 3;
opacity: 0.3;
}
.x-label.active {
font-weight: 600;
opacity: 1;
}
:global(html.dark) .area-path {
fill: rgba(212, 165, 116, 0.12);
}
:global(html.dark) .line-path {
stroke: #D4A574;
}
:global(html.dark) .dot {
fill: #D4A574;
}
:global(html.dark) .dot.active {
fill: #D4A574;
}
/* Clasificadores */
.clasificadores-grid {
display: flex;
flex-direction: column;
gap: 1rem;
}
.clasificador-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
background: var(--theme-surface);
border-radius: 16px;
padding: 1.25rem 1.5rem;
}
.clasificador-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
.clasificador-info {
display: flex;
align-items: baseline;
gap: 0.4rem;
min-width: 0;
overflow: hidden;
}
.clasificador-monto {
font-size: 0.95rem;
font-weight: 600;
color: var(--theme-titulo);
white-space: nowrap;
flex-shrink: 0;
}
.clasificador-padre {
font-size: 0.8rem;
font-weight: 400;
color: var(--theme-texto);
opacity: 0.5;
white-space: nowrap;
flex-shrink: 0;
}
.clasificador-nombre {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-titulo);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clasificador-label {
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.6;
flex-shrink: 0;
}
.barra-container {
width: 100%;
height: 48px;
border-radius: 4px;
overflow: hidden;
}
/* Responsive */
@media (max-width: 768px) {
.dashboard {
padding: 1rem;
padding-top: 0.5rem;
}
.nav-spacer {
height: 3rem;
}
.sticky-header {
margin: 0 -1rem 0.75rem -1rem;
padding: 0.5rem 1rem;
}
.sticky-row {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.sticky-left {
flex-direction: column;
gap: 0.25rem;
}
.da-titulo {
flex-direction: column;
gap: 0.15rem;
}
.entidad-nombre {
font-size: 0.95rem;
}
.da-nombre {
font-size: 0.95rem;
}
.entidad-search {
width: 100%;
}
.historia-grid {
grid-template-columns: 1fr;
}
.ranking-grid {
grid-template-columns: 1fr;
}
.ranking-grid.single {
max-width: 100%;
}
}
</style>
...
...
src/routes/test/+page.svelte
View file @
2a14871
...
...
@@ -3,97 +3,110 @@
import * as d3 from 'd3';
import * as Plot from '@observablehq/plot';
// Entidad de ejemplo
const entidad = {
codigo: 139,
nombre: 'Universidad Mayor de San Andrés',
sigla: 'UMSA',
sector: 'Universidades Públicas'
};
// Estado
let gestionSeleccionada = $state(2024);
const gestiones = [2020, 2021, 2022, 2023, 2024, 2025];
// Datos mock - Historia temporal (20 años)
const historiaIngresos = Array.from({ length: 20 }, (_, i) => ({
gestion: 2005 + i,
monto: 800000000 + Math.random() * 400000000 + i * 50000000
}));
const historiaGastos = Array.from({ length: 20 }, (_, i) => ({
gestion: 2005 + i,
monto: 750000000 + Math.random() * 350000000 + i * 45000000
}));
// Datos mock - Composición del gasto
const gastoObjetos = [
{ codigo: '10000', nombre: 'Servicios Personales', monto: 850000000, padre: 'Gasto Corriente' },
{ codigo: '20000', nombre: 'Servicios No Personales', monto: 180000000, padre: 'Gasto Corriente' },
{ codigo: '30000', nombre: 'Materiales y Suministros', monto: 120000000, padre: 'Gasto Corriente' },
{ codigo: '40000', nombre: 'Activos Reales', monto: 95000000, padre: 'Inversión' },
{ codigo: '70000', nombre: 'Transferencias', monto: 45000000, padre: 'Transferencias' },
{ codigo: '80000', nombre: 'Impuestos y Otros', monto: 25000000, padre: 'Otros' }
];
const gastoFinalidad = [
{ codigo: '22', nombre: 'Educación Superior', monto: 920000000, padre: 'Educación' },
{ codigo: '23', nombre: 'Investigación', monto: 180000000, padre: 'Educación' },
{ codigo: '14', nombre: 'Administración', monto: 150000000, padre: 'Servicios Generales' },
{ codigo: '31', nombre: 'Salud', monto: 45000000, padre: 'Servicios Sociales' },
{ codigo: '42', nombre: 'Extensión', monto: 20000000, padre: 'Cultura' }
];
const gastoSectores = [
{ codigo: 'EDU', nombre: 'Educación', monto: 1050000000, padre: 'Social' },
{ codigo: 'ADM', nombre: 'Administración Pública', monto: 180000000, padre: 'Gubernamental' },
{ codigo: 'SAL', nombre: 'Salud', monto: 55000000, padre: 'Social' },
{ codigo: 'CUL', nombre: 'Cultura y Deporte', monto: 30000000, padre: 'Social' }
];
// Datos mock - Origen del dinero
const ingresoRubros = [
{ codigo: '1200', nombre: 'Transferencias TGN', monto: 650000000, padre: 'Transferencias' },
{ codigo: '1400', nombre: 'Venta de Servicios', monto: 280000000, padre: 'Ingresos Propios' },
{ codigo: '1100', nombre: 'Matrículas y Aranceles', monto: 150000000, padre: 'Ingresos Propios' },
{ codigo: '1300', nombre: 'Regalías e IDH', monto: 95000000, padre: 'Coparticipación' },
{ codigo: '1900', nombre: 'Otros Ingresos', monto: 40000000, padre: 'Otros' }
];
const ingresoOrganismos = [
{ codigo: 'TGN', nombre: 'Tesoro General de la Nación', monto: 720000000, padre: 'Gobierno Central' },
{ codigo: 'PROP', nombre: 'Recursos Propios', monto: 350000000, padre: 'Autogestión' },
{ codigo: 'IDH', nombre: 'Impuesto Directo a Hidrocarburos', monto: 95000000, padre: 'Coparticipación' },
{ codigo: 'COOP', nombre: 'Cooperación Internacional', monto: 50000000, padre: 'Externo' }
];
// Ranking mock
const ranking = {
gastos: { posicion: 23, total: 647 },
ingresos: { posicion: 28, total: 647 }
};
// Contenedores para barras D3
// Estado reactivo
let codigoSeleccionado = $state('1901');
let gestionSeleccionada = $state(2025);
let resumenData = $state([]);
let distribucionesData = $state([]);
let codigosDisponibles = $state([]);
let cargando = $state(true);
// Datos derivados
let tipoCodigo = $derived.by(() => {
const row = resumenData.find(d => String(d.codigo) === codigoSeleccionado);
return row?.tipo_codigo || 'entidad';
});
let esEntidad = $derived(tipoCodigo === 'entidad');
let resumenFiltrado = $derived(
resumenData.filter(d => String(d.codigo) === codigoSeleccionado)
);
let historiaGastos = $derived(
resumenFiltrado
.filter(d => d.tipo === 'gastos')
.sort((a, b) => a.gestion - b.gestion)
);
let historiaIngresos = $derived(
resumenFiltrado
.filter(d => d.tipo === 'ingresos')
.sort((a, b) => a.gestion - b.gestion)
);
let gestiones = $derived(() => {
const years = [...new Set(resumenFiltrado.map(d => d.gestion))].sort();
return years;
});
let rankingGastos = $derived(() => {
const row = historiaGastos.find(d => d.gestion === gestionSeleccionada);
return row ? { posicion: row.ranking, total: '~600' } : null;
});
let rankingIngresos = $derived(() => {
const row = historiaIngresos.find(d => d.gestion === gestionSeleccionada);
return row ? { posicion: row.ranking, total: '~600' } : null;
});
// Distribuciones filtradas por codigo y gestion
let distFiltradas = $derived(
distribucionesData.filter(d =>
String(d.codigo) === codigoSeleccionado &&
d.gestion === gestionSeleccionada
)
);
// Clasificadores: cada hijo es un segmento de la barra
function prepararSegmentos(data) {
return data
.map(d => ({
codigo: d.hijo,
nombre: d.desc_hijo,
monto: d.devengado,
padre: d.desc_padre
}))
.filter(d => d.monto > 0)
.sort((a, b) => b.monto - a.monto);
}
let clasificadoresGasto = $derived.by(() => {
const gastos = distFiltradas.filter(d => d.tipo === 'gastos');
const dims = [
{ key: 'objeto', label: 'Objetos de gasto' },
{ key: 'finfun', label: 'Finalidad y función' },
{ key: 'acteco', label: 'Sectores económicos' }
];
return dims
.map(dim => ({
...dim,
data: prepararSegmentos(gastos.filter(d => d.dimension === dim.key))
}))
.filter(dim => dim.data.length > 0);
});
let clasificadoresIngreso = $derived.by(() => {
if (!esEntidad) return [];
const ingresos = distFiltradas.filter(d => d.tipo === 'ingresos');
const dims = [
{ key: 'rubro', label: 'Rubros de ingreso' },
{ key: 'organismo', label: 'Organismos financiadores' }
];
return dims
.map(dim => ({
...dim,
data: prepararSegmentos(ingresos.filter(d => d.dimension === dim.key))
}))
.filter(dim => dim.data.length > 0);
});
// Contenedores D3
let barContainers = $state({});
let hoverData = $state({});
// Contenedores para gráficos de historia
let chartIngresosContainer = $state(null);
let chartGastosContainer = $state(null);
// Clasificadores de gasto
const clasificadoresGasto = [
{ key: 'objetos', label: 'Objetos de gasto', data: gastoObjetos },
{ key: 'finalidad', label: 'Finalidad y función', data: gastoFinalidad },
{ key: 'sectores', label: 'Sectores económicos', data: gastoSectores }
];
// Clasificadores de ingreso
const clasificadoresIngreso = [
{ key: 'rubros', label: 'Rubros de ingreso', data: ingresoRubros },
{ key: 'organismos', label: 'Organismos financiadores', data: ingresoOrganismos }
];
// Totales
function getTotal(data) {
return data.reduce((sum, d) => sum + d.monto, 0);
...
...
@@ -129,7 +142,7 @@
// Renderizar barras D3
function renderizarBarras(key, data) {
const container = barContainers[key];
if (!container) return;
if (!container
|| data.length === 0
) return;
const total = getTotal(data);
const height = 48;
...
...
@@ -209,7 +222,7 @@
// Renderizar gráficos de historia
function renderizarHistoria() {
if (
chartIngresosContainer
) {
if (
esEntidad && chartIngresosContainer && historiaIngresos.length > 0
) {
chartIngresosContainer.innerHTML = '';
const plot = Plot.plot({
width: chartIngresosContainer.clientWidth,
...
...
@@ -222,15 +235,15 @@
x: { label: null, tickFormat: d => d },
y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
marks: [
Plot.areaY(historiaIngresos, { x: 'gestion', y: '
mont
o', fill: '#4A8B6E', fillOpacity: 0.3 }),
Plot.lineY(historiaIngresos, { x: 'gestion', y: '
mont
o', stroke: '#4A8B6E', strokeWidth: 2 }),
Plot.dot(historiaIngresos, { x: 'gestion', y: '
mont
o', fill: '#4A8B6E', r: 3 })
Plot.areaY(historiaIngresos, { x: 'gestion', y: '
devengad
o', fill: '#4A8B6E', fillOpacity: 0.3 }),
Plot.lineY(historiaIngresos, { x: 'gestion', y: '
devengad
o', stroke: '#4A8B6E', strokeWidth: 2 }),
Plot.dot(historiaIngresos, { x: 'gestion', y: '
devengad
o', fill: '#4A8B6E', r: 3 })
]
});
chartIngresosContainer.appendChild(plot);
}
if (chartGastosContainer) {
if (chartGastosContainer
&& historiaGastos.length > 0
) {
chartGastosContainer.innerHTML = '';
const plot = Plot.plot({
width: chartGastosContainer.clientWidth,
...
...
@@ -243,17 +256,17 @@
x: { label: null, tickFormat: d => d },
y: { label: null, tickFormat: d => formatearMontoCorto(d), grid: true },
marks: [
Plot.areaY(historiaGastos, { x: 'gestion', y: '
mont
o', fill: '#C9A751', fillOpacity: 0.3 }),
Plot.lineY(historiaGastos, { x: 'gestion', y: '
mont
o', stroke: '#C9A751', strokeWidth: 2 }),
Plot.dot(historiaGastos, { x: 'gestion', y: '
mont
o', fill: '#C9A751', r: 3 })
Plot.areaY(historiaGastos, { x: 'gestion', y: '
devengad
o', fill: '#C9A751', fillOpacity: 0.3 }),
Plot.lineY(historiaGastos, { x: 'gestion', y: '
devengad
o', stroke: '#C9A751', strokeWidth: 2 }),
Plot.dot(historiaGastos, { x: 'gestion', y: '
devengad
o', fill: '#C9A751', r: 3 })
]
});
chartGastosContainer.appendChild(plot);
}
}
//
Efecto
s
$effect(() =>
{
//
Renderizar todas las barra
s
function renderizarTodasLasBarras()
{
setTimeout(() => {
clasificadoresGasto.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
...
...
@@ -262,23 +275,65 @@
if (barContainers[key]) renderizarBarras(key, data);
});
}, 100);
}
// Efectos
$effect(() => {
// Trigger on data changes
clasificadoresGasto;
clasificadoresIngreso;
renderizarTodasLasBarras();
});
$effect(() => {
if (chartIngresosContainer && chartGastosContainer) {
if (chartGastosContainer) {
// Trigger on history data changes
historiaGastos;
historiaIngresos;
esEntidad;
setTimeout(renderizarHistoria, 150);
}
});
onMount(() => {
onMount(async () => {
// Cargar CSVs
const [resumenRaw, distRaw] = await Promise.all([
fetch('/resumen.csv').then(r => r.text()),
fetch('/distribuciones.csv').then(r => r.text())
]);
resumenData = d3.csvParse(resumenRaw, d => ({
tipo: d.tipo,
codigo: d.codigo,
gestion: +d.gestion,
devengado: +d.devengado,
ranking: +d.ranking,
tipo_codigo: d.tipo_codigo
}));
distribucionesData = d3.csvParse(distRaw, d => ({
tipo: d.tipo,
codigo: d.codigo,
dimension: d.dimension,
gestion: +d.gestion,
padre: d.padre,
desc_padre: d.desc_padre,
hijo: d.hijo,
desc_hijo: d.desc_hijo,
devengado: +d.devengado,
tipo_codigo: d.tipo_codigo
}));
// Extraer codigos únicos
codigosDisponibles = [...new Set(resumenData.map(d => d.codigo))].sort((a, b) => {
return parseFloat(a) - parseFloat(b);
});
cargando = false;
// Observer para tema
const observer = new MutationObserver(() => {
clasificadoresGasto.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
clasificadoresIngreso.forEach(({ key, data }) => {
if (barContainers[key]) renderizarBarras(key, data);
});
renderizarTodasLasBarras();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
...
...
@@ -297,60 +352,80 @@
</script>
<svelte:head>
<title>
{entidad.nombre} - Radiografía
</title>
<title>
Radiografía - {codigoSeleccionado}
</title>
</svelte:head>
{#if cargando}
<div class="dashboard">
<p>Cargando datos...</p>
</div>
{:else}
<div class="dashboard">
<!-- Header -->
<header class="dashboard-header">
<div class="header-info">
<h1 class="entidad-nombre">{entidad.nombre}</h1>
<div class="entidad-meta">
<span class="entidad-sigla">{entidad.sigla}</span>
<span class="meta-sep">·</span>
<span class="entidad-sector">{entidad.sector}</span>
<!-- Header sticky -->
<header class="sticky-header">
<div class="sticky-row">
<div class="sticky-left">
<h1 class="entidad-nombre">Código: {codigoSeleccionado}</h1>
<div class="entidad-meta">
<span class="tipo-badge" class:da={!esEntidad}>{tipoCodigo}</span>
<span class="entidad-sector">{esEntidad ? 'Gastos e Ingresos' : 'Solo Gastos'}</span>
</div>
</div>
<div class="sticky-controls">
<select class="control-select" bind:value={codigoSeleccionado}>
{#each codigosDisponibles as cod}
<option value={cod}>{cod}</option>
{/each}
</select>
<select class="control-select" bind:value={gestionSeleccionada}>
{#each gestiones() as g}
<option value={g}>{g}</option>
{/each}
</select>
</div>
</div>
<div class="header-control">
<label class="gestion-label">Gestión</label>
<select class="gestion-select" bind:value={gestionSeleccionada}>
{#each gestiones as g}
<option value={g}>{g}</option>
{/each}
</select>
</div>
</header>
<!-- Ranking -->
<div class="ranking-bar">
<div class="ranking-item">
<span class="ranking-label">Ranking gastos</span>
<span class="ranking-value">#{ranking.gastos.posicion}</span>
<span class="ranking-total">de {ranking.gastos.total}</span>
</div>
<div class="ranking-sep"></div>
<div class="ranking-item">
<span class="ranking-label">Ranking ingresos</span>
<span class="ranking-value">#{ranking.ingresos.posicion}</span>
<span class="ranking-total">de {ranking.ingresos.total}</span>
</div>
{#if rankingGastos()}
<div class="ranking-item">
<span class="ranking-label">Ranking gastos</span>
<span class="ranking-value">#{rankingGastos().posicion}</span>
<span class="ranking-total">de {rankingGastos().total}</span>
</div>
{/if}
{#if esEntidad && rankingIngresos()}
<div class="ranking-sep"></div>
<div class="ranking-item">
<span class="ranking-label">Ranking ingresos</span>
<span class="ranking-value">#{rankingIngresos().posicion}</span>
<span class="ranking-total">de {rankingIngresos().total}</span>
</div>
{/if}
</div>
<!-- Historia temporal -->
<section class="seccion">
<h2 class="seccion-titulo">Historia 2005 – 2025</h2>
<div class="historia-grid">
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Ingresos</span>
<span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].monto)} Bs</span>
<h2 class="seccion-titulo">{historiaGastos.length > 0 ? `${historiaGastos[0].gestion} – ${historiaGastos[historiaGastos.length - 1].gestion}` : ''}</h2>
<div class="historia-grid" class:single={!esEntidad}>
{#if esEntidad}
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Ingresos</span>
{#if historiaIngresos.length > 0}
<span class="historia-monto ingresos">{formatearMonto(historiaIngresos[historiaIngresos.length - 1].devengado)} Bs</span>
{/if}
</div>
<div class="historia-chart" bind:this={chartIngresosContainer}></div>
</div>
<div class="historia-chart" bind:this={chartIngresosContainer}></div>
</div>
{/if}
<div class="historia-card">
<div class="historia-header">
<span class="historia-label">Gastos</span>
<span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].monto)} Bs</span>
{#if historiaGastos.length > 0}
<span class="historia-monto gastos">{formatearMonto(historiaGastos[historiaGastos.length - 1].devengado)} Bs</span>
{/if}
</div>
<div class="historia-chart" bind:this={chartGastosContainer}></div>
</div>
...
...
@@ -358,49 +433,52 @@
</section>
<!-- Composición del gasto -->
<section class="seccion">
<h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresGasto as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
{@const total = getTotal(data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
<span class="clasificador-padre">({displayItem?.padre || ''})</span>
{#if clasificadoresGasto.length > 0}
<section class="seccion">
<h2 class="seccion-titulo">¿En qué gasta? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresGasto as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
<span class="clasificador-padre">{displayItem?.padre || ''}</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<
span class="clasificador-label">{label}</span
>
<
div class="barra-container" bind:this={barContainers[key]}></div
>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
</section>
<!-- Origen del dinero -->
<section class="seccion">
<h2 class="seccion-titulo">¿De dónde viene la plata? <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresIngreso as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
{@const total = getTotal(data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
<span class="clasificador-padre">({displayItem?.padre || ''})</span>
{/each}
</div>
</section>
{/if}
<!-- Origen del dinero (solo entidad) -->
{#if esEntidad && clasificadoresIngreso.length > 0}
<section class="seccion">
<h2 class="seccion-titulo">Fuentes de ingreso y organismos financiadores <span class="gestion-badge">{gestionSeleccionada}</span></h2>
<div class="clasificadores-grid">
{#each clasificadoresIngreso as { key, label, data }}
{@const displayItem = getDisplayItem(key, data)}
<div class="clasificador-card">
<div class="clasificador-header">
<div class="clasificador-info">
<span class="clasificador-monto">{formatearMonto(displayItem?.monto || 0)} Bs</span>
<span class="clasificador-padre">{displayItem?.padre || ''}</span>
<span class="clasificador-nombre">{displayItem?.nombre || ''}</span>
</div>
<span class="clasificador-label">{label}</span>
</div>
<
span class="clasificador-label">{label}</span
>
<
div class="barra-container" bind:this={barContainers[key]}></div
>
</div>
<div class="barra-container" bind:this={barContainers[key]}></div>
</div>
{/each}
</div>
</section>
{/each}
</div>
</section>
{/if}
</div>
{/if}
<style>
.dashboard {
...
...
@@ -408,70 +486,99 @@
background: var(--theme-fondo);
color: var(--theme-texto);
padding: 2rem;
padding-top: 2rem;
font-family: var(--font-sans);
}
/*
H
eader */
.
dashboard
-header {
display: flex
;
justify-content: space-between
;
align-items: flex-start
;
gap:
2rem;
margin
-bottom: 1.5
rem;
padding-bottom: 1.5rem
;
/*
Sticky h
eader */
.
sticky
-header {
position: sticky
;
top: 0
;
z-index: 50
;
padding: 0.75rem
2rem;
margin
: 0 -2rem 1.5rem -2
rem;
background: color-mix(in srgb, var(--theme-fondo) 90%, transparent)
;
border-bottom: 1px solid var(--theme-borde);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.sticky-row {
display: flex;
align-items: center;
gap: 1rem;
max-width: calc(100% - 220px); /* dejar espacio para el navbar fixed */
}
.sticky-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
min-width: 0;
}
.entidad-nombre {
font-size: 1.
75
rem;
font-size: 1.
1
rem;
font-weight: 700;
color: var(--theme-titulo);
margin: 0
0 0.5rem 0
;
margin: 0;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.entidad-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
gap: 0.4rem;
flex-shrink: 0;
}
.entidad-sector {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.
7
;
opacity: 0.
6
;
}
.entidad-sigla {
.tipo-badge {
font-size: 0.6rem;
font-weight: 600;
color: var(--theme-titulo);
opacity: 1;
padding: 0.15rem 0.45rem;
background: rgba(74, 139, 110, 0.15);
color: #4A8B6E;
border-radius: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.meta-sep {
opacity: 0.4;
.tipo-badge.da {
background: rgba(201, 167, 81, 0.15);
color: #C9A751;
}
.
header-control
{
.
sticky-controls
{
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.gestion-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme-texto);
opacity: 0.6;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.
gestion
-select {
padding: 0.
5rem 1
rem;
font-size: 0.
95
rem;
.
control
-select {
padding: 0.
35rem 0.6
rem;
font-size: 0.
8
rem;
font-weight: 600;
background: var(--theme-tarjeta);
border: 1px solid var(--theme-borde);
border-radius:
8
px;
border-radius:
6
px;
color: var(--theme-titulo);
cursor: pointer;
appearance: none;
-webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23888' d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
padding-right: 1.5rem;
}
/* Ranking */
...
...
@@ -546,6 +653,10 @@
gap: 1.5rem;
}
.historia-grid.single {
grid-template-columns: 1fr;
}
.historia-card {
background: var(--theme-tarjeta);
border-radius: 12px;
...
...
@@ -616,18 +727,19 @@
color: var(--theme-titulo);
}
.clasificador-padre {
font-size: 0.8rem;
font-weight: 400;
color: var(--theme-texto);
opacity: 0.5;
}
.clasificador-nombre {
font-size: 0.85rem;
font-weight: 500;
color: var(--theme-titulo);
}
.clasificador-padre {
font-size: 0.7rem;
color: var(--theme-texto);
opacity: 0.5;
}
.clasificador-label {
font-size: 0.65rem;
text-transform: uppercase;
...
...
@@ -650,13 +762,33 @@
padding: 1rem;
}
.dashboard-header {
.sticky-header {
margin: 0 -1rem 1rem -1rem;
padding: 0.5rem 1rem;
}
.sticky-row {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
max-width: calc(100% - 160px);
}
.sticky-left {
flex-direction: column;
gap:
1
rem;
gap:
0.25
rem;
}
.entidad-nombre {
font-size: 1.35rem;
font-size: 0.95rem;
}
.sticky-controls {
width: 100%;
}
.control-select {
flex: 1;
}
.historia-grid {
...
...
static/screen.png
0 → 100644
View file @
2a14871
52.3 KB
tablas.md
View file @
2a14871
...
...
@@ -377,6 +377,70 @@ Sector → Subsector → Área → Subárea → Entidad
---
## Tablas de Vista de Entidad
### 13. `entidad_resumen` - Resumen histórico por entidad/DA
Alimenta los gráficos de historia (area plots) y rankings en la vista de entidad (
`/entidad/[codigo]`
).
| Columna | Tipo | Descripción |
|---------|------|-------------|
| tipo | TEXT | Tipo de flujo: 'gastos' o 'ingresos' |
| tipo_codigo | TEXT | Tipo de código: 'entidad', 'entidad_da', 'municipio_ubigeo', etc. |
| codigo | TEXT | Código de la entidad o DA (ej: "1901", "1901.6") |
| desc | TEXT | Nombre de la entidad o DA |
| desc_padre | TEXT | Nombre de la entidad madre (para DAs) |
| gestion | INTEGER | Año fiscal |
| devengado | NUMERIC | Monto devengado en Bs |
| ranking | INTEGER | Posición en ranking por monto dentro de su tipo y gestión |
**Primary key:**
`(tipo, tipo_codigo, codigo, gestion)`
**Índices:**
```
sql
CREATE
INDEX
idx_entidad_resumen_codigo
ON
ppto
.
entidad_resumen
(
codigo
);
CREATE
INDEX
idx_entidad_resumen_gestion
ON
ppto
.
entidad_resumen
(
gestion
);
CREATE
INDEX
idx_entidad_resumen_tipo_codigo
ON
ppto
.
entidad_resumen
(
tipo_codigo
);
```
**Notas:**
-
~53K filas
-
`codigo`
es TEXT para soportar entidades ("1901") y DAs ("1901.6")
-
`desc`
y
`desc_padre`
permiten resolver nombres sin joins ni búsquedas externas
-
Una fila por combinación tipo + tipo_codigo + código + año
---
### 14. `entidad_distribuciones` - Distribución del gasto/ingreso por clasificador
Alimenta las barras de composición (clasificadores de gasto e ingreso) en la vista de entidad.
| Columna | Tipo | Descripción |
|---------|------|-------------|
| id | SERIAL (PK) | Identificador autoincremental |
| tipo | TEXT | Tipo de flujo: 'gastos' o 'ingresos' |
| tipo_codigo | TEXT | Tipo de código: 'entidad', 'entidad_da', etc. |
| codigo | TEXT | Código de la entidad o DA |
| dimension | TEXT | Clasificador: 'objeto', 'finfun', 'acteco', 'rubro', 'organismo' |
| gestion | INTEGER | Año fiscal |
| padre | TEXT | Código del grupo padre en la dimensión |
| desc_padre | TEXT | Descripción del grupo padre |
| hijo | TEXT | Código del ítem hijo |
| desc_hijo | TEXT | Descripción del ítem hijo |
| devengado | NUMERIC | Monto devengado en Bs |
**Índices:**
```
sql
CREATE
INDEX
idx_entidad_dist_codigo
ON
ppto
.
entidad_distribuciones
(
codigo
);
CREATE
INDEX
idx_entidad_dist_gestion
ON
ppto
.
entidad_distribuciones
(
gestion
);
CREATE
INDEX
idx_entidad_dist_codigo_gestion
ON
ppto
.
entidad_distribuciones
(
codigo
,
gestion
);
```
**Notas:**
-
~3.6M filas
-
Usa
`id SERIAL`
como PK porque un mismo
`hijo`
puede tener distintas descripciones dentro de la misma combinación padre/gestión
-
Cada fila es un segmento de las barras de composición: cuánto gastó/ingresó una entidad en un ítem específico de un clasificador en un año
---
*Documento generado para el equipo de sistemas - Marzo 2026*
...
...
Please
register
or
login
to post a comment