// InteractiveSimulator.jsx // // A rubycrm.ai-style live event panel: three buttons let a visitor force a // scenario (Optimal / Yield Loss Alert / GRN Backlog), and a log console // streams JSON payloads shaped exactly like the real backend's // Production/WIP event stream - // trans-backend/src/inventory/events/stock-movement.events.ts // (StockInEvent / StockOutEvent / GrnStockInEvent: companyId, productId, // warehouseId, quantity, stockMovementId, performedById, occurredAt) // trans-backend/src/entities/wastage-ledger.entity.ts // (stageId, productId, cycleId, accumulatedStageStockIn/Out, // remainingInProgressQuantity, wastageQuantity, wastagePercentage, // isCycleClosed, hasNegativeBalanceWarning, triggeredByEventAction) // trans-backend/src/types.ts (TargetEventAction enum: STOCK_IN, STOCK_OUT, // GRN_STOCK_IN, MANUAL_ADJUSTMENT) // // Scenario names are the real signals the data already carries - a // wastage-percentage spike with a negative WIP balance (hasNegativeBalanceWarning, // a real WastageLedger field) is labeled "Yield Loss Alert", and stalled // GRN receipts against continuing stock-out is labeled "GRN Backlog" - // not invented floor-automation buzzwords like a fictional temperature // sensor. No values here are live or connected to any company account - // every number is generated client-side on a timer, per SPEC-07 Section 5's // anti-hallucination guardrail for this exact component. // // Loaded as a classic (non-module) Babel-transformed script - see // index.html's header comment. React 19 ships no UMD global, so // `window.React` here is the bridge index.html's ESM import sets up; hooks // are called as `React.useState` etc. (fully qualified, not destructured at // top level) because sibling classic scripts share one global lexical // scope - a repeated top-level `const useState = ...` across files is a // SyntaxError there, not a harmless shadow. const SCENARIOS = { optimal: { key: "optimal", label: "Optimal", subtitle: "Yield within target range", dot: "bg-emerald-500", badge: "border-emerald-200 bg-emerald-50 text-emerald-700", ring: "ring-emerald-500/30", }, yieldAlert: { key: "yieldAlert", label: "Yield Loss Alert", subtitle: "Wastage spike, negative WIP balance flagged", dot: "bg-red-500", badge: "border-red-200 bg-red-50 text-red-700", ring: "ring-red-500/30", }, grnBacklog: { key: "grnBacklog", label: "GRN Backlog", subtitle: "GRN receipts lagging stage demand", dot: "bg-amber-500", badge: "border-amber-200 bg-amber-50 text-amber-700", ring: "ring-amber-500/30", }, }; const PRODUCTS = [ { id: "PC-8841", name: "HDPE Resin Pellets", unit: "KG" }, { id: "PC-2207", name: "Recycled PET Flakes", unit: "KG" }, { id: "PC-5519", name: "Woven Sack Fabric", unit: "MT" }, ]; const WAREHOUSES = [ { id: "WH-MAIN", name: "Main Warehouse" }, { id: "WH-DEPOT2", name: "Depot-2" }, ]; const STAGES = [ { id: "STG-EXT-01", name: "Extrusion" }, { id: "STG-BLND-02", name: "Blending" }, { id: "STG-PACK-03", name: "Packing" }, ]; const INITIAL_METRICS = { inputMass: 4820, wastage: 96, yieldLossPct: 2.1, negBalance: false }; function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function randomFloat(min, max, decimals = 2) { return parseFloat((Math.random() * (max - min) + min).toFixed(decimals)); } function randomId(prefix) { return `${prefix}_${Math.random().toString(16).slice(2, 10)}`; } function buildLogEntry(scenarioKey, tick) { const product = pick(PRODUCTS); const warehouse = pick(WAREHOUSES); const stage = pick(STAGES); const now = new Date().toISOString(); const emitWastageEval = tick % 3 === 2 || scenarioKey !== "optimal"; if (!emitWastageEval) { const eventType = scenarioKey === "grnBacklog" ? pick(["STOCK_OUT", "STOCK_OUT", "STOCK_IN"]) : pick(["STOCK_IN", "STOCK_OUT", "GRN_STOCK_IN"]); const eventNameMap = { STOCK_IN: "inventory.stock-in", STOCK_OUT: "inventory.stock-out", GRN_STOCK_IN: "inventory.grn-stock-in", }; const qty = scenarioKey === "grnBacklog" && eventType === "GRN_STOCK_IN" ? randomFloat(2, 15) : randomFloat(80, 420); return { tone: eventType === "STOCK_OUT" ? "neutral" : "positive", summary: `${eventType.replace(/_/g, " ")} · ${product.name} · ${warehouse.name} · ${qty} ${product.unit}`, json: { event: eventNameMap[eventType], payload: { companyId: randomId("co"), productId: product.id, warehouseId: warehouse.id, quantity: qty, stockMovementId: randomId("mv"), performedById: randomId("usr"), occurredAt: now, }, }, }; } const stockIn = scenarioKey === "grnBacklog" ? randomFloat(2, 20) : randomFloat(300, 600); const stockOut = scenarioKey === "yieldAlert" ? randomFloat(250, 520) : randomFloat(150, 350); const remaining = parseFloat((stockIn - stockOut).toFixed(2)); const wastage = scenarioKey === "yieldAlert" ? randomFloat(60, 140) : scenarioKey === "grnBacklog" ? randomFloat(5, 20) : randomFloat(2, 18); const wastagePct = parseFloat(((wastage / Math.max(stockIn, 1)) * 100).toFixed(2)); const negBalance = remaining < 0 || scenarioKey === "yieldAlert"; return { tone: scenarioKey === "yieldAlert" ? "danger" : scenarioKey === "grnBacklog" ? "warning" : "positive", summary: `WASTAGE EVAL · ${stage.name} · Yield Loss ${wastagePct}%${negBalance ? " ⚠ negative balance" : ""}`, json: { event: "production.wastage-ledger.evaluated", payload: { stageId: stage.id, productId: product.id, cycleId: randomId("cyc"), accumulatedStageStockIn: stockIn, accumulatedStageStockOut: stockOut, remainingInProgressQuantity: remaining, wastageQuantity: wastage, wastagePercentage: wastagePct, isCycleClosed: tick % 5 === 0, hasNegativeBalanceWarning: negBalance, triggeredByEventAction: scenarioKey === "grnBacklog" ? "STOCK_OUT" : "STOCK_IN", evaluatedAt: now, }, }, }; } function nextMetrics(scenarioKey, prev) { let { inputMass, wastage, yieldLossPct } = prev; let negBalance = prev.negBalance; if (scenarioKey === "optimal") { inputMass += randomFloat(40, 90); wastage += randomFloat(0.5, 2); yieldLossPct = parseFloat(Math.max(1, yieldLossPct + randomFloat(-0.3, 0.3)).toFixed(1)); negBalance = false; } else if (scenarioKey === "yieldAlert") { inputMass += randomFloat(10, 30); wastage += randomFloat(8, 22); yieldLossPct = parseFloat(Math.min(38, yieldLossPct + randomFloat(1.5, 4)).toFixed(1)); negBalance = yieldLossPct > 14; } else { inputMass += randomFloat(0, 8); wastage += randomFloat(1, 5); yieldLossPct = parseFloat(Math.min(22, yieldLossPct + randomFloat(0.5, 1.8)).toFixed(1)); negBalance = true; } return { inputMass: parseFloat(inputMass.toFixed(1)), wastage: parseFloat(wastage.toFixed(1)), yieldLossPct, negBalance, }; } const TONE_STYLES = { positive: { dot: "bg-emerald-500", text: "text-emerald-300" }, neutral: { dot: "bg-blue-300", text: "text-blue-200" }, warning: { dot: "bg-amber-400", text: "text-amber-300" }, danger: { dot: "bg-red-400", text: "text-red-300" }, }; function StatTile({ label, value, unit, danger }) { return (
{active.subtitle}
Waiting for the first event…
) : ( logs.map((entry) => { const tone = TONE_STYLES[entry.tone]; return (
{JSON.stringify(entry.json, null, 2)}