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:
parent
d7c1c351fe
commit
fb060ee40a
@ -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 (0–100) 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":"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":"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."}
|
||||
|
||||
@ -37,6 +37,7 @@ import { formFactorsRouter } from "./routes/form-factors";
|
||||
import { tipLlmRouter } from "./routes/tip-llm";
|
||||
import { equivalencesRouter } from "./routes/equivalences";
|
||||
import { priceHistoryRouter } from "./routes/price-history";
|
||||
import { kbRouter } from "./routes/kb";
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -108,6 +109,7 @@ app.use("/api/tip-llm", tipLlmRouter);
|
||||
app.use("/api/equivalences", equivalencesRouter);
|
||||
// Price history charts
|
||||
app.use("/api/price-history", priceHistoryRouter);
|
||||
app.use("/api/kb", kbRouter);
|
||||
|
||||
// Dashboard (static HTML)
|
||||
app.use("/dashboard", express.static(join(__dirname, "..", "..", "dashboard")));
|
||||
|
||||
47
packages/api/src/routes/kb.ts
Normal file
47
packages/api/src/routes/kb.ts
Normal 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) });
|
||||
}
|
||||
});
|
||||
@ -872,3 +872,74 @@ procurementRouter.get("/supply-squeeze", async (_req: Request, res: Response) =>
|
||||
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) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -136,3 +136,38 @@ vendorRouter.get("/:id", async (req: Request, res: Response) => {
|
||||
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) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -806,6 +806,7 @@
|
||||
<div class="tab" data-tab="stock">🏭 Stock</div>
|
||||
<div class="tab" data-tab="prices">💲 Price Comparison</div>
|
||||
<div class="tab" data-tab="equivalences">🔀 Equivalences</div>
|
||||
<div class="tab" data-tab="kb">📚 KB</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
@ -839,6 +840,49 @@
|
||||
<div class="stat-val" id="ov-news">—</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 -->
|
||||
<div class="card mb" id="verification-card">
|
||||
<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-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>
|
||||
<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="">
|
||||
</div>
|
||||
<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>
|
||||
<button class="btn" onclick="openCreateVendorModal()" style="background:var(--accent);color:#fff;white-space:nowrap">+ New Vendor</button>
|
||||
</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 class="loading pulse">Loading vendors…</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('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('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('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>
|
||||
@ -1657,6 +1726,28 @@
|
||||
<div id="proc-deadstock-list"><div style="color:var(--text-dim)">Loading…</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 -->
|
||||
<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>
|
||||
@ -2396,6 +2487,30 @@
|
||||
</div>
|
||||
</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><!-- .app -->
|
||||
@ -2767,6 +2882,7 @@ function goToTab(tabName) {
|
||||
if (tabName === 'switches') searchSwitches();
|
||||
if (tabName === 'news') loadNews(1);
|
||||
if (tabName === 'vendors') loadVendors();
|
||||
if (tabName === 'kb' && !window._kbLoaded) loadKB();
|
||||
if (tabName === 'standards') loadStandardsList();
|
||||
if (tabName === 'blog') { loadBlogDrafts(); loadSLLInsights(); loadBlogLLMStatus(); loadPostingTime(); }
|
||||
if (tabName === 'finder') document.getElementById('finder-switch-input').focus();
|
||||
@ -2946,6 +3062,9 @@ async function loadOverview() {
|
||||
+ '</div>';
|
||||
}).join('') || '<div class="loading">No news yet</div>');
|
||||
} catch(e) {}
|
||||
|
||||
// Async fire — don't block overview render
|
||||
loadProcurementPulse();
|
||||
}
|
||||
|
||||
// SEARCH
|
||||
@ -3712,18 +3831,24 @@ function searchTransceivers() {
|
||||
var ff = el('tx-ff-filter').value;
|
||||
var vf = el('tx-vendor-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 = [];
|
||||
if (q) params.push('q=' + encodeURIComponent(q));
|
||||
if (ff) params.push('form_factor=' + encodeURIComponent(ff));
|
||||
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');
|
||||
|
||||
api('/api/transceivers?' + params.join('&')).then(function(data) {
|
||||
lastTxData = data.data || data.transceivers || [];
|
||||
// Show result count in search bar placeholder
|
||||
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');
|
||||
if (txSearchEl && !activeFilter) txSearchEl.placeholder = 'Filter: Nexus 9300, QSFP28, 400G, coherent… (' + total + ' transceivers total)';
|
||||
// Show count above table + clear button
|
||||
@ -5076,6 +5201,7 @@ async function loadVendors() {
|
||||
return diff !== 0 ? diff : (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
filterVendorCards();
|
||||
loadVendorIntelligence();
|
||||
}
|
||||
|
||||
function filterVendorCards() {
|
||||
@ -7049,7 +7175,7 @@ var procAiClustersData = [];
|
||||
var procAiClustersMinTx = 0;
|
||||
|
||||
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) {
|
||||
var sec = el('proc-section-' + 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 === 'supply-squeeze' && !el('proc-squeeze-list').querySelector('div.card,table')) loadSupplySqueeze();
|
||||
if (name === 'dead-stock' && !el('proc-deadstock-list').querySelector('table')) loadDeadStockRevival();
|
||||
if (name === 'movers' && !window._moversLoaded) loadMovers(7);
|
||||
}
|
||||
|
||||
/* ── 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>'
|
||||
+ ' <span style="font-size:0.75rem;color:var(--green)">Gainers +' + (s.avgGainPct||0).toFixed(1) + '%</span>'
|
||||
+ ' <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> '
|
||||
+ '<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 src="/dashboard/hot-topics.js"></script>
|
||||
</body>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user