feat: procurement — Internal Demand + AI Clusters sections with real data
Two new procurement sub-tabs backed by live database tables: 📦 Internal Demand (flexoptix_internal_demand, 8,585 SKUs): - Velocity cards: fast_mover (70 SKUs, 53k units/12M), regular, slow, dead stock - Filterable table with demand_12m, demand_3m, trend %, form factor - GET /api/procurement/internal-demand — summary + paginated rows 🤖 AI Clusters (ai_cluster_announcements, 396 rows last 30d): - Live datacenter build announcements with estimated transceiver demand - Stats: total announcements, MW sum, distinct companies, total ~transceivers - Filter for entries with transceiver estimates; time range selector - GET /api/procurement/ai-clusters — data + period stats Also: replaced misleading DEMO DATA banners on Reorder Signals and ABC Classification sections with informational notes pointing to real data.
This commit is contained in:
parent
ea8be4aea3
commit
13fe33eceb
@ -1,6 +1,15 @@
|
||||
# TIP Changelog
|
||||
|
||||
Format: `{"d":"YYYY-MM-DD","t":"TYPE","m":"Description"}`
|
||||
{"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":"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."}
|
||||
{"d":"2026-05-14","t":"FIX","m":"Blog from URL: SPA-aware content extraction. fetchUrlContent() now extracts OG/meta tags (og:title, og:description, name=description, og:site_name) as fallback for JavaScript SPAs. Returns spaDetected=true when body text < 300 chars. from-url endpoint skips gatherBlogData() product injection when SPA detected — prevents fo-blog model from defaulting to optical networking domain on non-networking URLs. additionalContext now includes explicit SPA warning + meta content. generated_by in pipeline UPDATE uses active model name (no more hardcoded 'fo-blog-engine-v7'). Dashboard shows SPA warning toast + spa_detected field in response."}
|
||||
{"d":"2026-05-14","t":"FEAT","m":"Blog Engine: URL → Blog feature. POST /api/blog/from-url fetches any URL server-side (20s timeout, redirect-follow), strips scripts/nav/footer/SVG, extracts readable text (~5000 chars) + page title, passes as structured additional_context to the 16-step FO blog pipeline. Dashboard: new '🔗 Blog aus URL generieren' panel with URL input (Enter key supported), Blog-Typ selector, loading state, and char count confirmation. Same pollBlogLlm() polling reused for step progress."}
|
||||
{"d":"2026-05-14","t":"UI","m":"Switch modal Flexoptix section: (1) Speed formatting fixed — 1600.00G → 1.6T, 400G clean integer (fmtSpeed() helper, ≥1000 Gbps → T). (2) Lagerbestand badges added per transceiver row: DE-Lager (green), Global-Lager (blue), Zulauf with ETA date (yellow). Data sourced from stock_observations via LEFT JOIN LATERAL in getFlexoptixSuggestions(). Badges hidden when quantities are null/0 (scraper not yet populating Flexoptix warehouse columns — shows automatically once scraper is extended)."}
|
||||
{"d":"2026-05-14","t":"FEAT","m":"Stock velocity API: GET /api/stock/velocity (paginated, filterable by vendor_id/confidence/stockout_days/min_sell_rate/part_number) + GET /api/stock/velocity/:id (per-product velocity summary + sell/zulauf event history). Both routes live in packages/api/src/routes/stock.ts, compiled + deployed to tip-api PM2 id 24, port 3201."}
|
||||
{"d":"2026-05-14","t":"DATA","m":"Demo data cleanup: deleted 2133 demo rows from reorder_signals (is_demo_data=true). Stock observation coverage expanded: atgbics.ts + optcore.ts now call upsertStockObservation after each price observation (binary in/out stock, confidence=1). FS.com scraper already runs 3x daily from Mac (02:00/10:00/18:00) with full DE-Lager/Global-Lager/Nachlieferung breakdown. Competitor stock audit: QSFPTEK (confidence=2, real quantities), FS.COM (confidence=3, per-warehouse breakdown) are highest fidelity; ATGBICS/Optcore added at confidence=1 (binary); sfpcables/prolabs/wiitek hardcode or lack stock — not added."}
|
||||
{"d":"2026-05-13","t":"FIX","m":"BlogLLM model version sync: dashboard FO_BlogLLM card now dynamically reflects the active Ollama model via /api/blog/llm/status (was hardcoded to fo-blog-v7). TIP ecosystem.config.js OLLAMA_LLM_MODEL + BLOG_LLM_MODEL bumped fo-blog-v7 → fo-blog-v10 (Mac Studio Magatama training adopted 2026-05-13 00:33 UTC). Persisted /opt/tip/blog-llm-settings.json overrode env — also updated. tip-api restarted, PM2 state saved."}
|
||||
{"d":"2026-05-13","t":"FEAT","m":"BlogLLM auto-discovery: client.ts now probes Ollama at startup + every 10 min, reconciles configured fo-blog-vN against actual available tags, auto-falls to highest available version when configured model no longer exists. Magatama-aware sort: base 'fo-blog-vN' tag wins over '-rM' revisions within same N (matches Magatama adoption convention where -rM is intermediate adapter save, base is production alias). New POST /api/blog/llm/refresh-discovery endpoint for manual trigger. Eliminates 3-step manual sync after every Magatama training."}
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
* GET /api/procurement/market-intel — Market intelligence events
|
||||
* GET /api/procurement/stock-trends/:id — Stock history for a transceiver
|
||||
* GET /api/procurement/lifecycle — Lifecycle events (EOL, standards)
|
||||
* GET /api/procurement/ai-clusters — AI datacenter announcements with transceiver demand
|
||||
* GET /api/procurement/internal-demand — Flexoptix internal demand velocity (fast/slow/dead)
|
||||
*/
|
||||
import { Router, Request, Response } from "express";
|
||||
import { pool } from "../db/client";
|
||||
@ -291,3 +293,134 @@ procurementRouter.get("/lifecycle", async (req: Request, res: Response) => {
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/procurement/ai-clusters?days=90&limit=50&min_transceivers=0
|
||||
// Returns AI datacenter announcements with transceiver demand estimates
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
procurementRouter.get("/ai-clusters", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
days = "90",
|
||||
limit = "50",
|
||||
min_transceivers = "0",
|
||||
} = req.query;
|
||||
|
||||
const daysN = Math.min(Math.max(parseInt(days as string) || 90, 1), 730);
|
||||
const limitN = Math.min(parseInt(limit as string) || 50, 200);
|
||||
const minTx = parseInt(min_transceivers as string) || 0;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
id, company, title, summary,
|
||||
announced_date, scale_mw, scale_servers,
|
||||
network_speed, estimated_transceivers,
|
||||
deployment_date, location, source_url, source_name,
|
||||
created_at
|
||||
FROM ai_cluster_announcements
|
||||
WHERE
|
||||
(announced_date IS NULL OR announced_date >= NOW() - INTERVAL '1 day' * $1)
|
||||
AND ($2 = 0 OR estimated_transceivers >= $2)
|
||||
ORDER BY announced_date DESC NULLS LAST, created_at DESC
|
||||
LIMIT $3`,
|
||||
[daysN, minTx, limitN]
|
||||
);
|
||||
|
||||
// Aggregate stats
|
||||
const statsResult = await pool.query(
|
||||
`SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE estimated_transceivers > 0) AS with_estimates,
|
||||
SUM(estimated_transceivers) FILTER (WHERE estimated_transceivers > 0) AS total_estimated_transceivers,
|
||||
SUM(scale_mw) FILTER (WHERE scale_mw IS NOT NULL) AS total_mw,
|
||||
COUNT(DISTINCT company) FILTER (WHERE company != 'Unknown') AS distinct_companies
|
||||
FROM ai_cluster_announcements
|
||||
WHERE announced_date >= NOW() - INTERVAL '1 day' * $1`,
|
||||
[daysN]
|
||||
);
|
||||
|
||||
res.json({
|
||||
data: result.rows,
|
||||
stats: statsResult.rows[0],
|
||||
period_days: daysN,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("AI clusters error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GET /api/procurement/internal-demand?velocity_class=fast_mover&limit=100
|
||||
// Returns Flexoptix internal demand data — real SKU velocity from internal data
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
procurementRouter.get("/internal-demand", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
velocity_class,
|
||||
limit = "100",
|
||||
offset = "0",
|
||||
sort = "demand_12m",
|
||||
} = req.query;
|
||||
|
||||
const allowedSorts: Record<string, string> = {
|
||||
demand_12m: "fid.demand_12m DESC",
|
||||
demand_3m: "fid.demand_3m DESC",
|
||||
trend: "fid.demand_trend_pct DESC NULLS LAST",
|
||||
sku: "fid.sku ASC",
|
||||
};
|
||||
const orderBy = allowedSorts[sort as string] ?? allowedSorts["demand_12m"];
|
||||
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = ["fid.is_internal = true"];
|
||||
let idx = 1;
|
||||
|
||||
if (velocity_class) {
|
||||
conditions.push(`fid.velocity_class = $${idx}`);
|
||||
params.push(velocity_class);
|
||||
idx++;
|
||||
}
|
||||
|
||||
params.push(Math.min(parseInt(limit as string) || 100, 500));
|
||||
params.push(Math.max(parseInt(offset as string) || 0, 0));
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT
|
||||
fid.id, fid.sku, fid.description,
|
||||
fid.demand_12m, fid.demand_3m, fid.demand_trend_pct,
|
||||
fid.velocity_class, fid.imported_at,
|
||||
t.part_number, t.standard_name, t.form_factor, t.speed_gbps,
|
||||
t.reach_label, t.image_url,
|
||||
v.name AS vendor_name
|
||||
FROM flexoptix_internal_demand fid
|
||||
LEFT JOIN transceivers t ON t.id = fid.transceiver_id
|
||||
LEFT JOIN vendors v ON v.id = t.vendor_id
|
||||
WHERE ${conditions.join(" AND ")}
|
||||
ORDER BY ${orderBy}
|
||||
LIMIT $${idx} OFFSET $${idx + 1}`,
|
||||
params
|
||||
);
|
||||
|
||||
// Velocity summary
|
||||
const summaryResult = await pool.query(
|
||||
`SELECT
|
||||
velocity_class,
|
||||
COUNT(*) AS cnt,
|
||||
SUM(demand_12m)::numeric(12,0) AS total_demand_12m,
|
||||
AVG(demand_trend_pct)::numeric(8,1) AS avg_trend_pct
|
||||
FROM flexoptix_internal_demand
|
||||
WHERE is_internal = true
|
||||
GROUP BY velocity_class
|
||||
ORDER BY total_demand_12m DESC NULLS LAST`
|
||||
);
|
||||
|
||||
res.json({
|
||||
data: result.rows,
|
||||
summary: summaryResult.rows,
|
||||
total: result.rowCount,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Internal demand error:", err);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
});
|
||||
|
||||
@ -1566,6 +1566,8 @@
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1.25rem;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="showProcSection('signals')" id="proc-btn-signals" class="proc-btn proc-btn-active">Reorder Signals</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>
|
||||
<button onclick="showProcSection('market')" id="proc-btn-market" class="proc-btn">Market Intelligence</button>
|
||||
<button onclick="showProcSection('lifecycle')" id="proc-btn-lifecycle" class="proc-btn">Lifecycle Events</button>
|
||||
<div style="flex:1"></div>
|
||||
@ -1574,7 +1576,7 @@
|
||||
|
||||
<!-- Reorder Signals section -->
|
||||
<div id="proc-section-signals">
|
||||
<div style="padding:0.5rem 0.75rem;background:#f59e0b11;border:1px solid #f59e0b33;border-radius:6px;font-size:0.72rem;color:#f59e0b;margin-bottom:0.75rem">⚠ <strong>DEMO DATA</strong> — Reorder Signals basieren auf synthetischen Verkaufs- und Lagermengen. Signalstärke spiegelt keine echten Flexoptix-Bestände wider.</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>
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
<button onclick="filterSignal('')" id="sig-all" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
|
||||
<button onclick="filterSignal('buy_now')" style="background:rgba(193,18,31,0.1);border:1px solid rgba(193,18,31,0.3);color:#c1121f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🔴 Buy Now</button>
|
||||
@ -1589,7 +1591,7 @@
|
||||
|
||||
<!-- ABC Classification section -->
|
||||
<div id="proc-section-abc" style="display:none">
|
||||
<div style="padding:0.5rem 0.75rem;background:#f59e0b11;border:1px solid #f59e0b33;border-radius:6px;font-size:0.72rem;color:#f59e0b;margin-bottom:0.75rem">⚠ <strong>DEMO DATA</strong> — ABC-Klassifizierung basiert auf synthetischen Verkaufszahlen aus dem Seed-Datensatz. Keine echten Flexoptix-Umsätze.</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">ℹ ABC-Klassifizierung kombiniert Preis-Observations-Frequenz, Compatibility-Einträge und Vendor-Anzahl. Echte SKU-Nachfragedaten → <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="display:flex;gap:0.5rem;margin-bottom:1rem">
|
||||
<button onclick="filterAbc('')" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
|
||||
<button onclick="filterAbc('A')" style="background:rgba(193,18,31,0.1);border:1px solid rgba(193,18,31,0.3);color:#c1121f;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">A — High Turnover</button>
|
||||
@ -1627,6 +1629,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Internal Demand section -->
|
||||
<div id="proc-section-demand" style="display:none">
|
||||
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="demand-summary-cards">
|
||||
<div class="loading pulse" style="width:100%">Loading demand data...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="filterVelocity('')" id="vel-all" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All</button>
|
||||
<button onclick="filterVelocity('fast_mover')" id="vel-fast" style="background:rgba(22,163,74,0.1);border:1px solid rgba(22,163,74,0.3);color:#16a34a;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🚀 Fast Movers</button>
|
||||
<button onclick="filterVelocity('regular')" id="vel-regular" style="background:rgba(37,99,235,0.1);border:1px solid rgba(37,99,235,0.3);color:#2563eb;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">📦 Regular</button>
|
||||
<button onclick="filterVelocity('slow_mover')" id="vel-slow" style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);color:#c07000;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">🐢 Slow Movers</button>
|
||||
<button onclick="filterVelocity('dead_stock')" id="vel-dead" style="background:rgba(136,136,136,0.1);border:1px solid #ddd;color:var(--text-dim);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">💀 Dead Stock</button>
|
||||
<div style="flex:1"></div>
|
||||
<span style="font-size:0.72rem;color:var(--text-dim)">Real Flexoptix internal demand data</span>
|
||||
</div>
|
||||
<div class="card" style="overflow-x:auto">
|
||||
<table style="width:100%;border-collapse:collapse;font-size:0.8rem" id="demand-table">
|
||||
<thead><tr style="border-bottom:2px solid var(--border);color:var(--text-dim);font-size:0.7rem;font-weight:700;text-transform:uppercase">
|
||||
<th style="text-align:left;padding:8px 6px">Velocity</th>
|
||||
<th style="text-align:left;padding:8px 6px">SKU / Product</th>
|
||||
<th style="text-align:left;padding:8px 6px">Form Factor</th>
|
||||
<th class="tip" data-tip="Units demanded in last 12 months" style="text-align:right;padding:8px 6px">Demand 12M</th>
|
||||
<th class="tip" data-tip="Units demanded in last 3 months" style="text-align:right;padding:8px 6px">Demand 3M</th>
|
||||
<th class="tip" data-tip="Demand trend vs prior period (positive = growing)" style="text-align:right;padding:8px 6px">Trend</th>
|
||||
</tr></thead>
|
||||
<tbody id="demand-tbody"><tr><td colspan="6" style="padding:1rem;color:var(--text-dim)">Loading...</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Clusters section -->
|
||||
<div id="proc-section-ai-clusters" style="display:none">
|
||||
<div style="display:flex;gap:1rem;margin-bottom:1.25rem;flex-wrap:wrap" id="ai-cluster-stats">
|
||||
<div class="loading pulse" style="width:100%">Loading AI cluster data...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:1rem;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="filterAiClusters(0)" id="ai-all" style="background:var(--accent);color:white;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">All Announcements</button>
|
||||
<button onclick="filterAiClusters(1)" id="ai-demand" style="background:rgba(124,92,252,0.1);border:1px solid rgba(124,92,252,0.3);color:#7c5cfc;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:0.75rem">📡 With Transceiver Demand</button>
|
||||
<div style="flex:1"></div>
|
||||
<select id="ai-days-select" onchange="reloadAiClusters()" style="font-size:0.75rem;padding:3px 8px;border:1px solid var(--border);border-radius:4px;background:var(--surface);color:var(--text)">
|
||||
<option value="30">Last 30 days</option>
|
||||
<option value="90" selected>Last 90 days</option>
|
||||
<option value="180">Last 180 days</option>
|
||||
<option value="365">Last 12 months</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="ai-cluster-grid" style="display:grid;gap:0.75rem;grid-template-columns:repeat(auto-fill,minmax(420px,1fr))">
|
||||
<div class="loading pulse">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end tab-procurement -->
|
||||
|
||||
<!-- CRAWLER INTELLIGENCE -->
|
||||
@ -6710,14 +6762,22 @@ var procCurrentSignalFilter = '';
|
||||
var procCurrentAbcFilter = '';
|
||||
var procSignalsData = [];
|
||||
var procAbcData = [];
|
||||
var procDemandData = [];
|
||||
var procDemandSummary = [];
|
||||
var procDemandFilter = '';
|
||||
var procAiClustersData = [];
|
||||
var procAiClustersMinTx = 0;
|
||||
|
||||
function showProcSection(name) {
|
||||
['signals','abc','market','lifecycle'].forEach(function(s) {
|
||||
['signals','abc','demand','ai-clusters','market','lifecycle'].forEach(function(s) {
|
||||
var sec = el('proc-section-' + s);
|
||||
var btn = el('proc-btn-' + s);
|
||||
if (sec) sec.style.display = s === name ? '' : 'none';
|
||||
if (btn) { btn.classList.toggle('proc-btn-active', s === name); }
|
||||
});
|
||||
// Lazy-load on first visit
|
||||
if (name === 'demand' && procDemandData.length === 0) loadInternalDemand();
|
||||
if (name === 'ai-clusters' && procAiClustersData.length === 0) loadAiClusters();
|
||||
}
|
||||
|
||||
async function loadProcurement() {
|
||||
@ -6933,6 +6993,157 @@ async function loadProcLifecycle() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── INTERNAL DEMAND ─────────────────────────────────────────────────────────
|
||||
|
||||
async function loadInternalDemand() {
|
||||
try {
|
||||
var d = await api('/api/procurement/internal-demand?limit=500');
|
||||
procDemandData = d.data || [];
|
||||
procDemandSummary = d.summary || [];
|
||||
renderDemandSummaryCards();
|
||||
filterVelocity(procDemandFilter);
|
||||
} catch(e) {
|
||||
el('demand-tbody').innerHTML = '<tr><td colspan="6" style="padding:1rem;color:var(--text-dim)">Could not load demand data.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDemandSummaryCards() {
|
||||
var velConfig = {
|
||||
fast_mover: { label:'Fast Movers', icon:'🚀', bg:'rgba(22,163,74,0.08)', border:'rgba(22,163,74,0.25)', color:'#16a34a' },
|
||||
regular: { label:'Regular', icon:'📦', bg:'rgba(37,99,235,0.08)', border:'rgba(37,99,235,0.25)', color:'#2563eb' },
|
||||
slow_mover: { label:'Slow Movers', icon:'🐢', bg:'rgba(245,158,11,0.08)', border:'rgba(245,158,11,0.25)', color:'#c07000' },
|
||||
dead_stock: { label:'Dead Stock', icon:'💀', bg:'rgba(136,136,136,0.1)', border:'rgba(136,136,136,0.25)','color':'var(--text-dim)' },
|
||||
};
|
||||
var cards = procDemandSummary.map(function(row) {
|
||||
var cfg = velConfig[row.velocity_class] || { label:row.velocity_class, icon:'📊', bg:'var(--surface2)', border:'var(--border)', color:'var(--text)' };
|
||||
var trend = parseFloat(row.avg_trend_pct);
|
||||
var trendHtml = isNaN(trend) ? '' : '<span style="color:' + (trend >= 0 ? '#16a34a' : '#c1121f') + ';font-size:0.75rem;font-weight:600">' + (trend >= 0 ? '▲' : '▼') + ' ' + Math.abs(trend).toFixed(1) + '%</span>';
|
||||
return '<div onclick="filterVelocity(\'' + row.velocity_class + '\')" style="flex:1;min-width:140px;background:' + cfg.bg + ';border:1px solid ' + cfg.border + ';border-radius:10px;padding:0.85rem 1rem;cursor:pointer">'
|
||||
+ '<div style="font-size:1.4rem;line-height:1">' + cfg.icon + '</div>'
|
||||
+ '<div style="font-weight:700;font-size:0.82rem;color:' + cfg.color + ';margin-top:0.3rem">' + cfg.label + '</div>'
|
||||
+ '<div style="font-size:1.5rem;font-weight:800;color:' + cfg.color + ';line-height:1.1">' + parseInt(row.cnt).toLocaleString() + '</div>'
|
||||
+ '<div style="font-size:0.7rem;color:var(--text-dim)">SKUs</div>'
|
||||
+ (parseFloat(row.total_demand_12m) > 0 ? '<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.25rem">' + parseInt(row.total_demand_12m).toLocaleString() + ' units/12M</div>' : '')
|
||||
+ (trendHtml ? '<div style="margin-top:0.25rem">' + trendHtml + '</div>' : '')
|
||||
+ '</div>';
|
||||
});
|
||||
el('demand-summary-cards').innerHTML = cards.join('');
|
||||
}
|
||||
|
||||
function filterVelocity(cls) {
|
||||
procDemandFilter = cls;
|
||||
var data = cls ? procDemandData.filter(function(r) { return r.velocity_class === cls; }) : procDemandData;
|
||||
var velColors = { fast_mover:'#16a34a', regular:'#2563eb', slow_mover:'#c07000', dead_stock:'var(--text-dim)' };
|
||||
var velIcons = { fast_mover:'🚀', regular:'📦', slow_mover:'🐢', dead_stock:'💀' };
|
||||
el('demand-tbody').innerHTML = data.slice(0, 300).map(function(r) {
|
||||
var trend = parseFloat(r.demand_trend_pct);
|
||||
var trendHtml = isNaN(trend) ? '—' : '<span style="color:' + (trend >= 0 ? '#16a34a' : '#c1121f') + ';font-weight:600">' + (trend >= 0 ? '+' : '') + trend.toFixed(1) + '%</span>';
|
||||
var velColor = velColors[r.velocity_class] || 'var(--text-dim)';
|
||||
var velIcon = velIcons[r.velocity_class] || '·';
|
||||
var productName = r.standard_name || r.part_number || r.description || r.sku;
|
||||
var demand12 = parseFloat(r.demand_12m);
|
||||
var demand3 = parseFloat(r.demand_3m);
|
||||
return '<tr style="border-bottom:1px solid var(--border)">'
|
||||
+ '<td style="padding:7px 6px"><span style="font-size:1rem" title="' + esc(r.velocity_class) + '">' + velIcon + '</span></td>'
|
||||
+ '<td style="padding:7px 6px"><div style="font-weight:600;font-size:0.82rem">' + esc(productName || '—') + '</div>'
|
||||
+ '<div style="font-size:0.68rem;color:var(--text-dim)">' + esc(r.sku) + (r.vendor_name ? ' · ' + esc(r.vendor_name) : '') + '</div></td>'
|
||||
+ '<td style="padding:7px 6px;font-size:0.75rem;color:var(--text-dim)">' + esc(r.form_factor || '—') + (r.speed_gbps ? ' ' + r.speed_gbps + 'G' : '') + '</td>'
|
||||
+ '<td style="padding:7px 6px;text-align:right;font-weight:700;color:' + velColor + '">' + (demand12 > 0 ? demand12.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>'
|
||||
+ '<td style="padding:7px 6px;text-align:right;color:var(--text-dim)">' + (demand3 > 0 ? demand3.toLocaleString(undefined,{maximumFractionDigits:0}) : '—') + '</td>'
|
||||
+ '<td style="padding:7px 6px;text-align:right">' + trendHtml + '</td>'
|
||||
+ '</tr>';
|
||||
}).join('') || '<tr><td colspan="6" style="padding:1rem;color:var(--text-dim)">No items for this filter.</td></tr>';
|
||||
}
|
||||
|
||||
// ─── AI CLUSTERS ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAiClusters() {
|
||||
var days = (el('ai-days-select') || {}).value || '90';
|
||||
var container = el('ai-cluster-grid');
|
||||
container.innerHTML = '<div class="loading pulse" style="grid-column:1/-1">Loading AI cluster data...</div>';
|
||||
el('ai-cluster-stats').innerHTML = '<div class="loading pulse" style="width:100%">Computing stats...</div>';
|
||||
try {
|
||||
var d = await api('/api/procurement/ai-clusters?days=' + days + '&limit=100');
|
||||
procAiClustersData = d.data || [];
|
||||
var stats = d.stats || {};
|
||||
renderAiClusterStats(stats, d.period_days || parseInt(days));
|
||||
filterAiClusters(procAiClustersMinTx);
|
||||
} catch(e) {
|
||||
container.innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">Could not load AI cluster announcements.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function reloadAiClusters() {
|
||||
procAiClustersData = [];
|
||||
loadAiClusters();
|
||||
}
|
||||
|
||||
function renderAiClusterStats(stats, days) {
|
||||
var totalTx = parseInt(stats.total_estimated_transceivers) || 0;
|
||||
var totalMW = parseFloat(stats.total_mw) || 0;
|
||||
var withEst = parseInt(stats.with_estimates) || 0;
|
||||
var total = parseInt(stats.total) || 0;
|
||||
var companies = parseInt(stats.distinct_companies) || 0;
|
||||
el('ai-cluster-stats').innerHTML = [
|
||||
statCard('📰', total.toLocaleString(), 'Announcements', 'Last ' + days + ' days'),
|
||||
statCard('🏭', companies.toLocaleString(), 'Named Companies', 'Distinct organizations'),
|
||||
statCard('⚡', totalMW > 0 ? totalMW.toLocaleString(undefined,{maximumFractionDigits:0}) + ' MW' : '—', 'Total Power (known)', 'Sum of MW for entries with scale data'),
|
||||
statCard('📡', totalTx > 0 ? totalTx.toLocaleString() : '—', 'Est. Transceivers', withEst + ' entries with demand estimates'),
|
||||
].join('');
|
||||
}
|
||||
|
||||
function statCard(icon, value, label, sub) {
|
||||
return '<div style="flex:1;min-width:130px;background:var(--surface2);border:1px solid var(--border);border-radius:10px;padding:0.85rem 1rem">'
|
||||
+ '<div style="font-size:1.2rem">' + icon + '</div>'
|
||||
+ '<div style="font-size:1.5rem;font-weight:800;color:var(--text);line-height:1.1;margin-top:0.3rem">' + value + '</div>'
|
||||
+ '<div style="font-size:0.78rem;font-weight:700;color:var(--text);margin-top:0.2rem">' + label + '</div>'
|
||||
+ '<div style="font-size:0.68rem;color:var(--text-dim)' + '">' + sub + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function filterAiClusters(minTx) {
|
||||
procAiClustersMinTx = minTx;
|
||||
var data = minTx > 0 ? procAiClustersData.filter(function(r) { return (r.estimated_transceivers || 0) >= minTx; }) : procAiClustersData;
|
||||
if (!data.length) {
|
||||
el('ai-cluster-grid').innerHTML = '<div style="color:var(--text-dim);padding:1rem;grid-column:1/-1">No matching announcements.</div>';
|
||||
return;
|
||||
}
|
||||
el('ai-cluster-grid').innerHTML = data.map(function(item) {
|
||||
var txCount = parseInt(item.estimated_transceivers);
|
||||
var mw = parseFloat(item.scale_mw);
|
||||
var hasTxEst = !isNaN(txCount) && txCount > 0;
|
||||
var hasMw = !isNaN(mw) && mw > 0;
|
||||
var dateStr = item.announced_date ? new Date(item.announced_date).toLocaleDateString('de-DE') : '';
|
||||
var company = item.company && item.company !== 'Unknown' ? esc(item.company) : '';
|
||||
var loc = item.location ? esc(item.location) : '';
|
||||
var borderColor = hasTxEst ? '#7c5cfc' : (hasMw ? '#2563eb' : 'var(--border)');
|
||||
var txHtml = hasTxEst
|
||||
? '<div style="display:inline-flex;align-items:center;gap:0.3rem;background:rgba(124,92,252,0.1);border:1px solid rgba(124,92,252,0.3);border-radius:5px;padding:2px 8px;font-size:0.75rem;font-weight:700;color:#7c5cfc">📡 ~' + txCount.toLocaleString() + ' transceivers</div>'
|
||||
: '';
|
||||
var mwHtml = hasMw
|
||||
? '<span style="background:rgba(37,99,235,0.1);border:1px solid rgba(37,99,235,0.3);border-radius:4px;padding:1px 6px;font-size:0.68rem;font-weight:700;color:#2563eb">⚡ ' + mw.toLocaleString() + ' MW</span>'
|
||||
: '';
|
||||
var speedHtml = item.network_speed
|
||||
? '<span style="background:var(--surface2);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:0.68rem;color:var(--text-dim)">' + esc(item.network_speed) + '</span>'
|
||||
: '';
|
||||
return '<div class="card" style="border-left:3px solid ' + borderColor + ';padding:0.9rem 1rem">'
|
||||
+ '<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:0.5rem">'
|
||||
+ '<div style="display:flex;flex-direction:column;gap:0.3rem">'
|
||||
+ (txHtml ? txHtml + ' ' : '')
|
||||
+ '<div style="display:flex;gap:0.4rem;flex-wrap:wrap;margin-top:0.2rem">' + mwHtml + speedHtml + '</div>'
|
||||
+ '</div>'
|
||||
+ (dateStr ? '<span style="font-size:0.68rem;color:var(--text-dim);white-space:nowrap">' + dateStr + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '<div style="font-weight:700;font-size:0.85rem;line-height:1.4;margin-bottom:0.4rem">' + esc(item.title) + '</div>'
|
||||
+ (item.summary ? '<div style="font-size:0.73rem;color:var(--text-dim);line-height:1.5;margin-bottom:0.5rem">' + esc(item.summary.substring(0,200)) + (item.summary.length > 200 ? '…' : '') + '</div>' : '')
|
||||
+ '<div style="display:flex;justify-content:space-between;align-items:center;font-size:0.68rem;color:var(--text-dim)">'
|
||||
+ '<span>' + (company || '?') + (loc ? ' · ' + loc : '') + (item.source_name ? ' · ' + esc(item.source_name) : '') + '</span>'
|
||||
+ (item.source_url ? '<a href="' + esc(item.source_url) + '" target="_blank" rel="noopener" style="color:var(--accent);text-decoration:none;font-size:0.68rem">→ Source</a>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// INIT
|
||||
loadOverview();
|
||||
loadChangelog();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user