feat: 6 neue Dashboard-Features (A–F)

A) Price Movers Alert
   - GET /api/procurement/price-movers?days=N&limit=N
   - CTE cur vs prior period avg, |delta_pct| >= 2%, gainers+losers
   - Procurement tab: period toggle 7d/14d/30d, stats bar, Export CSV

B) Executive Overview Pulse
   - 5 KPI cards in Overview (Buy Signals, Arbitrage Ops, Supply Alerts,
     Price Gainers, Losers) via loadProcurementPulse()
   - Top-Movers mini-table in overview card, all → Procurement tab

C) CSV Export
   - exportMoversCSV() downloads gainers+losers as CSV

D) Vendor Intelligence
   - GET /api/vendors/intelligence: per-vendor 30d stats
     (sku_count, price_obs, avg/min/max price, last_seen)
   - Top-6 banner in Vendors tab

E) Advanced Transceiver Search
   - Speed filter (1G/10G/25G/40G/100G/200G/400G/800G)
   - Fiber type filter (SMF / MMF)
   - Verified-only checkbox
   - All params forwarded to GET /api/transceivers

F) Knowledge Base Browser
   - New KB tab with full-text search (ILIKE question/answer/subcategory)
   - GET /api/kb?q=&category=&limit= (packages/api/src/routes/kb.ts)
   - Category pills, entry cards with severity badge + FF/speed tags
This commit is contained in:
Rene Fichtmueller 2026-05-14 20:54:40 +02:00
parent d7c1c351fe
commit fb060ee40a
6 changed files with 581 additions and 3 deletions

View File

@ -7,6 +7,7 @@ Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
{"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."} {"d":"2026-05-14","t":"FEAT","m":"Dynamic Hype Cycle + Market Signal Engine: Hype Cycle tab is now fully data-driven. New GET /api/hype-cycle/market-signals endpoint blends 6 real data sources into a composite Market Signal Score (0100) per technology: (1) hype_score from Norton-Bass model (30% weight), (2) hyperscaler CapEx YoY avg (Microsoft +68.8%, Alphabet +107.4%, Meta +46.8%), (3) price observation activity ratio 30d vs prior 30d, (4) AI cluster estimated transceiver demand (90d window), (5) eBay secondary market sell-through velocity, (6) internal fast-mover demand trend. Score thresholds: ≥70 green, ≥50 yellow, ≥30 orange, <30 gray. Recommendation engine: buildRecommendation(phase, signalScore, capexYoyAvg, speedGbps) maps hype phase × capex boom × speed class Buy/Hold/Watch label with color + detail tooltip. Dashboard: Hype Cycle table shows Market Signal LIVE column (score + progress bar) + Recommendation column (emoji label, tooltip with reasoning). Market Context cards row above table shows Top Signal, CapEx Boom %, Fast Movers signal, eBay Velocity. New Hyperscaler CapEx panel (SEC filing data) + eBay Secondary Market panel at bottom of hype tab. Procurement: new 🛒 eBay Market sub-section with per-form-factor sell-through grid. All 6 queries run in parallel via Promise.all()."}
{"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."} {"d":"2026-05-14","t":"FEAT","m":"Procurement tab: 2 new sections with real data. (1) 📦 Internal Demand — Flexoptix internal SKU velocity from flexoptix_internal_demand table (8,585 SKUs: 70 fast-movers 53k units/12M, 239 regular, 979 slow, 7,297 dead stock). Summary cards with trend %%. Filter by velocity class. API: GET /api/procurement/internal-demand?velocity_class=&limit=&sort=. (2) 🤖 AI Clusters — live AI datacenter announcements from ai_cluster_announcements table (396 in last 30 days). Shows estimated transceiver demand per build, MW scale, company, location, source link. Filter for entries with transceiver estimates. Stats: total announcements, MW, distinct companies, total estimated transceivers. API: GET /api/procurement/ai-clusters?days=&limit=. Replaced misleading DEMO DATA banners on Signals + ABC sections with informational note pointing to Internal Demand data."}
{"d":"2026-05-14","t":"FEAT","m":"6 neue Dashboard-Features: (A) Price Movers Alert — GET /api/procurement/price-movers?days=N&limit=N, CTE-basiert (cur vs prior period avg per SKU+Vendor), zeigt Top-Gainers und Top-Losers (|delta_pct| >= 2%, obs >= 2). Procurement-Tab Sektion mit Period-Toggle 7d/14d/30d, Export CSV. (B) Executive Overview Pulse — 5 KPI-Karten (Buy Signals, Arbitrage Ops, Supply Alerts, Price Gainers, Losers) über `loadProcurementPulse()`, Top-Movers Mini-Tabelle im Overview, alle clickable → Procurement-Tab. (C) CSV Export — exportMoversCSV() generiert Gainers+Losers als CSV-Download. (D) Vendor Intelligence — GET /api/vendors/intelligence: per-Vendor in letzten 30d (sku_count, price_obs, avg/min/max price, last_seen), Top-6-Anbieter-Banner im Vendors-Tab. (E) Advanced Transceiver Search — Speed-Filter (1G/10G/.../800G), Fiber-Type-Filter (SMF/MMF), 'Verified Only'-Checkbox in Transceivers-Tab; searchTransceivers() übergibt speed_gbps=, fiber_type=, verified=price an GET /api/transceivers. (F) Knowledge Base Browser — neuer Tab KB, GET /api/kb?q=&category=&limit= (Full-Text ILIKE über question/answer/subcategory), Category-Pills, Entry-Cards mit Severity-Badge, Form-Factor/Speed-Tags."}
{"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."} {"d":"2026-05-14","t":"FEAT","m":"Equivalences Explorer: new dashboard tab '🔀 Equivalences' — search 63,362 cross-brand mappings (46 vendors, 7,516 competitor products → 846 Flexoptix alternatives, Ø 93.9% confidence). APIs: GET /api/equivalences (search), /api/equivalences/transceiver/:id (per-product), /api/equivalences/stats, /api/equivalences/top-vendors. Transceiver detail modal now shows equivalences panel (FX alternatives or competitor products) + SVG price history sparklines (30-day, per source vendor) from 392k+ price observations."}
{"d":"2026-05-14","t":"FEAT","m":"LinkedIn Distribution Status: Blog tab shows DRY_RUN badge, posted/dry_run/skipped/failed counters, history table with live URN links. GET /api/blog/linkedin/history reads blog_linkedin_distribution table + detects DRY_RUN mode from ecosystem config."} {"d":"2026-05-14","t":"FEAT","m":"LinkedIn Distribution Status: Blog tab shows DRY_RUN badge, posted/dry_run/skipped/failed counters, history table with live URN links. GET /api/blog/linkedin/history reads blog_linkedin_distribution table + detects DRY_RUN mode from ecosystem config."}
{"d":"2026-05-14","t":"FEAT","m":"MCP Server: 2 new tools — find_equivalences (search 63k+ verified cross-brand mappings with confidence filter, returns FX alternatives + competitor matches formatted for LLM) + get_price_history (392k+ obs, daily series, per-vendor min/max/avg, cheapest source identification). Total: 21 MCP tools."} {"d":"2026-05-14","t":"FEAT","m":"MCP Server: 2 new tools — find_equivalences (search 63k+ verified cross-brand mappings with confidence filter, returns FX alternatives + competitor matches formatted for LLM) + get_price_history (392k+ obs, daily series, per-vendor min/max/avg, cheapest source identification). Total: 21 MCP tools."}

View File

@ -37,6 +37,7 @@ import { formFactorsRouter } from "./routes/form-factors";
import { tipLlmRouter } from "./routes/tip-llm"; import { tipLlmRouter } from "./routes/tip-llm";
import { equivalencesRouter } from "./routes/equivalences"; import { equivalencesRouter } from "./routes/equivalences";
import { priceHistoryRouter } from "./routes/price-history"; import { priceHistoryRouter } from "./routes/price-history";
import { kbRouter } from "./routes/kb";
const app = express(); const app = express();
@ -108,6 +109,7 @@ app.use("/api/tip-llm", tipLlmRouter);
app.use("/api/equivalences", equivalencesRouter); app.use("/api/equivalences", equivalencesRouter);
// Price history charts // Price history charts
app.use("/api/price-history", priceHistoryRouter); app.use("/api/price-history", priceHistoryRouter);
app.use("/api/kb", kbRouter);
// Dashboard (static HTML) // Dashboard (static HTML)
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard"))); app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));

View File

@ -0,0 +1,47 @@
import { Router, Request, Response } from "express";
import { pool } from "../db/client";
export const kbRouter = Router();
// GET /api/kb — Knowledge base browser: FAQ + troubleshooting entries
// ?q=search&category=faq|troubleshooting|known_issue&limit=50
kbRouter.get("/", async (req: Request, res: Response) => {
const q = ((req.query.q as string) || "").trim();
const category = (req.query.category as string) || "";
const limit = Math.min(parseInt((req.query.limit as string) || "60"), 200);
try {
const [entries, cats] = await Promise.all([
pool.query(
`SELECT id, category, subcategory, question, answer,
applies_to_form_factors, applies_to_speeds, severity, tags
FROM knowledge_base
WHERE ($1 = '' OR category = $1)
AND ($2 = '' OR question ILIKE '%' || $2 || '%'
OR answer ILIKE '%' || $2 || '%'
OR subcategory ILIKE '%' || $2 || '%')
ORDER BY
CASE WHEN $2 != '' AND question ILIKE '%' || $2 || '%' THEN 0 ELSE 1 END,
category, subcategory, id
LIMIT $3`,
[category, q, limit]
),
pool.query(
`SELECT category, COUNT(*)::int AS count
FROM knowledge_base
GROUP BY category
ORDER BY count DESC`
),
]);
res.json({
success: true,
entries: entries.rows,
categories: cats.rows,
total: entries.rows.length,
query: q,
});
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -872,3 +872,74 @@ procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) =>
res.status(500).json({ error: String(err) }); res.status(500).json({ error: String(err) });
} }
}); });
// GET /api/procurement/price-movers — SKUs with biggest price delta vs prior period
procurementRouter.get("/price-movers", async (req: Request, res: Response) => {
const days = Math.min(parseInt(req.query.days as string) || 7, 90);
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
try {
const result = await pool.query(`
WITH cur AS (
SELECT transceiver_id, source_vendor_id, currency,
AVG(price) AS avg_price,
COUNT(*) AS obs
FROM price_observations
WHERE time >= NOW() - INTERVAL '${days} days'
AND price > 0 AND COALESCE(is_anomalous, false) = false
GROUP BY transceiver_id, source_vendor_id, currency
),
prior AS (
SELECT transceiver_id, source_vendor_id,
AVG(price) AS avg_price
FROM price_observations
WHERE time >= NOW() - INTERVAL '${days * 2} days'
AND time < NOW() - INTERVAL '${days} days'
AND price > 0 AND COALESCE(is_anomalous, false) = false
GROUP BY transceiver_id, source_vendor_id
)
SELECT
t.id, t.part_number, t.form_factor,
t.speed_gbps::text AS speed_gbps,
t.standard_name,
sv.name AS vendor_name,
ROUND(c.avg_price::numeric, 2) AS current_avg,
ROUND(p.avg_price::numeric, 2) AS prior_avg,
ROUND(((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100)::numeric, 1) AS delta_pct,
c.currency,
c.obs::int AS observations
FROM cur c
JOIN prior p ON p.transceiver_id = c.transceiver_id
AND p.source_vendor_id = c.source_vendor_id
JOIN transceivers t ON t.id = c.transceiver_id
JOIN vendors sv ON sv.id = c.source_vendor_id
WHERE ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) >= 2
AND c.obs::int >= 2
ORDER BY ABS((c.avg_price - p.avg_price) / NULLIF(p.avg_price, 0) * 100) DESC
LIMIT ${limit * 2}
`);
const rows = result.rows;
const gainers = rows.filter((r) => parseFloat(r.delta_pct) > 0).slice(0, limit);
const losers = rows.filter((r) => parseFloat(r.delta_pct) < 0).slice(0, limit);
const avgOf = (arr: typeof gainers, key: string) =>
arr.length ? Math.round(arr.reduce((s, r) => s + parseFloat(r[key]), 0) / arr.length * 10) / 10 : 0;
res.json({
success: true,
days,
gainers,
losers,
stats: {
totalMovers: rows.length,
gainersCount: gainers.length,
losersCount: losers.length,
avgGainPct: avgOf(gainers, "delta_pct"),
avgLossPct: avgOf(losers, "delta_pct"),
},
});
} catch (err) {
res.status(500).json({ error: String(err) });
}
});

View File

@ -136,3 +136,38 @@ vendorRouter.get("/:id", async (req: Request, res: Response) => {
return res.status(500).json({ success: false, error: "Internal server error" }); return res.status(500).json({ success: false, error: "Internal server error" });
} }
}); });
// GET /api/vendors/intelligence — per-vendor price + SKU market stats (last 30d)
vendorRouter.get("/intelligence", async (_req: Request, res: Response) => {
try {
const result = await pool.query(`
SELECT
v.id,
v.name,
v.type,
v.website,
COUNT(DISTINCT po.transceiver_id)::int AS sku_count,
COUNT(po.id)::int AS price_obs,
ROUND(AVG(po.price)::numeric, 2) AS avg_price,
ROUND(MIN(po.price)::numeric, 2) AS min_price,
ROUND(MAX(po.price)::numeric, 2) AS max_price,
MAX(po.time) AS last_seen,
(SELECT currency FROM price_observations
WHERE source_vendor_id = v.id
ORDER BY time DESC LIMIT 1) AS currency
FROM vendors v
LEFT JOIN price_observations po
ON po.source_vendor_id = v.id
AND po.time > NOW() - INTERVAL '30 days'
AND po.price > 0
AND COALESCE(po.is_anomalous, false) = false
GROUP BY v.id, v.name, v.type, v.website
HAVING COUNT(DISTINCT po.transceiver_id) > 0
ORDER BY COUNT(DISTINCT po.transceiver_id) DESC
LIMIT 60
`);
res.json({ success: true, data: result.rows });
} catch (err) {
res.status(500).json({ success: false, error: String(err) });
}
});

View File

@ -806,6 +806,7 @@
<div class="tab" data-tab="stock">🏭 Stock</div> <div class="tab" data-tab="stock">🏭 Stock</div>
<div class="tab" data-tab="prices">💲 Price Comparison</div> <div class="tab" data-tab="prices">💲 Price Comparison</div>
<div class="tab" data-tab="equivalences">🔀 Equivalences</div> <div class="tab" data-tab="equivalences">🔀 Equivalences</div>
<div class="tab" data-tab="kb">📚 KB</div>
</div> </div>
<div class="main"> <div class="main">
@ -839,6 +840,49 @@
<div class="stat-val" id="ov-news">&mdash;</div> <div class="stat-val" id="ov-news">&mdash;</div>
</div> </div>
</div> </div>
<!-- PROCUREMENT PULSE -->
<div id="ov-proc-pulse" class="grid mb" style="grid-template-columns:repeat(5,1fr);display:none">
<div class="stat-card" style="cursor:pointer" onclick="goToTab('procurement')">
<div class="stat-icon green">🟢</div>
<div class="stat-label">Buy Signals</div>
<div class="stat-val" id="ov-buy-signals"></div>
<div class="stat-sub" style="font-size:0.68rem;color:var(--text-dim)">buy_now</div>
</div>
<div class="stat-card" style="cursor:pointer" onclick="goToTab('procurement')">
<div class="stat-icon orange">💰</div>
<div class="stat-label">Arbitrage Ops</div>
<div class="stat-val" id="ov-arbitrage"></div>
<div class="stat-sub" style="font-size:0.68rem;color:var(--text-dim)">price pairs</div>
</div>
<div class="stat-card" style="cursor:pointer" onclick="goToTab('procurement')">
<div class="stat-icon red">⚠️</div>
<div class="stat-label">Supply Alerts</div>
<div class="stat-val" id="ov-supply-alerts"></div>
<div class="stat-sub" style="font-size:0.68rem;color:var(--text-dim)">critical/warning</div>
</div>
<div class="stat-card" style="cursor:pointer" onclick="goToTab('procurement')">
<div class="stat-icon purple">📈</div>
<div class="stat-label">Price Gainers</div>
<div class="stat-val" id="ov-gainers"></div>
<div class="stat-sub" style="font-size:0.68rem;color:var(--text-dim)">7-day movers</div>
</div>
<div class="stat-card" style="cursor:pointer" onclick="goToTab('procurement')">
<div class="stat-icon blue">🔀</div>
<div class="stat-label">Equivalences</div>
<div class="stat-val" id="ov-equiv-count"></div>
<div class="stat-sub" style="font-size:0.68rem;color:var(--text-dim)">cross-brand</div>
</div>
</div>
<!-- TOP PRICE MOVERS (overview) -->
<div id="ov-movers-card" class="card mb" style="display:none">
<div class="card-label" style="display:flex;align-items:center;justify-content:space-between">
<span>📈 Top Price Movers <span id="ov-movers-period" style="font-weight:400;color:var(--text-dim);font-size:0.75rem"></span></span>
<button class="btn-sm" onclick="goToTab('procurement')" style="font-size:0.7rem;padding:2px 8px">View all →</button>
</div>
<div id="ov-movers-inner" class="mt" style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem"></div>
</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>
@ -1029,6 +1073,25 @@
<button class="btn" id="tx-search-btn">Search</button> <button class="btn" id="tx-search-btn">Search</button>
<button class="btn" id="tx-export-btn" style="background:var(--green);color:#fff" title="Export CSV">Export CSV</button> <button class="btn" id="tx-export-btn" style="background:var(--green);color:#fff" title="Export CSV">Export CSV</button>
<button class="btn" id="tx-compare-btn" style="background:var(--purple);color:#fff" title="Compare selected">Compare</button> <button class="btn" id="tx-compare-btn" style="background:var(--purple);color:#fff" title="Compare selected">Compare</button>
<select id="tx-speed-filter" style="min-width:90px">
<option value="">All Speeds</option>
<option value="1">1G</option>
<option value="10">10G</option>
<option value="25">25G</option>
<option value="40">40G</option>
<option value="100">100G</option>
<option value="200">200G</option>
<option value="400">400G</option>
<option value="800">800G</option>
</select>
<select id="tx-fiber-filter" style="min-width:80px">
<option value="">SMF+MMF</option>
<option value="SMF">SMF</option>
<option value="MMF">MMF</option>
</select>
<label style="display:flex;align-items:center;gap:4px;font-size:0.78rem;color:var(--text-dim);white-space:nowrap;cursor:pointer">
<input type="checkbox" id="tx-verified-only" onchange="searchTransceivers()"> Verified only
</label>
<input type="hidden" id="tx-verified-filter" value=""> <input type="hidden" id="tx-verified-filter" value="">
</div> </div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.4rem">
@ -1063,6 +1126,11 @@
<span id="vendor-count" style="color:var(--text-dim);font-size:0.8rem"></span> <span id="vendor-count" style="color:var(--text-dim);font-size:0.8rem"></span>
<button class="btn" onclick="openCreateVendorModal()" style="background:var(--accent);color:#fff;white-space:nowrap">+ New Vendor</button> <button class="btn" onclick="openCreateVendorModal()" style="background:var(--accent);color:#fff;white-space:nowrap">+ New Vendor</button>
</div> </div>
<!-- VENDOR INTELLIGENCE BANNER -->
<div id="vendor-intel-bar" style="display:none;margin-bottom:0.75rem;padding:0.75rem 1rem;background:var(--surface2);border:1px solid var(--border);border-radius:8px;font-size:0.78rem">
<div style="font-weight:600;color:var(--text-bright);margin-bottom:0.4rem">📊 Vendor Market Intelligence (last 30 days)</div>
<div id="vendor-intel-inner" style="display:flex;flex-wrap:wrap;gap:0.4rem 1.2rem;color:var(--text-dim)">Loading…</div>
</div>
<div id="vendor-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:0.75rem"> <div id="vendor-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:0.75rem">
<div class="loading pulse">Loading vendors…</div> <div class="loading pulse">Loading vendors…</div>
</div> </div>
@ -1599,6 +1667,7 @@
<button onclick="showProcSection('switch-compat')" id="proc-btn-switch-compat" class="proc-btn" style="background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3);color:#818cf8">🖥 Switch Compat</button> <button onclick="showProcSection('switch-compat')" id="proc-btn-switch-compat" class="proc-btn" style="background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3);color:#818cf8">🖥 Switch Compat</button>
<button onclick="showProcSection('supply-squeeze')" id="proc-btn-supply-squeeze" class="proc-btn" style="background:rgba(239,68,68,0.08);border-color:rgba(239,68,68,0.3);color:#ef4444">⚠️ Supply Squeeze</button> <button onclick="showProcSection('supply-squeeze')" id="proc-btn-supply-squeeze" class="proc-btn" style="background:rgba(239,68,68,0.08);border-color:rgba(239,68,68,0.3);color:#ef4444">⚠️ Supply Squeeze</button>
<button onclick="showProcSection('dead-stock')" id="proc-btn-dead-stock" class="proc-btn" style="background:rgba(245,158,11,0.08);border-color:rgba(245,158,11,0.3);color:#f59e0b">🪦 Dead Stock Revival</button> <button onclick="showProcSection('dead-stock')" id="proc-btn-dead-stock" class="proc-btn" style="background:rgba(245,158,11,0.08);border-color:rgba(245,158,11,0.3);color:#f59e0b">🪦 Dead Stock Revival</button>
<button onclick="showProcSection('movers')" id="proc-btn-movers" class="proc-btn" style="background:rgba(99,102,241,0.08);border-color:rgba(99,102,241,0.3);color:#6366f1">📈 Price Movers</button>
<button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button> <button onclick="showProcSection('abc')" id="proc-btn-abc" class="proc-btn">ABC Classes</button>
<button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button> <button onclick="showProcSection('demand')" id="proc-btn-demand" class="proc-btn" style="background:rgba(22,163,74,0.08);border-color:rgba(22,163,74,0.3);color:#16a34a">📦 Internal Demand</button>
<button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button> <button onclick="showProcSection('ai-clusters')" id="proc-btn-ai-clusters" class="proc-btn" style="background:rgba(124,92,252,0.08);border-color:rgba(124,92,252,0.3);color:#7c5cfc">🤖 AI Clusters</button>
@ -1657,6 +1726,28 @@
<div id="proc-deadstock-list"><div style="color:var(--text-dim)">Loading…</div></div> <div id="proc-deadstock-list"><div style="color:var(--text-dim)">Loading…</div></div>
</div> </div>
<!-- Price Movers section -->
<div id="proc-section-movers" style="display:none">
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center">
<span style="font-size:0.8rem;color:var(--text-dim)">Period:</span>
<button onclick="setMoversDays(7)" id="mv-btn-7" style="background:var(--accent);color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">7d</button>
<button onclick="setMoversDays(14)" id="mv-btn-14" style="background:var(--surface2);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">14d</button>
<button onclick="setMoversDays(30)" id="mv-btn-30" style="background:var(--surface2);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">30d</button>
<span id="mv-stats" style="margin-left:auto;font-size:0.75rem;color:var(--text-dim)"></span>
<button onclick="exportMoversCSV()" style="background:var(--surface2);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">📥 Export CSV</button>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div>
<div style="font-size:0.82rem;font-weight:600;color:#10b981;margin-bottom:0.6rem">📈 Top Gainers</div>
<div id="mv-gainers"><div class="loading pulse">Loading…</div></div>
</div>
<div>
<div style="font-size:0.82rem;font-weight:600;color:#ef4444;margin-bottom:0.6rem">📉 Top Losers</div>
<div id="mv-losers"><div class="loading pulse">Loading…</div></div>
</div>
</div>
</div>
<!-- Reorder Signals section --> <!-- Reorder Signals section -->
<div id="proc-section-signals"> <div id="proc-section-signals">
<div style="padding:0.5rem 0.75rem;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;font-size:0.72rem;color:#16a34a;margin-bottom:0.75rem"> Reorder Signals basieren auf <strong>ABC-Klassifizierung + Preis-Observations-Frequenz</strong>. Echte Verkaufsmengendaten → <button onclick="showProcSection('demand')" style="background:none;border:none;color:#16a34a;text-decoration:underline;cursor:pointer;font-size:0.72rem;padding:0">📦 Internal Demand</button> Tab.</div> <div style="padding:0.5rem 0.75rem;background:#16a34a11;border:1px solid #16a34a33;border-radius:6px;font-size:0.72rem;color:#16a34a;margin-bottom:0.75rem"> Reorder Signals basieren auf <strong>ABC-Klassifizierung + Preis-Observations-Frequenz</strong>. Echte Verkaufsmengendaten → <button onclick="showProcSection('demand')" style="background:none;border:none;color:#16a34a;text-decoration:underline;cursor:pointer;font-size:0.72rem;padding:0">📦 Internal Demand</button> Tab.</div>
@ -2396,6 +2487,30 @@
</div> </div>
</div><!-- end tab-equivalences --> </div><!-- end tab-equivalences -->
<!-- KNOWLEDGE BASE -->
<div id="tab-kb" class="hidden fade-in">
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap">
<h2 style="margin:0;font-size:1.1rem">📚 Knowledge Base</h2>
<div id="kb-stats" style="font-size:0.78rem;color:var(--text-dim)">Loading…</div>
</div>
<div class="card mb" style="padding:0.85rem">
<div style="display:flex;gap:0.6rem;flex-wrap:wrap;align-items:center">
<input type="text" id="kb-q" placeholder="Search questions, answers, topics…"
style="flex:1;min-width:200px;background:var(--surface);border:1px solid var(--border);color:var(--text);padding:7px 12px;border-radius:6px;font-size:0.85rem"
oninput="debounceKB()" onkeydown="if(event.key==='Enter')searchKB()">
<select id="kb-cat" onchange="searchKB()"
style="padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font-size:0.82rem">
<option value="">All Categories</option>
</select>
<button onclick="searchKB()" style="background:var(--accent);color:#fff;border:none;padding:7px 16px;border-radius:6px;cursor:pointer;font-size:0.82rem;white-space:nowrap">Search</button>
</div>
<div id="kb-cat-pills" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.7rem"></div>
</div>
<div id="kb-results" style="display:flex;flex-direction:column;gap:0.6rem">
<div style="color:var(--text-dim);font-size:0.85rem;padding:0.5rem">Loading knowledge base…</div>
</div>
</div><!-- end tab-kb -->
</div> </div>
</div><!-- .app --> </div><!-- .app -->
@ -2767,6 +2882,7 @@ function goToTab(tabName) {
if (tabName === 'switches') searchSwitches(); if (tabName === 'switches') searchSwitches();
if (tabName === 'news') loadNews(1); if (tabName === 'news') loadNews(1);
if (tabName === 'vendors') loadVendors(); if (tabName === 'vendors') loadVendors();
if (tabName === 'kb' && !window._kbLoaded) loadKB();
if (tabName === 'standards') loadStandardsList(); if (tabName === 'standards') loadStandardsList();
if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); loadBlogLLMStatus(); loadPostingTime(); } if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); loadBlogLLMStatus(); loadPostingTime(); }
if (tabName === 'finder') document.getElementById('finder-switch-input').focus(); if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
@ -2946,6 +3062,9 @@ async function loadOverview() {
+ '</div>'; + '</div>';
}).join('') || '<div class="loading">No news yet</div>'); }).join('') || '<div class="loading">No news yet</div>');
} catch(e) {} } catch(e) {}
// Async fire — don't block overview render
loadProcurementPulse();
} }
// SEARCH // SEARCH
@ -3712,18 +3831,24 @@ function searchTransceivers() {
var ff = el('tx-ff-filter').value; var ff = el('tx-ff-filter').value;
var vf = el('tx-vendor-filter').value; var vf = el('tx-vendor-filter').value;
var verifiedF = (el('tx-verified-filter') || {}).value || ''; var verifiedF = (el('tx-verified-filter') || {}).value || '';
var spd = el('tx-speed-filter') ? el('tx-speed-filter').value : '';
var fib = el('tx-fiber-filter') ? el('tx-fiber-filter').value : '';
var verOnly = el('tx-verified-only') ? el('tx-verified-only').checked : false;
var params = []; var params = [];
if (q) params.push('q=' + encodeURIComponent(q)); if (q) params.push('q=' + encodeURIComponent(q));
if (ff) params.push('form_factor=' + encodeURIComponent(ff)); if (ff) params.push('form_factor=' + encodeURIComponent(ff));
if (vf) params.push('vendor=' + encodeURIComponent(vf)); if (vf) params.push('vendor=' + encodeURIComponent(vf));
if (verifiedF) params.push('verified=' + encodeURIComponent(verifiedF)); if (spd) params.push('speed_gbps=' + encodeURIComponent(spd));
if (fib) params.push('fiber_type=' + encodeURIComponent(fib));
if (verOnly) params.push('verified=price');
else if (verifiedF) params.push('verified=' + encodeURIComponent(verifiedF));
params.push('limit=200'); params.push('limit=200');
api('/api/transceivers?' + params.join('&')).then(function(data) { api('/api/transceivers?' + params.join('&')).then(function(data) {
lastTxData = data.data || data.transceivers || []; lastTxData = data.data || data.transceivers || [];
// Show result count in search bar placeholder // Show result count in search bar placeholder
var total = data.total || lastTxData.length; var total = data.total || lastTxData.length;
var activeFilter = q || ff || vf || verifiedF; var activeFilter = q || ff || vf || spd || fib || verOnly || verifiedF;
var txSearchEl = el('tx-search'); var txSearchEl = el('tx-search');
if (txSearchEl && !activeFilter) txSearchEl.placeholder = 'Filter: Nexus 9300, QSFP28, 400G, coherent… (' + total + ' transceivers total)'; if (txSearchEl && !activeFilter) txSearchEl.placeholder = 'Filter: Nexus 9300, QSFP28, 400G, coherent… (' + total + ' transceivers total)';
// Show count above table + clear button // Show count above table + clear button
@ -5076,6 +5201,7 @@ async function loadVendors() {
return diff !== 0 ? diff : (a.name || '').localeCompare(b.name || ''); return diff !== 0 ? diff : (a.name || '').localeCompare(b.name || '');
}); });
filterVendorCards(); filterVendorCards();
loadVendorIntelligence();
} }
function filterVendorCards() { function filterVendorCards() {
@ -7049,7 +7175,7 @@ var procAiClustersData = [];
var procAiClustersMinTx = 0; var procAiClustersMinTx = 0;
function showProcSection(name) { function showProcSection(name) {
['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock', ['signals','reorder-top','arbitrage','switch-compat','supply-squeeze','dead-stock','movers',
'abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) { 'abc','demand','marketplace','ai-clusters','market','lifecycle'].forEach(function(s) {
var sec = el('proc-section-' + s); var sec = el('proc-section-' + s);
var btn = el('proc-btn-' + s); var btn = el('proc-btn-' + s);
@ -7065,6 +7191,7 @@ function showProcSection(name) {
if (name === 'switch-compat' && !el('proc-switch-stats').innerHTML) loadSwitchCompatStats(); if (name === 'switch-compat' && !el('proc-switch-stats').innerHTML) loadSwitchCompatStats();
if (name === 'supply-squeeze' && !el('proc-squeeze-list').querySelector('div.card,table')) loadSupplySqueeze(); if (name === 'supply-squeeze' && !el('proc-squeeze-list').querySelector('div.card,table')) loadSupplySqueeze();
if (name === 'dead-stock' && !el('proc-deadstock-list').querySelector('table')) loadDeadStockRevival(); if (name === 'dead-stock' && !el('proc-deadstock-list').querySelector('table')) loadDeadStockRevival();
if (name === 'movers' && !window._moversLoaded) loadMovers(7);
} }
/* ── E: Buy-Now Reorder Intelligence ───────────────────────────────────── */ /* ── E: Buy-Now Reorder Intelligence ───────────────────────────────────── */
@ -9440,6 +9567,301 @@ document.querySelectorAll('.tab').forEach(function(tab) {
} }
}); });
}); });
// ═══════════════════════════════════════════════════════════════════════════
// A PRICE MOVERS
// ═══════════════════════════════════════════════════════════════════════════
window._moversData = null;
window._moversLoaded = false;
async function loadMovers(days) {
window._moversData = null;
var section = el('proc-section-movers');
if (!section) return;
var g = el('mv-gainers'), l = el('mv-losers'), stats = el('mv-stats');
if (g) g.innerHTML = '<div class="loading pulse">Loading…</div>';
if (l) l.innerHTML = '<div class="loading pulse">Loading…</div>';
if (stats) stats.innerHTML = '';
try {
var d = await api('/api/procurement/price-movers?days=' + days + '&limit=20');
window._moversData = d;
window._moversDays = days;
window._moversLoaded = true;
if (stats) {
var s = d.stats || {};
stats.innerHTML =
'<span style="font-size:0.75rem;color:var(--text-dim)">Movers: <b style="color:var(--text-bright)">' + (s.totalMovers||0) + '</b></span>'
+ ' &nbsp; <span style="font-size:0.75rem;color:var(--green)">Gainers +' + (s.avgGainPct||0).toFixed(1) + '%</span>'
+ ' &nbsp; <span style="font-size:0.75rem;color:var(--red)">Losers ' + (s.avgLossPct||0).toFixed(1) + '%</span>';
}
function renderMoversTable(rows, container, isGainer) {
if (!container) return;
if (!rows || rows.length === 0) {
container.innerHTML = '<div style="padding:1rem;color:var(--text-dim);font-size:0.8rem">No movers this period</div>';
return;
}
var html = '<table style="width:100%;border-collapse:collapse;font-size:0.78rem">'
+ '<thead><tr>'
+ '<th style="text-align:left;padding:5px 4px;color:var(--text-dim);font-weight:500;border-bottom:1px solid var(--border)">SKU</th>'
+ '<th style="text-align:right;padding:5px 4px;color:var(--text-dim);font-weight:500;border-bottom:1px solid var(--border)">Avg Price</th>'
+ '<th style="text-align:right;padding:5px 4px;color:var(--text-dim);font-weight:500;border-bottom:1px solid var(--border)">Change</th>'
+ '<th style="text-align:left;padding:5px 4px;color:var(--text-dim);font-weight:500;border-bottom:1px solid var(--border)">Vendor</th>'
+ '</tr></thead><tbody>';
rows.forEach(function(r) {
var pct = parseFloat(r.delta_pct);
var color = isGainer ? 'var(--green)' : 'var(--red)';
var sign = pct > 0 ? '+' : '';
html += '<tr style="border-bottom:1px solid var(--border)">'
+ '<td style="padding:5px 4px;color:var(--text-bright);font-family:var(--mono);cursor:pointer" onclick="openTxDetail(\'' + esc(String(r.transceiver_id)) + '\')">' + esc(r.model_name || r.part_number || String(r.transceiver_id)) + '</td>'
+ '<td style="text-align:right;padding:5px 4px;font-family:var(--mono)">' + (r.cur_avg ? '$' + parseFloat(r.cur_avg).toFixed(2) : '—') + '</td>'
+ '<td style="text-align:right;padding:5px 4px;font-weight:700;color:' + color + ';font-family:var(--mono)">' + sign + pct.toFixed(1) + '%</td>'
+ '<td style="padding:5px 4px;color:var(--text-dim)">' + esc(r.vendor_name || '—') + '</td>'
+ '</tr>';
});
html += '</tbody></table>';
buildDOM(container, html);
}
renderMoversTable(d.gainers, g, true);
renderMoversTable(d.losers, l, false);
} catch(e) {
if (g) g.innerHTML = '<div style="color:var(--text-dim);font-size:0.8rem;padding:1rem">Failed to load price movers</div>';
if (l) l.innerHTML = '';
}
}
function setMoversDays(d) {
[7,14,30].forEach(function(n) {
var btn = el('mv-btn-' + n);
if (!btn) return;
btn.style.background = (n === d) ? 'var(--accent)' : 'var(--surface2)';
btn.style.color = (n === d) ? '#fff' : '';
btn.style.border = (n === d) ? 'none' : '1px solid var(--border)';
});
loadMovers(d);
}
function exportMoversCSV() {
var d = window._moversData;
if (!d) return;
var rows = [['Type','SKU','Model','Vendor','Cur Avg Price','Prior Avg Price','Delta %','Obs Count']];
(d.gainers||[]).forEach(function(r) {
rows.push(['GAINER', r.transceiver_id, r.model_name||'', r.vendor_name||'', r.cur_avg||'', r.prior_avg||'', r.delta_pct||'', r.obs_count||'']);
});
(d.losers||[]).forEach(function(r) {
rows.push(['LOSER', r.transceiver_id, r.model_name||'', r.vendor_name||'', r.cur_avg||'', r.prior_avg||'', r.delta_pct||'', r.obs_count||'']);
});
var csv = rows.map(function(r) { return r.map(function(c) { return '"'+String(c).replace(/"/g,'""')+'"'; }).join(','); }).join('\n');
var blob = new Blob([csv], { type:'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'price-movers-' + (window._moversDays||7) + 'd.csv';
a.click();
}
// ═══════════════════════════════════════════════════════════════════════════
// B PROCUREMENT PULSE (overview cards)
// ═══════════════════════════════════════════════════════════════════════════
async function loadProcurementPulse() {
var pulse = el('ov-proc-pulse');
var moversCard = el('ov-movers-card');
if (!pulse) return;
try {
// Fire all in parallel
var [reorder, arb, squeeze, movers] = await Promise.all([
api('/api/procurement/reorder?limit=1').catch(function() { return null; }),
api('/api/procurement/arbitrage?limit=1').catch(function() { return null; }),
api('/api/procurement/supply-squeeze?limit=1').catch(function() { return null; }),
api('/api/procurement/price-movers?days=7&limit=10').catch(function() { return null; }),
api('/api/equivalences?limit=1').catch(function() { return null; }),
]);
var buySignals = reorder ? (reorder.total || (reorder.reorder_signals || []).length || 0) : 0;
var arbOps = arb ? (arb.total || (arb.opportunities || []).length || 0) : 0;
var supplyAlert = squeeze ? (squeeze.total || (squeeze.items || []).length || 0) : 0;
var gainers = movers ? ((movers.gainers || []).length) : 0;
var losers = movers ? ((movers.losers || []).length) : 0;
var cards = [
{ icon: '🛒', label: 'Buy Signals', val: buySignals, color: '#22c55e', tab: 'procurement' },
{ icon: '⚖️', label: 'Arbitrage Ops', val: arbOps, color: '#6366f1', tab: 'procurement' },
{ icon: '⚠️', label: 'Supply Alerts', val: supplyAlert, color: '#f97316', tab: 'procurement' },
{ icon: '📈', label: 'Price Gainers', val: gainers, color: '#10b981', tab: 'procurement' },
{ icon: '📉', label: 'Price Losers', val: losers, color: '#ef4444', tab: 'procurement' },
];
buildDOM(pulse, cards.map(function(c) {
return '<div onclick="goToTab(\'' + c.tab + '\')" style="cursor:pointer;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:0.9rem 0.75rem;text-align:center;transition:border-color 0.15s,box-shadow 0.15s" '
+ 'onmouseover="this.style.borderColor=\'' + c.color + '\';this.style.boxShadow=\'0 0 0 1px ' + c.color + '40\'" '
+ 'onmouseout="this.style.borderColor=\'\';this.style.boxShadow=\'\'">'
+ '<div style="font-size:1.5rem">' + c.icon + '</div>'
+ '<div style="font-size:1.4rem;font-weight:700;color:' + c.color + ';font-family:var(--mono);margin:0.2rem 0">' + c.val + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim)">' + c.label + '</div>'
+ '</div>';
}).join(''));
pulse.style.display = 'grid';
// Top movers mini-table in overview
if (moversCard && movers && ((movers.gainers||[]).length + (movers.losers||[]).length) > 0) {
var inner = el('ov-movers-inner');
if (inner) {
function miniMoversTable(rows, title, color) {
if (!rows || !rows.length) return '';
return '<div>'
+ '<div style="font-size:0.75rem;font-weight:600;color:' + color + ';margin-bottom:0.4rem">' + title + '</div>'
+ '<table style="width:100%;border-collapse:collapse;font-size:0.76rem">'
+ rows.slice(0,5).map(function(r) {
var pct = parseFloat(r.delta_pct);
var sign = pct > 0 ? '+' : '';
return '<tr><td style="padding:3px 0;color:var(--text-bright);font-family:var(--mono)">' + esc(r.model_name || String(r.transceiver_id)) + '</td>'
+ '<td style="text-align:right;padding:3px 0;color:' + color + ';font-family:var(--mono);font-weight:600">' + sign + pct.toFixed(1) + '%</td></tr>';
}).join('')
+ '</table></div>';
}
buildDOM(inner,
miniMoversTable(movers.gainers, 'Top Gainers (7d)', '#10b981')
+ miniMoversTable(movers.losers, 'Top Losers (7d)', '#ef4444')
);
moversCard.style.display = '';
}
}
} catch(e) {
// Silently fail — pulse cards are bonus info
}
}
// ═══════════════════════════════════════════════════════════════════════════
// D VENDOR INTELLIGENCE
// ═══════════════════════════════════════════════════════════════════════════
async function loadVendorIntelligence() {
var bar = el('vendor-intel-bar');
var inner = el('vendor-intel-inner');
if (!bar || !inner) return;
try {
var d = await api('/api/vendors/intelligence');
var items = d.vendors || d;
if (!items || !items.length) return;
// Show top 6 by sku_count
var top = items.slice(0, 6);
var html = top.map(function(v) {
return '<div style="display:inline-block;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.5rem 0.75rem;margin:0.2rem;vertical-align:top;min-width:140px">'
+ '<div style="font-size:0.8rem;font-weight:600;color:var(--text-bright)">' + esc(v.vendor_name || '—') + '</div>'
+ '<div style="font-size:0.7rem;color:var(--text-dim);margin-top:0.2rem">'
+ '<span style="color:var(--accent)">' + (v.sku_count||0) + ' SKUs</span> &nbsp;'
+ '<span>' + (v.price_obs||0) + ' obs</span>'
+ '</div>'
+ (v.avg_price ? '<div style="font-size:0.72rem;font-family:var(--mono);color:var(--green);margin-top:0.15rem">avg $' + parseFloat(v.avg_price).toFixed(2) + '</div>' : '')
+ '</div>';
}).join('');
buildDOM(inner, html);
bar.style.display = '';
} catch(e) {
// No intel available — keep bar hidden
}
}
// ═══════════════════════════════════════════════════════════════════════════
// F KNOWLEDGE BASE BROWSER
// ═══════════════════════════════════════════════════════════════════════════
window._kbLoaded = false;
var _kbDebounceTimer = null;
function debounceKB() {
clearTimeout(_kbDebounceTimer);
_kbDebounceTimer = setTimeout(searchKB, 280);
}
async function loadKB() {
window._kbLoaded = true;
await searchKB();
}
async function searchKB() {
var q = (el('kb-q') ? el('kb-q').value.trim() : '');
var cat = (el('kb-cat') ? el('kb-cat').value : '');
var res = el('kb-results');
var pills = el('kb-cat-pills');
if (res) res.innerHTML = '<div class="loading pulse">Loading knowledge base…</div>';
try {
var url = '/api/kb?limit=80';
if (q) url += '&q=' + encodeURIComponent(q);
if (cat) url += '&category=' + encodeURIComponent(cat);
var d = await api(url);
renderKB(d.entries || [], d.categories || [], d.total || 0, pills, res);
} catch(e) {
if (res) res.innerHTML = '<div style="color:var(--text-dim);padding:1.5rem">Failed to load knowledge base entries.</div>';
}
}
function renderKB(entries, cats, total, pillsEl, resultsEl) {
// ── Category pills ──────────────────────────────────────────────
if (pillsEl) {
var activeCat = el('kb-cat') ? el('kb-cat').value : '';
var pillHtml = '<span onclick="el(\'kb-cat\').value=\'\';searchKB()" '
+ 'style="cursor:pointer;display:inline-block;padding:3px 10px;border-radius:20px;font-size:0.73rem;margin:0 3px 4px 0;'
+ (activeCat === '' ? 'background:var(--accent);color:#fff' : 'background:var(--surface2);border:1px solid var(--border);color:var(--text-dim)')
+ '">All</span>';
cats.forEach(function(c) {
var active = activeCat === c.category;
pillHtml += '<span onclick="el(\'kb-cat\').value=\'' + esc(c.category) + '\';searchKB()" '
+ 'style="cursor:pointer;display:inline-block;padding:3px 10px;border-radius:20px;font-size:0.73rem;margin:0 3px 4px 0;'
+ (active ? 'background:var(--accent);color:#fff' : 'background:var(--surface2);border:1px solid var(--border);color:var(--text-dim)')
+ '">'
+ esc(c.category) + ' <span style="opacity:0.7">(' + c.count + ')</span>'
+ '</span>';
});
buildDOM(pillsEl, pillHtml);
}
// ── Results ──────────────────────────────────────────────────────
if (!resultsEl) return;
if (!entries.length) {
resultsEl.innerHTML = '<div style="color:var(--text-dim);padding:2rem;text-align:center">No entries found.</div>';
return;
}
var catColors = {
faq: '#6366f1',
troubleshooting: '#f97316',
known_issue: '#ef4444',
best_practice: '#10b981',
glossary: '#3b82f6',
reference: '#a855f7',
};
var sevColors = { low:'#22c55e', medium:'#f59e0b', high:'#f97316', critical:'#ef4444' };
var html = entries.map(function(e) {
var catColor = catColors[e.category] || 'var(--accent)';
var sevColor = sevColors[e.severity] || 'var(--text-dim)';
var tags = (e.tags || []).slice(0,4).map(function(t) {
return '<span style="display:inline-block;padding:1px 6px;border-radius:10px;font-size:0.68rem;background:var(--surface3);color:var(--text-dim);margin-right:3px">' + esc(t) + '</span>';
}).join('');
var formFactors = (e.applies_to_form_factors||[]).length
? '<span style="font-size:0.72rem;color:var(--text-dim)">FF: ' + esc((e.applies_to_form_factors||[]).join(', ')) + '</span>'
: '';
var speeds = (e.applies_to_speeds||[]).length
? '<span style="font-size:0.72rem;color:var(--text-dim);margin-left:0.75rem">Speed: ' + esc((e.applies_to_speeds||[]).join(', ')) + '</span>'
: '';
return '<div style="background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:1rem 1.1rem;margin-bottom:0.6rem;transition:border-color 0.15s" '
+ 'onmouseover="this.style.borderColor=\'' + catColor + '\'" onmouseout="this.style.borderColor=\'\'">'
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:0.5rem;margin-bottom:0.5rem">'
+ '<div style="font-size:0.9rem;font-weight:600;color:var(--text-bright);line-height:1.35">' + esc(e.question || '—') + '</div>'
+ '<div style="display:flex;gap:0.35rem;flex-shrink:0">'
+ '<span style="font-size:0.68rem;padding:2px 7px;border-radius:4px;background:' + catColor + '22;color:' + catColor + ';border:1px solid ' + catColor + '44;white-space:nowrap">' + esc(e.category) + '</span>'
+ (e.severity ? '<span style="font-size:0.68rem;padding:2px 7px;border-radius:4px;background:' + sevColor + '22;color:' + sevColor + ';border:1px solid ' + sevColor + '44">' + esc(e.severity) + '</span>' : '')
+ '</div></div>'
+ (e.subcategory ? '<div style="font-size:0.72rem;color:var(--text-dim);margin-bottom:0.5rem;font-style:italic">' + esc(e.subcategory) + '</div>' : '')
+ '<div style="font-size:0.82rem;color:var(--text-muted);line-height:1.6;white-space:pre-wrap">' + esc(e.answer || '—') + '</div>'
+ ((tags || formFactors || speeds) ? '<div style="margin-top:0.6rem">' + tags + formFactors + speeds + '</div>' : '')
+ '</div>';
}).join('');
buildDOM(resultsEl, '<div style="font-size:0.75rem;color:var(--text-dim);margin-bottom:0.75rem">' + total + ' entries</div>' + html);
}
</script> </script>
<script src="/dashboard/hot-topics.js"></script> <script src="/dashboard/hot-topics.js"></script>
</body> </body>