Factory console: work orders for the operators
Deploy test site / deploy (push) Successful in 51s

New "Work orders" section in the factory console (#work), derived from
the same simulated records as the rest of the page, one work order per
station and job:
- order stages: mastering, lacquer cutting, electroforming, stamper prep,
  test pressings, press run, packing, dispatch
- stamper changes (planned when a set nears its life, in progress during
  the change), fault clearing on a stopped press (maintenance)
- material lab: lab gate per compound lot, release test pressing, and the
  held lot's re-dry and retest (button starts it)
- audio QC: each sampled copy's listening check

Board: In progress (press floor first) / Next up (planned press runs and
stamper changes with time and press, jobs arriving at the next station)
/ Done today, filter per station, KPIs. Each work order opens a kiosk
view: the steps an operator follows (badge in, scan what the step asks
for, with the IDs to scan, confirm), confirmation times and who did it,
the order, lot or press it belongs to, and what it writes to the log.
Order detail lists the order's work orders by station. Mastering and
cutting are booked to the engineers, the lab, QC and maintenance to
technicians, the rest to numbered operators; jobs finishing at night are
booked to the late shift.

README, digital-factory.md demo paragraph and the landing page mention
the work orders.
This commit is contained in:
grabowski committed 2026-10-16 10:41:29 +07:00
1 parent dd7b749af9
commit f9b8407561
4 files changed
+189 -9

No files matched your search

+3 -2
View File
@@ -495,8 +495,9 @@ VictoriaMetrics. Packaged as a flake so the clan repo pins it as an input.
**Demo.** [Factory console and order status](factory-console.html) (test
site `/demos/factory/`) shows what the application would look like on a
simulated plant day: the press floor with live counters per press, the
optical inspection stream, audio samples, the material lab gate with a
simulated plant day: the press floor with live counters per press, work
orders per station in the kiosk form described below (badge in, scan
what the step asks for, confirm), the optical inspection stream, audio samples, the material lab gate with a
lot on hold, stamper metrology and genealogy, the hash-chained event log,
and the customer portal of the same orders (progress through the three
gates, copies pressed so far, the lab certificate of the compound lot,
+182 -5
View File
@@ -224,6 +224,42 @@ document.documentElement.setAttribute("data-theme",t);})();
.daysv b { font-family: var(--mono); font-weight: 600; color: var(--ink); font-size: 11px; }
.verdict { font-weight: 650; white-space: nowrap; }
/* work orders */
.kan { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; align-items: start; }
.kcol { background: var(--bg2); border: 1px solid var(--line); border-radius: 14px; padding: 10px; min-width: 0; }
.kcol > h3 { display: flex; justify-content: space-between; align-items: baseline; margin: 2px 4px 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--mute); }
.kcol > h3 b { font-family: var(--mono); color: var(--ink); }
.wo { display: flex; flex-direction: column; gap: 4px; background: var(--card); border: 1px solid var(--line); border-left: 3px solid var(--stroke); border-radius: 11px; padding: 9px 11px; margin-bottom: 8px; color: var(--ink); text-decoration: none; min-width: 0; }
.wo:hover { border-color: var(--stroke); text-decoration: none; }
.wo.doing { border-left-color: var(--acc); }
.wo.todo { border-left-color: var(--inv); }
.wo.done { border-left-color: var(--ok); opacity: 0.86; }
.wo-h { display: flex; align-items: center; gap: 8px; font-size: 11.5px; min-width: 0; }
.wo-h .who { margin-left: auto; color: var(--mute); white-space: nowrap; }
.wo b { font-size: 13.5px; font-weight: 650; }
.wo .rf { font-size: 12px; color: var(--mute); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wo small { color: var(--mute); }
.wo small.cs { color: var(--ink); }
.kgrid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr); gap: 16px; align-items: start; }
ol.ks { list-style: none; margin: 0; padding: 0; counter-reset: ks; }
ol.ks li { display: grid; grid-template-columns: 34px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 10px; border-radius: 12px; border: 1px solid transparent; margin-bottom: 4px; }
ol.ks li .n { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font-weight: 700; font-size: 13px; border: 2px solid var(--stroke); color: var(--mute); }
ol.ks li b { display: block; font-weight: 620; font-size: 15px; line-height: 1.3; }
ol.ks li small { color: var(--mute); }
ol.ks li.done .n { background: var(--ok); border-color: var(--ok); color: #fff; }
ol.ks li.done b { color: var(--mute); font-weight: 500; }
ol.ks li.now { background: var(--acc-s); border-color: color-mix(in srgb, var(--acc) 40%, transparent); }
ol.ks li.now .n { border-color: var(--acc); color: var(--acc); }
ol.ks li.next b { color: var(--mute); font-weight: 500; }
.scan { font-family: var(--mono); font-size: 11.5px; border: 1px dashed var(--stroke); border-radius: 8px; padding: 3px 8px; color: var(--mute); white-space: nowrap; max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
.scan::before { content: "▦ "; }
li.now .scan { border-color: var(--acc); color: var(--ink); }
.wol { display: flex; flex-direction: column; }
.wol a { display: grid; grid-template-columns: 96px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 6px 0; border-bottom: 1px dashed var(--line); color: var(--ink); font-size: 13px; text-decoration: none; }
.wol a:last-child { border-bottom: 0; }
.wol a:hover b { text-decoration: underline; }
.wol b { font-weight: 560; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* disc */
.disc .hole { fill: var(--bg); }
.disc .rot { animation: rot var(--rpm, 1.8s) linear infinite; }
@@ -289,6 +325,7 @@ document.documentElement.setAttribute("data-theme",t);})();
.sh2 { font-size: 17px; font-weight: 700; margin: 26px 0 12px; }
@media (max-width: 1100px) {
.kan, .kgrid { grid-template-columns: minmax(0, 1fr); }
.cols2 { grid-template-columns: minmax(0, 1fr); }
.cgrid { grid-template-columns: minmax(0, 1fr); }
}
@@ -316,6 +353,10 @@ document.documentElement.setAttribute("data-theme",t);})();
.fr .fo { display: none; }
.pipe a { grid-template-columns: 120px minmax(0, 1fr) 34px; }
.pareto div { grid-template-columns: 112px minmax(0, 1fr) 40px; }
ol.ks li { grid-template-columns: 30px minmax(0, 1fr); }
ol.ks li .scan { grid-column: 2; justify-self: start; max-width: 100%; }
.wol a { grid-template-columns: minmax(0, 1fr) auto; }
.wol a .mono { display: none; }
.demobar { font-size: 11px; }
}
</style>
@@ -424,7 +465,7 @@ const NOW0=(()=>{const d=new Date();d.setHours(10,5,0,0);return d.getTime();})()
/* ================= state ================= */
const S={t:NOW0-60*DAY,speed:20,run:true,warm:true,orders:[],byId:{},presses:[],lots:[],use:{black:null,clear:null},pend:{black:0,clear:0},regrind:[],feed:[],aud:[],led:[],ledN:0,day:{k:0},days:[],nextArr:0,seq:{o:1040,p:300,lot:2400,rg:880},
ui:{view:"factory",fpage:"floor",arg:null,fhash:"floor",filter:"active",q:"",cust:null}};
function mkPress(id,sys,kind){return {id,sys,kind,st:"off",next:0,until:0,order:null,set:null,since:0,why:"",tel:[],lc:30000,op:"",res:0};}
function mkPress(id,sys,kind){return {id,sys,kind,st:"off",next:0,until:0,order:null,set:null,since:0,why:"",tel:[],lc:30000,op:"",res:0,stops:[]};}
/* demo fleet for now: one Duplex system (two presses on one extruder set) and one Classic */
S.presses=[mkPress("D1-A","Duplex","duplex"),mkPress("D1-B","Duplex","duplex"),mkPress("C1","Classic","classic")];
const pById=id=>S.presses.find(p=>p.id===id);
@@ -596,7 +637,7 @@ function cycle(p,now){
mount(p,o,now);p.st="change";p.why="Stamper change to set "+(o.setIdx+1);p.until=now+20*MIN;p.next=p.until;p.since=0;
log(now,"press",`${p.id}: stamper set ${set.n} retired after ${fI(set.plays)} plays`,p.op||"Operator",o.id);return;
}
if(rnd()<1/480){p.st="stop";p.why=pick(STOPS);p.until=now+U(15,45)*MIN;p.next=p.until;log(now,"press",`${p.id} stopped: ${p.why}`,"PLC",o.id);return;}
if(rnd()<1/480){p.st="stop";p.why=pick(STOPS);p.until=now+U(15,45)*MIN;p.next=p.until;p.stops.push({t:now,why:p.why,until:p.until,order:o.id});if(p.stops.length>12)p.stops.shift();log(now,"press",`${p.id} stopped: ${p.why}`,"PLC",o.id);return;}
p.next=now+cycMs(p);
}
function sample(o,p,set,now,key,first){
@@ -783,8 +824,8 @@ function feedHTML(list,n){if(!list.length)return '<p class="mute">The presses ar
const kpi=(l,v,s,c)=>`<div class="kpi ${c||""}"><span>${l}</span><b>${v}</b><small>${s||"&nbsp;"}</small></div>`;
/* ================= factory pages ================= */
const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]];
function navHTML(){const cur=S.ui.fpage==="order"?"orders":S.ui.fpage==="press"?"floor":S.ui.fpage;
const NAV=[["floor","◉","Floor"],["orders","☰","Orders"],["work","⚒","Work orders"],["quality","✓","Quality"],["lab","⚗","Lab"],["metal","◎","Metal"],["ledger","⛓","Ledger"]];
function navHTML(){const cur=S.ui.fpage==="order"?"orders":S.ui.fpage==="press"?"floor":S.ui.fpage==="wo"?"work":S.ui.fpage;
const bd={floor:S.presses.filter(p=>p.st==="stop"||p.st==="nopvc").length,orders:cnt(o=>o.si!==SI.done),lab:S.lots.filter(L=>L.st==="hold").length,quality:S.aud.filter(x=>x.a.rev&&x.a.rt<=S.t&&S.t-x.a.rt<6*HR).length};
return NAV.map(([k,ic,l])=>{const b=bd[k];const bad=(k==="lab"||k==="floor"||k==="quality")&&b>0;return `<a href="#${k}" class="${cur===k?"on":""}"><span class="ic">${ic}</span>${l}${b?`<span class="bd ${bad?"bad":""}">${fI(b)}</span>`:""}</a>`;}).join("");}
function pressCard(p){
@@ -823,7 +864,7 @@ PAGES.floor={
<div class="presses" id="fP"></div>
<div class="cols2"><section class="card"><div class="ch"><h3>Optical inspection, every record</h3><span class="mute">record key: stamper · press · cycle time</span></div><div id="fFeed"></div></section>
<section class="card"><div class="ch"><h3>Needs attention</h3></div><div id="fAl"></div></section></div>
<div class="sh"><h2>Stations</h2></div><div class="stations" id="fS"></div>
<div class="sh"><h2>Stations</h2><a href="#work">Work orders by station →</a></div><div class="stations" id="fS"></div>
<div class="cols2"><section class="card"><div class="ch"><h3>Orders by stage</h3><a href="#orders">All orders</a></div><div id="fPipe"></div></section>
<section class="card"><div class="ch"><h3>Good records per day</h3><span class="mute">shifts 06:00 to 22:00</span></div><div id="fDays"></div></section></div>`,
blocks(){
@@ -891,6 +932,7 @@ PAGES.order={
<section class="card"><div class="ch"><h3>Compound lot</h3><span class="mute">lab gate</span></div>${b.comp}</section>
<section class="card"><div class="ch"><h3>Packing and shipping</h3></div>${b.pack}</section>
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Audio comparison, sampled</h3><span class="mute">first-off per stamper set, about every 50th record, every 25th near end of life</span></div>${audioTable(o.audio,false)}</section>
<section class="card"><div class="ch"><h3>Work orders</h3><span class="mute">one per station</span></div><div class="wol">${orderWOs(o).map(w=>`<a href="#wo=${w.id}"><span class="mono mute">${w.id}</span><b>${esc(w.title)}</b><span class="chip ${w.status==="doing"?"g-press":w.status==="done"?"g-out":"g-queue"}">${w.status==="doing"?"in progress":w.status==="done"?"done":"to do"}</span></a>`).join("")}</div></section>
<section class="card" style="grid-column:1/-1"><div class="ch"><h3>Events for this order</h3><span class="mute">from the hash-chained log</span></div>${ledgerTable(o.ev.slice(-14).reverse(),false)}</section>`
};
}
@@ -952,6 +994,140 @@ PAGES.ledger={
blocks:()=>({gL:ledgerTable(S.led.slice(-60).reverse(),true)+`<p class="note">Showing the latest 60 of ${fI(S.ledN)} events since the simulation started. The demo uses a short hash; the factory application uses SHA-256.</p>`})
};
/* ================= work orders: derived from the same records the stations write ================= */
const WST=[["mast","Mastering suite","g-pre"],["cut","Cutting room","g-metal"],["galv","Galvanics","g-metal"],["prep","Stamper prep","g-metal"],["press","Press floor","g-press"],["qc","Audio QC","g-out"],["lab","Material lab","g-pre"],["pack","Packing","g-out"],["ship","Dispatch","g-out"],["maint","Maintenance","g-gate"]];
const WSTN=Object.fromEntries(WST.map(x=>[x[0],x]));
const STG_WO={mast:"mast",cut:"cut",plate:"galv",prep:"prep",tp:"press",run:"press",fin:"pack",ship:"ship"};
const WO_LOG={mast:"session log, engineer, chain settings, reference files for gate 2",cut:"lacquer IDs, microscope result per side, run-out text and fiducial, stylus, operator and time",galv:"part IDs with their parents, bath and cell, XRF thickness, listening check of the mother, archive position",prep:"stamper metrology on four zones, hardness on the centre slug, centring, rack position",press:"press, stamper set, label lot, compound lot, every cycle's telemetry and every optical result",qc:"match against the master, clicks per side, noise floor, channel balance, verdict",lab:"result per instrument, certificate against the specification, release or hold",pack:"assembly scan per copy, barcode grade, carton IDs",ship:"cartons, weights, carrier and tracking",maint:"fault, fix, downtime, test cycle"};
/* who did it: engineers for mastering and cutting, numbered staff elsewhere; work finished at night is booked to the late shift */
function opAt(key,t,st){
if(!inShift(t)){if(!st)return "next shift";const d=new Date(t);if(d.getHours()<6)d.setDate(d.getDate()-1);d.setHours(21,0,0,0);t=d.getTime();}
const h=new Date(t).getHours(),k=fnv(key+":"+dayKey(t)+":"+(h<14?1:2),3);
return st==="mast"?"Mastering engineer":st==="cut"?"Cutting engineer":st==="lab"?"Lab tech "+(k%2+1):st==="qc"?"QC tech "+(k%2+1):st==="maint"?"Maintenance tech "+(k%2+1):"Operator "+pad(2+k%13);}
function stIds(o,k){const s=o.sets[k||0];return s?s.st.map(x=>x.id).join(", "):"";}
function stageSteps(o,stId){
const n=o.sets.length,ids=a=>a?a.map(x=>x.id).join(", "):"";
switch(stId){
case "mast":return [["Badge in at the mastering suite","badge"],["Load the approved files","checksums"],["Side plan: order, gaps, level against side length"],["Master side A"],["Master side B"],["Reference files to the customer for gate 2"]];
case "cut":return [["Badge in at Lathe 1","badge"],["Scan two lacquer blanks",ids(o.lac)||"blank IDs"],["Load the cutting plan from the approved master"],["Cut side A"],["Microscope check, side A"],["Cut side B"],["Microscope check, side B"],["Scribe the run-out and the fiducial",o.catno+" A / B"],["Pack, hand to galvanics the same day"]];
case "plate":return o.repress?[["Scan the archived mothers",ids(o.mo)],["Mothers into "+(o.cell||"the cell")],["Grow "+n+" stamper set"+(n>1?"s":"")],["Separate: laser back marks, XRF thickness",stIds(o)],["Hand over to stamper prep"]]
:[["Scan the lacquers",ids(o.lac)],["Silver the lacquers"],["Father into "+(o.cell||"the bath")],["Separate the father: laser back mark, XRF",ids(o.fa)],["Mother into the bath"],["Separate the mother: listening check, archive",ids(o.mo)],["Grow "+n+" stamper set"+(n>1?"s":"")],["Separate the stampers: laser back marks, XRF",stIds(o)],["Hand over to stamper prep"]];
case "prep":return [["Scan each stamper's Data Matrix",stIds(o)],["Back-finish (Sibert VSF)"],["Punch the centre (Sibert VSP)"],["Coin the edge (Sibert VSC)"],["Confocal groove check on four zones"],["Hardness on the centre slug"],["Centring check"],["Into the rack, position logged"]];
case "tp":return [["Scan stamper set 1",stIds(o)],["Scan the label lot and the compound lot"],["Mount and heat up"],["Press 10 test pressings"],["Optical check and audio comparison"],["Pack 5 for the customer, courier out"]];
case "run":{const k=Math.min(o.setIdx,Math.max(0,o.sets.length-1));return [["Badge in at the press","badge"],["Scan stamper set "+(k+1),stIds(o,k)],["Scan the label lot",o.labelLot||"label lot"],["Confirm the compound lot in the hopper",o.lots.slice(-1)[0]||"lot ID"],["Mount the stampers, heat up"],["First-off: optical pass, copy to audio QC"],["Press "+fI(o.qty)+" good copies"],["Unmount, stampers to the rack"]];}
case "fin":return [["Scan the order",o.id],["Sleeve and jacket each copy"],["Assembly scan: run-out code against the jacket barcode","each copy"],["Barcode verification on every unit"],...(o.shrink?[["Shrink wrap"]]:[]),["Cartons, packing list, label"]];
case "ship":return [["Pick the cartons",o.ship?o.ship.boxes+" cartons":""],["Weigh, print the carrier label"],["Hand over to "+(o.ship?o.ship.carrier:"the carrier"),o.ship?o.ship.trk:""]];
}
return [];
}
function stageTitle(o,stId){const n=(o.sets.length||Math.max(1,Math.ceil(o.qty*1.08/1000)))*2;
return {mast:"Vinyl mastering, sides A and B",cut:"Cut the lacquers, sides A and B",plate:o.repress?`Grow ${n} stampers from the archived mothers`:`Electroforming: father, mother, ${n} stampers`,prep:`Finish and measure ${n} stampers`,tp:"Press 10 test pressings",run:`Press run, ${fI(o.qty)} copies`,fin:`Pack ${fI(o.good||o.qty)} copies`,ship:`Dispatch ${o.ship?o.ship.boxes:Math.ceil(o.qty/50)} cartons`}[stId];}
function stageWO(o,stId,status){
const h=o.hist.filter(x=>x.id===stId).pop();
const w={id:`WO-${o.n}-${stId}`,st:STG_WO[stId],title:stageTitle(o,stId),o,ref:o.id,refT:o.title,status,steps:stageSteps(o,stId),pr:0,start:null,end:null,plan:null,where:""};
if(status==="done"){w.start=h.a;w.end=h.b;w.pr=1;}
else if(status==="doing"){w.start=o.ss;
if(stId==="run"){const p=pById(o.press),e=eta(o);w.end=e?e.runEnd:null;w.where=o.press||"waiting for a press";
w.pr=p&&p.st==="change"&&o.good===0?Math.max(0.13,Math.min(0.62,0.13+0.5*(1-(p.until-S.t)/(40*MIN)))):o.good===0?0.63:0.75+0.24*o.good/o.qty;}
else{w.end=o.ss+o.sd;w.pr=Math.max(0,Math.min(1,(S.t-o.ss)/o.sd));}}
else{if(stId==="run"){const s=schedEst(o);if(s){w.plan=s.start;w.end=s.end;w.where=s.press;}}else{const cur=STAGES[o.si];if(cur.d&&!cur.gate)w.plan=o.ss+o.sd;}}
if(stId==="tp")w.where="D1-"+(o.n%2?"A":"B");
const p=pById(o.press);
w.who=status==="todo"?"":stId==="run"&&status==="doing"&&p?p.op:opAt(w.id,status==="done"?w.end:S.t,status==="done"||STG_WO[stId]==="galv"?w.st:(inShift(S.t)?w.st:null));
return w;
}
function orderWOs(o){return STAGES.map((s,i)=>[s,i]).filter(([s])=>STG_WO[s.id]&&stageOn(o,s)).map(([s,i])=>{const h=o.hist.filter(x=>x.id===s.id).pop();return stageWO(o,s.id,h&&h.b!=null?"done":o.si===i?"doing":"todo");});}
function workOrders(){
const W=[],t0=S.day.t0,T=S.t;
for(const o of S.orders){
for(const h of o.hist)if(h.b!=null&&h.b>=t0&&h.b<=T&&STG_WO[h.id])W.push(stageWO(o,h.id,"done"));
const cur=STAGES[o.si].id;
if(STG_WO[cur])W.push(stageWO(o,cur,"doing"));
if(cur==="queue")W.push(stageWO(o,"run","todo"));
if(["mast","cut","plate","prep","fin"].includes(cur)){const ni=nextIdx(o),ns=STAGES[ni];if(STG_WO[ns.id]&&!ns.gate)W.push(stageWO(o,ns.id,"todo"));}
for(const s of o.sets)if(s.retired&&s.retired>=t0&&s.retired+20*MIN<=T)W.push(setWO(o,s.n+1,"done",s.retired,s.retired+20*MIN,s.press));
}
for(const p of S.presses){
const o=p.order;
if(o&&p.st==="change"&&p.why.startsWith("Stamper change"))W.push(setWO(o,o.setIdx+1,"doing",p.until-20*MIN,p.until,p.id));
else if(o&&p.set&&p.set.plays>p.set.life*0.75&&o.qty-o.good>p.set.life-p.set.plays)W.push(setWO(o,p.set.n+1,"todo",addShiftH(S.t,(p.set.life-p.set.plays)/rateH(p)),null,p.id));
for(const s of p.stops){const w={id:`WO-${p.id}-m${Math.floor(s.t/1000).toString(36)}`,st:"maint",title:"Clear fault: "+s.why.toLowerCase(),p,ref:p.id,refT:s.order||"",steps:[["Fault acknowledged on the press panel","badge"],["Fix: "+s.why.toLowerCase()],["Test cycle, back to production"]],start:s.t,end:s.until,where:p.id};
if(s.t<=T&&s.until>T)W.push(Object.assign(w,{status:"doing",pr:(T-s.t)/(s.until-s.t),who:opAt(w.id,T,"maint")}));
else if(s.until>=t0&&s.until<=T)W.push(Object.assign(w,{status:"done",pr:1,who:opAt(w.id,s.until,"maint")}));}
}
for(const L of S.lots){
const lab={id:`WO-${L.id}-lab`,st:"lab",title:"Lab gate, "+MAT[L.mat].toLowerCase(),L,ref:L.id,refT:SUP[L.sup],steps:[["Scan the lot label, take a sealed sample",L.id],["Moisture"],["Heat stability strips"],["Density"],[L.mat==="black"?"Colour and gloss":"Colour"],["Fines and ash"],["Plaque: speck count"],["Certificate against the specification"]],start:L.arr,end:L.labDone};
if(L.st==="lab")W.push(Object.assign(lab,{status:"doing",pr:Math.max(0,Math.min(1,(T-L.arr)/(L.labDone-L.arr))),who:opAt(lab.id,T,inShift(T)?"lab":null)}));
else if(L.labDone>=t0&&L.labDone<=T)W.push(Object.assign(lab,{status:"done",pr:1,who:opAt(lab.id,L.labDone,"lab")}));
const rel={id:`WO-${L.id}-rel`,st:"lab",title:"Release test pressing, "+L.id,L,ref:L.id,refT:MAT[L.mat],steps:[["Scan the lot into the test hopper",L.id],["Press release test pressings"],["Optical check and audio comparison"],["Release the lot in the system"]],start:L.labDone,end:L.tpDone||L.rel};
if(L.st==="tp")W.push(Object.assign(rel,{status:"doing",pr:Math.max(0,Math.min(1,(T-L.labDone)/(L.tpDone-L.labDone))),who:opAt(rel.id,T,inShift(T)?"lab":null)}));
else if(L.rel&&L.rel>=t0&&L.rel<=T)W.push(Object.assign(rel,{status:"done",pr:1,who:opAt(rel.id,L.rel,"lab")}));
if(L.st==="hold"||L.st==="dry"){const d={id:`WO-${L.id}-dry`,st:"lab",title:"Re-dry and retest "+L.id,L,ref:L.id,refT:"moisture "+f2(L.res.moist)+" %, limit "+f2(LIMS.moist)+" %",steps:[["Move the lot to the dryer",L.id],["Dry for 4 h"],["Retest moisture, then the release test pressing"]],prio:true};
if(L.st==="hold")W.push(Object.assign(d,{status:"todo",pr:0,plan:T,who:""}));
else W.push(Object.assign(d,{status:"doing",start:L.dryDone-4*HR,end:L.dryDone,pr:(T-(L.dryDone-4*HR))/(4*HR),who:opAt(d.id,T,inShift(T)?"lab":null)}));}
}
for(const x of S.aud){const o=S.byId[x.o],a=x.a;if(!o)continue;
const w={id:`WO-${o.n}-q${a.copy}s${a.set}`,st:"qc",title:"Listening check, "+(a.first?"first-off of set "+a.set:"copy #"+fI(a.copy)),o,ref:o.id,refT:o.title,steps:[["Scan the record key",a.key],["Needle-drop, side A"],["Needle-drop, side B"],["Compare with the source master"],["Result to the log"]],start:a.t,end:a.rt,where:"Audio QC station"};
if(a.t<=T&&a.rt>T)W.push(Object.assign(w,{status:"doing",pr:(T-a.t)/(a.rt-a.t),who:opAt(w.id,T,inShift(T)?"qc":null)}));
else if(a.rt>=t0&&a.rt<=T)W.push(Object.assign(w,{status:"done",pr:1,who:opAt(w.id,a.rt,"qc")}));}
return W;
}
function setWO(o,k,status,start,end,pid){const old=o.sets[k-2],nw=o.sets[k-1];
return {id:`WO-${o.n}-set${k}`,st:"press",title:`Stamper change to set ${k} on ${pid}`,o,ref:o.id,refT:o.title,status,pr:status==="done"?1:status==="doing"?Math.max(0,Math.min(1,(S.t-start)/(end-start))):0,start:status==="todo"?null:start,end,plan:status==="todo"?start:null,where:pid,
steps:[["Scan the worn set out",old?old.st.map(x=>x.id).join(", "):""],["Scan set "+k+" in",nw?nw.st.map(x=>x.id).join(", "):"from the rack"],["Mount and heat up"],["First-off to audio QC"]],who:status==="todo"?"":(pById(pid)&&pById(pid).op)||opAt(o.id+k,start,"press")};}
function woById(id){const W=workOrders().find(w=>w.id===id);if(W)return W;
const m=/^WO-(\d+)-(mast|cut|plate|prep|tp|run|fin|ship)$/.exec(id||"");if(!m)return null;
const o=S.byId["CNX-"+m[1]];if(!o)return null;return orderWOs(o).find(w=>w.id===id)||null;}
function woCur(w){const n=w.steps.length;return w.status==="done"?n:w.status==="todo"?0:Math.min(n-1,Math.floor(w.pr*n));}
function woTime(w){
if(w.status==="done")return "done "+fDT(w.end)+(w.who?" · "+w.who:"");
if(w.status==="doing")return "started "+fDT(w.start)+(w.end&&isFinite(w.end)?", done about "+fDT(w.end):"");
if(w.prio&&w.plan)return "now, waiting for the lab";
return w.plan?(w.st==="press"?"planned ":"arrives about ")+fDT(w.plan)+(w.where?" on "+w.where:""):"later";
}
const stChip=k=>`<span class="chip ${WSTN[k][2]}">${WSTN[k][1]}</span>`;
function woCard(w){const c=woCur(w),n=w.steps.length;
return `<a class="wo ${w.status}" href="#wo=${w.id}"><div class="wo-h"><span class="mono">${w.id}</span>${stChip(w.st)}<span class="who">${esc(w.who||"")}</span></div><b>${esc(w.title)}</b><div class="rf">${esc(w.ref)}${w.refT?" · "+esc(w.refT):""}</div>${w.status==="doing"?bar(w.pr,w.st==="press"?"acc":w.st==="maint"?"warn":"")+`<small class="cs">Step ${c+1} of ${n}: ${esc(w.steps[c][0])}</small>`:""}<small>${esc(woTime(w))}</small></a>`;}
PAGES.work={
layout:()=>`<div class="ph"><div><div class="eyebrow">Factory console</div><h1>Work orders</h1></div><p class="mute" style="max-width:560px">What every station does now and next. On the floor each station shows its own list on a kiosk: the operator taps the access badge and scans what each step asks for, and every confirmation lands in the event log.</p></div><div class="kpis" id="wK"></div><div class="filters" id="wF" style="margin-top:14px"></div><div class="kan" id="wB"></div>`,
blocks(){
const all=workOrders(),f=S.ui.wst||"all",W=all.filter(w=>f==="all"||w.st===f);
const col=(k,l,list)=>`<div class="kcol"><h3>${l}<b>${list.length}</b></h3>${list.slice(0,14).map(woCard).join("")||'<p class="mute" style="margin:4px">Nothing here.</p>'}${list.length>14?`<p class="note" style="margin:4px">+ ${list.length-14} more; pick a station above.</p>`:""}</div>`;
const SO={press:0,maint:1,cut:2,galv:3,prep:4,lab:5,pack:6,ship:7,mast:8,qc:9};
const doing=W.filter(w=>w.status==="doing").sort((a,b)=>SO[a.st]-SO[b.st]||(a.end||9e15)-(b.end||9e15)),todo=W.filter(w=>w.status==="todo").sort((a,b)=>(b.prio?1:0)-(a.prio?1:0)||(a.plan||9e15)-(b.plan||9e15)),done=W.filter(w=>w.status==="done").sort((a,b)=>b.end-a.end);
const busy=new Set(all.filter(w=>w.status==="doing").map(w=>w.st)).size;
return {
wK:kpi("In progress",fI(all.filter(w=>w.status==="doing").length),"across "+busy+" of "+WST.length+" stations")+kpi("Next up",fI(all.filter(w=>w.status==="todo").length),"planned or arriving")+kpi("Done today",fI(all.filter(w=>w.status==="done").length),"closed since 00:00")+kpi("Blocked by customers",fI(cnt(o=>STAGES[o.si].gate)),"orders at an approval gate"),
wF:[["all","All stations"],...WST].map(([k,l])=>{const n=all.filter(w=>(k==="all"||w.st===k)&&w.status!=="done").length;return `<button type="button" class="fbtn ${f===k?"on":""}" data-act="wst" data-arg="${k}">${l}<b>${n}</b></button>`;}).join(""),
wB:col("doing","In progress",doing)+col("todo","Next up",todo)+col("done","Done today",done)
};
}
};
PAGES.wo={
layout:()=>`<a class="back" href="#work">← Work orders</a><div id="kH"></div><div class="kgrid" id="kG"></div>`,
blocks(){
const w=woById(S.ui.arg);
if(!w)return {kH:'<p class="mute">This work order is closed and no longer on the board.</p>',kG:""};
const c=woCur(w),n=w.steps.length,span=w.end&&w.start&&isFinite(w.end)?w.end-w.start:0;
/* when each finished step was confirmed: spread over the whole job when done, over the time so far when in progress (a press run's setup steps fall in the 40 min changeover) */
const run=/-run$/.test(w.id);
const stepAt=i=>run&&w.start?(i<5?w.start+(i+1)*8*MIN:i===5?w.start+41*MIN:w.end):w.status==="done"?(span?w.start+span*(i+1)/n:0):w.start?w.start+(S.t-w.start)*(i+1)/(c+0.5):0;
const steps=`<ol class="ks">${w.steps.map(([t,scan],i)=>{const st=i<c?"done":i===c&&w.status==="doing"?"now":"next";const ta=st==="done"?stepAt(i):0,at=ta?fT(ta):"";
return `<li class="${st}"><span class="n">${st==="done"?"✓":i+1}</span><div><b>${esc(t)}</b>${st==="done"?`<small>${at}${w.who?" · "+esc(w.who):""}</small>`:st==="now"?`<small>in progress${w.st==="press"&&w.o&&w.o.press&&i===6?": "+fI(w.o.good)+" of "+fI(w.o.qty)+" good":""}</small>`:""}</div>${scan?`<span class="scan" title="${esc(scan)}">${esc(scan)}</span>`:"<span></span>"}</li>`;}).join("")}</ol>`;
let ref="";
if(w.o){const o=w.o;ref=`<div class="pc-o" style="margin-bottom:12px">${discSVG(o,64,false)}<div><b>${esc(o.title)}</b><div class="mute">${esc(o.artist)}</div><div class="mute">${esc(fmtS(o))} · ${fI(o.qty)} copies</div></div></div>`;}
else if(w.L){const L=w.L,s=lotStatus(L);ref=`<p style="margin:0 0 10px"><b class="mono">${L.id}</b> ${esc(MAT[L.mat])}<br><span class="mute">${esc(SUP[L.sup])}, ${fI(L.kg)} kg, received ${fDT(L.arr)}</span> <span class="chip ${s[0]}">${s[1]}</span></p>`;}
else if(w.p){ref=`<p style="margin:0 0 10px"><b class="mono">${w.p.id}</b> ${w.p.kind==="duplex"?"Newbilt Duplex press":"Newbilt Classic"}<br><span class="mute">${esc(pstate(w.p))}</span></p>`;}
const link=w.o?`<a class="btn" href="#order=${w.o.id}">Order ${w.o.id}</a>`:w.L?`<a class="btn" href="#lab">Lab</a>`:w.p?`<a class="btn" href="#press=${w.p.id}">Press ${w.p.id}</a>`:"";
const act=w.L&&w.L.st==="hold"&&w.status==="todo"?`<button type="button" class="btn pri" data-act="redry" data-arg="${w.L.id}">Start re-drying</button>`:"";
return {
kH:`<div class="ph"><div><div class="eyebrow"><span class="mono">${w.id}</span></div><h1>${esc(w.title)}</h1><div style="margin-top:6px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">${stChip(w.st)}<span class="chip ${w.status==="doing"?"g-press":w.status==="done"?"g-out":"g-pre"}">${w.status==="doing"?"In progress":w.status==="done"?"Done":"Next up"}</span><span class="mute">${esc(woTime(w))}</span></div></div><div class="acts" style="display:flex;gap:8px">${act}${link}</div></div>`,
kG:`<section class="card"><div class="ch"><h3>${w.o?"Order":w.L?"Compound lot":"Press"}</h3><span class="mute">${esc(w.ref)}</span></div>${ref}<dl class="kv"><dt>Station</dt><dd>${WSTN[w.st][1]}${w.where?" · "+esc(w.where):""}</dd><dt>Assigned</dt><dd>${esc(w.who||"next free operator")}</dd>${w.start?`<dt>Started</dt><dd>${fDT(w.start)}</dd>`:""}${w.plan&&w.status==="todo"&&!w.prio?`<dt>Planned</dt><dd>${fDT(w.plan)}</dd>`:""}${w.end&&isFinite(w.end)?`<dt>${w.status==="done"?"Done":"Done about"}</dt><dd>${fDT(w.end)}</dd>`:""}</dl>${w.status==="doing"?`<div style="margin-top:12px">${bar(w.pr,"acc")}</div>`:""}<p class="note">Writes to the log: ${esc(WO_LOG[w.st])}.</p></section>
<section class="card"><div class="ch"><h3>Steps</h3><span class="mute">kiosk view: badge in, scan, confirm</span></div>${steps}<p class="note">Steps follow the simulation in this demo. At the station each confirmation is an event in the hash-chained log, so the order history and the customer portal update as the work is done.</p></section>`
};
}
};
/* ================= customer portal ================= */
function cStatus(o){
const st=STAGES[o.si].id,p=pById(o.press),e=eta(o);
@@ -1056,6 +1232,7 @@ const ACT={
view(v){location.hash=v==="customer"?"customer="+(S.ui.cust||DEMO.press):(S.ui.fhash||"floor");},
speed(v){S.speed=+v;S.run=true;topRender();},
play(){S.run=!S.run;topRender();},
wst(v){S.ui.wst=v;render();},
filter(v){S.ui.filter=v;if(S.ui.fpage!=="orders")location.hash="orders";else render();},
approve(id){const o=S.byId[id];const g=o&&STAGES[o.si].gate;if(!g)return;approve(o,g,S.t,"Customer portal");if(g===3)o.prio=1;enter(o,nextIdx(o),S.t);
toast(g===3?"Approved. Your pressing slot is booked; the start time is on the order now.":"Approved. The next step starts now.");render();},