Files
sap-erp/public/work-order-print.html
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

396 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Work Order — Print</title>
<script src="/auth-guard.js?v=4"></script>
<script src="/company-list.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,Helvetica,sans-serif;background:#8a94a6;color:#000;font-size:10px}
#doc{padding:14px}
.loading{padding:40px;text-align:center;color:#fff;font-size:14px}
.toolbar{position:sticky;top:0;display:flex;gap:8px;justify-content:center;padding:8px;background:#0d1626;z-index:5}
.toolbar button{padding:7px 16px;border:none;border-radius:6px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit}
.tb-print{background:#1a6fff;color:#fff}
.tb-close{background:#243650;color:#c8d6e8}
.page{width:277mm;min-height:auto;background:#fff;margin:0 auto 16px;padding:6mm;border:1px solid #999}
table{border-collapse:collapse;width:100%}
td,th{border:1px solid #000;padding:2px 4px;vertical-align:middle;font-size:9px}
.mono{font-family:'Courier New',monospace}
.l{text-align:left}
/* Header band */
table.hdr td{text-align:center}
table.hdr .logo{width:120px}
table.hdr .logo img{max-height:46px;max-width:112px;object-fit:contain}
.co-name{font-size:16px;font-weight:800;letter-spacing:.3px}
.co-addr{font-size:10px}
table.hdr .k{font-weight:700;background:#f2f2f2;white-space:nowrap}
.title-bar{border:1px solid #000;border-top:none;text-align:center;font-weight:800;font-size:11px;padding:3px;background:#f2f2f2}
/* Info box */
table.info{border-top:none}
table.info .k{font-weight:700;background:#f2f2f2;white-space:nowrap;width:130px}
/* Materials */
.sol-cap{font-weight:700;font-size:9.5px;padding:3px 2px 1px;text-align:center;background:#f2f2f2;border:1px solid #000;border-bottom:none}
table.mt{margin-top:6px}
table.mt th{background:#f2f2f2;font-weight:700;text-align:center;font-size:8.5px;line-height:1.1}
table.mt td{text-align:center;font-size:8.5px;height:24px}
table.mt td.l{text-align:left}
.sec-gap{height:8px}
.sig-cell{text-align:center;vertical-align:middle}
.sig-cell img.sig-img{max-height:34px;max-width:110px;object-fit:contain;display:block;margin:0 auto 1px}
.sig-name{font-size:8px;font-weight:700}
.sig-dt{font-size:7.5px;color:#333}
/* Sign-off block */
table.signoff{margin-top:8px}
table.signoff .k{font-weight:700;width:230px;background:#f2f2f2;height:34px}
table.signoff .sig{height:34px}
table.signoff .sig img{max-height:30px;max-width:150px;object-fit:contain;vertical-align:middle}
table.signoff .sn{font-size:8.5px;font-weight:700}
table.signoff .sd{font-size:8px;color:#333}
@media print{
body{background:#fff}
.toolbar{display:none}
#doc{padding:0}
.page{width:auto;margin:0;border:none;padding:0}
.page + .page{page-break-before:always}
}
@page{size:A4 landscape;margin:8mm}
</style>
</head>
<body>
<div class="toolbar">
<button class="tb-print" onclick="window.print()">🖨 Print / Save as PDF</button>
<button class="tb-close" onclick="window.close()">Close</button>
</div>
<div id="doc"><div class="loading">Loading work order…</div></div>
<script>
'use strict';
const BE='/api';
let _tok=null;
try{_tok=sessionStorage.getItem('portal_token');}catch(_e){}
if(!_tok)window.location.href='/';
const _params=new URLSearchParams(location.search);
const _id=_params.get('id');
const _co=window.__DEFAULT_COMPANY__||'';
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
const _MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
function fmtD(iso){if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';return `${String(d.getDate()).padStart(2,'0')}-${_MON[d.getMonth()]}-${d.getFullYear()}`;}
function num3(v){if(v==null||v==='')return '';const n=Number(v);if(!isFinite(n)||n===0)return '';return String(Math.round(n*1000)/1000);}
// Raw Material's "Qty Req./100 ml" / "Qty Req." auto-scale for display —
// EXACT mirror of work-order.html's own "View" scaling (that's the
// reference this must match). Read-only here: this page never edits
// anything, it only needs to reproduce the same scaled numbers/units the
// app itself shows on-screen (Weight family mg/gm/Kg/Ton, Volume family
// µL/mL/Ltrs/KL — ratios are IDENTICAL between the two, so the same
// underlying number relabels cleanly as either), honoring each row's own
// saved display-unit choice (stdQtyDispUnit/qtyReqDispUnit) first and
// falling back to the auto-picked best-fit unit within the item's own
// family (volume for a liquid real UOM like LTR, weight otherwise).
function round3(n){return Math.round((Number(n)||0)*1000)/1000;}
function toStockQty(grams,uom){const u=(uom||'').toUpperCase().trim();
return (u.startsWith('KG')||u.startsWith('LTR')||u.startsWith('LITR')||u==='L'||u==='LT'||u==='KL')?grams/1000:grams;}
function fromStockQty(qty,uom){const u=(uom||'').toUpperCase().trim();
return (u.startsWith('KG')||u.startsWith('LTR')||u.startsWith('LITR')||u==='L'||u==='LT'||u==='KL')?qty*1000:qty;}
const UNIT_FAMILIES={
volume:{'µL':1,'mL':1000,'Ltrs':1e6,'KL':1e9},
weight:{'mg':1,'gm':1000,'Kg':1e6,'Ton':1e9},
};
function defaultRawFamily(uom){
const u=(uom||'').trim().toUpperCase();
return ['LTR','LTRS','LITRE','LITER','LITRES','LITERS','ML','KL','L'].includes(u)?'volume':'weight';
}
const RAW_UNIT_OPTIONS={...UNIT_FAMILIES.weight,...UNIT_FAMILIES.volume};
function scaleGrams(grams,uom){
const totalBase=(Number(grams)||0)*1000;
const units=UNIT_FAMILIES[defaultRawFamily(uom)];
const entries=Object.entries(units).sort((a,b)=>b[1]-a[1]);
for(const[u,mult] of entries){ if(totalBase>=mult) return {value:round3(totalBase/mult),label:u}; }
const smallest=entries[entries.length-1];
return {value:round3(totalBase/smallest[1]),label:smallest[0]};
}
function scaleGramsToUnit(grams,unit){
const mult=RAW_UNIT_OPTIONS[unit]!=null?RAW_UNIT_OPTIONS[unit]:1000;
return round3((Number(grams)||0)*1000/mult);
}
// Admin-configured per-item SOP display factor — see work-order.html's
// potencyFactor() for the full rationale. Applies ONLY to stdQty ("Qty
// Req./100 ml"); qtyReq ("Qty Req.") is never adjusted.
function potencyFactor(itemCode){
const p=parseFloat((_cfg.woRawPotencyFactors||{})[itemCode]);
return (p>0)?p/100:1;
}
// srcField lets a caller display a DIFFERENT row value (e.g. the persisted
// "qtyIssued") using 'field''s own unit/scaling conventions — used by the Qty
// Issued column for Raw Material rows (see rawTables() below).
function rawScaledDisp(r,field,noUnit,srcField){
const vf=srcField||field;
if(r.directRaw)return esc(r[vf]);
const dispField=field==='stdQty'?'stdQtyDispUnit':'qtyReqDispUnit';
const raw=parseFloat(r[vf])||0;
const grams=field==='stdQty'?raw*potencyFactor(r.itemCode):fromStockQty(raw,r.uom);
const unit=r[dispField]||scaleGrams(grams,r.uom).label;
const value=scaleGramsToUnit(grams,unit);
return noUnit?String(value):`${value} ${esc(unit)}`;
}
// Scaled unit label alone, for the UOM column — shows Qty Req./100 ml's
// unit (the user's own chosen one, if set) instead of the item's static SAP
// stock UOM.
function rawScaledUnit(r){
if(r.directRaw)return esc(r.uom);
const grams=(parseFloat(r.stdQty)||0)*potencyFactor(r.itemCode);
return esc(r.stdQtyDispUnit||scaleGrams(grams,r.uom).label);
}
async function api(p){
const r=await fetch(BE+p,{headers:{Authorization:'Bearer '+_tok}});
if(r.status===401){window.location.href='/';throw new Error('Session expired');}
const d=await r.json();
if(!r.ok||d.success===false)throw new Error(d.message||('HTTP '+r.status));
return d;
}
let _cfg={},_wo=null,_iss={qtyByItem:{}},_sig={},_newItemCode='';
let _woQtyIssuedSource='issue_for_production'; // Admin → System Settings — see issueRowCells()
// ── header / info / signoff (repeated per page) ────────────────────
function headerBand(){
const logo=_cfg.woLogo?`<img src="${_cfg.woLogo}"/>`:'';
return `<table class="hdr">
<tr>
<td class="logo" rowspan="2">${logo}</td>
<td colspan="6"><div class="co-name">${esc(_cfg.woCompanyName||'')}</div><div class="co-addr">${esc(_cfg.woCompanyAddress||'')}</div></td>
</tr>
<tr>
<td class="k">FORM NO.</td><td>${esc(_cfg.woFormNo||'')}</td>
<td class="k">EFFECTIVE DATE</td><td>${esc(_cfg.woEffectiveDate||'')}</td>
<td class="k">REVIEW DATE</td><td>${esc(_cfg.woReviewDate||'')}</td>
</tr>
</table>`;
}
function titleBar(suffix){return `<div class="title-bar">PRODUCTION WORK ORDER${suffix||''}</div>`;}
function infoBox(){
const w=_wo;
const pn=[w.productName,w.productDesc].filter(Boolean).join(' — ');
return `<table class="info">
<tr><td class="k">Product Name &amp; Description</td><td colspan="5" class="l">${esc(pn)}</td></tr>
<tr><td class="k">Generic Name</td><td colspan="5" class="l">${esc(w.genericName||'')}</td></tr>
<tr><td class="k">Batch Number</td><td class="l">${esc(w.batchNumber||'')}</td><td class="k">Batch Size</td><td class="l">${esc(w.batchSize||'')}</td><td class="k">Total Units</td><td class="l">${esc(w.totalUnits||'')}</td></tr>
<tr><td class="k">MFG. Date</td><td class="l">${esc(w.mfgDate||'')}</td><td class="k">EXP. Date</td><td class="l">${esc(w.expDate||'')}</td><td class="k">Pack Size</td><td class="l">${esc(w.packSize||'')}</td></tr>
<tr><td class="k">Reference</td><td class="l">${esc(w.reference||'')}</td><td class="k">Product code</td><td class="l">${esc(w.productCode||'')}${_newItemCode&&_newItemCode.trim().toUpperCase()!==(w.productCode||'').trim().toUpperCase()?` (${esc(_newItemCode)})`:''}</td><td class="k">Type</td><td class="l">${esc(w.type||'')}</td></tr>
<tr><td class="k">Market</td><td class="l">${esc(w.market||'')}</td><td class="k">Intimation No.</td><td colspan="3" class="l">${esc(w.intimationDocNo||'')}</td></tr>
</table>`;
}
// bottom five sign-offs, auto-filled from the WO workflow log. 'aliases'
// covers a step whose display name changed later (e.g. "Checked By
// Production" → "Checked By Store In-Charge") — older Work Orders'
// workflowLog entries were recorded with the OLD text at the time (an
// immutable historical record), so the lookup must still match it.
const STEP_LABELS=[
{step:'Prepared By QA', label:'Prepared By (QA)'},
{step:'Checked By QC', label:'Checked By (QC)'},
{step:'Checked By Store In-Charge', aliases:['Checked By Production'], label:'Checked By (Store In-Charge)'},
{step:'Checked By (Manager Production)',label:'Checked By (Manager Production)'},
{step:'Approved By (Manager QA)', label:'Approved By (Manager QA)'},
];
function logFor(step,aliases){
const log=Array.isArray(_wo.workflowLog)?_wo.workflowLog:[];
// last non-rejected, non-auto entry matching this step
for(let i=log.length-1;i>=0;i--){const l=log[i];if((l.step===step||(aliases||[]).includes(l.step))&&l.action!=='rejected'&&l.action!=='auto-skipped')return l;}
return null;
}
function signInline(user,name,at){
const img=user&&_sig[user]?`<img src="${_sig[user]}"/> `:'';
const nm=name?`<span class="sn">${esc(name)}</span>`:'';
const dt=at?` <span class="sd">${fmtD(at)}</span>`:'';
return img+nm+dt;
}
function signoffs(){
// A step this WO auto-skipped (e.g. "Checked By QC" while workOrderSkipQc
// is on) is left off the printed list — matches the stepper on the Work
// Order page itself and the server-generated PDF.
const log=Array.isArray(_wo.workflowLog)?_wo.workflowLog:[];
const skipped=new Set(log.filter(l=>l.action==='auto-skipped').map(l=>l.step));
const visible=STEP_LABELS.filter(s=>!skipped.has(s.step)&&!(s.aliases||[]).some(a=>skipped.has(a)));
return `<table class="signoff">${visible.map(s=>{
const l=logFor(s.step,s.aliases);
return `<tr><td class="k">${s.label}</td><td class="sig">${l?signInline(l.by,l.byName||l.by,l.at):''}</td></tr>`;
}).join('')}</table>`;
}
// ── issuance columns — Qty stays SAP-derived per item; Issued/Received/
// Verified are PER-ROW one-click stamps (see verify-work-order.html) since
// different items can be issued/received/verified on different days. ───────
function qi(code){return num3(_iss.qtyByItem?.[(code||'').trim()]);}
function rowSig(row,which){ const at=row[which+'At']; if(!at)return null; return {name:row[which+'ByName']||row[which+'By'],user:row[which+'By'],at}; }
function sigTd(sig){
if(!sig||!sig.name)return `<td></td>`;
const img=sig.user&&_sig[sig.user]?`<img class="sig-img" src="${_sig[sig.user]}"/>`:'';
return `<td class="sig-cell">${img}<div class="sig-name">${esc(sig.name)}</div><div class="sig-dt">${fmtD(sig.at)}</div></td>`;
}
function issueHeadCells(){return `<th>Qty issued</th><th>Issued by /date</th><th>Received by</th><th>Verified by</th>`;}
// Raw Material rows are never SAP Production Order lines themselves (they're
// the exploded recipe of a Solution/SFG item, which IS the PO line), so their
// Qty Issued always comes from the server-PERSISTED row.qtyIssued (per Admin
// → System Settings → "Raw Material Qty Issued Source"). Packing
// Material/Components rows ARE real PO lines, so under the default
// 'issue_for_production' they read live off qi()/iss.qtyByItem; only under
// 'mark_issued' do they ALSO switch to the persisted row.qtyIssued.
function issueRowCells(row,isRaw){
const usePersisted=isRaw||_woQtyIssuedSource==='mark_issued';
const qty=usePersisted?(row.qtyIssued?(isRaw?rawScaledDisp(row,'qtyReq',false,'qtyIssued'):num3(row.qtyIssued)):''):qi(row.itemCode);
return `<td>${qty}</td>${sigTd(rowSig(row,'issued'))}${sigTd(rowSig(row,'received'))}${sigTd(rowSig(row,'verified'))}`;
}
// ── material tables ────────────────────────────────────────────────
function rawTables(){
const map=new Map();
// solBatchSize can legitimately be 0 — check null/undefined, not truthiness.
(_wo.rawMaterials||[]).forEach(r=>{const k=r.solCode||'';if(!map.has(k))map.set(k,{name:r.solName||'',batchSize:r.solBatchSize!=null?r.solBatchSize:'',rows:[]});map.get(k).rows.push(r);});
return [...map.values()].map(g=>{
const cap=g.name?`<div class="sol-cap">${esc(g.name)}${g.batchSize!==''?` — Batch Size ${esc(g.batchSize)} Ltr`:''}</div>`:'';
return cap+`<table class="mt">
<thead><tr><th>Item code</th><th>Raw materials</th><th>Spec</th><th>Qty Req./100 ml</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>Weighing Balance ID</th><th>AR No.</th>${issueHeadCells()}</tr></thead>
<tbody>${g.rows.map((r)=>`<tr>
<td class="mono">${esc(r.itemCode)}</td><td class="l">${esc(r.rawMaterial)}</td><td>${esc(r.spec)}</td><td>${rawScaledDisp(r,'stdQty',true)}</td><td>${rawScaledUnit(r)}</td><td>${esc(r.ovg)}</td><td>${rawScaledDisp(r,'qtyReq')}</td><td>${esc(r.weighingBalanceId)}</td><td>${esc(r.arNo)}</td>${issueRowCells(r,true)}
</tr>`).join('')}</tbody></table>`;
}).join('<div class="sec-gap"></div>');
}
function packTable(){
const rows=_wo.packingMaterials||[];
const body=rows.length?rows.map((r)=>{
const std=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
const qty=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
return `<tr><td class="mono">${esc(r.itemCode)}</td><td class="l">${esc(r.packingMaterial)}</td><td>${esc(r.artworkNo)}</td><td>${std}</td><td>${esc(r.uom||'-')}</td><td>${esc(r.ovgPercent)}</td><td>${qty}</td><td>${esc(r.arNo||'')}</td>${issueRowCells(r)}</tr>`;
}).join(''):`<tr><td colspan="12">—</td></tr>`;
return `<table class="mt">
<thead><tr><th>Item code</th><th>Packing Materials</th><th>Artwork No.</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.%</th><th>Qty Req.(Units)</th><th>AR No.</th>${issueHeadCells()}</tr></thead>
<tbody>${body}</tbody></table>`;
}
function componentsTable(){
const rows=_wo.packingMaterials||[]; // components-only mode stores rows here
const body=rows.length?rows.map((r)=>{
const std=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
const qty=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
return `<tr><td class="mono">${esc(r.itemCode)}</td><td class="l">${esc(r.packingMaterial)}</td><td>${std}</td><td>${esc(r.uom||'-')}</td><td>${esc(r.ovgPercent)}</td><td>${qty}</td><td>${esc(r.arNo||'')}</td>${issueRowCells(r)}</tr>`;
}).join(''):`<tr><td colspan="11">—</td></tr>`;
return `<table class="mt">
<thead><tr><th>Item code</th><th>Components</th><th>Std. Qty/Unit</th><th>UOM</th><th>Ovg.</th><th>Qty Req.</th><th>AR No.</th>${issueHeadCells()}</tr></thead>
<tbody>${body}</tbody></table>`;
}
function page(suffix,tablesHtml){
return `<div class="page">${headerBand()}${titleBar(suffix)}${infoBox()}${tablesHtml}${signoffs()}</div>`;
}
async function loadSignatures(){
const users=new Set();
(Array.isArray(_wo.workflowLog)?_wo.workflowLog:[]).forEach(l=>{if(l.by)users.add(l.by);});
[...(_wo.rawMaterials||[]),...(_wo.packingMaterials||[])].forEach(r=>{
['issuedBy','receivedBy','verifiedBy'].forEach(k=>{if(r[k])users.add(r[k]);});
});
await Promise.all([...users].map(async u=>{
try{const r=await api(`/users/signature/by-username/${encodeURIComponent(u)}`);if(r.signature)_sig[u]=r.signature;}catch(_e){}
}));
}
function render(){
const w=_wo;
// Authoritative flag, not inferred from rawMaterials.length — a
// componentsOnly save also writes a legacy-compatible copy of the same
// components into rawMaterials, so its length alone can't be used to tell
// components-only orders apart from real split Raw/Packing ones.
const componentsMode=!!w.componentsOnly;
let html;
if(componentsMode){
html=page('',componentsTable());
}else{
html=page(' (RAW MATERIAL)',rawTables())+page(' (PACKING MATERIAL)',packTable());
}
document.getElementById('doc').innerHTML=html;
document.title=`WO ${w.woNo||_id} — ${w.productCode||''}`;
}
// Work Order Header Profiles (Blood Bag / CAPD / Accessories / …) — resolve
// which profile applies from the WO's product's SAP item group, and use its
// Company Name/Address/FORM NO./dates/Logo instead of the old single flat
// System Settings values. Falls back to whichever profile is marked
// Default; if the profile list can't be loaded at all (e.g. very first
// request before the one-time migration seed has run), _cfg's own wo*
// fields are left untouched — appSettingsStore.js still returns the same
// legacy values as a last-resort fallback.
async function applyHeaderProfile(){
try{
const [profRes,grpRes]=await Promise.all([
api('/wo-header-profiles'),
api(`/sap/lookup/item-groups-for?codes=${encodeURIComponent(_wo.productCode||'')}&company=${encodeURIComponent(_co)}`),
]);
const profiles=profRes.data||[];
if(!profiles.length)return;
const groupCode=String((grpRes.data||{})[_wo.productCode]??'');
const match=profiles.find(p=>groupCode&&p.itemGroups.includes(groupCode))||profiles.find(p=>p.isDefault)||profiles[0];
if(match){
// Only override a field the matched profile actually set — an empty
// profile field (e.g. no logo ever uploaded to it) must NOT blank out
// the perfectly good value /config already put in _cfg (the legacy
// global System Settings fallback, which is also what the server-side
// "Download PDF" uses directly — that's why the PDF always had the
// logo while this print page silently lost it).
if(match.companyName) _cfg.woCompanyName=match.companyName;
if(match.companyAddress) _cfg.woCompanyAddress=match.companyAddress;
if(match.formNo) _cfg.woFormNo=match.formNo;
if(match.effectiveDate) _cfg.woEffectiveDate=match.effectiveDate;
if(match.reviewDate) _cfg.woReviewDate=match.reviewDate;
if(match.logo) _cfg.woLogo=match.logo;
}
}catch(_e){ /* fall back to whatever /config already put in _cfg */ }
}
// Logo (and signature) <img> tags can still be mid-load/decode the instant
// render() returns — a plain setTimeout() guess used to run print() before
// they'd actually painted, so the logo came out blank in the print/PDF-via-
// print-dialog output (the separate "Download PDF" path embeds the image
// server-side and never had this race). Wait for every <img> in the
// document to genuinely finish (or fail) loading instead of guessing.
function waitForImages(){
const imgs=[...document.querySelectorAll('#doc img')];
if(!imgs.length)return Promise.resolve();
return Promise.all(imgs.map(img=>{
if(img.complete&&img.naturalWidth>0)return Promise.resolve();
return new Promise(res=>{
img.addEventListener('load',res,{once:true});
img.addEventListener('error',res,{once:true});
setTimeout(res,2000); // safety net — never block printing indefinitely
});
}));
}
async function init(){
try{
const [cfg,wo]=await Promise.all([api('/config'),api(`/work-orders/${_id}?company=${encodeURIComponent(_co)}`)]);
_cfg=cfg||{};_wo=wo.data||wo;
if(_cfg.woQtyIssuedSource)_woQtyIssuedSource=_cfg.woQtyIssuedSource;
await applyHeaderProfile();
try{const is=await api(`/work-orders/${_id}/issuance-info?company=${encodeURIComponent(_co)}`);_iss=is.data||_iss;}catch(_e){}
// U_NewItemCode (OITM) for the header Product Code only — printed as
// "(code)" right after it, when SAP has a value.
try{const ni=await api(`/sap/lookup/new-item-codes?codes=${encodeURIComponent(_wo.productCode||'')}&company=${encodeURIComponent(_co)}`);
_newItemCode=(ni.data&&ni.data[_wo.productCode])||'';
}catch(_e){}
await loadSignatures();
render();
await waitForImages();
try{window.print();}catch(_e){}
}catch(e){
document.getElementById('doc').innerHTML=`<div class="loading">Could not load work order: ${esc(e.message)}</div>`;
}
}
if(!_id){document.getElementById('doc').innerHTML='<div class="loading">No work order id.</div>';}
else init();
</script>
</body>
</html>