'
+ imgHtml
+ '
'
@@ -10397,6 +10400,28 @@ function exportMoversCSV() {
// ═══════════════════════════════════════════════════════════════════════════
// B – PROCUREMENT PULSE (overview cards)
// ═══════════════════════════════════════════════════════════════════════════
+async function loadResearchRobot() {
+ try {
+ var d = await api("/api/research-robot");
+ if (!d.runs || !d.runs.length) return;
+ var r = d.runs[0], f = r.freshness || {}, dec = r.decision || {};
+ var card = el("research-robot-card"); if (!card) return;
+ var total = f.jobsTotal || 0, stale = f.stale || 0;
+ var escd = (dec.escalated && dec.escalated.length) ? dec.escalated : [];
+ var disp = r.dispatched || [];
+ var h = "
🤖 Research Robot letzter Lauf " + esc(new Date(r.run_at).toLocaleString()) + " · " + esc(r.model||"") + "
";
+ h += "
";
+ h += "
" + (total-stale) + " /" + total + " Jobs frisch
";
+ h += "
0?"#f59e0b":"var(--green)") + "\">" + stale + " ueberfaellig
";
+ if (escd.length) h += "
" + escd.length + " eskaliert
";
+ h += "
";
+ if (dec.assessment) h += "
KI-Urteil (lokales LLM): " + esc(dec.assessment) + "
";
+ if (disp.length) h += "
Neu eingereiht: " + disp.map(esc).join(", ") + "
";
+ if (escd.length) h += "
⚠ Dauerhaft fehlschlagend: " + escd.map(esc).join(", ") + "
";
+ card.innerHTML = h; card.style.display = "";
+ } catch(e) {}
+}
+
async function loadProcurementPulse() {
var pulse = el('ov-proc-pulse');
var moversCard = el('ov-movers-card');
@@ -12224,6 +12249,130 @@ function roiCard(title, val, sub, color) {
+ '
';
}
+
+/* ── PRICE HISTORY CHART (signal-card click) ──────────────────────────── */
+async function openSignalPriceChart(txId, partNumber) {
+ var modal = document.createElement('div');
+ modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:9000;display:flex;align-items:center;justify-content:center;padding:1rem';
+ modal.innerHTML = '
'
+ + '
'
+ + '
Price History — ' + (partNumber || txId) + '
'
+ + '
Loading…
'
+ + '
';
+ modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
+ document.body.appendChild(modal);
+
+ try {
+ var tok = (window.loadToken ? window.loadToken() : '') || '';
+ var d = await (await fetch('/api/price-history/' + txId + '?days=180', { headers: { 'Authorization': 'Bearer ' + tok } })).json();
+ var tx = d.transceiver || {};
+ document.getElementById('ph-title').textContent = 'Price History — ' + (tx.part_number || partNumber || txId)
+ + (tx.form_factor ? ' (' + tx.form_factor + (tx.speed_gbps ? ' · ' + tx.speed_gbps + 'G' : '') + ')' : '');
+
+ var series = d.series || [];
+ if (!series.length) {
+ document.getElementById('ph-body').innerHTML = '
No price data available for this product.
';
+ return;
+ }
+
+ // Build vendor legend + aggregate to daily median per vendor
+ var vendors = {}, cur = (d.current_prices || []);
+ series.forEach(function(r) { if (r.source_vendor) vendors[r.source_vendor] = r.currency; });
+ var vendorList = Object.keys(vendors).slice(0, 6);
+ var COLORS = ['#6366f1','#22c55e','#f59e0b','#ef4444','#0ea5e9','#a855f7'];
+
+ // Date-bucketed: one entry per day per vendor → collect dates
+ var dates = [];
+ var seen = {};
+ series.forEach(function(r) { var d2 = r.day ? r.day.slice(0,10) : ''; if (d2 && !seen[d2]) { seen[d2]=1; dates.push(d2); } });
+ dates.sort();
+
+ // For each vendor, build a price array indexed by date
+ var vData = {};
+ vendorList.forEach(function(v) { vData[v] = {}; });
+ series.forEach(function(r) {
+ if (r.source_vendor && vData[r.source_vendor] !== undefined && r.day)
+ vData[r.source_vendor][r.day.slice(0,10)] = parseFloat(r.price_avg || r.price_min);
+ });
+
+ // Determine Y range
+ var allPrices = [];
+ vendorList.forEach(function(v) { Object.values(vData[v]).forEach(function(p) { allPrices.push(p); }); });
+ var pMin = Math.min.apply(null, allPrices), pMax = Math.max.apply(null, allPrices);
+ var pRange = pMax - pMin || 1;
+ var W = 620, H = 200, PAD = { l:52, r:12, t:10, b:36 };
+ var iW = W - PAD.l - PAD.r, iH = H - PAD.t - PAD.b;
+
+ function xOf(i) { return PAD.l + (dates.length < 2 ? iW/2 : i / (dates.length-1) * iW); }
+ function yOf(p) { return PAD.t + iH - ((p - pMin) / pRange * iH); }
+
+ var currency = series[0] ? (series[0].currency || '') : '';
+
+ // Grid lines (Y)
+ var nGridY = 4;
+ var gridSvg = '';
+ for (var gi = 0; gi <= nGridY; gi++) {
+ var gy = PAD.t + gi * iH / nGridY;
+ var gv = pMax - gi * pRange / nGridY;
+ gridSvg += '
';
+ gridSvg += '
'
+ + (gv >= 1000 ? (gv/1000).toFixed(1)+'k' : gv.toFixed(0)) + '';
+ }
+
+ // X axis labels (up to 6 evenly spaced)
+ var xLabels = '';
+ var nXLabels = Math.min(6, dates.length);
+ for (var xi = 0; xi < nXLabels; xi++) {
+ var didx = Math.round(xi * (dates.length-1) / Math.max(nXLabels-1,1));
+ var lx = xOf(didx);
+ var ldate = dates[didx] || '';
+ xLabels += '
' + ldate.slice(5) + '';
+ }
+
+ // Polylines per vendor
+ var linesSvg = '';
+ vendorList.forEach(function(v, vi) {
+ var pts = dates.map(function(d3, di) {
+ var p = vData[v][d3];
+ return p !== undefined ? (xOf(di).toFixed(1) + ',' + yOf(p).toFixed(1)) : null;
+ }).filter(Boolean);
+ if (!pts.length) return;
+ linesSvg += '
';
+ // Last point dot
+ var lastPt = pts[pts.length-1].split(',');
+ linesSvg += '
';
+ });
+
+ // Legend
+ var legend = vendorList.map(function(v, vi) {
+ var lastP = Object.values(vData[v]).slice(-1)[0];
+ return '
'
+ + ''
+ + esc(v) + (lastP ? ' ' + lastP.toFixed(0) + ' ' + currency : '') + '';
+ }).join('');
+
+ // Current best price
+ var bestHtml = '';
+ if (cur.length) {
+ bestHtml = '
Best now: '
+ + cur.slice(0,3).map(function(c) { return '' + c.best_price + ' ' + c.currency + ' @ ' + esc(c.source_vendor); }).join(' · ')
+ + '
';
+ }
+
+ document.getElementById('ph-body').innerHTML =
+ '
'
+ + '
' + legend + '
'
+ + bestHtml;
+
+ } catch(e) {
+ var b = document.getElementById('ph-body');
+ if (b) b.innerHTML = '
Failed to load price history: ' + esc(e.message) + '
';
+ }
+}
+/* ── END PRICE HISTORY CHART ─────────────────────────────────────────────── */