375 lines
28 KiB
HTML
375 lines
28 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<script src="/auth-guard.js?v=4"></script>
|
|
<script src="/company-list.js"></script>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0"/>
|
|
<title>Verify Work Order — SAP B1</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"/>
|
|
<style>
|
|
:root{--ink:#080e18;--ink2:#0d1626;--blue:#1a6fff;--amber:#f5a623;--green:#0dbb8a;--red:#e53e3e;--teal:#06b6d4;--border:#1c2e48;--border2:#243650;--text:#c8d6e8;--text2:#7a9ab8;--text3:#3d5470;--surface:#0f1d30;--surface2:#142338;--surface3:#0b1520;--radius:10px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--shadow:0 8px 32px rgba(0,0,0,.5)}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:var(--font);background:var(--ink);color:var(--text);min-height:100vh}
|
|
nav{position:sticky;top:0;z-index:200;background:rgba(8,14,24,.92);backdrop-filter:blur(20px);border-bottom:1px solid var(--border);padding:0 12px;height:50px;display:flex;align-items:center;justify-content:space-between;gap:6px}
|
|
.nav-logo{background:var(--blue);color:#fff;font-family:var(--mono);font-weight:700;font-size:14px;letter-spacing:2px;padding:3px 8px;border-radius:5px}
|
|
.nav-links{display:flex;gap:2px;overflow-x:auto;flex:1;justify-content:center}
|
|
.nav-link{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:600;color:var(--text2);text-decoration:none;white-space:nowrap}.nav-link.active{color:var(--blue);background:rgba(26,111,255,.1)}
|
|
.btn-sm{padding:4px 8px;border-radius:5px;font-size:10px;font-weight:700;border:1px solid var(--border2);color:var(--text3);background:none;cursor:pointer;font-family:var(--font)}.btn-sm:hover{border-color:var(--red);color:var(--red)}
|
|
main{padding:12px;max-width:1200px;margin:0 auto}
|
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden}
|
|
.card-hd{background:var(--surface3);padding:10px 14px;font-size:12px;font-weight:700;display:flex;align-items:center;gap:8px;border-bottom:1px solid var(--border)}
|
|
.card-hd .right{margin-left:auto;font-size:11px;color:var(--text3)}
|
|
.card-bd{padding:12px 14px}
|
|
.fl{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);margin-bottom:3px;display:block}
|
|
.fi{width:100%;padding:7px 9px;border:1.5px solid var(--border2);border-radius:7px;font-size:12.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none;height:34px}
|
|
.fi:focus{border-color:var(--blue)}select.fi{cursor:pointer}
|
|
.btn{padding:8px 15px;border-radius:8px;border:none;font-family:var(--font);font-size:12px;font-weight:700;cursor:pointer}.btn:disabled{opacity:.45;cursor:not-allowed}
|
|
.b-primary{background:var(--blue);color:#fff}.b-verify{background:var(--green);color:#04231a}.b-verify:hover:not(:disabled){filter:brightness(1.08)}
|
|
.b-receive{background:var(--teal);color:#04231f}.b-receive:hover:not(:disabled){filter:brightness(1.08)}
|
|
.hint{font-size:11px;color:var(--amber);background:rgba(245,166,35,.08);border:1px solid rgba(245,166,35,.22);border-radius:8px;padding:8px 11px;margin-bottom:12px}
|
|
.po{display:flex;gap:14px;align-items:flex-start;padding:12px 14px;background:var(--surface2);border:1px solid var(--border);border-radius:12px;margin-bottom:8px}
|
|
.po-main{flex:1;min-width:0}
|
|
.po-t{font-size:14px;font-weight:700;color:var(--text)}
|
|
.po-sub{font-size:11px;color:var(--text3);margin-top:3px;display:flex;gap:8px;flex-wrap:wrap}
|
|
.po-sub b{color:var(--text2);font-family:var(--mono)}
|
|
.po-right{display:flex;flex-direction:column;gap:6px;align-items:flex-end;min-width:230px}
|
|
.po-right .fi{height:32px}
|
|
.chips{display:flex;gap:5px;flex-wrap:wrap;margin-top:6px}
|
|
.chip{font-size:9px;font-weight:700;color:var(--text2);background:var(--surface3);border:1px solid var(--border2);border-radius:6px;padding:2px 8px;text-transform:uppercase;letter-spacing:.3px}
|
|
.pill{font-size:9px;font-weight:800;padding:2px 8px;border-radius:20px;text-transform:uppercase}
|
|
.p-ok{background:rgba(13,187,138,.14);color:#0aa457}.p-pend{background:rgba(245,166,35,.14);color:#c78a1a}
|
|
.vinfo{font-size:11px;color:var(--green);text-align:right}
|
|
.vinfo b{color:var(--text2)}
|
|
.empty-msg{text-align:center;padding:24px;color:var(--text3);font-size:12px}
|
|
.sp{width:14px;height:14px;border:2px solid rgba(26,111,255,.25);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite;display:inline-block}
|
|
@keyframes spin{to{transform:rotate(360deg)}}
|
|
.tabs{display:flex;gap:6px;margin-bottom:12px}
|
|
.tab{padding:7px 14px;border-radius:8px;font-size:12px;font-weight:700;border:1px solid var(--border2);background:var(--surface2);color:var(--text2);cursor:pointer}
|
|
.tab.on{background:var(--blue);color:#fff;border-color:var(--blue)}
|
|
.toast-c{position:fixed;top:56px;right:10px;z-index:9999;display:flex;flex-direction:column;gap:6px}
|
|
.toast{padding:9px 13px;border-radius:8px;font-size:12px;font-weight:600;box-shadow:var(--shadow)}
|
|
.t-ok{background:var(--green);color:#fff}.t-err{background:var(--red);color:#fff}
|
|
.po{cursor:pointer;transition:border-color .15s}
|
|
.po:hover{border-color:var(--blue)}
|
|
.modal-ov{display:none;position:fixed;inset:0;z-index:500;background:rgba(4,8,14,.7);backdrop-filter:blur(3px);align-items:flex-start;justify-content:center;padding:26px 12px;overflow:auto}
|
|
.modal-ov.show{display:flex}
|
|
.modal-w{width:100%;max-width:900px;background:var(--surface);border:1px solid var(--border2);border-radius:14px;box-shadow:var(--shadow);overflow:hidden}
|
|
.modal-hd2{display:flex;align-items:center;gap:10px;padding:13px 16px;background:var(--surface3);border-bottom:1px solid var(--border)}
|
|
.modal-hd2-t{font-size:14px;font-weight:700}
|
|
.modal-x{margin-left:auto;width:28px;height:28px;border-radius:8px;border:1px solid var(--border2);background:none;color:var(--text2);cursor:pointer}
|
|
.modal-x:hover{border-color:var(--red);color:var(--red)}
|
|
.modal-bd2{padding:14px 16px;max-height:calc(100vh - 170px);overflow:auto}
|
|
.dt-grid{display:grid;grid-template-columns:1fr 1fr;gap:2px 18px;font-size:12px;margin-bottom:14px}
|
|
.dt-row{display:flex;justify-content:space-between;gap:10px;padding:5px 0;border-bottom:1px solid var(--border)}
|
|
.dt-l{color:var(--text3);font-size:10.5px;text-transform:uppercase;letter-spacing:.3px}
|
|
.dt-v{color:var(--text);font-weight:600;text-align:right}
|
|
.sec-t{font-size:10px;font-weight:700;text-transform:uppercase;color:var(--text3);margin:12px 0 8px}
|
|
.modal-ft{display:flex;gap:8px;padding:13px 16px;border-top:1px solid var(--border);background:var(--surface3);align-items:center}
|
|
.wo-th{text-align:left;padding:6px 8px;font-size:8.5px;text-transform:uppercase;letter-spacing:.4px;color:var(--text3);font-weight:700;border-bottom:1px solid var(--border2);white-space:nowrap}
|
|
.wo-td{padding:6px 8px;border-bottom:1px solid var(--border);font-size:11px;color:var(--text2)}
|
|
.wo-td.mono{font-family:var(--mono);color:var(--text)}
|
|
.btn-mini{padding:4px 9px;border-radius:6px;border:1px solid var(--border2);background:var(--surface2);color:var(--teal);font-size:10px;font-weight:700;cursor:pointer;white-space:nowrap}
|
|
.btn-mini:hover:not(:disabled){border-color:var(--teal);background:rgba(6,182,212,.1)}
|
|
.btn-mini:disabled{opacity:.5;cursor:not-allowed}
|
|
.fi-mini{width:100%;min-width:76px;padding:4px 6px;border:1px solid var(--border2);border-radius:5px;font-size:10.5px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none}
|
|
.fi-mini:focus{border-color:var(--blue)}
|
|
</style>
|
|
<link rel="stylesheet" href="/theme.css?v=3"/>
|
|
</head>
|
|
<body>
|
|
<div class="toast-c" id="toast-c"></div>
|
|
<nav>
|
|
<span class="nav-logo">SAP</span>
|
|
<div class="nav-links"><a href="/" class="nav-link">Home</a><a href="/verify-work-order" class="nav-link active">Verify Work Order</a></div>
|
|
<button class="btn-sm" id="btn-logout">Logout</button>
|
|
</nav>
|
|
<main id="app"></main>
|
|
<div class="modal-ov" id="detail-modal"><div class="modal-w" id="detail-modal-inner"></div></div>
|
|
|
|
<script>
|
|
'use strict';
|
|
let _tok=null,_user=null;const BE='/api';
|
|
try{_tok=sessionStorage.getItem('portal_token');const u=sessionStorage.getItem('portal_user');if(u)_user=JSON.parse(u);}catch(_e){}
|
|
if(!_tok)location.href='/';
|
|
document.getElementById('btn-logout').onclick=()=>{sessionStorage.clear();location.href='/';};
|
|
|
|
// This screen's permission gates — each material ROW is stamped/edited
|
|
// independently (different items can be issued/received/verified on
|
|
// different days by different people), so each of the three sign-offs plus
|
|
// the Weighing Balance ID/AR No. edit right is its OWN approval step:
|
|
// work_order:issue edit → fill Weighing Balance ID / AR No. on a row
|
|
// work_order:issue approve → stamp "Issued By/Date" on a row
|
|
// work_order:receive approve → stamp "Received By" on a row
|
|
// work_order:verify approve → stamp "Verified By" on a row
|
|
// 'production_order:verify' is kept (currently unused) for possible future
|
|
// use — the server still accepts it on the legacy order-level endpoints.
|
|
function hasStepPerm(fullKey,perm){
|
|
if(_user&&_user.role==='admin')return true;
|
|
const steps=Array.isArray(_user&&_user.approvalSteps)?_user.approvalSteps:[];
|
|
for(const s of steps){ if(typeof s==='string'){if(s===fullKey)return true;} else if(s&&s.step===fullKey){return Array.isArray(s.perms)&&s.perms.includes(perm);} }
|
|
return false;
|
|
}
|
|
// True if ANY perm at all is assigned on this step — mirrors the server's
|
|
// hasStepAssigned(), used so "Mark Issued" also works for whoever is
|
|
// assigned Issue for Production (production_order:issuance), not only
|
|
// someone separately granted work_order:issue.
|
|
function hasStepAssignedClient(fullKey){
|
|
return ['view','add','edit','approve','delete'].some(p=>hasStepPerm(fullKey,p));
|
|
}
|
|
const CAN_VIEW=hasStepPerm('work_order:verify','view')||hasStepPerm('work_order:receive','view')||hasStepPerm('work_order:issue','view')||hasStepAssignedClient('production_order:issuance');
|
|
const CAN_EDIT_FIELDS=hasStepPerm('work_order:issue','edit');
|
|
const CAN_MARK_ISSUED=hasStepPerm('work_order:issue','approve')||hasStepAssignedClient('production_order:issuance');
|
|
const CAN_MARK_RECEIVED=hasStepPerm('work_order:receive','approve');
|
|
const CAN_MARK_VERIFIED=hasStepPerm('work_order:verify','approve');
|
|
|
|
let _co=window.__DEFAULT_COMPANY__;let _companies=[{value:_co,label:_co}];
|
|
let _rows=[],_loading=false,_hasSig=false,_curPO=null;
|
|
// This user's allowed Item Groups for issuing (Admin → user → Issue Items).
|
|
// Empty = no restriction. Same rule the real SAP issuance enforces.
|
|
let _issueGroups=[];
|
|
let _balanceIds=[]; // Admin → System Settings — allowed Weighing Balance IDs; empty = free text
|
|
|
|
function esc(v){return(v==null?'':String(v)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
function q(s){return document.querySelector(s);}
|
|
function toast(m,t='ok'){const c=q('#toast-c');const e=document.createElement('div');e.className='toast t-'+t;e.textContent=m;c.appendChild(e);setTimeout(()=>e.remove(),4200);}
|
|
function fmt(iso){if(!iso)return '—';const d=new Date(iso);if(isNaN(d))return '—';const p=n=>String(n).padStart(2,'0');return `${p(d.getDate())}-${['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'][d.getMonth()]}-${d.getFullYear()} ${p(d.getHours())}:${p(d.getMinutes())}`;}
|
|
function coOptions(sel){return _companies.map(c=>`<option value="${esc(c.value)}" ${sel===c.value?'selected':''}>${esc(c.label)}</option>`).join('');}
|
|
|
|
async function api(m,p,b){
|
|
const h={'Content-Type':'application/json'};if(_tok)h.Authorization='Bearer '+_tok;
|
|
const o={method:m,headers:h};if(b)o.body=JSON.stringify(b);
|
|
const r=await fetch(BE+p,o);if(r.status===401){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;
|
|
}
|
|
|
|
async function load(){
|
|
_loading=true;render();
|
|
try{const r=await api('GET',`/production-orders/for-verification?company=${encodeURIComponent(_co)}`);_rows=r.data||[];}
|
|
catch(e){toast(e.message,'err');_rows=[];}
|
|
_loading=false;render();
|
|
}
|
|
// One-click per-row stamp — no typing, current logged-in user + right now.
|
|
async function markRow(woId,section,idx,which,btn){
|
|
if(btn){btn.disabled=true;btn.innerHTML='<span class="sp"></span>';}
|
|
try{
|
|
await api('POST',`/work-orders/${woId}/row/${section}/${idx}/mark`,{which});
|
|
await refreshDetail();
|
|
}catch(e){toast(e.message,'err');if(btn){btn.disabled=false;}}
|
|
}
|
|
// Per-row Weighing Balance ID / AR No. edit — saved on blur/Enter.
|
|
async function saveField(woId,section,idx,field,value,input){
|
|
try{
|
|
await api('PATCH',`/work-orders/${woId}/row/${section}/${idx}`,{[field]:value});
|
|
if(input){input.style.borderColor='var(--green)';setTimeout(()=>{if(input)input.style.borderColor='';},900);}
|
|
}catch(e){toast(e.message,'err');}
|
|
}
|
|
async function refreshDetail(){ if(_curPO) await openDetail(_curPO); }
|
|
|
|
// ── Full Work Order detail (same layout as the Work Order page's own popup:
|
|
// header fields + Raw Material/Packing Material tables, or a single
|
|
// Components table when there's no real raw material) ──────────────────────
|
|
function num3wo(v){ if(v==null||v==='')return '—'; const n=Number(v); if(!isFinite(n)||n===0)return '—'; return String(Math.round(n*1000)/1000); }
|
|
function issSigDisp(sig){ return sig&&sig.name ? `${esc(sig.name)}<div style="font-size:9.5px;color:var(--text3)">${esc(fmt(sig.at))}</div>` : '<span style="color:var(--text3)">—</span>'; }
|
|
|
|
function rowSig(row,which){ const at=row[which+'At']; if(!at) return null; return {name:row[which+'ByName']||row[which+'By'], at}; }
|
|
// Enforced order per row: Issued → Received → Verified (matches the server's
|
|
// ROW_STAMP_PREREQ check) — the preceding stamp must exist first.
|
|
const ROW_STAMP_PREREQ={received:'issued',verified:'received'};
|
|
// A row's Issued/Received/Verified cell: signer+date if already stamped, a
|
|
// one-click "Mark …" button if this user may stamp it AND its prerequisite
|
|
// stamp already exists, a "waiting on …" note if not, else a dash.
|
|
function stampCell(row,canMark,which,label){
|
|
const sig=rowSig(row,which);
|
|
if(sig)return issSigDisp(sig);
|
|
const prereq=ROW_STAMP_PREREQ[which];
|
|
if(prereq&&!row[prereq+'At'])return `<span style="color:var(--text3);font-size:9.5px">Waiting on ${prereq}</span>`;
|
|
if(canMark)return `<button class="btn-mini" onclick="markRow(${row.__woId},'${row.__section}',${row.__idx},'${which}',this)">${esc(label)}</button>`;
|
|
return '<span style="color:var(--text3)">—</span>';
|
|
}
|
|
// Weighing Balance ID / AR No. — editable inline by whoever holds 'edit' on
|
|
// work_order:issue (the person physically issuing knows these at that
|
|
// moment); read-only text for everyone else.
|
|
function fieldInput(row,field,placeholder){
|
|
if(!CAN_EDIT_FIELDS)return esc(row[field]||'');
|
|
if(field==='weighingBalanceId'&&_balanceIds.length){
|
|
const opts=_balanceIds.map(id=>`<option value="${esc(id)}" ${row[field]===id?'selected':''}>${esc(id)}</option>`).join('');
|
|
return `<select class="fi-mini" onchange="saveField(${row.__woId},'${row.__section}',${row.__idx},'${field}',this.value,this)"><option value="">— Select —</option>${opts}</select>`;
|
|
}
|
|
return `<input class="fi-mini" value="${esc(row[field]||'')}" placeholder="${esc(placeholder)}" onchange="saveField(${row.__woId},'${row.__section}',${row.__idx},'${field}',this.value,this)"/>`;
|
|
}
|
|
// This user's Item Group restriction (Admin → user → Issue Items) — same
|
|
// rule the real SAP issuance enforces. Empty _issueGroups = no restriction.
|
|
function itemGroupAllowed(itemCode,groupMap){
|
|
if(!_issueGroups.length)return true;
|
|
const grp=groupMap&&groupMap[(itemCode||'').trim()];
|
|
return grp!=null&&_issueGroups.includes(String(grp));
|
|
}
|
|
const ISS_THS='<th class="wo-th">Qty Issued</th><th class="wo-th">Issued By /Date</th><th class="wo-th">Received By</th><th class="wo-th">Verified By</th>';
|
|
function issCells(row,iss,groupMap){
|
|
// 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 can't be read live off iss.qtyByItem like Packing
|
|
// Material's can. It's prorated and PERSISTED on the row itself server-side
|
|
// (see routes/sap.js's /issue-production), same as work-order-print.html
|
|
// and workOrderPdf.js already do.
|
|
const qtyVal=row.__section==='raw'?row.qtyIssued:(iss.qtyByItem&&iss.qtyByItem[(row.itemCode||'').trim()]);
|
|
const qty=`<td class="wo-td">${num3wo(qtyVal)}</td>`;
|
|
const canIssue=CAN_MARK_ISSUED&&itemGroupAllowed(row.itemCode,groupMap);
|
|
return qty+`<td class="wo-td">${stampCell(row,canIssue,'issued','Mark Issued')}</td><td class="wo-td">${stampCell(row,CAN_MARK_RECEIVED,'received','Mark Received')}</td><td class="wo-td">${stampCell(row,CAN_MARK_VERIFIED,'verified','Mark Verified')}</td>`;
|
|
}
|
|
|
|
function buildWoMaterials(w,iss,groupMap){
|
|
iss=iss||{qtyByItem:{}};
|
|
const woId=w.id;
|
|
// Tag each row with its own (section,index) so the inline handlers above
|
|
// know exactly which row to patch/stamp.
|
|
const rawRows=(w.rawMaterials||[]).map((r,idx)=>({...r,__woId:woId,__section:'raw',__idx:idx}));
|
|
const packRows=(w.packingMaterials||[]).map((r,idx)=>({...r,__woId:woId,__section:'pack',__idx:idx}));
|
|
const rawGroupMap=new Map();
|
|
// solBatchSize can legitimately be 0 — check null/undefined, not truthiness.
|
|
rawRows.forEach(r=>{const k=r.solCode||'';if(!rawGroupMap.has(k))rawGroupMap.set(k,{name:r.solName||'',batchSize:r.solBatchSize!=null?r.solBatchSize:'',rows:[]});rawGroupMap.get(k).rows.push(r);});
|
|
const raw=[...rawGroupMap.values()].map(g=>`
|
|
<div style="font-size:11.5px;font-weight:700;color:var(--text2);margin:8px 0 3px">${g.name?esc(g.name):'Raw Materials'}${g.batchSize!==''?` · Batch Size ${esc(g.batchSize)} Ltr`:''}</div>
|
|
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
|
|
<thead><tr style="background:var(--surface2)"><th class="wo-th">Item Code</th><th class="wo-th">Raw Materials</th><th class="wo-th">Spec</th><th class="wo-th">Qty Req./100 ml</th><th class="wo-th">UOM</th><th class="wo-th">Ovg.</th><th class="wo-th">Qty Req.</th><th class="wo-th">Weighing Balance ID</th><th class="wo-th">AR No.</th>${ISS_THS}</tr></thead>
|
|
<tbody>${g.rows.map(r=>`<tr><td class="wo-td mono">${esc(r.itemCode)}</td><td class="wo-td">${esc(r.rawMaterial)}</td><td class="wo-td">${esc(r.spec)}</td><td class="wo-td">${esc(r.stdQty)}</td><td class="wo-td">${esc(r.uom)}</td><td class="wo-td">${esc(r.ovg)}</td><td class="wo-td">${esc(r.qtyReq)}</td><td class="wo-td">${fieldInput(r,'weighingBalanceId','Balance ID')}</td><td class="wo-td">${fieldInput(r,'arNo','AR No.')}</td>${issCells(r,iss,groupMap)}</tr>`).join('')}</tbody></table></div>`).join('');
|
|
const singleTableMode=!(w.rawMaterials&&w.rawMaterials.length);
|
|
const pack=packRows.map(r=>{
|
|
const stdDisp=r.stdQtyUnit?`${esc(r.stdQtyPerUnit)} ${esc(r.stdQtyUnit)}`:esc(r.stdQtyPerUnit);
|
|
const qtyDisp=r.qtyUnitLabel?`${esc(r.qtyReqUnits)} ${esc(r.qtyUnitLabel)}`:esc(r.qtyReqUnits);
|
|
const arTd=`<td class="wo-td">${fieldInput(r,'arNo','AR No.')}</td>`;
|
|
return singleTableMode
|
|
?`<tr><td class="wo-td mono">${esc(r.itemCode)}</td><td class="wo-td">${esc(r.packingMaterial)}</td><td class="wo-td">${stdDisp}</td><td class="wo-td">${esc(r.ovgPercent)}</td><td class="wo-td">${qtyDisp}</td>${arTd}${issCells(r,iss,groupMap)}</tr>`
|
|
:`<tr><td class="wo-td mono">${esc(r.itemCode)}</td><td class="wo-td">${esc(r.packingMaterial)}</td><td class="wo-td">${esc(r.artworkNo)}</td><td class="wo-td">${stdDisp}</td><td class="wo-td">${esc(r.ovgPercent)}</td><td class="wo-td">${qtyDisp}</td>${arTd}${issCells(r,iss,groupMap)}</tr>`;
|
|
}).join('');
|
|
if(singleTableMode){
|
|
return `<div class="sec-t">Components</div>
|
|
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
|
|
<thead><tr style="background:var(--surface2)"><th class="wo-th">Item Code</th><th class="wo-th">Components</th><th class="wo-th">Std. Qty/Unit</th><th class="wo-th">Ovg.</th><th class="wo-th">Qty Req.</th><th class="wo-th">AR No.</th>${ISS_THS}</tr></thead>
|
|
<tbody>${pack||'<tr><td colspan="10" style="color:var(--text3);padding:10px">—</td></tr>'}</tbody></table></div>`;
|
|
}
|
|
return `<div class="sec-t">Raw Material</div>
|
|
${raw||'<div style="color:var(--text3);font-size:12px">—</div>'}
|
|
<div class="sec-t">Packing Material</div>
|
|
<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:11px">
|
|
<thead><tr style="background:var(--surface2)"><th class="wo-th">Item Code</th><th class="wo-th">Packing Materials</th><th class="wo-th">Artwork No.</th><th class="wo-th">Std. Qty/Unit</th><th class="wo-th">Ovg.%</th><th class="wo-th">Qty Req.(Units)</th><th class="wo-th">AR No.</th>${ISS_THS}</tr></thead>
|
|
<tbody>${pack||'<tr><td colspan="11" style="color:var(--text3);padding:10px">—</td></tr>'}</tbody></table></div>`;
|
|
}
|
|
|
|
function closeDetail(){q('#detail-modal').classList.remove('show');_curPO=null;}
|
|
// `row` is a slim Work-Order-shaped record from GET /production-orders/for-verification
|
|
// (row.id IS the Work Order's own id — this screen lists APPROVED Work
|
|
// Orders directly now, independent of their linked Production Order's own
|
|
// stage; row.po carries that Production Order's info ONLY for display, when
|
|
// one happens to exist yet).
|
|
async function openDetail(row){
|
|
_curPO=row;
|
|
const modal=q('#detail-modal'),inner=q('#detail-modal-inner');
|
|
modal.classList.add('show');
|
|
inner.innerHTML=`<div class="modal-hd2"><div class="modal-hd2-t">Loading…</div><button class="modal-x" onclick="closeDetail()">✕</button></div><div class="modal-bd2" style="text-align:center;padding:40px"><span class="sp"></span></div>`;
|
|
try{
|
|
let w=null,woErr='';
|
|
try{const r=await api('GET',`/work-orders/${row.id}`);w=r.data||r;}
|
|
catch(e){woErr=e.message;}
|
|
// Qty issued stays SAP-derived (linked Production Order's live issued
|
|
// qty, if any exists yet); Issued/Received/Verified are per-row stamps
|
|
// read straight off each material row (see buildWoMaterials). Keyed
|
|
// purely by Work Order id — computeIssuance() on the server already
|
|
// handles "no Production Order yet" gracefully (empty qtyByItem).
|
|
let iss={qtyByItem:{}};
|
|
try{const r=await api('GET',`/work-orders/${row.id}/issuance-info?company=${encodeURIComponent(_co)}`);iss=r.data||iss;}catch(_e){}
|
|
// Item group per item code (Admin → user → Issue Items restriction) — only
|
|
// needed when this user actually has a restriction, to hide "Mark Issued"
|
|
// on items outside their allowed groups.
|
|
let groupMap={};
|
|
if(w&&_issueGroups.length){
|
|
const codes=[...new Set([...(w.rawMaterials||[]),...(w.packingMaterials||[])].map(r=>r.itemCode).filter(Boolean))];
|
|
if(codes.length){ try{const r=await api('GET',`/sap/lookup/item-groups-for?codes=${encodeURIComponent(codes.join(','))}&company=${encodeURIComponent(_co)}`);groupMap=r.data||{};}catch(_e){} }
|
|
}
|
|
const dt=(l,v)=>`<div class="dt-row"><span class="dt-l">${esc(l)}</span><span class="dt-v">${v==null||v===''?'—':esc(String(v))}</span></div>`;
|
|
const titleName=w?[w.productName,w.productDesc].filter(Boolean).join(' — '):(row.itemName||'');
|
|
const woBody = w
|
|
? `<div class="dt-grid">
|
|
${dt('Work Order #',w.woNo)}
|
|
${dt('Reference',w.reference)}
|
|
${dt('Generic Name',w.genericName)}
|
|
${dt('Batch Number',w.batchNumber)}
|
|
${dt('Batch Size',w.batchSize)}
|
|
${dt('Total Units',w.totalUnits)}
|
|
${dt('MFG. Date',w.mfgDate)}
|
|
${dt('EXP. Date',w.expDate)}
|
|
${dt('Pack Size',w.packSize)}
|
|
${dt('Type',w.type)}
|
|
${dt('Market',w.market)}
|
|
${dt('WO Status',w.status)}
|
|
</div>
|
|
${buildWoMaterials(w,iss,groupMap)}`
|
|
: `<div style="color:var(--text3);font-size:12px;padding:10px">${woErr?esc('Could not load this Work Order: '+woErr):'Could not load this Work Order.'}</div>`;
|
|
inner.innerHTML=`
|
|
<div class="modal-hd2">
|
|
<div class="modal-hd2-t">${esc(row.itemCode||'')} <span style="color:var(--text3);font-weight:400">— ${esc(titleName)}</span></div>
|
|
<button class="modal-x" onclick="closeDetail()">✕</button>
|
|
</div>
|
|
<div class="modal-bd2">
|
|
<div style="font-size:11px;color:var(--text3);margin-bottom:10px">${row.po?`Linked Production Order: <b style="color:var(--text2)">${esc(row.po.sapDocNum?('Doc #'+row.po.sapDocNum):('#'+row.po.id))}</b> · Stage <b style="color:var(--text2)">${esc(row.po.currentStep||('#'+row.po.stage))}</b>`:'No Production Order created for this Work Order yet.'}</div>
|
|
${woBody}
|
|
</div>
|
|
`;
|
|
}catch(e){inner.innerHTML=`<div class="modal-hd2"><div class="modal-hd2-t">Error</div><button class="modal-x" onclick="closeDetail()">✕</button></div><div class="modal-bd2" style="color:var(--red)">${esc(e.message)}</div>`;}
|
|
}
|
|
window.closeDetail=closeDetail;
|
|
window.markRow=markRow;
|
|
window.saveField=saveField;
|
|
|
|
// Order-level status is no longer a single yes/no — each material row has
|
|
// its own Issued/Received/Verified state — so the card is just a summary +
|
|
// an entry point into the per-row table. Every APPROVED Work Order shows
|
|
// here regardless of its linked Production Order's own stage (or whether
|
|
// one even exists yet) — the "Stage" chip is informational only.
|
|
function poCard(w){
|
|
const title=esc(w.woNo||('WO #'+w.id));
|
|
const sub=`<span>${esc(w.itemCode||'')} ${esc(w.itemName||'')}</span>${w.plannedQty?`<span>·</span><span>Qty <b>${esc(w.plannedQty)}</b></span>`:''}${w.batchNumber?`<span>·</span><span>Batch <b>${esc(w.batchNumber)}</b></span>`:''}`;
|
|
const poChip=w.po?`<span class="chip">${esc(w.po.sapDocNum?('Doc #'+w.po.sapDocNum):('PO #'+w.po.id))} · ${esc(w.po.currentStep||('#'+w.po.stage))}</span>`:`<span class="chip">No Production Order yet</span>`;
|
|
const chips=`<span class="chip">${esc(w.status||'')}</span>${poChip}`;
|
|
return `<div class="po" data-open="${w.id}">
|
|
<div class="po-main"><div class="po-t">${title}</div><div class="po-sub">${sub}</div><div class="chips">${chips}</div></div>
|
|
<div class="po-right"><button class="btn b-primary" data-view="${w.id}">View Materials</button></div>
|
|
</div>`;
|
|
}
|
|
|
|
function render(){
|
|
const app=document.getElementById('app');
|
|
if(!CAN_VIEW){app.innerHTML='<div class="card"><div class="card-bd empty-msg">You are not assigned any Work Order sign-off permission. Ask an admin to grant Approval Steps → Work Order → Issued By / Received By / Verify Work Order.</div></div>';return;}
|
|
app.innerHTML=`<div class="card">
|
|
<div class="card-hd"><span>✔ Verify Work Order</span><span class="right">${_loading?'':`${_rows.length} approved`}</span></div>
|
|
<div class="card-bd">
|
|
${(CAN_MARK_ISSUED||CAN_MARK_RECEIVED||CAN_MARK_VERIFIED)&&!_hasSig?`<div class="hint">You don't have a signature image on file — upload one under <b>Profile → My Signature</b> so it prints on the Work Order's "Issued/Received/Verified By" columns.</div>`:''}
|
|
<div style="display:flex;gap:8px;align-items:flex-end;margin-bottom:12px;flex-wrap:wrap">
|
|
<div style="min-width:180px"><label class="fl">Company</label><select class="fi" id="sel-co">${coOptions(_co)}</select></div>
|
|
<button class="btn b-primary" id="btn-reload" style="height:34px">Reload</button>
|
|
</div>
|
|
${_loading?`<div class="empty-msg"><span class="sp"></span> Loading…</div>`:
|
|
!_rows.length?`<div class="empty-msg">No approved work orders yet.</div>`:
|
|
_rows.map(poCard).join('')}
|
|
</div></div>`;
|
|
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;load();});
|
|
q('#btn-reload')&&(q('#btn-reload').onclick=load);
|
|
document.querySelectorAll('[data-open]').forEach(el=>el.onclick=function(){
|
|
const p=_rows.find(x=>x.id===parseInt(this.dataset.open));if(p)openDetail(p);
|
|
});
|
|
}
|
|
|
|
async function init(){
|
|
try{const list=await window.fetchCompanyList();if(list.length)_companies=list;}catch(_e){}
|
|
try{const r=await api('GET','/auth/signature');_hasSig=!!(r&&r.signature);}catch(_e){}
|
|
try{const pr=await api('GET','/auth/profile');_issueGroups=Array.isArray(pr?.user?.issueItemGroups)?pr.user.issueItemGroups.map(String):[];}catch(_e){}
|
|
try{const cfg=await api('GET','/config');if(Array.isArray(cfg?.woWeighingBalanceIds))_balanceIds=cfg.woWeighingBalanceIds;}catch(_e){}
|
|
if(CAN_VIEW)load();else render();
|
|
}
|
|
init();
|
|
</script>
|
|
<script src="/sidebar.js?v=25"></script>
|
|
</body>
|
|
</html>
|