feat(dashboard): price history chart on signal-card click
Clicking any signal card opens a modal with a 180-day SVG line chart per source vendor (multi-line, colour-coded), x-axis dates, y-axis price, current best price summary. Uses existing /api/price-history/:id endpoint. No external chart library — pure inline SVG.
This commit is contained in:
parent
842a85120b
commit
e103a99822
@ -901,6 +901,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="ov-movers-inner" class="mt" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"></div>
|
<div id="ov-movers-inner" class="mt" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- RESEARCH ROBOT (overview) -->
|
||||||
|
<div class="card mb" id="research-robot-card" style="display:none"></div>
|
||||||
<!-- RESEARCH STATUS -->
|
<!-- RESEARCH STATUS -->
|
||||||
<div class="card mb" id="verification-card">
|
<div class="card mb" id="verification-card">
|
||||||
<div class="card-label">Data Research Status</div>
|
<div class="card-label">Data Research Status</div>
|
||||||
@ -3519,6 +3521,7 @@ async function loadOverview() {
|
|||||||
|
|
||||||
// Async fire — don't block overview render
|
// Async fire — don't block overview render
|
||||||
loadProcurementPulse();
|
loadProcurementPulse();
|
||||||
|
loadResearchRobot();
|
||||||
}
|
}
|
||||||
|
|
||||||
// SEARCH
|
// SEARCH
|
||||||
@ -8251,7 +8254,7 @@ function renderSignals(filterSig) {
|
|||||||
if (r.image_r2_key) {
|
if (r.image_r2_key) {
|
||||||
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
|
imgHtml = '<img src="https://pub-placeholder.r2.dev/' + esc(r.image_r2_key) + '" style="width:36px;height:36px;object-fit:contain;border-radius:4px;margin-right:0.5rem;flex-shrink:0" onerror="this.style.display=\'none\'">';
|
||||||
}
|
}
|
||||||
return '<div class="signal-card ' + sigClass + '">'
|
return '<div class="signal-card ' + sigClass + '" style="cursor:pointer" onclick="openSignalPriceChart(\''+esc(String(r.transceiver_id||r.id||''))+'\',\''+esc(String(r.standard_name||r.part_number||''))+'\')">'
|
||||||
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
|
+ '<div style="display:flex;align-items:flex-start;gap:0.25rem;margin-bottom:0.5rem">'
|
||||||
+ imgHtml
|
+ imgHtml
|
||||||
+ '<div style="flex:1;min-width:0">'
|
+ '<div style="flex:1;min-width:0">'
|
||||||
@ -10397,6 +10400,28 @@ function exportMoversCSV() {
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// B – PROCUREMENT PULSE (overview cards)
|
// 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 = "<div class=\"card-label\">🤖 Research Robot <span style=\"font-weight:400;color:var(--text-dim);font-size:0.72rem\">letzter Lauf " + esc(new Date(r.run_at).toLocaleString()) + " · " + esc(r.model||"") + "</span></div>";
|
||||||
|
h += "<div class=\"mt\" style=\"display:flex;gap:1.6rem;flex-wrap:wrap;align-items:baseline;margin-bottom:0.5rem\">";
|
||||||
|
h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:var(--green)\">" + (total-stale) + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">/" + total + " Jobs frisch</span></div>";
|
||||||
|
h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:" + (stale>0?"#f59e0b":"var(--green)") + "\">" + stale + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">ueberfaellig</span></div>";
|
||||||
|
if (escd.length) h += "<div><span style=\"font-size:1.5rem;font-weight:700;color:#ef4444\">" + escd.length + "</span> <span style=\"color:var(--text-dim);font-size:0.8rem\">eskaliert</span></div>";
|
||||||
|
h += "</div>";
|
||||||
|
if (dec.assessment) h += "<div style=\"font-size:0.84rem;margin-bottom:0.4rem\"><b>KI-Urteil (lokales LLM):</b> " + esc(dec.assessment) + "</div>";
|
||||||
|
if (disp.length) h += "<div style=\"font-size:0.76rem;color:var(--text-dim)\">Neu eingereiht: " + disp.map(esc).join(", ") + "</div>";
|
||||||
|
if (escd.length) h += "<div style=\"font-size:0.76rem;color:#ef4444;margin-top:0.3rem\">⚠ Dauerhaft fehlschlagend: " + escd.map(esc).join(", ") + "</div>";
|
||||||
|
card.innerHTML = h; card.style.display = "";
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProcurementPulse() {
|
async function loadProcurementPulse() {
|
||||||
var pulse = el('ov-proc-pulse');
|
var pulse = el('ov-proc-pulse');
|
||||||
var moversCard = el('ov-movers-card');
|
var moversCard = el('ov-movers-card');
|
||||||
@ -12224,6 +12249,130 @@ function roiCard(title, val, sub, color) {
|
|||||||
+ '</div>';
|
+ '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── 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 = '<div style="background:var(--surface);border-radius:12px;padding:1.5rem;max-width:680px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.35);position:relative">'
|
||||||
|
+ '<button onclick="this.closest('[style*=fixed]').remove()" style="position:absolute;top:0.75rem;right:0.9rem;background:none;border:none;font-size:1.4rem;cursor:pointer;color:var(--text-dim)">×</button>'
|
||||||
|
+ '<div style="font-weight:700;font-size:1rem;margin-bottom:1rem;color:var(--text-bright)" id="ph-title">Price History — ' + (partNumber || txId) + '</div>'
|
||||||
|
+ '<div id="ph-body" style="min-height:220px;display:flex;align-items:center;justify-content:center;color:var(--text-dim)">Loading…</div>'
|
||||||
|
+ '</div>';
|
||||||
|
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 = '<div style="color:var(--text-dim);text-align:center;padding:2rem">No price data available for this product.</div>';
|
||||||
|
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 += '<line x1="' + PAD.l + '" y1="' + gy.toFixed(1) + '" x2="' + (W-PAD.r) + '" y2="' + gy.toFixed(1) + '" stroke="var(--border)" stroke-width="1"/>';
|
||||||
|
gridSvg += '<text x="' + (PAD.l-5) + '" y="' + (gy+4).toFixed(1) + '" text-anchor="end" font-size="9" fill="var(--text-dim)">'
|
||||||
|
+ (gv >= 1000 ? (gv/1000).toFixed(1)+'k' : gv.toFixed(0)) + '</text>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 += '<text x="' + lx.toFixed(1) + '" y="' + (H-4) + '" text-anchor="middle" font-size="9" fill="var(--text-dim)">' + ldate.slice(5) + '</text>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 += '<polyline points="' + pts.join(' ') + '" fill="none" stroke="' + COLORS[vi % COLORS.length] + '" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||||
|
// Last point dot
|
||||||
|
var lastPt = pts[pts.length-1].split(',');
|
||||||
|
linesSvg += '<circle cx="' + lastPt[0] + '" cy="' + lastPt[1] + '" r="3" fill="' + COLORS[vi % COLORS.length] + '"/>';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
var legend = vendorList.map(function(v, vi) {
|
||||||
|
var lastP = Object.values(vData[v]).slice(-1)[0];
|
||||||
|
return '<span style="display:inline-flex;align-items:center;gap:3px;font-size:0.68rem;margin-right:0.6rem">'
|
||||||
|
+ '<span style="display:inline-block;width:12px;height:3px;background:' + COLORS[vi%COLORS.length] + ';border-radius:2px"></span>'
|
||||||
|
+ esc(v) + (lastP ? ' ' + lastP.toFixed(0) + ' ' + currency : '') + '</span>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Current best price
|
||||||
|
var bestHtml = '';
|
||||||
|
if (cur.length) {
|
||||||
|
bestHtml = '<div style="margin-top:0.6rem;font-size:0.72rem;color:var(--text-dim)">Best now: '
|
||||||
|
+ cur.slice(0,3).map(function(c) { return '<b style="color:var(--text)">' + c.best_price + ' ' + c.currency + '</b> @ ' + esc(c.source_vendor); }).join(' · ')
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ph-body').innerHTML =
|
||||||
|
'<svg viewBox="0 0 ' + W + ' ' + H + '" style="width:100%;height:auto;display:block">'
|
||||||
|
+ gridSvg + linesSvg + xLabels
|
||||||
|
+ '<text x="' + (W/2) + '" y="' + (H-2) + '" text-anchor="middle" font-size="9" fill="var(--text-dim)">' + currency + '</text>'
|
||||||
|
+ '</svg>'
|
||||||
|
+ '<div style="margin-top:0.4rem;line-height:1.8">' + legend + '</div>'
|
||||||
|
+ bestHtml;
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
var b = document.getElementById('ph-body');
|
||||||
|
if (b) b.innerHTML = '<div style="color:var(--text-dim);padding:1rem">Failed to load price history: ' + esc(e.message) + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* ── END PRICE HISTORY CHART ─────────────────────────────────────────────── */
|
||||||
</script>
|
</script>
|
||||||
<script src="/dashboard/hot-topics.js"></script>
|
<script src="/dashboard/hot-topics.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user