@bitam/ebavel-apps 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,25 @@
1
+ declare const RUNNER_RESOURCE_URI: "ui://ebavel/dashboard";
2
+ /** Tipo MIME de una MCP App (`RESOURCE_MIME_TYPE` de ext-apps). */
3
+ declare const RUNNER_MIME_TYPE: "text/html;profile=mcp-app";
4
+ /** Forma de `_meta.ui.csp` del estándar MCP Apps (`McpUiResourceCsp`). */
5
+ interface RunnerCsp {
6
+ connectDomains: string[];
7
+ resourceDomains: string[];
8
+ frameDomains: string[];
9
+ baseUriDomains: string[];
10
+ }
11
+ interface RunnerResource {
12
+ uri: typeof RUNNER_RESOURCE_URI;
13
+ mimeType: typeof RUNNER_MIME_TYPE;
14
+ html: string;
15
+ csp: RunnerCsp;
16
+ }
17
+ /** CSP del recurso: los tres CDNs para scripts, estilos, fuentes e imágenes; ninguna conexión. */
18
+ declare const RUNNER_RESOURCE_CSP: Readonly<RunnerCsp>;
19
+ /** Contenido del HTML (se lee una sola vez). Junto a `dist/resource.js`; desde el código fuente, de `dist/`. */
20
+ declare function runnerHtml(): string;
21
+ /** Recurso listo para `appResources` de `mcp3` (o cualquier servidor MCP Apps). */
22
+ declare function runnerResource(): RunnerResource;
23
+
24
+ export { RUNNER_MIME_TYPE, RUNNER_RESOURCE_CSP, RUNNER_RESOURCE_URI, runnerHtml, runnerResource };
25
+ export type { RunnerCsp, RunnerResource };
@@ -0,0 +1,41 @@
1
+ import {
2
+ CDN_ORIGINS
3
+ } from "./chunk-FFVQA4KG.js";
4
+
5
+ // src/resource.ts
6
+ import { readFileSync } from "fs";
7
+ var RUNNER_RESOURCE_URI = "ui://ebavel/dashboard";
8
+ var RUNNER_MIME_TYPE = "text/html;profile=mcp-app";
9
+ var RUNNER_RESOURCE_CSP = Object.freeze({
10
+ connectDomains: [],
11
+ resourceDomains: [...CDN_ORIGINS],
12
+ frameDomains: [],
13
+ baseUriDomains: []
14
+ });
15
+ var cached;
16
+ function runnerHtml() {
17
+ if (cached !== void 0) return cached;
18
+ for (const rel of ["./ebavel-runner.html", "../dist/ebavel-runner.html"]) {
19
+ try {
20
+ cached = readFileSync(new URL(rel, import.meta.url), "utf8");
21
+ return cached;
22
+ } catch {
23
+ }
24
+ }
25
+ throw new Error("No se encontr\xF3 ebavel-runner.html: ejecuta `npm run build -w @bitam/ebavel-apps`.");
26
+ }
27
+ function runnerResource() {
28
+ return {
29
+ uri: RUNNER_RESOURCE_URI,
30
+ mimeType: RUNNER_MIME_TYPE,
31
+ html: runnerHtml(),
32
+ csp: { ...RUNNER_RESOURCE_CSP, connectDomains: [], resourceDomains: [...RUNNER_RESOURCE_CSP.resourceDomains], frameDomains: [], baseUriDomains: [] }
33
+ };
34
+ }
35
+ export {
36
+ RUNNER_MIME_TYPE,
37
+ RUNNER_RESOURCE_CSP,
38
+ RUNNER_RESOURCE_URI,
39
+ runnerHtml,
40
+ runnerResource
41
+ };
@@ -0,0 +1,382 @@
1
+ <!doctype html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Solicitudes de equipos de cómputo</title>
7
+ <!-- El SDK: en Claude lo carga el artifact desde el CDN (versión fija); en MCP Apps el runner ya lo inyectó y esta copia no se reinicializa. -->
8
+ <script src="https://cdn.jsdelivr.net/npm/@bitam/ebavel-apps@0.1.0/dist/ebavel.js"></script>
9
+ </head>
10
+ <body>
11
+ <!-- Fuente exacta: lo que se exporta es el contenido de esta plantilla (el SDK nunca envía el documento de Claude, que trae credenciales).
12
+ El arranque de abajo la monta (los scripts de una plantilla no corren solos). -->
13
+ <template id="ebavel-source">
14
+ <style>
15
+ :root {
16
+ --bg: var(--color-background-primary, #ffffff); --card: var(--color-background-secondary, #f5f6f8);
17
+ --fg: var(--color-text-primary, #1b1d21); --muted: var(--color-text-secondary, #5a606b);
18
+ --line: var(--color-border-primary, #d5d8de); --accent: #1d5fd1; --accent2: #0f8a6d; --accent3: #b4540a; --accent4: #7b3fb5;
19
+ --danger: #b3261e; color-scheme: light;
20
+ }
21
+ :root[data-theme="dark"] {
22
+ --bg: var(--color-background-primary, #1e1f22); --card: var(--color-background-secondary, #2a2c30);
23
+ --fg: var(--color-text-primary, #eceef2); --muted: var(--color-text-secondary, #b4b9c3);
24
+ --line: var(--color-border-primary, #474b52); --accent: #7fb0ff; --accent2: #4cc9a6; --accent3: #f0a35e; --accent4: #c39bef;
25
+ --danger: #ff8a80; color-scheme: dark;
26
+ }
27
+ * { box-sizing: border-box; }
28
+ body { margin: 0; padding: 16px; background: var(--bg); color: var(--fg); font: 14px/1.45 var(--font-sans, system-ui, sans-serif); }
29
+ h1 { font-size: 20px; margin: 0 0 4px; } h2 { font-size: 15px; margin: 0 0 2px; }
30
+ .top { display: flex; gap: 12px; align-items: flex-start; justify-content: space-between; flex-wrap: wrap; }
31
+ .note { color: var(--muted); font-size: 12px; margin: 0 0 8px; }
32
+ .filters { display: flex; flex-wrap: wrap; gap: 8px 12px; align-items: end; margin: 12px 0; }
33
+ .filters label { display: grid; gap: 2px; font-size: 12px; color: var(--muted); }
34
+ select, button { font: inherit; color: var(--fg); background: var(--card); border: 1px solid var(--line); border-radius: 6px; padding: 5px 8px; }
35
+ button { cursor: pointer; } button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
36
+ .primary { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 600; }
37
+ .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; }
38
+ .kpi, .card { background: var(--card); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
39
+ .kpi .lab { font-size: 12px; color: var(--muted); } .kpi .num { font-size: 24px; font-weight: 650; min-height: 32px; } .kpi .sub { font-size: 12px; color: var(--muted); }
40
+ .card { margin-top: 12px; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; } .grid .card { margin-top: 0; }
41
+ .rows { display: grid; gap: 4px; }
42
+ .row { display: grid; grid-template-columns: minmax(90px, 38%) 1fr auto; gap: 8px; align-items: center; width: 100%; text-align: left; padding: 3px 6px; border: 1px solid transparent; background: none; }
43
+ .row.click:hover { background: var(--bg); } .row.sel { border-color: var(--accent); } .row.dim { opacity: .45; }
44
+ .track { height: 10px; background: var(--bg); border-radius: 5px; overflow: hidden; } .fill { display: block; height: 100%; border-radius: 5px; }
45
+ .row .v { font-variant-numeric: tabular-nums; }
46
+ table { width: 100%; border-collapse: collapse; font-size: 13px; } th, td { padding: 5px 8px; border-bottom: 1px solid var(--line); text-align: right; white-space: nowrap; }
47
+ th:first-child, td:first-child { text-align: left; } th { cursor: pointer; color: var(--muted); font-weight: 600; } .tablebox { overflow-x: auto; }
48
+ .skeleton { min-height: 32px; border-radius: 6px; background: var(--bg); animation: pulse 1.2s ease-in-out infinite; }
49
+ @keyframes pulse { 50% { opacity: .5; } } @media (prefers-reduced-motion: reduce) { .skeleton { animation: none; } }
50
+ .fail { color: var(--danger); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
51
+ .banner { margin: 8px 0; padding: 8px 12px; border: 1px solid var(--danger); border-radius: 8px; color: var(--danger); }
52
+ svg text { fill: var(--muted); font-size: 11px; } .bar { fill: var(--accent); } .bar:hover { opacity: .8; } .grid-line { stroke: var(--line); }
53
+ [hidden] { display: none !important; }
54
+ </style>
55
+ <header class="top">
56
+ <div>
57
+ <h1>Solicitudes de equipos de cómputo</h1>
58
+ <p class="note" id="take" aria-live="polite"></p>
59
+ </div>
60
+ <div>
61
+ <button class="primary" id="export" type="button" hidden>Exportar a la plataforma</button>
62
+ <p class="note" id="export-msg" role="status"></p>
63
+ </div>
64
+ </header>
65
+ <div class="banner" id="banner" role="alert" hidden></div>
66
+
67
+ <section class="filters" aria-label="Filtros">
68
+ <label>Periodo<select id="f-periodo"></select></label>
69
+ <label>Departamento<select id="f-depto"></select></label>
70
+ <label>Estatus<select id="f-estatus"></select></label>
71
+ <label>Tipo de solicitud<select id="f-tipo"></select></label>
72
+ <button id="reset" type="button">Limpiar filtros</button>
73
+ </section>
74
+
75
+ <section class="kpis" aria-label="Indicadores">
76
+ <div class="kpi"><span class="lab">Solicitudes</span><div class="num" id="k-tot"></div><span class="sub" id="k-tot-s"></span></div>
77
+ <div class="kpi"><span class="lab">Finalizadas</span><div class="num" id="k-fin"></div><span class="sub" id="k-fin-s"></span></div>
78
+ <div class="kpi"><span class="lab">En trámite</span><div class="num" id="k-tra"></div><span class="sub">jefe, administración o abiertas</span></div>
79
+ <div class="kpi"><span class="lab">Rechazadas o canceladas</span><div class="num" id="k-rej"></div><span class="sub" id="k-rej-s"></span></div>
80
+ <div class="kpi"><span class="lab">Equipo nuevo</span><div class="num" id="k-nue"></div><span class="sub" id="k-nue-s"></span></div>
81
+ <div class="kpi"><span class="lab">Monto registrado</span><div class="num" id="k-mon"></div><span class="sub" id="k-mon-s"></span></div>
82
+ </section>
83
+
84
+ <section class="card">
85
+ <h2>Solicitudes por mes</h2>
86
+ <p class="note" id="meses-note">Todo el histórico, sin filtros.</p>
87
+ <div id="c-meses"></div>
88
+ </section>
89
+
90
+ <section class="grid">
91
+ <div class="card"><h2>Por departamento</h2><p class="note">Clic en una barra para filtrar.</p><div id="c-depto"></div></div>
92
+ <div class="card"><h2>Por estatus</h2><p class="note">Clic en una barra para filtrar.</p><div id="c-estatus"></div></div>
93
+ <div class="card"><h2>Nuevo o reemplazo</h2><p class="note">Clic en una barra para filtrar.</p><div id="c-tipo"></div></div>
94
+ <div class="card"><h2>Artículos más pedidos</h2><p class="note">Una solicitud puede incluir varios artículos. Sin filtros.</p><div id="c-art"></div></div>
95
+ </section>
96
+
97
+ <section class="card">
98
+ <h2>Detalle por departamento</h2>
99
+ <p class="note">Respeta los filtros. El monto es el histórico registrado por departamento.</p>
100
+ <div class="tablebox" id="c-tabla"></div>
101
+ </section>
102
+
103
+ <script>
104
+ (() => {
105
+ const $ = (id) => document.getElementById(id);
106
+ const ENTITY = 'FRM_2FA60A35';
107
+ const COUNT = { n: { fn: 'count' } };
108
+ const fmtN = new Intl.NumberFormat('es-MX');
109
+ const fmtM = new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', maximumFractionDigits: 0 });
110
+ const pct = new Intl.NumberFormat('es-MX', { style: 'percent', maximumFractionDigits: 0 });
111
+ const MES = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic'];
112
+ const pad = (n) => String(n).padStart(2, '0');
113
+ const clean = (s) => String(s).replace(/\s+/g, ' ').trim();
114
+ const cat = (e) => (e === 'Finalizada' ? 'f' : /^Rechazada|^Cancelada/.test(e) ? 'r' : 't');
115
+ const mesLabel = (m) => MES[+m.slice(5, 7) - 1] + ' ' + m.slice(2, 4);
116
+
117
+ // --- Datos: cada dataset es UNA llamada a ebavel.query (≤ 10 queries). Se agrega en la query, no en el navegador. ---
118
+ const ym = (i) => [2023 + Math.floor((11 + i) / 12), ((11 + i) % 12) + 1]; // i = 0 → dic 2023
119
+ const monthQuery = (i) => {
120
+ const [y, m] = ym(i), [y2, m2] = ym(i + 1);
121
+ return [`m${y}_${pad(m)}`, { entity: ENTITY, aggregations: COUNT, limit: 1, where: { 'Fecha de solicitud': { gte: `${y}-${pad(m)}-01`, lt: `${y2}-${pad(m2)}-01` } } }];
122
+ };
123
+ const ARTICULOS = ['Audífonos', 'Laptop', 'Monitor', 'Mouse', 'Celular de trabajo', 'Computadora', 'Teclado', 'Soporte para Laptop', 'Lentes Anti Blue Light', 'Regulador de Voltaje', 'Unidad de Almacenamiento', 'Hub USB', 'Memoria RAM', 'Mouse Pad', 'Cargador de laptop', 'Adaptador ethernet a usb', 'Soporte para Monitor', 'Cable HDMI', 'Mochila para Laptop', 'Unidad de Almacenamiento Externa', 'No Break (UPS)', 'Teléfono Inalambrico', 'Licencia de Windows', 'Webcam', 'Antivirus', 'Bateria de Reemplazo para Laptop', 'Fuente de Poder', 'Tarjeta de Red', 'Adaptador VGA-HDMI', 'Cable de Red'];
124
+ const chunk = (list, n) => Array.from({ length: Math.ceil(list.length / n) }, (_, i) => list.slice(i * n, i * n + n));
125
+ const queries = (entries) => ({ queries: Object.fromEntries(entries) });
126
+ const label = (cell, none) => (cell && cell.label ? cell.label : none);
127
+ const GROUP = ['Departamento', 'Estatus de Solicitud', 'Tipo de Solicitud'];
128
+ const PERIODOS = { p2324: ['2023-2024', '2023-01-01', '2025-01-01'], p2025: ['2025', '2025-01-01', '2026-01-01'], p2026: ['2026', '2026-01-01', '2027-01-01'] };
129
+
130
+ const DATASETS = {
131
+ cruce: {
132
+ args: queries(Object.entries(PERIODOS).map(([alias, [, gte, lt]]) => [alias, { entity: ENTITY, aggregations: COUNT, groupBy: GROUP, limit: 50, where: { 'Fecha de solicitud': { gte, lt } } }])),
133
+ parse: (res) => Object.entries(PERIODOS).flatMap(([alias, [periodo]]) => {
134
+ const r = res[alias];
135
+ if (r.hasMore) throw new Error('El resultado tiene más de 50 grupos'); // un agregado incompleto no se muestra como total
136
+ // Las filas se indexan por el UUID del campo (`columns[].key`): se busca por el nombre de la columna.
137
+ const key = (name) => r.columns.find((c) => c.name === name).key;
138
+ const [dep, est, tipo] = GROUP.map(key);
139
+ return r.rows.map((x) => ({ periodo, departamento: label(x[dep], 'Sin departamento'), estatus: label(x[est], 'Sin estatus'), tipo: label(x[tipo], 'Sin tipo'), n: Number(x.n) }));
140
+ }),
141
+ },
142
+ monto: {
143
+ args: queries([['dep', { entity: ENTITY, aggregations: { monto: { field: 'Monto', fn: 'sum' }, n: { fn: 'count' } }, groupBy: ['Departamento'], limit: 50, orderBy: [{ field: 'n', direction: 'desc' }] }]]),
144
+ parse: (res) => {
145
+ const r = res.dep;
146
+ if (r.hasMore) throw new Error('El resultado tiene más de 50 grupos');
147
+ const dep = r.columns.find((c) => c.name === 'Departamento').key;
148
+ return r.rows.map((x) => ({ departamento: label(x[dep], 'Sin departamento'), solicitudes: Number(x.n), monto: Number(x.monto || 0) }));
149
+ },
150
+ },
151
+ };
152
+ chunk(Array.from({ length: 35 }, (_, i) => i), 10).forEach((idx, k) => {
153
+ DATASETS['meses_' + (k + 1)] = {
154
+ args: queries(idx.map(monthQuery)),
155
+ parse: (res) => Object.entries(res).map(([alias, r]) => ({ mes: alias.slice(1).replace('_', '-'), n: Number(r.rows[0].n) })),
156
+ };
157
+ });
158
+ chunk(ARTICULOS, 10).forEach((names, k) => {
159
+ DATASETS['art_' + (k + 1)] = {
160
+ args: queries(names.map((a, i) => ['a' + i, { entity: ENTITY, aggregations: COUNT, limit: 1, where: { 'Artículo(s)': { eq: a } } }])),
161
+ parse: (res) => names.map((a, i) => ({ articulo: a, n: Number(res['a' + i].rows[0].n) })),
162
+ };
163
+ });
164
+ const MESES = ['meses_1', 'meses_2', 'meses_3', 'meses_4'];
165
+ const ARTS = ['art_1', 'art_2', 'art_3'];
166
+
167
+ const state = {}; // id → { status: 'loading' | 'ok' | 'error', data, error }
168
+ async function load(id) {
169
+ const ds = DATASETS[id];
170
+ state[id] = { status: 'loading' };
171
+ draw();
172
+ try {
173
+ const payload = await ebavel.query(ds.args);
174
+ if (!payload || payload.success === false) throw new Error((payload && payload.errors && payload.errors[0] && payload.errors[0].message) || 'Sin respuesta de eBavel');
175
+ for (const [alias, r] of Object.entries(payload.results)) if (!r.ok) throw new Error(`${alias}: ${r.error.message}`); // errores por query
176
+ state[id] = { status: 'ok', data: ds.parse(payload.results) };
177
+ } catch (e) {
178
+ state[id] = { status: 'error', error: e };
179
+ }
180
+ draw();
181
+ }
182
+
183
+ // --- Interfaz: filtros del lado del cliente (los datos de `cruce` ya vienen agregados por periodo × depto × estatus × tipo) ---
184
+ const params = { periodo: '', depto: '', estatus: '', tipo: '' };
185
+ const FILTERS = [['periodo', 'f-periodo', 'Todos los periodos'], ['depto', 'f-depto', 'Todos los departamentos'], ['estatus', 'f-estatus', 'Todos los estatus'], ['tipo', 'f-tipo', 'Todos los tipos']];
186
+ const sort = { key: 'total', dir: -1 };
187
+ const text = (tag, cls, txt) => { const el = document.createElement(tag); if (cls) el.className = cls; if (txt !== undefined) el.textContent = txt; return el; };
188
+
189
+ // Un cuadro muestra su estado hasta que todas sus fuentes cargan; si una falla, un mensaje con «Reintentar».
190
+ function gate(el, ids) {
191
+ const st = ids.map((i) => state[i] || { status: 'loading' });
192
+ if (st.every((s) => s.status === 'ok')) return true;
193
+ const failed = ids.filter((i, k) => st[k].status === 'error');
194
+ el.replaceChildren();
195
+ if (failed.length) {
196
+ const d = text('div', 'fail', 'No se pudo cargar: ' + (st[ids.indexOf(failed[0])].error.message || 'error'));
197
+ const b = text('button', '', 'Reintentar');
198
+ b.type = 'button';
199
+ b.addEventListener('click', () => failed.forEach(load));
200
+ d.append(b);
201
+ el.append(d);
202
+ } else {
203
+ el.append(text('div', 'skeleton'));
204
+ }
205
+ return false;
206
+ }
207
+ const setText = (id, txt, ids) => { const el = $(id); if (gate(el, ids)) el.replaceChildren(document.createTextNode(txt)); };
208
+ const sum = (rows, f) => rows.reduce((a, r) => a + f(r), 0);
209
+
210
+ function hbars(el, items, selected, onClick, color) {
211
+ const max = Math.max(1, ...items.map((d) => d.value));
212
+ const wrap = text('div', 'rows');
213
+ for (const d of items) {
214
+ const row = document.createElement(onClick ? 'button' : 'div');
215
+ row.className = 'row' + (onClick ? ' click' : '') + (selected === d.key ? ' sel' : '') + (selected && selected !== d.key ? ' dim' : '');
216
+ if (onClick) { row.type = 'button'; row.setAttribute('aria-pressed', String(selected === d.key)); row.addEventListener('click', () => onClick(d.key)); }
217
+ const fill = text('span', 'fill');
218
+ fill.style.width = (100 * d.value) / max + '%';
219
+ fill.style.background = color;
220
+ const track = text('span', 'track');
221
+ track.append(fill);
222
+ row.append(text('span', '', d.label), track, text('span', 'v', fmtN.format(d.value)));
223
+ wrap.append(row);
224
+ }
225
+ if (!items.length) wrap.append(text('p', 'note', 'Sin solicitudes con estos filtros.'));
226
+ el.replaceChildren(wrap);
227
+ }
228
+
229
+ function monthsChart(el, data) {
230
+ const NS = 'http://www.w3.org/2000/svg';
231
+ const w = 700, h = 230, m = { t: 12, r: 8, b: 28, l: 34 };
232
+ const max = Math.max(1, ...data.map((d) => d.n));
233
+ const step = Math.pow(10, Math.floor(Math.log10(max))), top = Math.ceil(max / step) * step;
234
+ const x0 = (i) => m.l + ((w - m.l - m.r) * i) / data.length, bw = ((w - m.l - m.r) / data.length) * 0.75;
235
+ const y = (v) => h - m.b - ((h - m.t - m.b) * v) / top;
236
+ const svg = document.createElementNS(NS, 'svg');
237
+ svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
238
+ svg.setAttribute('width', '100%');
239
+ svg.setAttribute('role', 'img');
240
+ svg.setAttribute('aria-label', 'Solicitudes por mes');
241
+ const add = (tag, attrs, txt) => { const n = document.createElementNS(NS, tag); for (const k in attrs) n.setAttribute(k, attrs[k]); if (txt !== undefined) n.textContent = txt; svg.append(n); return n; };
242
+ for (let t = 0; t <= 4; t++) {
243
+ const v = (top * t) / 4;
244
+ add('line', { class: 'grid-line', x1: m.l, x2: w - m.r, y1: y(v), y2: y(v) });
245
+ add('text', { x: m.l - 6, y: y(v) + 3, 'text-anchor': 'end' }, String(Math.round(v)));
246
+ }
247
+ const every = Math.ceil(data.length / 12);
248
+ data.forEach((d, i) => {
249
+ add('rect', { class: 'bar', x: x0(i), width: bw, y: y(d.n), height: y(0) - y(d.n), rx: 2 }).append(Object.assign(document.createElementNS(NS, 'title'), { textContent: `${mesLabel(d.mes)}: ${fmtN.format(d.n)} solicitudes` }));
250
+ if (i % every === 0) add('text', { x: x0(i) + bw / 2, y: h - 8, 'text-anchor': 'middle' }, mesLabel(d.mes));
251
+ });
252
+ el.replaceChildren(svg);
253
+ }
254
+
255
+ function draw() {
256
+ const cs = state.cruce || { status: 'loading' };
257
+ const rows = cs.status === 'ok' ? cs.data : [];
258
+ const flt = (skip) => rows.filter((r) => (skip === 'periodo' || !params.periodo || r.periodo === params.periodo) && (skip === 'depto' || !params.depto || r.departamento === params.depto) && (skip === 'estatus' || !params.estatus || r.estatus === params.estatus) && (skip === 'tipo' || !params.tipo || r.tipo === params.tipo));
259
+ const group = (rs, key) => [...rs.reduce((m, r) => m.set(r[key], (m.get(r[key]) || 0) + r.n), new Map())].map(([k, v]) => ({ key: k, label: k, value: v })).sort((a, b) => b.value - a.value);
260
+
261
+ // Banner global: sin conexión o sin permiso, un solo mensaje en lugar de un error por cuadro.
262
+ const blocked = Object.values(state).find((s) => s.status === 'error' && s.error && (s.error.code === 'NOT_CONNECTED' || s.error.code === 'DECLINED'));
263
+ $('banner').hidden = !blocked;
264
+ if (blocked) $('banner').textContent = blocked.error.message;
265
+
266
+ if (cs.status === 'ok') { // Filtros: las opciones salen de los datos.
267
+ const opts = { periodo: ['2023-2024', '2025', '2026'], depto: group(rows, 'departamento').map((d) => d.key).sort(), estatus: group(rows, 'estatus').map((d) => d.key).sort(), tipo: group(rows, 'tipo').map((d) => d.key).sort() };
268
+ for (const [name, id, all] of FILTERS) {
269
+ const s = $(id);
270
+ if (s === document.activeElement) continue;
271
+ s.replaceChildren(...['', ...opts[name]].map((v) => Object.assign(document.createElement('option'), { value: v, textContent: v || all })));
272
+ s.value = params[name];
273
+ }
274
+ }
275
+ const active = FILTERS.some(([name]) => params[name]);
276
+
277
+ const base = flt();
278
+ const tot = sum(base, (r) => r.n);
279
+ const by = (c) => sum(base.filter((r) => cat(r.estatus) === c), (r) => r.n);
280
+ const f = by('f'), t = by('t'), rj = by('r');
281
+ const nuevo = sum(base.filter((r) => r.tipo === 'Nuevo'), (r) => r.n);
282
+ const share = (x) => (tot ? pct.format(x / tot) + ' del total' : '');
283
+ setText('k-tot', fmtN.format(tot), ['cruce']);
284
+ setText('k-tot-s', active ? 'con los filtros elegidos' : 'desde dic 2023', ['cruce']);
285
+ setText('k-fin', fmtN.format(f), ['cruce']);
286
+ setText('k-fin-s', share(f), ['cruce']);
287
+ setText('k-tra', fmtN.format(t), ['cruce']);
288
+ setText('k-rej', fmtN.format(rj), ['cruce']);
289
+ setText('k-rej-s', share(rj), ['cruce']);
290
+ setText('k-nue', fmtN.format(nuevo), ['cruce']);
291
+ setText('k-nue-s', tot ? pct.format(nuevo / tot) + ' son equipo nuevo' : '', ['cruce']);
292
+
293
+ const ms = state.monto || { status: 'loading' };
294
+ const mrows = ms.status === 'ok' ? ms.data.filter((r) => !params.depto || r.departamento === params.depto) : [];
295
+ setText('k-mon', fmtM.format(sum(mrows, (r) => r.monto)), ['monto']);
296
+ setText('k-mon-s', params.depto ? 'de ' + params.depto : 'histórico, todos los deptos', ['monto']);
297
+
298
+ if (cs.status === 'ok' && tot > 0) {
299
+ const top = group(base, 'departamento')[0];
300
+ $('take').textContent = `${fmtN.format(tot)} solicitudes${active ? ' con los filtros elegidos' : ''}: ${pct.format(f / tot)} finalizadas y ${fmtN.format(t)} en trámite. ${top.label} es el departamento que más pide (${fmtN.format(top.value)}).`;
301
+ } else {
302
+ $('take').textContent = cs.status === 'ok' ? 'No hay solicitudes con estos filtros.' : '';
303
+ }
304
+
305
+ // Cada gráfica ignora su propio filtro para poder cambiar la selección (filtro cruzado).
306
+ const toggle = (name) => (key) => { params[name] = params[name] === key ? '' : key; draw(); };
307
+ if (gate($('c-depto'), ['cruce'])) hbars($('c-depto'), group(flt('depto'), 'departamento'), params.depto, toggle('depto'), 'var(--accent)');
308
+ if (gate($('c-estatus'), ['cruce'])) hbars($('c-estatus'), group(flt('estatus'), 'estatus'), params.estatus, toggle('estatus'), 'var(--accent2)');
309
+ if (gate($('c-tipo'), ['cruce'])) hbars($('c-tipo'), group(flt('tipo'), 'tipo'), params.tipo, toggle('tipo'), 'var(--accent3)');
310
+
311
+ if (gate($('c-art'), ARTS)) {
312
+ const items = ARTS.flatMap((i) => state[i].data).map((d) => ({ key: clean(d.articulo), label: clean(d.articulo), value: d.n })).filter((d) => d.value > 0).sort((a, b) => b.value - a.value).slice(0, 15);
313
+ hbars($('c-art'), items, null, null, 'var(--accent4)');
314
+ }
315
+
316
+ if (gate($('c-meses'), MESES)) {
317
+ const data = MESES.flatMap((i) => state[i].data).sort((a, b) => a.mes.localeCompare(b.mes));
318
+ const peak = data.reduce((a, d) => (d.n > a.n ? d : a), data[0]);
319
+ $('meses-note').textContent = `Todo el histórico, sin filtros. Mes con más solicitudes: ${mesLabel(peak.mes)} (${fmtN.format(peak.n)}).`;
320
+ monthsChart($('c-meses'), data);
321
+ }
322
+
323
+ const tb = $('c-tabla');
324
+ if (gate(tb, ['cruce', 'monto'])) {
325
+ const montos = new Map(ms.data.map((r) => [r.departamento, r.monto]));
326
+ const byDep = [...base.reduce((m, r) => m.set(r.departamento, [...(m.get(r.departamento) || []), r]), new Map())].map(([dep, v]) => ({
327
+ dep, total: sum(v, (r) => r.n), fin: sum(v.filter((r) => cat(r.estatus) === 'f'), (r) => r.n), tra: sum(v.filter((r) => cat(r.estatus) === 't'), (r) => r.n),
328
+ rej: sum(v.filter((r) => cat(r.estatus) === 'r'), (r) => r.n), monto: montos.get(dep) || 0,
329
+ }));
330
+ byDep.sort((a, b) => (sort.key === 'dep' ? sort.dir * a.dep.localeCompare(b.dep) : sort.dir * (a[sort.key] - b[sort.key])));
331
+ const table = document.createElement('table');
332
+ const head = table.createTHead().insertRow();
333
+ for (const [k, name] of [['dep', 'Departamento'], ['total', 'Total'], ['fin', 'Finalizadas'], ['tra', 'En trámite'], ['rej', 'Rechazadas'], ['monto', 'Monto']]) {
334
+ const th = text('th', '', name + (sort.key === k ? (sort.dir < 0 ? ' ↓' : ' ↑') : ''));
335
+ th.setAttribute('aria-sort', sort.key === k ? (sort.dir < 0 ? 'descending' : 'ascending') : 'none');
336
+ th.addEventListener('click', () => { sort.dir = sort.key === k ? -sort.dir : k === 'dep' ? 1 : -1; sort.key = k; draw(); });
337
+ head.append(th);
338
+ }
339
+ const body = table.createTBody();
340
+ for (const r of byDep) {
341
+ const tr = body.insertRow();
342
+ [r.dep, fmtN.format(r.total), fmtN.format(r.fin), fmtN.format(r.tra), fmtN.format(r.rej), r.monto ? fmtM.format(r.monto) : '—'].forEach((txt) => { tr.insertCell().textContent = txt; });
343
+ }
344
+ tb.replaceChildren(table);
345
+ }
346
+ }
347
+
348
+ for (const [name, id] of FILTERS) $(id).addEventListener('change', (e) => { params[name] = e.target.value; draw(); });
349
+ $('reset').addEventListener('click', () => { for (const k in params) params[k] = ''; draw(); });
350
+
351
+ // --- Exportar: ebavel.export() envía el HTML de este dashboard (nunca las credenciales del anfitrión) y abre el enlace. ---
352
+ $('export').addEventListener('click', async () => {
353
+ const btn = $('export'), msg = $('export-msg');
354
+ btn.disabled = true;
355
+ msg.replaceChildren(document.createTextNode('Exportando…'));
356
+ try {
357
+ const r = await ebavel.export();
358
+ if (r.opened) msg.textContent = 'Se abrió el enlace de importación en una pestaña nueva.';
359
+ else { // el anfitrión no pudo abrirlo: se muestra para copiarlo
360
+ const input = Object.assign(document.createElement('input'), { value: r.importUrl, readOnly: true, size: 40 });
361
+ input.addEventListener('focus', () => input.select());
362
+ msg.replaceChildren(document.createTextNode('Copia este enlace y ábrelo en tu navegador: '), input);
363
+ }
364
+ } catch (e) {
365
+ msg.textContent = e.code === 'UNSUPPORTED' ? 'Este dashboard ya está guardado.' : `No se pudo exportar (${e.code}): ${e.message}`;
366
+ } finally {
367
+ btn.disabled = false;
368
+ }
369
+ });
370
+
371
+ ebavel.ready.then((info) => { $('export').hidden = !info.canExport; });
372
+ Object.keys(DATASETS).forEach(load); // el SDK limita a 4 llamadas en vuelo y encola el resto
373
+ })();
374
+ </script>
375
+ </template>
376
+ <script>
377
+ const fuente = document.getElementById('ebavel-source').content.cloneNode(true);
378
+ fuente.querySelectorAll('script').forEach((s) => { const n = document.createElement('script'); for (const a of s.attributes) n.setAttribute(a.name, a.value); n.textContent = s.textContent; n.async = false; s.replaceWith(n); });
379
+ document.body.append(fuente);
380
+ </script>
381
+ </body>
382
+ </html>
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@bitam/ebavel-apps",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "SDK `ebavel.*` para dashboards de eBavel escritos como HTML/JS (artifacts de Claude y MCP Apps) y runner MCP Apps genérico `ui://ebavel/dashboard`.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js"
16
+ },
17
+ "./resource": {
18
+ "types": "./dist/resource.d.ts",
19
+ "import": "./dist/resource.js"
20
+ },
21
+ "./ebavel.js": "./dist/ebavel.js",
22
+ "./runner.html": "./dist/ebavel-runner.html",
23
+ "./SDK.md": "./SDK.md",
24
+ "./examples/*": "./examples/*",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "types": "./dist/index.d.ts",
28
+ "unpkg": "dist/ebavel.js",
29
+ "jsdelivr": "dist/ebavel.js",
30
+ "files": [
31
+ "dist",
32
+ "examples",
33
+ "SDK.md",
34
+ "CHANGELOG.md",
35
+ "LICENSE"
36
+ ],
37
+ "scripts": {
38
+ "build": "tsup && node scripts/build.mjs",
39
+ "build:assets": "node scripts/build.mjs",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "dev:host": "node scripts/dev-host.mjs"
42
+ },
43
+ "devDependencies": {
44
+ "@modelcontextprotocol/ext-apps": "1.7.5",
45
+ "@modelcontextprotocol/sdk": "1.32.2",
46
+ "acorn": "8.19.0",
47
+ "esbuild": "0.28.2",
48
+ "happy-dom": "21.0.4",
49
+ "parse5": "8.1.1",
50
+ "zod": "4.6.6"
51
+ },
52
+ "license": "Apache-2.0",
53
+ "keywords": [
54
+ "ebavel",
55
+ "apps",
56
+ "sdk"
57
+ ]
58
+ }