671 lines
51 KiB
HTML
671 lines
51 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&family=Bebas+Neue&display=swap" rel="stylesheet"/>
|
|
<style>
|
|
:root{--ink:#080e18;--ink2:#0d1626;--purple:#9f5afd;--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:12px;--font:'Space Grotesk',sans-serif;--mono:'JetBrains Mono',monospace;--display:'Bebas Neue',sans-serif;--shadow:0 8px 32px rgba(0,0,0,.5);--shadow-sm:0 1px 2px rgba(0,0,0,.3),0 6px 18px rgba(0,0,0,.22)}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{font-family:var(--font);background:var(--ink);background-image:radial-gradient(circle at 12% 0%,rgba(159,90,253,.06),transparent 45%),radial-gradient(circle at 88% 8%,rgba(26,111,255,.05),transparent 40%);background-attachment:fixed;color:var(--text);min-height:100vh;-webkit-font-smoothing:antialiased}
|
|
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(--display);font-size:15px;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;font-family:var(--font);transition:background .12s,color .12s}.nav-link:hover{color:var(--text);background:rgba(255,255,255,.05)}.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);transition:border-color .12s,color .12s}.btn-sm:hover{border-color:var(--red);color:var(--red)}
|
|
main{padding:18px 16px 40px;max-width:1200px;margin:0 auto}
|
|
.page-head{display:flex;align-items:center;gap:12px;margin-bottom:18px}
|
|
.page-icon{width:38px;height:38px;flex-shrink:0;border-radius:10px;background:linear-gradient(135deg,var(--green),var(--teal));display:flex;align-items:center;justify-content:center;font-family:var(--display);font-size:16px;letter-spacing:.5px;color:#04231a;box-shadow:0 4px 14px rgba(13,187,138,.35)}
|
|
.page-title{font-size:20px;font-weight:700;letter-spacing:-.2px;color:var(--text)}
|
|
.page-sub{font-size:11px;color:var(--text3);text-transform:uppercase;letter-spacing:.6px}
|
|
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:12px;overflow:hidden;box-shadow:var(--shadow-sm)}
|
|
.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;transition:filter .12s,transform .12s}.btn:hover:not(:disabled){filter:brightness(1.1)}.btn:active:not(:disabled){transform:translateY(1px)}.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;transition:background .12s,color .12s,border-color .12s}
|
|
.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)}
|
|
|
|
/* ── Search + status filter bar, and the card grid (matches Work Order's
|
|
own list view) ─────────────────────────────────────────────────────── */
|
|
.vf-bar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:16px}
|
|
.vf-search{position:relative;flex:1;min-width:220px}
|
|
.vf-search input{width:100%;padding:9px 12px 9px 34px;border:1.5px solid var(--border2);border-radius:9px;font-size:13px;font-family:var(--font);background:var(--surface2);color:var(--text);outline:none}
|
|
.vf-search input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(26,111,255,.15)}
|
|
.vf-search i{position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--text3);pointer-events:none;font-size:13px}
|
|
.vf-chips{display:flex;gap:6px;flex-wrap:wrap}
|
|
.vf-chip{padding:8px 14px;border-radius:20px;border:1.5px solid var(--border2);background:var(--surface2);color:var(--text3);font-size:12px;font-weight:700;font-family:var(--font);cursor:pointer;transition:all .12s;white-space:nowrap}
|
|
.vf-chip:hover{border-color:var(--blue);color:var(--text2)}
|
|
.vf-chip.on{background:var(--blue);border-color:var(--blue);color:#fff}
|
|
.vf-count{font-size:12px;color:var(--text3);white-space:nowrap}
|
|
.vf-pager{display:flex;align-items:center;justify-content:center;gap:12px;margin-top:18px}
|
|
.vf-pager button{padding:7px 14px;border-radius:8px;border:1.5px solid var(--border2);background:var(--surface2);color:var(--text2);font-size:12px;font-weight:700;font-family:var(--font);cursor:pointer;transition:all .12s}
|
|
.vf-pager button:hover:not(:disabled){border-color:var(--blue);color:var(--text)}
|
|
.vf-pager button:disabled{opacity:.4;cursor:not-allowed}
|
|
.vf-pager span{font-size:12px;color:var(--text3);white-space:nowrap}
|
|
.wo-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:16px}
|
|
.wo-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;box-shadow:var(--shadow-sm);padding:16px;cursor:pointer;transition:box-shadow .15s,transform .15s,border-color .15s;aspect-ratio:1/1;display:flex;flex-direction:column;overflow:hidden}
|
|
.wo-card:hover{transform:translateY(-2px);border-color:var(--blue)}
|
|
.wc-top{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;flex-wrap:wrap}
|
|
.wc-no{font-family:var(--mono);font-size:14px;font-weight:700;color:var(--blue)}
|
|
.vw-status-badge{font-size:9.5px;font-weight:700;padding:3px 9px;border-radius:20px;text-transform:uppercase;letter-spacing:.3px;white-space:nowrap}
|
|
.vw-st-pending{background:rgba(245,166,35,.14);color:var(--amber);border:1px solid rgba(245,166,35,.3)}
|
|
.vw-st-verified{background:rgba(13,187,138,.14);color:var(--green);border:1px solid rgba(13,187,138,.3)}
|
|
.wc-icon{width:40px;height:40px;border-radius:10px;background:rgba(26,111,255,.12);color:var(--blue);display:flex;align-items:center;justify-content:center;margin:10px 0}
|
|
.wc-prod{font-size:13px;font-weight:700;color:var(--text);line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
.wc-code{font-size:11px;color:var(--text3);margin-top:2px;font-family:var(--mono)}
|
|
.wc-meta{font-size:11.5px;color:var(--text3);margin-top:auto;padding-top:8px;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:3px}
|
|
.wc-meta span{display:flex;align-items:center;gap:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
</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="/production" class="nav-link">Production</a>
|
|
<a href="/work-order" class="nav-link">Work Order</a><a href="/rejection-register" class="nav-link">Rejection Register</a>
|
|
<a href="/rejection-analytics" class="nav-link">Rejection Analytics</a><a href="/production-dashboard" class="nav-link">Production Dashboard</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>
|
|
<div class="modal-ov" id="verify-ov-modal"><div class="modal-w" style="max-width:420px" id="verify-ov-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(). Kept so someone assigned Issue for Production
|
|
// (production_order:issuance) can still reach this page to look up a Work
|
|
// Order — view-only, harmless. Actually MARKING a row Issued is a separate,
|
|
// explicit work_order:issue "approve" grant (see CAN_MARK_ISSUED below) —
|
|
// Production Order Issuance and Work Order row-stamping are different
|
|
// screens/responsibilities and are no longer coupled together.
|
|
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');
|
|
const CAN_MARK_RECEIVED=hasStepPerm('work_order:receive','approve');
|
|
const CAN_MARK_VERIFIED=hasStepPerm('work_order:verify','approve');
|
|
// Admin → Edit User → "Verify Work Order: can sign as any user & backdate" —
|
|
// same bypass every approval-step check gives the admin role elsewhere (see
|
|
// hasStepPerm above), so admin gets this automatically without needing the
|
|
// checkbox too. Off for everyone else unless explicitly granted.
|
|
const CAN_VERIFY_OVERRIDE=!!(_user&&(_user.role==='admin'||_user.woVerifyOverride));
|
|
let _userNames=null; // lazy-loaded [{username,fullName}], only fetched if CAN_VERIFY_OVERRIDE
|
|
|
|
let _co=window.__DEFAULT_COMPANY__;let _companies=[{value:_co,label:_co}];
|
|
let _rows=[],_loading=false,_hasSig=false,_curPO=null;
|
|
let _viewSearch='',_viewStatus='',_viewPage=0; // _viewStatus: '' | 'ISSUED' | 'RECEIVED' | 'VERIFIED' — every row here is an approved Work Order already, so the filter is about which sign-off is still outstanding, not WO approval status
|
|
let _viewDateFrom='',_viewDateTo=''; // filters by the Work Order's Created date
|
|
let _sapBatchMatches=[], _sapBatchTimer=null; // cross-reference results from /sap/lookup/batch-in-sap — same rationale as work-order.html's identical function
|
|
const VF_PAGE_SIZE=24;
|
|
// 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
|
|
// Admin → System Settings → "Raw Material Qty Issued Source". Only under
|
|
// 'issue_for_production' (the default) does Qty Issued get set BEFORE this
|
|
// row's own "Mark Issued" stamp (derived from the real SAP posting) — so
|
|
// only then does it make sense to require that qty first. Under
|
|
// 'mark_issued', THIS stamp is what sets Qty Issued to the full required
|
|
// amount in the first place, so gating the button on qty already being
|
|
// there would make it permanently unreachable.
|
|
let _rawQtyIssuedSource='issue_for_production';
|
|
|
|
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;}}
|
|
}
|
|
// ── Sign override (Admin → Edit User → woVerifyOverride) ────────────────────
|
|
// Only reachable via this flag: sign ANY of the three per-row stamps
|
|
// (Issued/Received/Verified) as a different user and/or backdate the sign
|
|
// date, instead of always self + right now — including re-signing a stamp
|
|
// that's already been set, to correct it.
|
|
const STAMP_LABEL={issued:'Issued',received:'Received',verified:'Verified'};
|
|
let _voTarget=null; // {woId,section,idx,which} for the row currently in the picker
|
|
function toLocalDT(d){
|
|
const p=n=>String(n).padStart(2,'0');
|
|
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
}
|
|
async function openVerifyOverride(woId,section,idx,which,currentUsername,currentAt){
|
|
_voTarget={woId,section,idx,which};
|
|
if(!_userNames){
|
|
try{const r=await api('GET','/users/names');_userNames=r.data||[];}
|
|
catch(e){toast(e.message,'err');_userNames=[];}
|
|
}
|
|
const label=STAMP_LABEL[which]||which;
|
|
const isResign=!!currentAt;
|
|
const initDate=isResign&&!isNaN(new Date(currentAt))?new Date(currentAt):new Date();
|
|
const nowLocal=toLocalDT(new Date());
|
|
const others=_userNames.filter(u=>u.username!==(_user&&_user.username));
|
|
const dlOpts=others.map(u=>`<option value="${esc(voOptionLabel(u))}"></option>`).join('');
|
|
const currentUser=currentUsername?others.find(u=>u.username===currentUsername):null;
|
|
const myselfLabel=`Myself (${_user&&(_user.name||_user.username)})`;
|
|
const initSignAs=currentUser?voOptionLabel(currentUser):myselfLabel;
|
|
q('#verify-ov-inner').innerHTML=`
|
|
<div class="modal-hd2"><div class="modal-hd2-t">${isResign?'Re-sign '+label:'Mark '+label}</div><button class="modal-x" onclick="closeVerifyOverride()">✕</button></div>
|
|
<div class="modal-bd2">
|
|
<label class="fl">Sign as</label>
|
|
<input type="text" class="fi" id="vo-signas" list="vo-signas-list" autocomplete="off" value="${esc(initSignAs)}" placeholder="${esc(myselfLabel)}"/>
|
|
<datalist id="vo-signas-list"><option value="${esc(myselfLabel)}"></option>${dlOpts}</datalist>
|
|
<label class="fl" style="margin-top:10px">Sign date & time</label>
|
|
<input type="datetime-local" class="fi" id="vo-date" value="${toLocalDT(initDate)}" max="${nowLocal}"/>
|
|
<div style="font-size:10.5px;color:var(--text3);margin-top:8px">${isResign?'Change who this is recorded as signed by and/or its date, then confirm.':'Leave "Myself" and today\'s date/time to sign normally. Change either only to record paperwork already signed on paper by someone else, or on an earlier date.'} Type a name to search.</div>
|
|
</div>
|
|
<div class="modal-ft">
|
|
<button class="btn" style="background:var(--surface2);color:var(--text2)" onclick="closeVerifyOverride()">Cancel</button>
|
|
<button class="btn b-verify" id="vo-confirm" onclick="submitVerifyOverride()">${isResign?'Confirm Re-sign':'Confirm '+label}</button>
|
|
</div>`;
|
|
q('#verify-ov-modal').classList.add('show');
|
|
}
|
|
function voOptionLabel(u){ return `${u.fullName} (${u.username})`; }
|
|
function closeVerifyOverride(){q('#verify-ov-modal').classList.remove('show');_voTarget=null;}
|
|
async function submitVerifyOverride(){
|
|
if(!_voTarget)return;
|
|
const typed=(q('#vo-signas').value||'').trim();
|
|
const myselfLabel=`Myself (${_user&&(_user.name||_user.username)})`;
|
|
let actAsUsername;
|
|
if(typed&&typed!==myselfLabel){
|
|
const match=(_userNames||[]).find(u=>voOptionLabel(u)===typed);
|
|
if(!match){toast('Please pick a valid name from the list','err');return;}
|
|
actAsUsername=match.username;
|
|
}
|
|
const btn=q('#vo-confirm');btn.disabled=true;btn.innerHTML='<span class="sp"></span>';
|
|
const dtVal=q('#vo-date').value;
|
|
const signedAt=dtVal?new Date(dtVal).toISOString():undefined;
|
|
const which=_voTarget.which;
|
|
try{
|
|
await api('POST',`/work-orders/${_voTarget.woId}/row/${_voTarget.section}/${_voTarget.idx}/mark`,{which,actAsUsername,signedAt});
|
|
closeVerifyOverride();
|
|
await refreshDetail();
|
|
toast(`Marked ${STAMP_LABEL[which]||which}`);
|
|
}catch(e){toast(e.message,'err');btn.disabled=false;btn.textContent='Confirm';}
|
|
}
|
|
window.openVerifyOverride=openVerifyOverride;
|
|
window.closeVerifyOverride=closeVerifyOverride;
|
|
window.submitVerifyOverride=submitVerifyOverride;
|
|
// 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,blockedMsg){
|
|
const sig=rowSig(row,which);
|
|
if(sig){
|
|
// Already signed — normally final. A woVerifyOverride/admin user who
|
|
// ALSO holds ordinary "approve" on this specific step can still
|
|
// re-sign it (correct who it's recorded as signed by, and/or its
|
|
// date) via the same picker, pre-filled with the current signer/date —
|
|
// for ANY of the three stamps, not just Verified.
|
|
if(canMark&&CAN_VERIFY_OVERRIDE)
|
|
return `${issSigDisp(sig)}<button class="btn-mini" style="margin-top:4px" onclick="openVerifyOverride(${row.__woId},'${row.__section}',${row.__idx},'${which}','${esc(row[which+'By']||'')}','${esc(row[which+'At']||'')}')">Re-sign</button>`;
|
|
return issSigDisp(sig);
|
|
}
|
|
const prereq=ROW_STAMP_PREREQ[which];
|
|
// The NEXT-action button only ever appears once the prior stage is
|
|
// genuinely done — no exception, for anyone, including a woVerifyOverride/
|
|
// admin user. Override is only ever for RE-SIGNING an already-completed
|
|
// stamp (see the `sig` branch above — correcting who signed/when), never
|
|
// for skipping straight past a stage that hasn't happened yet.
|
|
if(prereq&&!row[prereq+'At'])return `<span style="color:var(--text3);font-size:9.5px">Waiting on ${prereq}</span>`;
|
|
if(canMark){
|
|
if(CAN_VERIFY_OVERRIDE)
|
|
return `<button class="btn-mini" onclick="openVerifyOverride(${row.__woId},'${row.__section}',${row.__idx},'${which}')">${esc(label)}</button>`;
|
|
return `<button class="btn-mini" onclick="markRow(${row.__woId},'${row.__section}',${row.__idx},'${which}',this)">${esc(label)}</button>`;
|
|
}
|
|
if(blockedMsg)return `<span style="color:var(--text3);font-size:9.5px">${esc(blockedMsg)}</span>`;
|
|
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>`;
|
|
// "Mark Issued" only appears once this row's FULL required quantity has
|
|
// actually been issued (in SAP for Packing/live off the PO; persisted on
|
|
// the row itself for Raw Material — see the comment above) — not merely
|
|
// whenever some partial quantity has gone out. A 0.001 tolerance absorbs
|
|
// float rounding from the prorated Raw Material figure.
|
|
const reqQty=parseFloat(row.__section==='raw'?row.qtyReq:row.qtyReqUnits)||0;
|
|
// The 'mark_issued' bypass ONLY applies to Raw Material — its displayed
|
|
// Qty Issued is the persisted, prorated value this exact stamp writes
|
|
// under that mode (see the server route), so checking it first would be
|
|
// circular. Packing Material's displayed Qty Issued always comes straight
|
|
// off the live SAP posting (qtyByItem above) no matter which mode is set —
|
|
// it's never written by this stamp — so it must always be checked for
|
|
// real, or a Work Order with no Production Order/issuance yet (qtyVal
|
|
// empty) would wrongly show "Mark Issued" as available.
|
|
// Even under the 'mark_issued' bypass, nothing is issuable before a
|
|
// Production Order actually exists for this Work Order — there's no
|
|
// production event at all yet for the raw-material stamp to be recording.
|
|
// This is what actually closes the reported bug: a fresh WO with "No
|
|
// Production Order created yet" was still showing every raw-material row
|
|
// as markable, because the bypass alone had no precondition of its own.
|
|
const rawBypass=row.__section==='raw'&&_rawQtyIssuedSource==='mark_issued'&&iss.hasPO;
|
|
const fullyIssued=rawBypass||(iss.hasPO&&(reqQty<=0||(parseFloat(qtyVal)||0)+0.001>=reqQty));
|
|
const permittedToIssue=CAN_MARK_ISSUED&&itemGroupAllowed(row.itemCode,groupMap);
|
|
const canIssue=permittedToIssue&&fullyIssued;
|
|
const issuedBlockedMsg=(permittedToIssue&&!fullyIssued)?(iss.hasPO?'Awaiting full qty issued':'No Production Order yet'):undefined;
|
|
// Received/Verified must also require a Production Order to exist — not
|
|
// just because Issued isn't stamped yet (the ordinary prereq check below
|
|
// already covers that for everyone), but specifically because a
|
|
// woVerifyOverride/admin user is otherwise allowed to sign OUT of the
|
|
// normal order to catch up genuine paperwork, and that bypass alone has no
|
|
// "did production even start" precondition of its own. Without gating it
|
|
// here too, that same user could stamp Received/Verified on a Work Order
|
|
// with no Production Order at all — nothing to actually be catching up on.
|
|
const canReceive=CAN_MARK_RECEIVED&&iss.hasPO;
|
|
const canVerify=CAN_MARK_VERIFIED&&iss.hasPO;
|
|
return qty+`<td class="wo-td">${stampCell(row,canIssue,'issued','Mark Issued',issuedBlockedMsg)}</td><td class="wo-td">${stampCell(row,canReceive,'received','Mark Received')}</td><td class="wo-td">${stampCell(row,canVerify,'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('');
|
|
// Must match work-order.html's own criterion EXACTLY (its explicit,
|
|
// persisted componentsOnly flag) — NOT "rawMaterials is empty". A
|
|
// componentsOnly order's rawMaterials is never actually empty: it holds a
|
|
// legacy-compatible MIRROR copy of the same components (written at save
|
|
// time so other consumers like Production Order issuance can still find
|
|
// them under rawMaterials), which is not the authoritative table anyone
|
|
// else reads. Inferring singleTableMode from array length used to make
|
|
// this page show a separate "Raw Material" section and stamp Issued/
|
|
// Received/Verified onto that mirror copy — signatures that then never
|
|
// appeared anywhere else (Work Order's own view, Print, PDF), because
|
|
// those all read packingMaterials for a componentsOnly order.
|
|
const singleTableMode=!!w.componentsOnly;
|
|
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.
|
|
// Every row here is already an APPROVED Work Order (the server's own
|
|
// /for-verification filter guarantees that) — so unlike Work Order's own
|
|
// list, there's no meaningful "Rejected" bucket to filter on here. The
|
|
// status chips instead bucket by which sign-off is still outstanding — a WO
|
|
// with even one row not yet Issued/Received/Verified shows up under that
|
|
// chip, same three stamps the detail popup itself signs per row.
|
|
function filteredRows(){
|
|
let list=_rows;
|
|
if(_viewStatus==='ISSUED')list=list.filter(w=>!w.issuedComplete);
|
|
else if(_viewStatus==='RECEIVED')list=list.filter(w=>!w.receivedComplete);
|
|
else if(_viewStatus==='VERIFIED')list=list.filter(w=>!w.verified);
|
|
const s=_viewSearch.trim().toUpperCase();
|
|
if(s)list=list.filter(w=>
|
|
(w.woNo||'').toUpperCase().includes(s)||
|
|
(w.itemCode||'').toUpperCase().includes(s)||
|
|
(w.itemName||'').toUpperCase().includes(s)||
|
|
(w.batchNumber||'').toUpperCase().includes(s)||
|
|
(w.intimationDocNo||'').toUpperCase().includes(s));
|
|
if(_viewDateFrom||_viewDateTo)list=list.filter(w=>{
|
|
if(!w.createdAt)return false;
|
|
const d=w.createdAt.slice(0,10);
|
|
if(_viewDateFrom&&d<_viewDateFrom)return false;
|
|
if(_viewDateTo&&d>_viewDateTo)return false;
|
|
return true;
|
|
});
|
|
return list;
|
|
}
|
|
function woCard(w){
|
|
const badge=!w.issuedComplete?['Pending Issue','vw-st-pending']
|
|
:!w.receivedComplete?['Pending Receipt','vw-st-pending']
|
|
:!w.verified?['Pending Verify','vw-st-pending']
|
|
:['Verified','vw-st-verified'];
|
|
const poChip=w.po?`${esc(w.po.sapDocNum?('Doc #'+w.po.sapDocNum):('PO #'+w.po.id))} · ${esc(w.po.currentStep||('#'+w.po.stage))}`:'No Production Order yet';
|
|
return `<div class="wo-card" data-open="${w.id}">
|
|
<div class="wc-top">
|
|
<span class="wc-no">${esc(w.woNo||('WO #'+w.id))}</span>
|
|
<span class="vw-status-badge ${badge[1]}">${badge[0]}</span>
|
|
</div>
|
|
<div class="wc-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 7.28a1 1 0 0 0-.5-.86l-8-4.61a1 1 0 0 0-1 0l-8 4.6a1 1 0 0 0-.5.87v9.44a1 1 0 0 0 .5.87l8 4.6a1 1 0 0 0 1 0l8-4.6a1 1 0 0 0 .5-.87Z"/><path d="m3.3 7 8.7 5 8.7-5M12 22V12"/></svg></div>
|
|
<div class="wc-prod" title="${esc(w.itemName||'')}">${esc(w.itemName||w.itemCode||'')}</div>
|
|
<div class="wc-code">${esc(w.itemCode||'')}</div>
|
|
<div class="wc-meta">
|
|
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg> Batch ${esc(w.batchNumber||'—')}</span>
|
|
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg> Intimation ${esc(w.intimationDocNo||'—')}</span>
|
|
<span><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> ${esc(w.createdByName||w.createdBy||'—')}</span>
|
|
${w.createdAt?`<span>${new Date(w.createdAt).toLocaleDateString('en-IN',{day:'2-digit',month:'short',year:'2-digit'})}</span>`:''}
|
|
<span style="color:var(--text3)">${esc(poChip)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function render(){
|
|
const app=document.getElementById('app');
|
|
const pageHead=`<div class="page-head"><div class="page-icon">VW</div><div><div class="page-title">Verify Work Order</div><div class="page-sub">Post-Issuance sign-off, per row</div></div></div>`;
|
|
if(!CAN_VIEW){app.innerHTML=pageHead+'<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;}
|
|
const list=filteredRows();
|
|
const totalPages=Math.max(1,Math.ceil(list.length/VF_PAGE_SIZE));
|
|
if(_viewPage>=totalPages)_viewPage=totalPages-1;
|
|
if(_viewPage<0)_viewPage=0;
|
|
const pageList=list.slice(_viewPage*VF_PAGE_SIZE,(_viewPage+1)*VF_PAGE_SIZE);
|
|
const chips=[['','All'],['ISSUED','Issued By'],['RECEIVED','Received By'],['VERIFIED','Verified By']];
|
|
app.innerHTML=`${pageHead}<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>`:`
|
|
<div class="vf-bar">
|
|
<div class="vf-search"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="position:absolute;left:11px;top:50%;transform:translateY(-50%);color:var(--text3)"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg><input id="vf-search" placeholder="Search WO No, product, batch, intimation…" value="${esc(_viewSearch)}" style="padding-left:32px"/></div>
|
|
<input class="fi" id="vf-datefrom" type="date" value="${esc(_viewDateFrom)}" style="width:140px"/>
|
|
<span style="font-size:11px;color:var(--text3)">to</span>
|
|
<input class="fi" id="vf-dateto" type="date" value="${esc(_viewDateTo)}" style="width:140px"/>
|
|
<div class="vf-chips">${chips.map(([v,lbl])=>`<button class="vf-chip ${_viewStatus===v?'on':''}" data-vfstatus="${v}" title="${v?'Still pending '+lbl:'All approved Work Orders'}">${lbl}</button>`).join('')}</div>
|
|
<span class="vf-count">${list.length} of ${_rows.length}</span>
|
|
</div>
|
|
${_sapBatchMatches.length?`<div style="margin:0 0 12px;padding:10px 12px;background:var(--surface2,#f5f7fa);border:1px solid var(--border,#dfe4ea);border-radius:10px">
|
|
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:6px">Also found in SAP (not one of this portal's own Work Orders):</div>
|
|
${_sapBatchMatches.map(m=>m.source==='productionOrder'
|
|
?`<div style="font-size:12px;padding:3px 0"><a href="/production#order=${m.absEntry}&company=${encodeURIComponent(_co)}" style="color:#2563eb" target="_blank">Production Order ${esc(String(m.docNum))}</a> — ${esc(m.itemCode)} ${esc(m.itemName||'')} · Batch (SAP Comments) <span style="font-family:monospace">${esc(m.batch)}</span></div>`
|
|
:`<div style="font-size:12px;padding:3px 0">SAP Batch Master — ${esc(m.itemCode)} ${esc(m.itemName||'')} · <span style="font-family:monospace">${esc(m.batch)}</span></div>`
|
|
).join('')}
|
|
</div>`:''}
|
|
${pageList.length?`<div class="wo-grid">${pageList.map(woCard).join('')}</div>`:'<div class="empty-msg">No work orders match this search/filter.</div>'}
|
|
${list.length>VF_PAGE_SIZE?`<div class="vf-pager">
|
|
<button id="vf-prev" ${_viewPage===0?'disabled':''}>← Prev</button>
|
|
<span>Page ${_viewPage+1} of ${totalPages}</span>
|
|
<button id="vf-next" ${_viewPage>=totalPages-1?'disabled':''}>Next →</button>
|
|
</div>`:''}
|
|
`}
|
|
</div></div>`;
|
|
q('#sel-co')&&(q('#sel-co').onchange=function(){_co=this.value;load();});
|
|
q('#btn-reload')&&(q('#btn-reload').onclick=load);
|
|
q('#vf-search')&&(q('#vf-search').oninput=function(){
|
|
const pos=this.selectionStart;_viewSearch=this.value;_viewPage=0;render();
|
|
const el=q('#vf-search');if(el){el.focus();try{el.setSelectionRange(pos,pos);}catch(_e){}}
|
|
scheduleSapBatchLookup();
|
|
});
|
|
document.querySelectorAll('[data-vfstatus]').forEach(b=>b.onclick=function(){_viewStatus=this.dataset.vfstatus;_viewPage=0;render();});
|
|
q('#vf-datefrom')&&(q('#vf-datefrom').onchange=function(){_viewDateFrom=this.value;_viewPage=0;render();});
|
|
q('#vf-dateto')&&(q('#vf-dateto').onchange=function(){_viewDateTo=this.value;_viewPage=0;render();});
|
|
if(q('#vf-prev'))q('#vf-prev').onclick=function(){_viewPage--;render();};
|
|
if(q('#vf-next'))q('#vf-next').onclick=function(){_viewPage++;render();};
|
|
document.querySelectorAll('[data-open]').forEach(el=>el.onclick=function(){
|
|
const p=_rows.find(x=>x.id===parseInt(this.dataset.open));if(p)openDetail(p);
|
|
});
|
|
}
|
|
|
|
// Same cross-reference lookup as Batch Issuance/Work Order's own search
|
|
// boxes — see batch-issuance.html's identical function for the full
|
|
// rationale (SAP Production Orders created directly in SAP B1 store their
|
|
// batch number as free text in Comments, invisible to every local search).
|
|
function scheduleSapBatchLookup(){
|
|
clearTimeout(_sapBatchTimer);
|
|
const term=_viewSearch.trim();
|
|
if(term.length<4){
|
|
if(_sapBatchMatches.length){_sapBatchMatches=[];render();}
|
|
return;
|
|
}
|
|
_sapBatchTimer=setTimeout(async()=>{
|
|
if(_viewSearch.trim()!==term)return;
|
|
try{
|
|
const r=await api('GET',`/sap/lookup/batch-in-sap?batch=${encodeURIComponent(term)}&company=${encodeURIComponent(_co)}`);
|
|
if(_viewSearch.trim()!==term)return;
|
|
_sapBatchMatches=r.data||[];
|
|
}catch(_e){_sapBatchMatches=[];}
|
|
const pos=q('#vf-search')?.selectionStart;
|
|
render();
|
|
const s2=q('#vf-search');if(s2&&pos!=null){s2.focus();try{s2.setSelectionRange(pos,pos);}catch(_e){}}
|
|
},450);
|
|
}
|
|
|
|
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;if(cfg?.woRawQtyIssuedSource)_rawQtyIssuedSource=cfg.woRawQtyIssuedSource;}catch(_e){}
|
|
if(CAN_VIEW)load();else render();
|
|
}
|
|
init();
|
|
</script>
|
|
<script src="/sidebar.js?v=31"></script>
|
|
</body>
|
|
</html>
|