Rafael Lopez

docs

......@@ -114,7 +114,7 @@
} else if (!isClass) {
// Programa/proyecto
tipo = 'programa';
codigo = `${meta.entidad || ''}-${meta.programa || ''}-${meta.proyecto || ''}`;
codigo = String(meta.programa || '');
}
// Contexto extra para programas
......@@ -213,7 +213,7 @@
rubro: '/rubro/',
organismo: '/organismo/',
ubigeo: '/ubicacion/',
programa: '/proyecto/'
programa: '/programa/'
};
const base = routes[item.tipo];
if (base) {
......
......@@ -807,7 +807,7 @@
} else if (isClassResult) {
url = `/clasificador/${hit.document.class_}/${hit.document.id}`;
} else {
url = `/proyecto/${generateCodigo(hit)}`;
url = `/programa/${generateCodigo(hit)}`;
}
window.location.href = url;
......@@ -921,7 +921,7 @@
function generateCodigo(hit) {
const m = parseMetadatos(hit.document.metadatos);
return `${m.entidad}-${m.programa}-${m.proyecto}-${m.actividad}-${hit.document.gestion}`;
return String(m.programa || '');
}
$: stats = filteredHits.length > 0 ? getStats(
......@@ -1485,7 +1485,7 @@
? `/rubro/${rubroCode}`
: isOrganismoClass
? `/organismo/${meta.organismo || ''}`
: (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/proyecto/${generateCodigo(hit)}`)}
: (isClassResult ? `/clasificador/${hit.document.class_}/${hit.document.id}` : `/programa/${generateCodigo(hit)}`)}
class="result-card"
class:result-card-selected={selectedResultIndex === i}
data-result-index={i}
......
const API_BASE = 'http://136.112.29.74/api/diario';
export async function GET({ url }) {
const tipo = url.searchParams.get('tipo') || 'status';
const endpoints = {
status: `${API_BASE}/status`,
timeseries: `${API_BASE}/timeseries`,
entidades: `${API_BASE}/entidades`,
clasificadores: `${API_BASE}/clasificadores`,
timeline: `${API_BASE}/timeline`
};
const apiUrl = endpoints[tipo];
if (!apiUrl) {
return new Response(JSON.stringify({ error: 'Invalid tipo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
const API_BASE = 'http://136.112.29.74/api/programa_proyecto';
export async function GET({ url }) {
const codigo = url.searchParams.get('codigo') || '';
const tipo = url.searchParams.get('tipo') || 'detalle'; // detalle, proyectos
if (!codigo) {
return new Response(JSON.stringify({ error: 'Missing codigo' }), {
status: 400, headers: { 'Content-Type': 'application/json' }
});
}
const apiUrl = tipo === 'proyectos'
? `${API_BASE}/${codigo}/proyectos`
: `${API_BASE}/${codigo}`;
try {
const response = await fetch(apiUrl);
if (!response.ok) {
return new Response(JSON.stringify({ error: `API error: ${response.status}` }), {
status: response.status, headers: { 'Content-Type': 'application/json' }
});
}
const data = await response.json();
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json' }
});
} catch {
return new Response(JSON.stringify({ error: 'Failed to fetch' }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}
......@@ -11,8 +11,11 @@
let searchableItems = $state([]);
let isMac = $state(false);
// Markdown content embedded directly
const MARKDOWN_CONTENT = `# Introducción
// Markdown content loaded from static file
let MARKDOWN_CONTENT = $state('');
// Placeholder for initial content
const INITIAL_CONTENT = `# Introducción
El presupuesto público se presenta en dos distribuciones de datos: una histórica y otra en tiempo real.
......@@ -1043,6 +1046,12 @@ Ejemplo: \`5637823.65\`.
isMac = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
try {
// Cargar markdown desde archivo estático
const res = await fetch('/docs.md');
if (res.ok) {
MARKDOWN_CONTENT = await res.text();
}
const result = processMarkdown(MARKDOWN_CONTENT);
content = result.html;
headings = buildHierarchy(result.headings);
......@@ -1327,12 +1336,6 @@ Ejemplo: \`5637823.65\`.
<header class="docs-header">
<div class="docs-header-content">
<div class="docs-header-top">
<a href="/" class="docs-back">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
<span>Inicio</span>
</a>
<!-- Mobile menu button -->
<button class="docs-menu-btn" onclick={toggleSidebar}>
......@@ -1501,7 +1504,7 @@ Ejemplo: \`5637823.65\`.
.docs-header-content {
max-width: 1000px;
margin: 0 auto;
padding: 1rem 1.5rem;
padding: 80px 1.5rem 1rem;
}
.docs-header-top {
......@@ -1568,7 +1571,7 @@ Ejemplo: \`5637823.65\`.
.docs-layout-wrapper {
max-width: 700px;
margin: 0 auto;
padding-top: 140px;
padding-top: 340px;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
......@@ -1603,7 +1606,7 @@ Ejemplo: \`5637823.65\`.
.docs-sidebar-content {
padding-right: 1rem;
padding-top: 2rem;
padding-top: 10rem;
}
}
......@@ -1824,7 +1827,7 @@ Ejemplo: \`5637823.65\`.
@media (max-width: 1099px) {
.docs-layout-wrapper {
padding-top: 130px;
padding-top: 330px;
padding-left: 1rem;
padding-right: 1rem;
}
......@@ -2049,6 +2052,7 @@ Ejemplo: \`5637823.65\`.
.docs-search-container {
position: relative;
margin-top: 1rem;
max-width: 320px;
}
.docs-search-input-wrapper {
......
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { tweened } from 'svelte/motion';
import { cubicOut } from 'svelte/easing';
import BarChart from '$lib/components/objeto/BarChart.svelte';
let codigo = $derived($page.params.codigo);
let mounted = $state(false);
let loading = $state(true);
let linkCopied = $state(false);
let programaData = $state([]);
let proyectos = $state([]);
let hoveredYear = $state(null);
let selectedTab = $state('objetos'); // objetos, finfun, acteco, entidades
// Serie temporal
let datosAnuales = $derived(programaData.filter(d => d.gestion >= 2016).sort((a, b) => a.gestion - b.gestion));
let nombre = $derived(datosAnuales[0]?.desc || `Programa ${codigo}`);
let nombrePadre = $derived(datosAnuales[0]?.desc_padre || '');
// Datos para el gráfico
let gastoPerCapita = $derived(
datosAnuales.map(d => ({
año: d.gestion,
monto: d.devengado,
perCapita: d.devengado
}))
);
let primerAño = $derived(datosAnuales.length > 0 ? datosAnuales[0].gestion : 2016);
let ultimoAño = $derived(datosAnuales.length > 0 ? datosAnuales[datosAnuales.length - 1].gestion : 2025);
// Año activo (hover o último)
let currentAnual = $derived(hoveredYear ? datosAnuales.find(d => d.gestion === hoveredYear) : datosAnuales[datosAnuales.length - 1]);
let displayMonto = $derived(currentAnual?.devengado ?? 0);
let displayEntidades = $derived(currentAnual?.n_entidad ?? 0);
let displayObjetos = $derived(currentAnual?.n_objeto ?? 0);
let displayPeriodo = $derived(hoveredYear ? hoveredYear : `${primerAño}-${ultimoAño}`);
// Distribución según tab seleccionada
let distribucion = $derived.by(() => {
if (!currentAnual) return [];
const items = currentAnual[selectedTab === 'entidades' ? 'entidad' : selectedTab === 'finfun' ? 'finfun' : selectedTab === 'acteco' ? 'acteco' : 'objeto'] || [];
return [...items].filter(d => d.devengado > 0).sort((a, b) => b.devengado - a.devengado);
});
let totalDistribucion = $derived(distribucion.reduce((s, d) => s + d.devengado, 0));
// Tweens
const tw = { duration: 300, easing: cubicOut };
const twMonto = tweened(0, tw);
$effect(() => { twMonto.set(typeof displayMonto === 'number' ? displayMonto : 0); });
function formatMonto(v) {
if (v >= 1e9) return `${(v / 1e9).toFixed(1)} mil mill.`;
if (v >= 1e6) return `${(v / 1e6).toFixed(1)} mill.`;
if (v >= 1e3) return `${(v / 1e3).toFixed(0)} mil`;
return v?.toLocaleString('es-BO') || '0';
}
async function loadData() {
loading = true;
try {
const [detRes, proRes] = await Promise.all([
fetch(`/api/programa?codigo=${codigo}&tipo=detalle`),
fetch(`/api/programa?codigo=${codigo}&tipo=proyectos`)
]);
const det = await detRes.json();
const pro = await proRes.json();
if (Array.isArray(det)) programaData = det;
if (Array.isArray(pro)) proyectos = pro.sort((a, b) => b.devengado - a.devengado);
} catch (err) {
console.error('[Programa] Error:', err);
}
loading = false;
}
onMount(async () => {
mounted = true;
await loadData();
});
const TABS = [
{ id: 'objetos', label: 'En qué se gasta' },
{ id: 'finfun', label: 'Para qué función' },
{ id: 'acteco', label: 'Sector económico' },
{ id: 'entidades', label: 'Quién ejecuta' }
];
</script>
<svelte:head>
<title>{nombre} | Programa</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&family=DM+Mono:wght@400;500&family=Instrument+Sans:wght@400;500;600&display=swap" rel="stylesheet" />
</svelte:head>
<div class="page" class:mounted>
<div class="layout">
<!-- Header -->
<header class="header">
<div class="header-top">
<div class="pills">
<span class="pill pill-tipo">Programa</span>
<span class="pill pill-codigo">{codigo}</span>
</div>
<div class="header-actions">
<button class="share-btn" onclick={() => {
navigator.clipboard.writeText(window.location.href);
linkCopied = true;
setTimeout(() => linkCopied = false, 2000);
}}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if linkCopied}
<path d="M20 6L9 17l-5-5"/>
{:else}
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
{/if}
</svg>
{linkCopied ? 'Copiado' : 'Compartir'}
</button>
<a href="/" class="back-link">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5M12 19l-7-7 7-7"/>
</svg>
Volver
</a>
</div>
</div>
<h1 class="title">{nombre}</h1>
{#if nombrePadre}
<p class="subtitle">{nombrePadre}</p>
{/if}
</header>
{#if loading}
<div class="loading">Cargando...</div>
{:else}
<!-- Main content -->
<div class="content">
<!-- Gráfico + KPIs -->
<div class="chart-card">
<div class="chart-header">
<span class="chart-label">Bs ejecutados <span class="chart-period">· {displayPeriodo}</span></span>
</div>
<div class="chart-wrapper">
<BarChart data={gastoPerCapita} bind:hoveredYear height={200} fill={true} />
</div>
<div class="kpis">
<div class="kpi">
<span class="kpi-value">{formatMonto(Math.round($twMonto))}</span>
<span class="kpi-label">{hoveredYear ? `ejecutados en ${hoveredYear}` : `ejecutados ${primerAño}-${ultimoAño}`}</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayEntidades}</span>
<span class="kpi-label">entidades</span>
</div>
<div class="kpi">
<span class="kpi-value">{displayObjetos}</span>
<span class="kpi-label">partidas de gasto</span>
</div>
</div>
</div>
<!-- Distribución -->
<div class="dist-card">
<div class="tabs">
{#each TABS as tab}
<button class="tab" class:active={selectedTab === tab.id} onclick={() => selectedTab = tab.id}>
{tab.label}
</button>
{/each}
</div>
<div class="dist-list">
{#each distribucion as item, i}
{@const pct = totalDistribucion > 0 ? (item.devengado / totalDistribucion * 100) : 0}
<div class="dist-item">
<div class="dist-bar" style="width: {pct}%"></div>
<span class="dist-rank">#{i + 1}</span>
<span class="dist-name">{item.desc || `Código ${item.codigo}`}</span>
<span class="dist-pct">{pct.toFixed(1)}%</span>
</div>
{/each}
{#if distribucion.length === 0}
<p class="dist-empty">Sin datos para este clasificador</p>
{/if}
</div>
</div>
<!-- Proyectos/Actividades -->
{#if proyectos.length > 0}
<div class="proyectos-card">
<h3 class="card-title">Actividades y proyectos ({proyectos.length})</h3>
<div class="proyectos-list">
{#each proyectos as proy, i}
{@const pct = proyectos[0]?.devengado > 0 ? (proy.devengado / proyectos[0].devengado * 100) : 0}
<div class="proy-item">
<span class="proy-rank">#{i + 1}</span>
<div class="proy-info">
<span class="proy-name">{proy.desc}</span>
<span class="proy-code">{proy.codigo}</span>
</div>
<span class="proy-monto">{formatMonto(proy.devengado)} Bs</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.page {
min-height: 100vh;
width: 100%;
overflow-x: hidden;
background: var(--theme-body);
color: var(--theme-titulo);
font-family: var(--font-sans), -apple-system, sans-serif;
opacity: 0;
transition: opacity 0.4s;
}
.page.mounted { opacity: 1; }
:global(html:not(.dark)) .page { background: #f5f5f7; }
.layout {
max-width: 1200px;
margin: 0 auto;
padding: 80px 2rem 2rem;
}
/* Header */
.header { margin-bottom: 1.5rem; }
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.375rem;
}
.pills { display: flex; gap: 0.375rem; }
.pill {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
border-radius: 4px;
letter-spacing: 0.03em;
}
.pill-tipo { background: rgba(201, 167, 81, 0.15); color: var(--theme-accent); }
.pill-codigo { background: var(--theme-borde); color: var(--theme-texto); }
.header-actions { display: flex; align-items: center; gap: 0.75rem; }
.share-btn, .back-link {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.6;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: none;
transition: opacity 0.15s;
}
.share-btn:hover, .back-link:hover { opacity: 1; }
.back-link { color: var(--theme-accent); opacity: 1; }
.title {
font-family: 'DM Serif Display', serif;
font-size: 2rem;
font-weight: 400;
margin: 0;
line-height: 1.15;
}
.subtitle {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.6;
margin: 0.25rem 0 0;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 50vh;
color: var(--theme-texto);
opacity: 0.5;
}
/* Content */
.content {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Chart card */
.chart-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem;
}
.chart-header { margin-bottom: 0.5rem; }
.chart-label {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.chart-period { opacity: 0.5; }
.chart-wrapper { height: 200px; }
.kpis {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--theme-borde);
}
.kpi { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 0.125rem; }
.kpi-value { font-family: 'Qanelas', var(--font-sans); font-size: 1rem; font-weight: 600; color: var(--theme-titulo); }
.kpi-label { font-size: 0.625rem; color: var(--theme-texto); opacity: 0.5; }
/* Distribution */
.dist-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem;
}
.tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 0.75rem;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
font-size: 0.6875rem;
font-weight: 500;
padding: 0.375rem 0.75rem;
border: 1px solid var(--theme-borde);
border-radius: 6px;
background: transparent;
color: var(--theme-texto);
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
}
.tab:hover { border-color: var(--theme-accent); }
.tab.active {
background: var(--theme-accent);
border-color: var(--theme-accent);
color: white;
font-weight: 600;
}
.dist-list { display: flex; flex-direction: column; gap: 0.25rem; }
.dist-item {
position: relative;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 6px;
overflow: hidden;
}
.dist-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: rgba(201, 167, 81, 0.1);
border-radius: 6px;
}
.dist-rank {
position: relative;
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.dist-name {
position: relative;
flex: 1;
font-size: 0.75rem;
color: var(--theme-titulo);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dist-pct {
position: relative;
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-texto);
opacity: 0.7;
}
.dist-empty {
font-size: 0.8125rem;
color: var(--theme-texto);
opacity: 0.5;
text-align: center;
padding: 1rem;
}
/* Proyectos */
.proyectos-card {
background: var(--theme-surface);
border-radius: 12px;
padding: 1rem;
}
.card-title {
font-size: 0.875rem;
font-weight: 700;
margin: 0 0 0.75rem;
}
.proyectos-list { display: flex; flex-direction: column; gap: 0.375rem; }
.proy-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0;
border-bottom: 1px dotted rgba(128, 128, 128, 0.2);
}
.proy-item:last-child { border-bottom: none; }
.proy-rank {
font-family: 'DM Mono', monospace;
font-size: 0.625rem;
color: var(--theme-accent);
font-weight: 600;
min-width: 1.5rem;
}
.proy-info { flex: 1; min-width: 0; }
.proy-name { font-size: 0.75rem; color: var(--theme-titulo); display: block; }
.proy-code {
font-family: 'DM Mono', monospace;
font-size: 0.5625rem;
color: var(--theme-texto);
opacity: 0.5;
}
.proy-monto {
font-family: 'DM Mono', monospace;
font-size: 0.6875rem;
color: var(--theme-titulo);
white-space: nowrap;
}
/* Responsive */
@media (max-width: 640px) {
.layout { padding: 70px 1rem 1rem; }
.title { font-size: 1.5rem; }
.kpis { grid-template-columns: repeat(3, 1fr); }
.header-top { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
}
</style>
......@@ -35,18 +35,24 @@
}
async function cargarMapa(codigo) {
console.time('[MAPA] total');
const cached = get(mapaCache);
if (cached) {
console.log('[MAPA] usando cache');
mapaData = cached;
} else if (!mapaData) {
console.time('[MAPA] fetch');
const res = await fetch('/mapa.json');
mapaData = await res.json();
console.timeEnd('[MAPA] fetch');
delete mapaData.bolivia.crs;
delete mapaData.departamentos.crs;
delete mapaData.municipios.crs;
console.time('[MAPA] fixWinding');
fixWinding(mapaData.bolivia);
fixWinding(mapaData.departamentos);
fixWinding(mapaData.municipios);
console.timeEnd('[MAPA] fixWinding');
mapaCache.set(mapaData);
}
......@@ -73,11 +79,14 @@
mapaMunicipioPath = pathGen(municipio) || '';
// Pre-calcular paths de todos los municipios para hover
console.time('[MAPA] allMunisPaths');
mapaAllMunisPaths = mapaData.municipios.features.map(f => ({
path: pathGen(f) || '',
codigo: f.properties.codigo,
isCurrent: f.properties.codigo === codigoNum
}));
console.timeEnd('[MAPA] allMunisPaths');
console.timeEnd('[MAPA] total');
}
// Datos desde el loader (Supabase)
......
<script>
import { onMount } from 'svelte';
import { parquetReadObjects, asyncBufferFromUrl } from 'hyparquet';
import * as Plot from '@observablehq/plot';
import * as d3 from 'd3';
......@@ -124,10 +123,9 @@
// Navegación interna entre secciones
let seccionActiva = $state(0);
const secciones = [
{ id: 'clasificadores', titulo: '¿En qué se gasta?' },
{ id: 'entidades', titulo: '¿Quién ejecuta más?' },
{ id: 'programas', titulo: '¿En qué programas?' },
{ id: 'evolucion', titulo: '¿Cómo evoluciona?' }
{ id: 'clasificadores', titulo: '¿En qué se gasta?' },
{ id: 'entidades', titulo: '¿Quién ejecuta más?' }
];
function irASeccion(index) {
......@@ -219,11 +217,11 @@
};
}
// Cargar archivo Parquet usando asyncBufferFromUrl
async function cargarParquet(url) {
const file = await asyncBufferFromUrl({ url });
const data = await parquetReadObjects({ file });
return data;
// Cargar datos desde API
async function cargarDiario(tipo) {
const res = await fetch(`/api/diario?tipo=${tipo}`);
if (!res.ok) throw new Error(`Error ${res.status}`);
return await res.json();
}
onMount(async () => {
......@@ -251,12 +249,12 @@
themeObserver.observe(document.documentElement, { attributes: true });
try {
// Cargar todos los parquets en paralelo
// Cargar todos los datos en paralelo desde API
const [tsData, entData, clasData, tlData] = await Promise.all([
cargarParquet('/timeserie@4.parquet'),
cargarParquet('/ejecucion_entidades@1.parquet'),
cargarParquet('/ejecucion_clasificadores@3.parquet'),
cargarParquet('/timeline@1.parquet')
cargarDiario('timeseries'),
cargarDiario('entidades'),
cargarDiario('clasificadores'),
cargarDiario('timeline')
]);
// Los datos ya vienen como objetos
......@@ -282,12 +280,12 @@
// Filtrar datos por transferencias y días
let timeserieFiltered = $derived(
timeserie.filter(d => d.transferencias === incluirTransferencias)
timeserie.filter(d => d.transferencias === undefined || d.transferencias === incluirTransferencias)
);
// Datos por clasificador para las barras apiladas (sin agrupar, cada hijo es un segmento)
let datosPorClasificador = $derived.by(() => {
const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados;
const dias = diasSeleccionados === 'total' ? 0 : parseInt(diasSeleccionados);
const result = {};
clasificadores.forEach(({ key }) => {
......@@ -447,7 +445,7 @@
// Todas las entidades filtradas (sin límite)
let entidadesTodas = $derived.by(() => {
const dias = diasSeleccionados === 'total' ? 'total' : diasSeleccionados;
const dias = diasSeleccionados === 'total' ? 0 : parseInt(diasSeleccionados);
let filtered = ejecucionEntidades.filter(d =>
d.transferencias === incluirTransferencias &&
String(d.dias) === String(dias) &&
......@@ -975,7 +973,71 @@
<p>Error: {error}</p>
</div>
{:else}
<!-- SECCIÓN 1: Clasificadores -->
<!-- SECCIÓN 1: Programas (timeline) -->
<section class="vista-seccion" id="programas">
<div class="vista-contenido">
<div class="contenido-centrado">
<header class="seccion-header seccion-header-centrado">
<h1 class="seccion-titulo-grande">¿En qué programas?</h1>
<p class="seccion-descripcion">Detalle de programas y actividades ejecutadas en la última semana</p>
</header>
<div class="timeline-search">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
<input
type="text"
placeholder="Buscar entidad..."
bind:value={busquedaTimeline}
class="timeline-search-input"
/>
</div>
<div class="timeline-cards">
{#each timelineByEntity as entidad}
<div class="timeline-card">
<div class="timeline-card-header">
<div class="timeline-card-entity">
<span class="timeline-card-area">{entidad.entidad_desc_area}</span>
<span class="timeline-card-nombre">{entidad.entidad_desc_entidad}</span>
</div>
<div class="timeline-card-total">
<span class="timeline-card-total-label">Ejecutado</span>
<span class="timeline-card-total-monto">Bs. {formatearNumeroCompleto(entidad.total)}</span>
</div>
</div>
<div class="timeline-card-body">
{#each cardsExpandidas[entidad.entidad] ? entidad.programas : entidad.programas.slice(0, 8) as prog}
<div class="timeline-prog-row">
<div class="timeline-prog-info">
<span class="timeline-prog-programa">{prog.programa}</span>
<span class="timeline-prog-actividad">{prog.actividad}</span>
</div>
<span class="timeline-prog-monto">Bs. {formatearNumeroCompleto(prog.devengado)}</span>
</div>
{/each}
{#if entidad.programas.length > 8}
<button
class="timeline-ver-mas"
onclick={() => {
cardsExpandidas[entidad.entidad] = !cardsExpandidas[entidad.entidad];
cardsExpandidas = { ...cardsExpandidas };
}}
>
{cardsExpandidas[entidad.entidad] ? 'Ver menos' : `Ver ${entidad.programas.length - 8} más...`}
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
</section>
<!-- SECCIÓN 2: Clasificadores -->
<section class="vista-seccion" id="clasificadores">
<div class="vista-contenido">
<div class="contenido-centrado">
......@@ -1143,73 +1205,9 @@
</div>
</section>
<!-- SECCIÓN 3: Programas -->
<section class="vista-seccion" id="programas">
<div class="vista-contenido">
<div class="contenido-centrado">
<!-- Título de sección -->
<header class="seccion-header seccion-header-centrado">
<h1 class="seccion-titulo-grande">¿En qué programas?</h1>
<p class="seccion-descripcion">Detalle de programas y actividades ejecutadas en la última semana</p>
</header>
<div class="timeline-search">
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
<input
type="text"
placeholder="Buscar entidad..."
bind:value={busquedaTimeline}
class="timeline-search-input"
/>
</div>
<div class="timeline-cards">
{#each timelineByEntity as entidad}
<div class="timeline-card">
<div class="timeline-card-header">
<div class="timeline-card-entity">
<span class="timeline-card-area">{entidad.entidad_desc_area}</span>
<span class="timeline-card-nombre">{entidad.entidad_desc_entidad}</span>
</div>
<div class="timeline-card-total">
<span class="timeline-card-total-label">Ejecutado</span>
<span class="timeline-card-total-monto">Bs. {formatearNumeroCompleto(entidad.total)}</span>
</div>
</div>
<div class="timeline-card-body">
{#each cardsExpandidas[entidad.entidad] ? entidad.programas : entidad.programas.slice(0, 8) as prog}
<div class="timeline-prog-row">
<div class="timeline-prog-info">
<span class="timeline-prog-programa">{prog.programa}</span>
<span class="timeline-prog-actividad">{prog.actividad}</span>
</div>
<span class="timeline-prog-monto">Bs. {formatearNumeroCompleto(prog.devengado)}</span>
</div>
{/each}
{#if entidad.programas.length > 8}
<button
class="timeline-ver-mas"
onclick={() => {
cardsExpandidas[entidad.entidad] = !cardsExpandidas[entidad.entidad];
cardsExpandidas = { ...cardsExpandidas };
}}
>
{cardsExpandidas[entidad.entidad] ? 'Ver menos' : `Ver ${entidad.programas.length - 8} más...`}
</button>
{/if}
</div>
</div>
{/each}
</div>
</div>
</div>
</section>
<!-- SECCIÓN 4: Evolución temporal -->
<section class="vista-seccion" id="evolucion">
<section class="vista-seccion" id="evolucion" style="display: none;">
<div class="vista-contenido">
<div class="contenido-centrado">
<!-- Título de sección -->
......
This diff could not be displayed because it is too large.