Download index.html from Hugdrigo/bomforge-manufacturing-planner: direct link, hf CLI and curl.
- Browser
- Download file 47.5 kB
-
https://huggingface.co/spaces/Hugdrigo/bomforge-manufacturing-planner/resolve/main/index.html
- Command line
-
hf download hf://spaces/Hugdrigo/bomforge-manufacturing-planner/index.html
-
curl -L -o index.html https://huggingface.co/spaces/Hugdrigo/bomforge-manufacturing-planner/resolve/main/index.html
47.5 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark light"> | |
| <title>BOMForge | Manufacturing Requirements Studio</title> | |
| <meta name="description" content="A transparent material requirements planning laboratory with BOM explosion, demand scenarios and procurement workbench."> | |
| <style> | |
| :root{--bg:#081322;--panel:#101f32;--panel2:#142840;--border:#27405a;--text:#eef6fc;--muted:#9cb1c7;--blue:#67bafc;--mint:#4fe2bf;--amber:#ffca80;--red:#ff8692;--plot:#193751;--rail:#0b1728;--shadow:0 20px 70px #0003;--radius:17px;color-scheme:dark} | |
| [data-theme="light"]{--bg:#eef4fa;--panel:#fff;--panel2:#f4f8fc;--border:#d6e3ee;--text:#15293e;--muted:#617990;--blue:#176da7;--mint:#0b9878;--amber:#a76a14;--red:#c24c5d;--plot:#e5f2fd;--rail:#edf5fc;--shadow:0 15px 40px #183a5f13;color-scheme:light} | |
| *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.52 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:disabled{opacity:.45;cursor:not-allowed}a{color:var(--blue)}.app{display:grid;grid-template-columns:246px minmax(0,1fr);min-height:100vh}.rail{background:var(--rail);border-right:1px solid var(--border);padding:28px 17px;display:flex;flex-direction:column;gap:18px;position:sticky;top:0;height:100vh}.logo{display:flex;align-items:center;gap:11px;padding:4px 10px 24px}.mark{width:41px;height:41px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(145deg,#71d4fc,#2a63b0);font-weight:900;color:#061521;box-shadow:0 5px 22px #5fb7f044}.logo strong{display:block;letter-spacing:-.5px;font-size:19px}.logo small{display:block;letter-spacing:1.5px;font-size:9px;color:var(--muted);font-weight:800}.nav{display:grid;gap:7px}.nav button{display:flex;align-items:center;gap:11px;background:transparent;color:var(--muted);padding:12px 14px;border:1px solid transparent;border-radius:12px;text-align:left;width:100%;font-weight:650}.nav button.active{color:var(--text);background:var(--panel2);border-color:var(--border)}.nav button:hover{color:var(--text)}.glyph{font-size:18px;width:20px;text-align:center}.rail-note{margin-top:auto;border:1px solid var(--border);border-radius:15px;padding:17px;background:var(--panel);font-size:12px;color:var(--muted)}.rail-note strong{display:block;color:var(--mint);font-size:13px;margin-bottom:7px}.main{padding:28px clamp(16px,3.5vw,48px) 44px;min-width:0;max-width:1750px;width:100%;margin:auto}.top{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:27px;flex-wrap:wrap}.eyebrow{color:var(--blue);font-size:10px;letter-spacing:2px;text-transform:uppercase;font-weight:850}.header-actions{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.btn{border:1px solid var(--border);background:var(--panel2);color:var(--text);border-radius:10px;padding:10px 14px;font-weight:650;display:inline-flex;gap:7px;align-items:center;justify-content:center}.btn.primary{background:linear-gradient(125deg,#87dbff,#54b5f8);border-color:#72c4f8;color:#082039}.btn:hover{filter:brightness(1.07)}.btn.small{padding:6px 9px;font-size:12px}.btn.danger{color:var(--red)}.status{display:inline-flex;align-items:center;gap:6px;color:var(--mint);font-size:11px;font-weight:750}.dot{background:var(--mint);border-radius:50%;width:7px;height:7px}h1{font-size:clamp(29px,3vw,42px);line-height:1.1;letter-spacing:-1.2px;margin:7px 0 10px;font-weight:820}h2{font-size:19px;margin:0;letter-spacing:-.3px}h3{font-size:14px;margin:0}.subtitle,.sub{color:var(--muted)}.subtitle{max-width:730px;font-size:14px;margin:0}.page{display:none}.page.active{display:block}.hero{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:25px}.hero-label{display:flex;align-items:center;gap:8px;font-weight:700;color:var(--mint);font-size:11px;text-transform:uppercase;letter-spacing:1.4px}.tag{display:inline-flex;gap:6px;align-items:center;font-size:11px;border:1px solid var(--border);border-radius:30px;padding:5px 9px;color:var(--muted);background:var(--panel2)}.tag.warn{color:var(--amber)}.tag.bad{color:var(--red)}.tag.good{color:var(--mint)}.stats{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:17px}.metric{background:var(--panel);border:1px solid var(--border);padding:19px 20px;border-radius:var(--radius);min-width:0;box-shadow:var(--shadow)}.metric .name{color:var(--muted);font-size:12px;font-weight:640}.metric .number{font-size:clamp(22px,2vw,30px);letter-spacing:-1px;font-weight:810;white-space:nowrap;margin:11px 0 3px}.metric .foot{font-size:11px;color:var(--muted)}.metric .spark{color:var(--mint)}.cols{display:grid;grid-template-columns:minmax(0,1.65fr) minmax(285px,1fr);gap:17px;margin-bottom:17px}.card{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);padding:21px;min-width:0;box-shadow:var(--shadow)}.card-h{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:17px}.card-h p{color:var(--muted);margin:4px 0 0;font-size:12px}.key{display:flex;gap:13px;flex-wrap:wrap;color:var(--muted);font-size:11px}.swatch{display:inline-block;height:8px;width:8px;border-radius:3px;margin-right:5px;background:var(--blue)}.swatch.mint{background:var(--mint)}.swatch.amber{background:var(--amber)}.chart{width:100%;height:245px;display:block;overflow:visible}.chart text{font-family:inherit;fill:var(--muted);font-size:11px}.chart .gridline{stroke:var(--border);stroke-width:1;stroke-dasharray:4 5}.bar-track{background:var(--plot);height:9px;border-radius:6px;overflow:hidden;margin-top:7px}.bar-fill{height:100%;background:linear-gradient(90deg,#54b1f5,#78d3ed);border-radius:6px}.flow-list{display:grid;gap:14px}.flow-row .flex{display:flex;justify-content:space-between;gap:10px;font-size:12px}.flow-row .flex span:last-child{font-weight:800}.notice{display:flex;gap:11px;align-items:flex-start;padding:14px 15px;background:color-mix(in srgb,var(--amber) 9%,var(--panel2));border:1px solid color-mix(in srgb,var(--amber) 30%,var(--border));border-radius:12px;font-size:12px}.notice strong{color:var(--amber)}.bottom{display:grid;grid-template-columns:minmax(0,1.2fr) minmax(280px,1fr);gap:17px}.table-wrap{overflow-x:auto;max-width:100%;border-radius:10px}.data-table{border-collapse:collapse;width:100%;min-width:650px}.data-table th{font-size:10px;text-transform:uppercase;letter-spacing:1px;text-align:left;color:var(--muted);font-weight:800;background:var(--panel2)}.data-table th,.data-table td{padding:12px 11px;border-bottom:1px solid var(--border);white-space:nowrap}.data-table td{font-size:12px}.data-table tbody tr:hover{background:var(--panel2)}.data-table td strong{color:var(--text)}.empty{color:var(--muted);padding:20px;text-align:center}.compact{font-size:11px}.muted{color:var(--muted)}.text-right{text-align:right}.line{height:1px;background:var(--border);margin:15px 0}.controls{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:13px;margin:17px 0}.field{display:grid;gap:6px;min-width:0}.field label{color:var(--muted);font-weight:700;font-size:11px}.field input,.field select{border:1px solid var(--border);border-radius:10px;color:var(--text);background:var(--panel2);padding:10px 11px;width:100%;min-width:0;outline:none}.field input:focus,.field select:focus{border-color:var(--blue)}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:15px}.three{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.inline-tools{display:flex;gap:9px;align-items:center;flex-wrap:wrap}.section-gap{display:grid;gap:17px}.pill{display:inline-flex;padding:3px 8px;border-radius:50px;background:var(--panel2);color:var(--muted);border:1px solid var(--border);font-size:10px;white-space:nowrap}.pill.ok{color:var(--mint)}.pill.risk{color:var(--red)}.pill.warn{color:var(--amber)}.scroll-graph{overflow-x:auto;padding-bottom:4px}.timeline{min-width:680px;display:grid;gap:7px}.time-row{display:grid;grid-template-columns:143px repeat(8,minmax(54px,1fr));gap:4px;align-items:center}.time-row>div{border-radius:7px;padding:8px 5px;text-align:center;font-size:11px}.time-row>.row-title{text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--muted)}.time-row.head>div{background:var(--panel2);color:var(--muted);font-weight:800}.time-cell{background:var(--panel2);color:var(--text)}.time-cell.highlight{background:color-mix(in srgb,var(--blue) 23%,var(--panel2));border:1px solid color-mix(in srgb,var(--blue) 50%,var(--border))}.time-cell.risk{color:var(--red);border:1px solid color-mix(in srgb,var(--red) 30%,var(--border))}.help{font-size:12px;line-height:1.7;color:var(--muted)}.help strong{color:var(--text)}.help code{font-size:11px;color:var(--blue);word-break:break-all}.project-banner{padding:17px;border:1px solid var(--border);border-radius:13px;background:var(--panel2);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}.project-banner strong{font-size:13px}.project-banner span{font-size:11px;color:var(--muted)}.toast{position:fixed;bottom:22px;right:22px;z-index:60;background:var(--panel2);border:1px solid var(--border);padding:13px 18px;border-radius:12px;box-shadow:var(--shadow);max-width:min(360px,90vw);display:none}.toast.show{display:block}.modal-back{position:fixed;inset:0;background:#000b;display:none;align-items:center;justify-content:center;padding:20px;z-index:100}.modal-back.show{display:flex}.modal{width:min(620px,100%);max-height:92vh;overflow-y:auto;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:22px;box-shadow:var(--shadow)}.modal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px}.modal-footer{display:flex;justify-content:flex-end;gap:9px;margin-top:18px}.input-error{color:var(--red);font-size:12px;min-height:16px}.mobile-nav{display:none}.mobile-nav button{flex:1;min-width:75px;background:transparent;border:0;color:var(--muted);padding:9px 3px;font-size:11px}.mobile-nav button.active{color:var(--blue);font-weight:800}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)} | |
| @media(max-width:1180px){.app{grid-template-columns:205px minmax(0,1fr)}.rail{padding:20px 9px}.stats{grid-template-columns:repeat(2,minmax(0,1fr))}.cols,.bottom{grid-template-columns:1fr}.metric .number{font-size:27px}} | |
| @media(max-width:790px){.app{display:block}.rail{display:none}.mobile-nav{display:flex;position:sticky;bottom:0;z-index:40;border-top:1px solid var(--border);background:var(--rail);overflow-x:auto}.main{padding:19px 16px 30px}.top{margin-bottom:21px}.hero{flex-direction:column}.controls{grid-template-columns:1fr 1fr}.two,.three{grid-template-columns:1fr 1fr}} | |
| @media(max-width:480px){.main{padding:15px 12px 30px}.stats{gap:9px}.metric{padding:14px 12px}.metric .number{font-size:21px}.metric .name{font-size:11px}.card{padding:15px}.controls,.two,.three{grid-template-columns:1fr}.hero{margin-bottom:16px}.header-actions{width:100%}.btn{padding:9px 10px}.top{gap:10px}.chart{height:200px}.card-h{flex-wrap:wrap}} | |
| @media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;animation:none!important;transition:none!important}} | |
| </style> | |
| </head> | |
| <body> | |
| <div class="app"><aside class="rail"><div class="logo"><div class="mark">▦</div><div><strong>BOMForge</strong><small>OPERATIONS INTELLIGENCE</small></div></div><div class="nav" id="desktopNav"> | |
| <button data-page="overview" class="active"><span class="glyph">◫</span> Overview</button><button data-page="planner"><span class="glyph">◈</span> MRP planner</button><button data-page="materials"><span class="glyph">▤</span> Materials</button><button data-page="scenarios"><span class="glyph">◉</span> Scenario lab</button><button data-page="method"><span class="glyph">≡</span> Methodology</button></div><div class="rail-note"><strong>● LOCAL SIMULATION</strong>All data remains in your browser. No account, tracking, APIs or paid services.<div class="line"></div><span>HUGDRIGO / PROJECT 113</span></div></aside> | |
| <main class="main"><div class="top"><div><span class="eyebrow">Hugdrigo collection / Project 113</span><div class="status"><span class="dot"></span> Simulation workspace</div></div><div class="header-actions"><button class="btn" id="themeToggle" title="Toggle color theme">◐ Theme</button><button class="btn" id="exportCSV">⇩ CSV</button><button class="btn" id="exportJSON">⇩ Backup</button><button class="btn" id="importJSON">⇧ Import</button><button class="btn primary" id="openNew">+ New material</button></div></div> | |
| <section class="page active" id="page-overview"><div class="hero"><div><div class="hero-label">● Manufacturing planning intelligence</div><h1>Material clarity.<br>Production confidence.</h1><p class="subtitle">See what's needed, when to release orders, and which components could delay your plan. A transparent, multi-level material requirements planning laboratory.</p></div><span class="tag">◉ 8-week horizon · synthetic data</span></div><div class="stats" id="overviewStats"></div><div class="cols"><div class="card"><div class="card-h"><div><h2>Demand vs planned receipts</h2><p>Aggregated units across finished goods · weeks 1 to 8</p></div><span class="tag">MRP</span></div><svg id="overviewChart" class="chart" viewBox="0 0 680 245" role="img" aria-label="Weekly gross demand and planned receipts chart"></svg><div class="key"><span><i class="swatch"></i>Gross demand</span><span><i class="swatch mint"></i>Planned receipts</span></div></div><div class="card"><div class="card-h"><div><h2>Procurement concentration</h2><p>Top material costs · bars relative to largest cost</p></div></div><div id="spendBars" class="flow-list"></div></div></div><div class="bottom"><div class="card"><div class="card-h"><div><h2>Priority action queue</h2><p>Releases that need an earlier start or immediate attention</p></div><button class="btn small" data-go="planner">Open plan ↗</button></div><div id="actionQueue" class="table-wrap"></div></div><div class="card"><div class="card-h"><div><h2>Planning signals</h2><p>What this scenario means</p></div></div><div id="signals" class="section-gap"></div></div></div></section> | |
| <section class="page" id="page-planner"><div class="hero"><div><div class="hero-label">◈ Material requirements planning</div><h1>Build the production plan.</h1><p class="subtitle">A time-phased bill-of-materials explosion. Planned receipts meet gross requirements, while release dates incorporate lead times.</p></div></div><div class="card"><div class="card-h"><div><h2>Plan configuration</h2><p>Changes recalculate requirements across all levels of the bill of materials</p></div><button class="btn small" id="resetPlan">Restore defaults</button></div><div class="controls"><div class="field"><label for="scenario">Scenario</label><select id="scenario"><option value="baseline">Baseline</option><option value="surge">Demand surge</option><option value="delay">Supplier delays</option><option value="buffer">Buffer investment</option></select></div><div class="field"><label for="demandMult">Demand adjustment (%)</label><input type="number" min="50" max="200" id="demandMult" step="5"></div><div class="field"><label for="safetyBoost">Safety stock adjustment (%)</label><input type="number" min="0" max="200" id="safetyBoost" step="5"></div></div><div class="notice" id="plannerNotice"></div></div><div class="card" style="margin-top:17px"><div class="card-h"><div><h2>Time-phased requirements</h2><p>Choose a material to inspect gross demand, on-hand stock and replenishment orders</p></div><div class="field" style="min-width:200px"><label for="focusSku">Selected material</label><select id="focusSku"></select></div></div><div id="mrpTimeline" class="scroll-graph"></div><div id="mrpSummary" style="margin-top:16px" class="three"></div></div><div class="card" style="margin-top:17px"><div class="card-h"><div><h2>Complete material plan</h2><p>All quantities are simulated; receipts are assumed available on time</p></div><button class="btn small" id="exportPlan">⇩ Export MRP</button></div><div id="mrpTable" class="table-wrap"></div></div></section> | |
| <section class="page" id="page-materials"><div class="hero"><div><div class="hero-label">▤ Editable bill of materials</div><h1>Understand every component.</h1><p class="subtitle">Maintain a multi-level component tree, opening inventories, lead times and unit costs. Changes immediately affect the complete plan.</p></div></div><div class="card"><div class="card-h"><div><h2>Material master</h2><p>Editing is local to this browser · archived items are preserved in your backup</p></div><div class="inline-tools"><button class="btn small" id="restoreArchived">Restore archived</button><button class="btn small primary" data-new="1">+ Add SKU</button></div></div><div id="materialsTable" class="table-wrap"></div></div><div class="card" style="margin-top:17px"><div class="card-h"><div><h2>Bill-of-materials relationships</h2><p>Each parent consumes component quantities when its planned production is released</p></div></div><div id="bomTree" class="section-gap"></div></div></section> | |
| <section class="page" id="page-scenarios"><div class="hero"><div><div class="hero-label">◉ Supply and demand stress test</div><h1>Test before you commit.</h1><p class="subtitle">Compare baseline material purchases, release risks and projected inventory with alternative planning assumptions.</p></div></div><div class="stats" id="scenarioStats"></div><div class="card"><div class="card-h"><div><h2>Scenario comparison</h2><p>Changes calculated using the same opening inventory and bill of materials</p></div></div><div id="scenarioTable" class="table-wrap"></div></div><div class="two" style="margin-top:17px"><div class="card"><div class="card-h"><div><h2>Demand by finished product</h2><p>Master production schedule · editable weekly forecast</p></div></div><div id="forecastEditor"></div></div><div class="card"><div class="card-h"><div><h2>Interpretation</h2><p>Limits and decision considerations</p></div></div><div id="scenarioNotes" class="section-gap"></div></div></div></section> | |
| <section class="page" id="page-method"><div class="hero"><div><div class="hero-label">≡ Reproducible planning assumptions</div><h1>Transparent by design.</h1><p class="subtitle">Understand how this studio calculates its material plan, where assumptions enter and what the model does not prove.</p></div></div><div class="two"><div class="card"><h2>Planning algorithm</h2><div class="help"><p><strong>1. Topological BOM order.</strong> Finished assemblies are processed before their subassemblies and raw materials. A cyclic component relationship is rejected.</p><p><strong>2. Gross requirements.</strong> Finished goods start with the editable master demand schedule. Child demand is triggered by their parents' planned order releases, multiplied by the bill-of-materials quantity.</p><p><strong>3. Inventory netting.</strong> At each week, available inventory is deducted from gross need. An order receipt is planned when needed to cover requirements plus the safety stock target.</p><p><strong>4. Planned releases.</strong> Every receipt is shifted earlier by the material's lead time. Releases prior to week 1 are flagged as <em>pre-horizon</em> and the corresponding children are assumed to require material at the planning boundary.</p><p><strong>5. Cost estimate.</strong> Planned order receipts multiplied by synthetic per-unit costs, without taxes, freight, MOQ, yields or contract restrictions.</p></div></div><div class="card"><h2>Important limitations</h2><div class="help"><p><strong>Not connected to ERP.</strong> The demo does not see stock, purchase orders, suppliers or factories in real time.</p><p><strong>Not an advanced production scheduler.</strong> Labor, work-center capacity, waste, variable yields, setup times, calendar holidays and transportation are excluded.</p><p><strong>Timing risk is a signal.</strong> A pre-horizon release indicates potential infeasibility; it is not a calibrated probability of a late delivery.</p><p><strong>Inventory assumption.</strong> All planned receipts are presumed on time. There are no unmodeled outstanding orders or backorders.</p><p><strong>Privacy.</strong> Everything runs locally in this browser. Import only information you are comfortable storing on this device; backups are plain JSON.</p><p><strong>Reproducibility.</strong> All demo inputs and calculation rules are inspectable in this single HTML file. No API key, login or remote service is required.</p></div><div class="line"></div><div class="project-banner"><div><strong>Hugdrigo / Project 113</strong><br><span>Open-source educational manufacturing simulator · MIT</span></div><button class="btn small danger" id="resetAll">↻ Reset demo</button></div></div></div></section> | |
| </main></div><nav class="mobile-nav" id="mobileNav" aria-label="Mobile navigation"><button class="active" data-page="overview">◫<br>Overview</button><button data-page="planner">◈<br>Planner</button><button data-page="materials">▤<br>Materials</button><button data-page="scenarios">◉<br>Scenarios</button><button data-page="method">≡<br>Method</button></nav> | |
| <div id="toast" class="toast" role="status" aria-live="polite"></div> | |
| <div id="modalBack" class="modal-back" role="dialog" aria-modal="true" aria-label="Edit material"><form id="materialForm" class="modal"><div class="modal-header"><h2 id="modalTitle">Edit material</h2><button type="button" id="closeModal" class="btn small">✕</button></div><div class="two"><div class="field"><label for="mSku">SKU code</label><input id="mSku" maxlength="24" required pattern="[A-Za-z0-9-]+"></div><div class="field"><label for="mName">Material name</label><input id="mName" maxlength="75" required></div><div class="field"><label for="mKind">Category</label><select id="mKind"><option value="finished">Finished good</option><option value="subassembly">Subassembly</option><option value="raw">Raw material</option></select></div><div class="field"><label for="mStock">On-hand inventory (units)</label><input type="number" id="mStock" min="0" max="1000000" step="1" required></div><div class="field"><label for="mSafety">Safety stock (units)</label><input type="number" id="mSafety" min="0" max="1000000" step="1" required></div><div class="field"><label for="mLead">Lead time (weeks)</label><input type="number" id="mLead" min="0" max="6" step="1" required></div><div class="field"><label for="mCost">Estimated unit cost (USD)</label><input type="number" id="mCost" min="0" max="10000000" step="0.01" required></div><div class="field"><label for="mParent">BOM parent (optional)</label><select id="mParent"></select></div><div class="field"><label for="mQty">Units per parent</label><input type="number" id="mQty" min="1" max="10000" step="1"></div></div><p class="sub compact">An item may have one parent when created or edited here. Existing multi-parent relationships are preserved unless changed by the parent selector.</p><div class="input-error" id="formError" role="alert"></div><div class="modal-footer"><button type="button" class="btn" id="cancelModal">Cancel</button><button type="submit" class="btn primary">Save material</button></div></form></div> | |
| <input type="file" accept=".json,application/json" id="importFile" class="sr-only"> | |
| <script> | |
| ; | |
| (function(){ | |
| const H=8,STORE='hugdrigo-bomforge-113-v1'; | |
| const deep=x=>JSON.parse(JSON.stringify(x)); | |
| const defaults={items:[ | |
| {sku:'FG-100',name:'Sensor starter kit',kind:'finished',stock:22,safety:12,lead:1,cost:182,active:true}, | |
| {sku:'FG-200',name:'Monitoring bundle',kind:'finished',stock:14,safety:8,lead:1,cost:269,active:true}, | |
| {sku:'SUB-110',name:'Controller module',kind:'subassembly',stock:48,safety:20,lead:2,cost:88,active:true}, | |
| {sku:'SUB-210',name:'Power assembly',kind:'subassembly',stock:58,safety:15,lead:1,cost:45,active:true}, | |
| {sku:'MAT-110',name:'Processor chip',kind:'raw',stock:140,safety:45,lead:3,cost:19,active:true}, | |
| {sku:'MAT-120',name:'Circuit board',kind:'raw',stock:66,safety:28,lead:2,cost:28,active:true}, | |
| {sku:'MAT-130',name:'Protective enclosure',kind:'raw',stock:80,safety:25,lead:2,cost:14,active:true}, | |
| {sku:'MAT-210',name:'Battery module',kind:'raw',stock:48,safety:22,lead:3,cost:32,active:true}, | |
| {sku:'MAT-220',name:'Cable harness',kind:'raw',stock:120,safety:35,lead:1,cost:7,active:true}, | |
| {sku:'MAT-230',name:'Packaging and labels',kind:'raw',stock:130,safety:35,lead:1,cost:4,active:true} | |
| ],bom:[{parent:'FG-100',child:'SUB-110',qty:1},{parent:'FG-100',child:'SUB-210',qty:1},{parent:'FG-100',child:'MAT-130',qty:1},{parent:'FG-100',child:'MAT-230',qty:1},{parent:'FG-200',child:'SUB-110',qty:2},{parent:'FG-200',child:'SUB-210',qty:1},{parent:'FG-200',child:'MAT-130',qty:1},{parent:'FG-200',child:'MAT-220',qty:2},{parent:'FG-200',child:'MAT-230',qty:1},{parent:'SUB-110',child:'MAT-110',qty:2},{parent:'SUB-110',child:'MAT-120',qty:1},{parent:'SUB-210',child:'MAT-210',qty:1},{parent:'SUB-210',child:'MAT-220',qty:1}],forecast:{'FG-100':[30,34,32,42,39,46,44,50],'FG-200':[16,20,18,24,22,27,26,30]},scenario:'baseline',demandMult:100,safetyBoost:100,focus:'MAT-110',theme:'dark'}; | |
| const $=id=>document.getElementById(id),money=n=>'$'+Number(n).toLocaleString('en-US',{maximumFractionDigits:0}),num=n=>Number(n).toLocaleString('en-US',{maximumFractionDigits:0}); | |
| const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| const csvSafe=x=>{let s=String(x??'');if(/^[\s]*[=+\-@\t\r]/.test(s))s="'"+s;return '"'+s.replace(/"/g,'""')+'"'}; | |
| const scenarioCfg={baseline:{name:'Baseline',demand:1,lead:0,safety:1},surge:{name:'Demand surge',demand:1.35,lead:0,safety:1},delay:{name:'Supplier delays',demand:1,lead:1,safety:1},buffer:{name:'Buffer investment',demand:1,lead:0,safety:1.5}}; | |
| const feasible=(data)=>{ | |
| if(!data||!Array.isArray(data.items)||!Array.isArray(data.bom)||typeof data.forecast!=='object'||!data.forecast||data.items.length>90||data.bom.length>250)return false; | |
| const ids=new Set();for(const it of data.items){if(!it||typeof it.sku!=='string'||!/^[A-Za-z0-9-]{2,24}$/.test(it.sku)||ids.has(it.sku)||typeof it.name!=='string'||it.name.length<1||it.name.length>75||!['raw','subassembly','finished'].includes(it.kind)||!Number.isInteger(it.stock)||it.stock<0||it.stock>1e6||!Number.isInteger(it.safety)||it.safety<0||it.safety>1e6||!Number.isInteger(it.lead)||it.lead<0||it.lead>6||!Number.isFinite(it.cost)||it.cost<0||it.cost>1e7||typeof it.active!=='boolean')return false;ids.add(it.sku)} | |
| for(const b of data.bom){if(!b||!ids.has(b.parent)||!ids.has(b.child)||b.parent===b.child||!Number.isInteger(b.qty)||b.qty<1||b.qty>10000)return false} | |
| for(const [id,values] of Object.entries(data.forecast)){if(!ids.has(id)||!Array.isArray(values)||values.length!==H||values.some(n=>!Number.isInteger(n)||n<0||n>100000))return false} | |
| if(!['baseline','surge','delay','buffer'].includes(data.scenario)||!Number.isFinite(data.demandMult)||data.demandMult<50||data.demandMult>200||!Number.isFinite(data.safetyBoost)||data.safetyBoost<0||data.safetyBoost>200)return false; | |
| return !!topo(data.items.filter(x=>x.active),data.bom.filter(x=>ids.has(x.parent)&&ids.has(x.child)&&data.items.find(i=>i.sku===x.parent)?.active&&data.items.find(i=>i.sku===x.child)?.active)); | |
| }; | |
| function topo(items,bom){const ids=items.map(x=>x.sku),deg=Object.fromEntries(ids.map(s=>[s,0])),out=Object.fromEntries(ids.map(s=>[s,[]]));for(const x of bom){if(!(x.parent in deg)||!(x.child in deg))continue;out[x.parent].push(x.child);deg[x.child]++}const q=ids.filter(x=>deg[x]===0),order=[];for(let i=0;i<q.length;i++){let id=q[i];order.push(id);for(const ch of out[id])if(--deg[ch]===0)q.push(ch)}return order.length===ids.length?order:null} | |
| let state;try{const s=JSON.parse(localStorage.getItem(STORE)||'null');state=feasible(s)?{...s,theme:s.theme==='light'?'light':'dark',focus:typeof s.focus==='string'?s.focus:defaults.focus}:deep(defaults)}catch{state=deep(defaults)}; | |
| function save(){try{localStorage.setItem(STORE,JSON.stringify(state))}catch{} } | |
| function activeItems(st=state){return st.items.filter(x=>x.active)} | |
| function calc(st=state,scenario=st.scenario){const cfg=scenarioCfg[scenario]||scenarioCfg.baseline,items=activeItems(st),ids=new Set(items.map(i=>i.sku)),edges=st.bom.filter(b=>ids.has(b.parent)&&ids.has(b.child)),order=topo(items,edges);if(!order)return null; | |
| const gross=Object.fromEntries(order.map(x=>[x,Array(H).fill(0)])),bySku={},alerts=[]; | |
| for(const item of items){if(item.kind==='finished'){const demands=st.forecast[item.sku]||Array(H).fill(0);for(let w=0;w<H;w++)gross[item.sku][w]+=Math.round((demands[w]||0)*cfg.demand*st.demandMult/100)}} | |
| for(const sku of order){const it=items.find(x=>x.sku===sku),received=Array(H).fill(0),released=Array(H).fill(0),stock=Array(H).fill(0),early=Array(H).fill(0);let available=it.stock;const lead=Math.min(7,it.lead+(it.kind==='raw'?cfg.lead:0)),target=Math.round(it.safety*cfg.safety*st.safetyBoost/100); | |
| for(let w=0;w<H;w++){const need=gross[sku][w];let purchase=Math.max(0,need+target-available);purchase=Math.ceil(purchase);received[w]=purchase;available+=purchase-need;stock[w]=available;const week=w-lead;if(purchase>0){if(week<0){early[w]=purchase;released[0]+=purchase;alerts.push({sku,name:it.name,week:w+1,qty:purchase,lead,reason:'Pre-horizon release'})}else released[week]+=purchase}} | |
| bySku[sku]={sku,it,gross:gross[sku].slice(),received,released,stock,early,lead,target,orderTotal:received.reduce((a,b)=>a+b,0)}; | |
| for(const b of edges.filter(x=>x.parent===sku)){for(let w=0;w<H;w++)gross[b.child][w]+=released[w]*b.qty}} | |
| const materialCost=items.filter(it=>it.kind==='raw').reduce((a,it)=>a+bySku[it.sku].orderTotal*it.cost,0),allCost=items.reduce((a,it)=>a+bySku[it.sku].orderTotal*it.cost,0),units=items.filter(x=>x.kind==='finished').reduce((a,it)=>a+gross[it.sku].reduce((p,q)=>p+q,0),0),rawCount=items.filter(x=>x.kind==='raw').length; | |
| return {bySku,items,order,edges,alerts,materialCost,allCost,units,rawCount,releaseRisk:alerts.length,scenario};} | |
| function toast(t){$('toast').textContent=t;$('toast').classList.add('show');clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('toast').classList.remove('show'),2800)} | |
| let current='overview';function nav(page){if(!['overview','planner','materials','scenarios','method'].includes(page))return;current=page;document.querySelectorAll('.page').forEach(el=>el.classList.toggle('active',el.id==='page-'+page));document.querySelectorAll('[data-page]').forEach(b=>b.classList.toggle('active',b.dataset.page===page));render();window.scrollTo({top:0,behavior:'instant'})} | |
| function badge(text,kind=''){return `<span class="pill ${kind}">${esc(text)}</span>`} | |
| function graph(g){const svg=$('overviewChart'),fin=g.items.filter(x=>x.kind==='finished').map(x=>g.bySku[x.sku]);let gross=Array(H).fill(0),recv=Array(H).fill(0);for(const x of fin)for(let w=0;w<H;w++){gross[w]+=x.gross[w];recv[w]+=x.received[w]}const max=Math.max(1,...gross,...recv)*1.15,W=680,base=204,high=171,left=33;let parts=[];for(let v=0;v<=4;v++){let y=base-v*high/4;parts.push(`<line class="gridline" x1="${left}" x2="${W-12}" y1="${y}" y2="${y}"/><text x="${left-5}" y="${y+4}" text-anchor="end">${num(Math.round(max*v/4))}</text>`)}for(let i=0;i<H;i++){const cx=75+i*79,hh=gross[i]/max*high,rr=recv[i]/max*high;parts.push(`<rect x="${cx-21}" y="${base-hh}" width="24" height="${hh}" rx="4" fill="#62baf7"/><rect x="${cx+6}" y="${base-rr}" width="24" height="${rr}" rx="4" fill="#4fe2bf"/><text x="${cx+4}" y="227" text-anchor="middle">W${i+1}</text>`)}svg.innerHTML=parts.join('')} | |
| function renderOverview(g){const raw=g.items.filter(x=>x.kind==='raw');$('overviewStats').innerHTML=[['Demand units',num(g.units),'Finished-goods forecast · 8 weeks'],['Raw materials',num(g.rawCount),'Active component SKUs'],['Planned material spend',money(g.materialCost),'Illustrative purchase estimate'],['Release warnings',num(g.releaseRisk),'Orders requiring pre-horizon start']].map(([a,b,c])=>`<div class="metric"><div class="name">${a}</div><div class="number">${b}</div><div class="foot">${c}</div></div>`).join('');graph(g); | |
| const spend=raw.map(it=>({it,cost:g.bySku[it.sku].orderTotal*it.cost})).sort((a,b)=>b.cost-a.cost).slice(0,5),total=Math.max(1,...spend.map(x=>x.cost));$('spendBars').innerHTML=spend.map(x=>`<div class="flow-row"><div class="flex"><span>${esc(x.it.name)}</span><span>${money(x.cost)}</span></div><div class="bar-track"><div class="bar-fill" style="width:${x.cost/total*100}%"></div></div></div>`).join('')||'<div class="empty">No active raw materials</div>'; | |
| $('actionQueue').innerHTML=g.alerts.length?`<table class="data-table"><thead><tr><th>SKU</th><th>Needed</th><th>Quantity</th><th>Lead time</th><th>Signal</th></tr></thead><tbody>${g.alerts.slice(0,9).map(a=>`<tr><td><strong>${esc(a.sku)}</strong></td><td>W${a.week}</td><td>${num(a.qty)}</td><td>${a.lead} weeks</td><td>${badge('Release before W1','risk')}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No pre-horizon release warnings in this scenario.</div>'; | |
| $('signals').innerHTML=[['Demand plan',`There are ${num(g.units)} finished goods scheduled across the horizon.`],['Procurement',`Raw-material planned receipts total ${money(g.materialCost)} in simulated purchase cost.`],['Timing',`${g.releaseRisk} order events start before the planning horizon. Address these before relying on the plan.`]].map(([t,b])=>`<div class="notice"><span style="color:var(--blue)">✦</span><div><strong style="color:var(--text)">${t}</strong><div class="muted">${b}</div></div></div>`).join('')} | |
| function renderPlanner(g){$('scenario').value=state.scenario;$('demandMult').value=state.demandMult;$('safetyBoost').value=state.safetyBoost;const idset=new Set(g.items.map(x=>x.sku));if(!idset.has(state.focus))state.focus=g.items[0]?.sku||'';$('focusSku').innerHTML=g.items.map(it=>`<option value="${esc(it.sku)}" ${it.sku===state.focus?'selected':''}>${esc(it.sku+' · '+it.name)}</option>`).join('');const d=g.bySku[state.focus];$('plannerNotice').innerHTML=g.releaseRisk?`<span>⚠</span><div><strong>${g.releaseRisk} early-release event(s).</strong> Lead times imply starting some orders before week 1. Their component requirements are assigned to week 1 for an actionable eight-week plan.</div>`:'<span>✓</span><div><strong>No pre-horizon releases.</strong> The plan has no release dates earlier than the selected horizon.</div>'; | |
| if(!d){$('mrpTimeline').innerHTML='<div class="empty">No active materials</div>';return}const r=[['Gross requirements',d.gross],['Planned receipts',d.received],['Planned releases',d.released],['Projected stock',d.stock]];$('mrpTimeline').innerHTML=`<div class="timeline"><div class="time-row head"><div class="row-title">MEASURE</div>${Array.from({length:H},(_,i)=>`<div>W${i+1}</div>`).join('')}</div>${r.map(([name,arr])=>`<div class="time-row"><div class="row-title">${name}</div>${arr.map((x,w)=>`<div class="time-cell ${x>0&&name==='Planned receipts'?'highlight':''} ${d.early[w]>0&&name==='Planned receipts'?'risk':''}">${num(x)}</div>`).join('')}</div>`).join('')}</div>`; | |
| $('mrpSummary').innerHTML=[['Opening stock',num(d.it.stock)],['Total order receipts',num(d.orderTotal)],['Lead time',d.lead+' weeks']].map(([k,v])=>`<div class="metric"><div class="name">${k}</div><div class="number">${v}</div></div>`).join(''); | |
| $('mrpTable').innerHTML=`<table class="data-table"><thead><tr><th>SKU</th><th>Category</th><th>Gross units</th><th>Receipts</th><th>End stock</th><th>Purchase cost</th><th>Release risk</th></tr></thead><tbody>${g.order.map(s=>{const x=g.bySku[s];return `<tr><td><strong>${esc(s)}</strong><div class="muted compact">${esc(x.it.name)}</div></td><td>${esc(x.it.kind)}</td><td>${num(x.gross.reduce((a,b)=>a+b))}</td><td>${num(x.orderTotal)}</td><td>${num(x.stock[H-1])}</td><td>${money(x.it.cost*x.orderTotal)}</td><td>${x.early.some(n=>n>0)?badge('Pre-horizon','warn'):badge('In-horizon','ok')}</td></tr>`}).join('')}</tbody></table>`} | |
| function renderMaterials(g){$('materialsTable').innerHTML=`<table class="data-table"><thead><tr><th>Material</th><th>Type</th><th>On hand</th><th>Safety</th><th>Lead</th><th>Unit cost</th><th>Status</th><th>Actions</th></tr></thead><tbody>${state.items.map(it=>`<tr><td><strong>${esc(it.sku)}</strong><div class="muted compact">${esc(it.name)}</div></td><td>${esc(it.kind)}</td><td>${num(it.stock)}</td><td>${num(it.safety)}</td><td>${it.lead} wk</td><td>${money(it.cost)}</td><td>${badge(it.active?'Active':'Archived',it.active?'ok':'warn')}</td><td><div class="inline-tools"><button class="btn small" data-edit="${esc(it.sku)}">Edit</button><button class="btn small ${it.active?'danger':''}" data-archive="${esc(it.sku)}">${it.active?'Archive':'Restore'}</button></div></td></tr>`).join('')}</tbody></table>`; | |
| const parents=g.items.filter(x=>g.edges.some(b=>b.parent===x.sku));$('bomTree').innerHTML=parents.map(p=>`<div class="project-banner"><div><strong>${esc(p.sku)} · ${esc(p.name)}</strong><br><span>${g.edges.filter(b=>b.parent===p.sku).map(b=>{const child=g.items.find(it=>it.sku===b.child);return `${b.qty}× ${esc(child?.sku||b.child)}`}).join(' · ')}</span></div>${badge('Assembly')}</div>`).join('')||'<div class="empty">No active BOM relationships.</div>'} | |
| function renderScenarios(){const baseline=calc(state,'baseline'),variants=Object.keys(scenarioCfg).map(k=>calc(state,k));const currentCalc=calc(state);$('scenarioStats').innerHTML=[['Current material spend',money(currentCalc.materialCost),'Planned raw material receipts'],['Finished demand',num(currentCalc.units),'Units over the horizon'],['Early order events',num(currentCalc.releaseRisk),'Potential timing issues'],['Active BOM links',num(currentCalc.edges.length),'Parent-component relationships']].map(([a,b,c])=>`<div class="metric"><div class="name">${a}</div><div class="number">${b}</div><div class="foot">${c}</div></div>`).join(''); | |
| $('scenarioTable').innerHTML=`<table class="data-table"><thead><tr><th>Scenario</th><th>Finished units</th><th>Material purchases</th><th>Change vs baseline</th><th>Pre-horizon events</th><th>Apply</th></tr></thead><tbody>${variants.map(v=>`<tr><td><strong>${esc(scenarioCfg[v.scenario].name)}</strong></td><td>${num(v.units)}</td><td>${money(v.materialCost)}</td><td>${v.scenario==='baseline'?'Reference':(v.materialCost-baseline.materialCost>=0?'+':'')+money(v.materialCost-baseline.materialCost)}</td><td>${num(v.releaseRisk)}</td><td><button class="btn small" data-setscenario="${v.scenario}">${state.scenario===v.scenario?'Selected':'Apply'}</button></td></tr>`).join('')}</tbody></table>`; | |
| const fins=state.items.filter(it=>it.kind==='finished'&&it.active);$('forecastEditor').innerHTML=fins.map(it=>`<div class="section-gap" style="margin-bottom:18px"><h3>${esc(it.sku)} · ${esc(it.name)}</h3><div class="scroll-graph"><div class="timeline"><div class="time-row head"><div class="row-title">WEEKLY FORECAST</div>${Array.from({length:H},(_,i)=>`<div>W${i+1}</div>`).join('')}</div><div class="time-row"><div class="row-title">Units</div>${(state.forecast[it.sku]||Array(H).fill(0)).map((v,w)=>`<div style="padding:0"><input aria-label="${esc(it.sku)} week ${w+1}" data-forecast="${esc(it.sku)}" data-week="${w}" type="number" min="0" max="100000" step="1" value="${v}" style="width:100%;min-width:50px;text-align:center;background:var(--panel2);color:var(--text);border:1px solid var(--border);padding:8px 0;border-radius:7px"></div>`).join('')}</div></div></div></div>`).join('')||'<div class="empty">No active finished goods.</div>'; | |
| $('scenarioNotes').innerHTML=[['Baseline','Demand and replenishment at the displayed weekly forecast.'],['Demand surge','Increases finished product requirements 35%, propagating to every linked component.'],['Supplier delays','Adds one week to raw-material lead times. More orders may require pre-horizon release.'],['Buffer investment','Raises safety stock targets 50%, increasing projected procurement needs.']].map(([t,b])=>`<div class="notice"><span style="color:var(--mint)">◆</span><div><strong style="color:var(--text)">${t}</strong><div class="muted">${b}</div></div></div>`).join('')} | |
| function render(){document.documentElement.setAttribute('data-theme',state.theme);const g=calc();if(!g){toast('Invalid BOM relationships; restore defaults.');return}if(current==='overview')renderOverview(g);if(current==='planner')renderPlanner(g);if(current==='materials')renderMaterials(g);if(current==='scenarios')renderScenarios(g)} | |
| function openModal(id){const it=state.items.find(x=>x.sku===id);$('modalTitle').textContent=it?'Edit material':'Add material';$('mSku').value=it?.sku||'';$('mSku').disabled=!!it;$('mName').value=it?.name||'';$('mKind').value=it?.kind||'raw';$('mStock').value=it?.stock??0;$('mSafety').value=it?.safety??0;$('mLead').value=it?.lead??1;$('mCost').value=it?.cost??10;const relationships=state.bom.filter(b=>b.child===id);$('mParent').innerHTML='<option value="">No parent / preserve existing</option>'+state.items.filter(x=>x.sku!==id&&x.active).map(x=>`<option value="${esc(x.sku)}">${esc(x.sku+' · '+x.name)}</option>`).join('');$('mParent').value=relationships.length===1?relationships[0].parent:'';$('mQty').value=relationships.length===1?relationships[0].qty:1;$('formError').textContent='';$('materialForm').dataset.id=id||'';$('modalBack').classList.add('show');$('mName').focus()} | |
| function closeModal(){$('modalBack').classList.remove('show')} | |
| $('materialForm').addEventListener('submit',e=>{e.preventDefault();const id=$('materialForm').dataset.id,sku=id||$('mSku').value.trim().toUpperCase(),parent=$('mParent').value;const item={sku,name:$('mName').value.trim(),kind:$('mKind').value,stock:Number($('mStock').value),safety:Number($('mSafety').value),lead:Number($('mLead').value),cost:Number($('mCost').value),active:true};if(!/^[A-Za-z0-9-]{2,24}$/.test(sku)||item.name.length<1||item.name.length>75){$('formError').textContent='Enter a valid SKU and name.';return}const next=deep(state);const i=next.items.findIndex(x=>x.sku===id);if(i>=0)next.items[i]={...next.items[i],...item};else {if(next.items.some(x=>x.sku===sku)){ $('formError').textContent='SKU already exists.';return}next.items.push(item)} | |
| if(!id&&item.kind==='finished')next.forecast[sku]=Array(H).fill(0); | |
| if(parent){next.bom=next.bom.filter(b=>b.child!==sku);next.bom.push({parent,child:sku,qty:Number($('mQty').value)})} | |
| if(!feasible(next)){ $('formError').textContent='Invalid item values or cyclic/invalid BOM relationships. Please review the inputs.';return}state=next;save();closeModal();render();toast('Material saved and plan recalculated')}); | |
| function download(name,content,mime){const blob=new Blob([content],{type:mime}),u=URL.createObjectURL(blob),a=document.createElement('a');a.href=u;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(u),600)} | |
| function csv(rows){return rows.map(row=>row.map(csvSafe).join(',')).join('\r\n')+'\r\n'} | |
| function exportSummary(){const g=calc();download('bomforge-material-plan.csv',csv([['SKU','Name','Category','Gross requirements','Planned receipts','Projected ending stock','Unit cost USD','Purchase cost USD','Lead weeks','Pre-horizon receipts'],...g.order.map(s=>{const x=g.bySku[s];return [s,x.it.name,x.it.kind,x.gross.reduce((a,b)=>a+b),x.orderTotal,x.stock[H-1],x.it.cost,(x.it.cost*x.orderTotal).toFixed(2),x.lead,x.early.reduce((a,b)=>a+b)]})]),'text/csv;charset=utf-8')} | |
| function exportDetail(){const g=calc();download('bomforge-weekly-mrp.csv',csv([['SKU','Week','Gross requirements','Planned receipts','Planned releases','Projected stock','Pre-horizon receipts'],...g.order.flatMap(s=>Array.from({length:H},(_,w)=>{const x=g.bySku[s];return[s,w+1,x.gross[w],x.received[w],x.released[w],x.stock[w],x.early[w]]}))]),'text/csv;charset=utf-8')} | |
| $('exportCSV').onclick=exportSummary;$('exportPlan').onclick=exportDetail;$('importJSON').onclick=()=>$('importFile').click();$('exportJSON').onclick=()=>download('bomforge-backup.json',JSON.stringify({format:'BOMForge-113',state},null,2),'application/json'); | |
| $('importFile').addEventListener('change',async e=>{const file=e.target.files?.[0];e.target.value='';if(!file)return;if(file.size>350000){toast('Backup too large (limit 350 KB)');return}try{const obj=JSON.parse(await file.text());if(!obj||obj.format!=='BOMForge-113'||!feasible(obj.state)){toast('Invalid backup; no data changed');return}state={...obj.state,theme:obj.state.theme==='light'?'light':'dark',focus:typeof obj.state.focus==='string'?obj.state.focus:defaults.focus};save();render();toast('Backup restored successfully')}catch{toast('Could not read backup; no data changed')}}); | |
| $('themeToggle').onclick=()=>{state.theme=state.theme==='light'?'dark':'light';save();render()};$('openNew').onclick=()=>openModal();$('closeModal').onclick=closeModal;$('cancelModal').onclick=closeModal;$('modalBack').addEventListener('click',e=>{if(e.target===$('modalBack'))closeModal()}); | |
| $('resetPlan').onclick=()=>{state.scenario='baseline';state.demandMult=100;state.safetyBoost=100;save();render();toast('Planning controls restored')};$('resetAll').onclick=()=>{if(confirm('Replace this browser’s workspace with the original demo data? Export a backup first if needed.')){state=deep(defaults);save();nav('overview');toast('Demo restored')}}; | |
| $('restoreArchived').onclick=()=>{state.items.forEach(x=>x.active=true);if(!feasible(state)){toast('Cannot restore because of invalid BOM relationships');return}save();render();toast('Archived materials restored')}; | |
| $('scenario').onchange=e=>{state.scenario=e.target.value;save();render()};for(const id of ['demandMult','safetyBoost'])$(id).onchange=e=>{const v=Number(e.target.value);if(!Number.isFinite(v)||v<(id==='demandMult'?50:0)||v>200){render();toast('Enter a valid percentage');return}state[id]=v;save();render()};$('focusSku').onchange=e=>{state.focus=e.target.value;save();render()}; | |
| for(const navEl of [$('desktopNav'),$('mobileNav')])navEl.addEventListener('click',e=>{const b=e.target.closest('[data-page]');if(b)nav(b.dataset.page)}); | |
| document.addEventListener('click',e=>{const a=e.target.closest('[data-go]');if(a)nav(a.dataset.go);const n=e.target.closest('[data-new]');if(n)openModal();const ed=e.target.closest('[data-edit]');if(ed)openModal(ed.dataset.edit);const ar=e.target.closest('[data-archive]');if(ar){const it=state.items.find(x=>x.sku===ar.dataset.archive);if(!it)return;it.active=!it.active;if(!feasible(state)){it.active=!it.active;toast('Archiving would invalidate the material graph');return}save();render();toast(it.active?'Material restored':'Material archived')};const ss=e.target.closest('[data-setscenario]');if(ss){state.scenario=ss.dataset.setscenario;save();nav('planner')}}); | |
| document.addEventListener('change',e=>{const input=e.target.closest('[data-forecast]');if(!input)return;let sku=input.dataset.forecast,w=Number(input.dataset.week),n=Number(input.value);if(!Number.isInteger(n)||n<0||n>100000){toast('Forecast must be an integer between 0 and 100,000');render();return}if(!state.forecast[sku])state.forecast[sku]=Array(H).fill(0);state.forecast[sku][w]=n;save();render();toast('Demand forecast updated')}); | |
| window.__BOMFORGE_TEST__={defaults:()=>deep(defaults),feasible,topo,calc,scenarioCfg,getState:()=>deep(state),csvSafe};render(); | |
| })(); | |
| </script></body></html> | |