/* Work Package (IWP) form — the downstream installation work package record. Parameters (numbering, types, QC, sources) are inherited from the project SOP exported by the Configuration tool. Implements the AWP constraint checklist + release gate from IR 272-2 Vol II. */ // ── SAMPLE SOP (same shape the Configuration tool exports) ─────────────────── const SAMPLE_SOP = { meta:{tool:'Work Package Configuration', sample:true}, project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'}, roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}], governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' }, woTypes:[ {name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true}, {name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true}, {name:'Instrument Install', enabled:true}, {name:'Panel Install', enabled:true}, ], sources:[ {label:'Design Drawings',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/Drawings?csf=1&web=1&e=Hx4lZF'}, {label:'Data Sheets',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/DataSheets?csf=1&web=1&e=PkPrXc'}, {label:'Specifications',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/Specs?csf=1&web=1&e=ZQ19ai'}, {label:'IO List',system:'controls.dev',notes:'',link:'https://controls.dev/io'}, {label:'Cable Schedule',system:'SharePoint',notes:'',link:'https://primecontrols.sharepoint.com/cable-schedule'}, ], field:{ trackPlatform:'CxAlloy' }, quality:{ qcReq:'Yes', qcScope:'Detailed inspection items', photo:'Key checkpoints only', cxTool:'CxAlloy', holdPoints:'HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.' }, sequence:[{label:'Layout',kind:'step'},{label:'Conduit Install',kind:'step'},{label:'Tray Install',kind:'step'},{label:'Wire Pull',kind:'step'},{label:'Terminations',kind:'step'},{label:'Commissioning',kind:'step'}], costCodes:['5100 - Rough In','5200 - Wire Pull','5300 - Terminations','5400 - Instrumentation'], }; // Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists) const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment']; const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman']; const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','QC','Closed']; const ISSUED_IDX = STATUS_ORDER.indexOf('Issued'); // Acumatica cost codes (comment 10) — code|description const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract']; // Acumatica allowed units of measure (comment 15) — common first const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN']; // Example built work package (comment 4 / "Load Example") — WP02 export const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"}; // ── STATE ──────────────────────────────────────────────────────────────────── let SOP=null, editingId=null, numberDirty=false; let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled let activeProjectId=''; // set at boot from ?project=; stamped onto saved WPs for the API let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[]; let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3) let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines) let pkgScope={}; // {discipline:[steps]} — per-discipline scope when multi-discipline let pkgDiscStatus={}; // {discipline:status} — per-discipline phase tracking (e.g. Issued-Electrical) let prevStatus='Draft'; let devMode=false; // comment 7: pauses usage tracking during review let savedPackages=[]; let currentView='Work Package Form'; // ── HELPERS ────────────────────────────────────────────────────────────────── function gv(id){ return document.getElementById(id)?.value?.trim() || ''; } // Attribute-safe HTML escaper (also escapes " and ' so values are safe inside // href="…" / src="…" attributes, not just element text). function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } // Only allow http(s) URLs into an href; anything else (e.g. javascript:) → '#'. function hrefAttr(u){ return /^https?:\/\//i.test(String(u||'')) ? esc(u) : '#'; } function ns(){ return ''; } function cell(v){ return v ? esc(v) : ns(); } function pad2(n){ return n<10?'0'+n:''+n; } function typeCode(name){ return (name||'').replace(/[^a-z0-9]+/gi,''); } function linkify(v){ if(!v) return ''; return esc(v).replace(/(https?:\/\/[^\s]+)/g,u=>`${u}`); } function toast(msg){ let t=document.getElementById('toast'); if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); } t.textContent=msg; t.classList.add('show'); clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200); } function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; } function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); } function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); } function constraintNames(){ let cs = (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS; // On a BIM-enabled project, EWPs use the BIM gates and IWPs use the install gates. if(bimSOP()) cs = cs.filter(c => (c && typeof c==='object') ? (isEwp() ? c.bim : !c.bim) : !isEwp()); return cs; } function nextSeq(){ return savedPackages.length+1; } // ── SOP LOADING ────────────────────────────────────────────────────────────── function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); } function importSOP(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ alert('That file does not look like an SOP export from the Configuration tool.'); return; } SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported'); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); } function applySOP(){ renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims(); buildDisciplinePicker(); renderScope(); onHoursChange(); renderSopRefLinks(); renderSpecFolderLink(); applySpecFromType(); initSopHintTips(); // SOP-inherited Quality fields — populated then locked (editable only with a logged reason) if(SOP.quality){ document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — '); document.getElementById('wp_photo').value=SOP.quality.photo||''; document.getElementById('wp_hold').value=SOP.quality.holdPoints||''; } lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : ''; buildConstraints(); buildSignoffs(); applyKind(); if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); } if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } if(!pkgAssets.length){ buildAssets(); } // renders the "no assets yet" empty state if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); } updateNumber(); updateReleaseBanner(); } // Per-package kind. A project whose SOP has bimEnabled produces both install (IWP) // and BIM (EWP) packages; the kind selector tailors which fields, WP types, and // release gates apply. Install-only projects never show the selector. // A project is on the BIM path only if its SOP enabled it AND an administrator // has the BIM/VDC tooling switched on app-wide (Admin Console → Features). With // the flag off, a SOP that already has BIM keeps its data but every package here // behaves as install-only — no IWP/EWP choice, no BIM fields or gates. function bimSOP(){ return !!(SOP && SOP.bimEnabled) && (typeof wpBimEnabled === 'function' ? wpBimEnabled() : false); } function isEwp(){ return bimSOP() && pkgKind === 'ewp'; } function setKind(k){ if(pkgKind === k) return; pkgKind = (k === 'ewp') ? 'ewp' : 'iwp'; const tEl = document.getElementById('wp_type'); if(tEl) tEl.value = ''; // type list changes with kind applyKind(); numberDirty = false; updateNumber(); updateReleaseBanner(); track('kind_changed', {kind: pkgKind}); } // Which cards this package kind uses. Split out from applyKind() because // showForm() clears every card's inline display and has to restore just the // visibility — without rebuilding the type picker and constraint rows. function applyKindVisibility(){ const bimProj = bimSOP(), ewp = isEwp(); const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; }; show('kind-row', bimProj); show('bim-card', ewp); // model area / clash + IFF # / scan show('asset-card', !ewp); // Micron DB assets show('material-card', !ewp); // bill of materials show('mimo-card', !ewp); // kitting / MIMO show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it } function applyKind(){ const bimProj = bimSOP(); applyKindVisibility(); if(bimProj) setRadio('pkgkind', pkgKind); buildTypePicker(); // filtered by kind buildConstraints(); // filtered by kind } // The IFF number is the GC's sign-off reference, so it only becomes meaningful // once coordination reaches "Signed off (IFF)" — at which point it's required. function iffRequired(){ return (gv('wp_clash') || '') === 'Signed off (IFF)'; } function onClashChange(){ const hint = document.getElementById('iff-hint'); const inp = document.getElementById('wp_iff'); if(!hint || !inp) return; const need = iffRequired(); if(need && !inp.value.trim()){ hint.textContent = 'Required — coordination is signed off, so record the IFF number.'; hint.style.color = 'var(--accent-amber)'; } else if(need){ hint.textContent = ''; } else { hint.textContent = 'Recorded when the GC signs the model package off.'; hint.style.color = ''; } } function buildCostCodes(){ const sel=document.getElementById('wp_cost'); const cur=sel.value; sel.innerHTML=``+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return ``;}).join(''); if(cur) sel.value=cur; } // ── SOP-LOCKED QUALITY FIELDS (comment 3) ──────────────────────────────────── function sopValueFor(id){ if(!SOP||!SOP.quality) return ''; if(id==='wp_qc') return [SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — '); if(id==='wp_photo') return SOP.quality.photo||''; if(id==='wp_hold') return SOP.quality.holdPoints||''; return ''; } function lockQuality(id){ const el=document.getElementById(id); if(!el) return; const fromSOP = !!(SOP&&SOP.quality) && !pkgOverrides[id]; el.readOnly = fromSOP; el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field el.classList.toggle('locked-field', false); const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note'); if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP'; if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : ''; } function editQuality(id){ if(pkgOverrides[id]){ // currently overridden -> revert to SOP value delete pkgOverrides[id]; document.getElementById(id).value=sopValueFor(id); lockQuality(id); track('quality_reverted',{field:id}); return; } const reason=prompt('This field is set by the project SOP. Enter the reason for overriding it on this package:'); if(reason===null) return; if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; } pkgOverrides[id]=reason.trim(); const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus(); lockQuality(id); track('quality_overridden',{field:id}); } function renderCtxBar(){ const bar=document.getElementById('ctx-bar'); if(!SOP){ bar.innerHTML = activeProjectId ? `
No SOP found for this project yet — complete the SOP Configuration first, then return here.
` : `
No SOP loaded — or import one from the Configuration tool.
`; return; } const p=SOP.project||{}, g=SOP.governance||{}; const sample=SOP.meta&&SOP.meta.sample?`SAMPLE`:''; bar.innerHTML=`
${esc(p.name||'Untitled')} ${sample}
${esc(p.number||'')}${p.division?' · '+esc(p.division):''}
${enabledTypes().length} typesformat ${esc(g.woFormat||'—')}track: ${esc((SOP.field&&SOP.field.trackPlatform)||'—')}
`; } // ── DEV MODE (comment 7) ───────────────────────────────────────────────────── function toggleDevMode(){ devMode=!devMode; document.body.classList.toggle('dev-mode', devMode); document.getElementById('dev-banner').style.display = devMode ? '' : 'none'; toast(devMode ? 'DEV MODE on — usage tracking paused' : 'DEV MODE off — tracking resumed'); } // ── SOP REFERENCE LINKS (comments 1, 3, 4) ─────────────────────────────────── function sopLinkedSources(){ return ((SOP&&SOP.sources)||[]).filter(s=>s.link); } function renderSopRefLinks(){ const box=document.getElementById('sop-ref-links'); if(!box) return; const srcs=sopLinkedSources(); if(!srcs.length){ box.innerHTML=''; return; } box.innerHTML=``+ ``; } function renderSpecFolderLink(){ const el=document.getElementById('spec-folder-link'); if(!el) return; const spec=sopLinkedSources().find(s=>/spec/i.test(s.label)); const link = spec ? `↗ Open spec folder (${esc(spec.system||'SOP')})` : ''; // Say where the value came from, since the field itself is read-only now. const src = gv('wp_spec') ? `from the SOP’s WP type${link ? ' · ' : ''}` : `no spec section set on this WP type in the SOP`; el.innerHTML = src + link; } // ── PEOPLE PICKERS: Assignees + Distribution ────────────────────────────────── // Both were free-text lists. They're now multi-selects over the project team named // on the SOP (site comments 8/3), while still accepting a typed name for someone // with no user account — subcontractors and GC contacts have to stay addable. // // Stored shape keeps BOTH: `assignees`/`distribution` remain comma-joined display // strings (print, export and the dashboard already read those), and // `assigneeIds`/`distributionIds` carry the account ids that notification routing // will need. The hidden inputs keep gv()/set() working unchanged. let pkgPeople = { assignees: [], distribution: [] }; // [{id,name}] — id '' = typed name function peopleFieldId(kind){ return kind === 'assignees' ? 'wp_assignees' : 'wp_distribution'; } // The SOP team first (they're this project's named people), then anyone else on // the project. Mirrors the Owner picker's ordering. function peopleOptions(){ const team = sopTeamIds(); const onTeam = projectMembers.filter(u => team.includes(u.id)); const others = projectMembers.filter(u => !team.includes(u.id)); return { onTeam, others }; } function cmMember(){ const cmId = (SOP && SOP.project && SOP.project.cmId) || ''; return cmId ? (projectMembers.find(u => u.id === cmId) || null) : null; } function syncPeopleField(kind){ const el = document.getElementById(peopleFieldId(kind)); if(el) el.value = pkgPeople[kind].map(p => p.name).filter(Boolean).join(', '); } function renderPeoplePicker(kind){ const box = document.getElementById('pick_' + kind); if(!box) return; const cm = cmMember(); const isCm = p => !!(cm && p.id === cm.id && kind === 'distribution'); const chips = pkgPeople[kind].map((p, i) => { // The CM stays on distribution by default but can be dropped per package, so // the chip is marked rather than locked. const tag = isCm(p) ? ' pp-locked' : ''; const title = isCm(p) ? 'Construction Manager — included by default' : (p.id ? '' : 'Typed name (no user account)'); return `${esc(p.name)}` + ``; }).join(''); const { onTeam, others } = peopleOptions(); const opt = u => { const on = pkgPeople[kind].some(p => p.id === u.id); return ``; }; const menu = (onTeam.length ? `
Project team (from SOP)
${onTeam.map(opt).join('')}` : '') + (others.length ? `
${onTeam.length?'Others on this project':'On this project'}
${others.map(opt).join('')}` : '') + (!onTeam.length && !others.length ? `
No project members found
` : '') + `
Someone with no user account — they can't be emailed by the suite.
`; box.innerHTML = chips + ` `; syncPeopleField(kind); } function togglePeopleMenu(kind){ const m = document.getElementById('ppmenu_' + kind); if(!m) return; const open = m.hidden; document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; }); m.hidden = !open; } document.addEventListener('click', e => { if(!e.target.closest || !e.target.closest('.pp-add')){ document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; }); } }); function togglePerson(kind, userId, on){ const u = projectMembers.find(x => x.id === userId); if(!u) return; const list = pkgPeople[kind]; const ix = list.findIndex(p => p.id === userId); if(on && ix < 0) list.push({ id: u.id, name: u.full_name || u.username }); else if(!on && ix >= 0) list.splice(ix, 1); renderPeoplePicker(kind); } function addTypedPerson(kind, input){ const name = (input.value || '').trim(); if(!name) return; if(!pkgPeople[kind].some(p => p.name.toLowerCase() === name.toLowerCase())){ pkgPeople[kind].push({ id: '', name }); } input.value = ''; renderPeoplePicker(kind); } function removePerson(kind, i){ pkgPeople[kind].splice(i, 1); renderPeoplePicker(kind); } // Parse whatever is stored on a package back into chips. Names that match a // project member are re-linked to that account; the rest stay as typed names. function loadPeopleFromPkg(p){ const parse = (str, ids) => { const names = String(str || '').split(',').map(x => x.trim()).filter(Boolean); const byId = (ids || []).map(id => projectMembers.find(u => u.id === id)).filter(Boolean) .map(u => ({ id: u.id, name: u.full_name || u.username })); const out = byId.slice(); names.forEach(n => { if(out.some(x => x.name.toLowerCase() === n.toLowerCase())) return; const u = projectMembers.find(m => (m.full_name || m.username || '').toLowerCase() === n.toLowerCase()); out.push(u ? { id: u.id, name: u.full_name || u.username } : { id: '', name: n }); }); return out; }; pkgPeople.assignees = parse(p && p.assignees, p && p.assigneeIds); pkgPeople.distribution = parse(p && p.distribution, p && p.distributionIds); renderPeoplePicker('assignees'); renderPeoplePicker('distribution'); } // A new package starts with the project's CM on distribution (site comment 8/3). function resetPeopleForNewPackage(){ pkgPeople = { assignees: [], distribution: [] }; const cm = cmMember(); if(cm) pkgPeople.distribution.push({ id: cm.id, name: cm.full_name || cm.username }); renderPeoplePicker('assignees'); renderPeoplePicker('distribution'); } // ── PREDECESSOR WORK PACKAGES ───────────────────────────────────────────────── // "Package Predecessor" used to be a free-text SOP phase label, which couldn't // express "WP04 waits on WP02" and gated nothing. It's now a set of references to // other packages on the project, and an unclosed predecessor makes a package not // release-ready (server: enforce_release_gates). The SOP phase survives as the // descriptive "Sequence phase" field beside it. let pkgPredecessors = []; // [wpId] let pkgGateOverride = null; // {reason, at, by} once a planner releases early function wpById(id){ return savedPackages.find(p => p.id === id) || null; } // Everything this package already blocks, directly or through a chain — offering // any of them as a predecessor would create a cycle, so they're excluded. function descendantsOf(id){ const out = new Set(); const stack = [id]; while(stack.length){ const cur = stack.pop(); savedPackages.forEach(p => { if((p.predecessors || []).includes(cur) && !out.has(p.id)){ out.add(p.id); stack.push(p.id); } }); } return out; } function predCandidates(){ const self = editingId || ''; const banned = self ? descendantsOf(self) : new Set(); return savedPackages.filter(p => p.id !== self && !banned.has(p.id)); } // Predecessors that aren't Closed yet. Missing ones (deleted since) don't block — // a deleted package must not freeze everything downstream of it. function blockingPredecessors(){ return pkgPredecessors.map(id => wpById(id)).filter(p => p && p.status !== 'Closed'); } function predLabel(p){ return (p.number || '(unnumbered)') + ' — ' + (p.subject || 'untitled'); } function renderPredPicker(){ const box = document.getElementById('pick_predecessors'); if(!box) return; const chips = pkgPredecessors.map((id, i) => { const p = wpById(id); const gone = !p; const done = p && p.status === 'Closed'; const title = gone ? 'This package no longer exists — it does not block release.' : (done ? 'Closed — cleared' : p.status + ' — blocks release until Closed'); const cls = done ? ' pp-locked' : ''; const name = gone ? '(deleted package)' : (p.number || p.subject || id); return `${done?'✓ ':''}${esc(name)}` + ``; }).join(''); const cands = predCandidates(); const opts = cands.map(p => { const on = pkgPredecessors.includes(p.id); return ``; }).join(''); box.innerHTML = chips + ` `; renderPredHint(); } function renderPredHint(){ const el = document.getElementById('pred-hint'); if(!el) return; const blocking = blockingPredecessors(); if(!pkgPredecessors.length){ el.textContent = 'None — this package can be released as soon as its constraints are cleared.'; el.style.color = ''; } else if(blocking.length){ el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') + '. Release is gated until they are Closed.'; el.style.color = 'var(--red)'; } else { el.textContent = '✓ All predecessors are Closed.'; el.style.color = 'var(--accent-green)'; } } function togglePredecessor(id, on){ const ix = pkgPredecessors.indexOf(id); if(on && ix < 0) pkgPredecessors.push(id); else if(!on && ix >= 0) pkgPredecessors.splice(ix, 1); // The set changed, so a previously-granted override no longer describes reality. pkgGateOverride = null; renderPredPicker(); updateReleaseBanner(); } function removePredecessor(i){ pkgPredecessors.splice(i, 1); pkgGateOverride = null; renderPredPicker(); updateReleaseBanner(); } // ── SOP-inherited hints → label tooltips (site comment 8/3) ─────────────────── // The blue "from SOP types" subtext under a field becomes a small SOP chip on the // label, with the detail on hover. The hint elements stay in the DOM (hidden) so // the code that writes into them keeps working; an observer mirrors their text // into the chip's tooltip. function initSopHintTips(){ document.querySelectorAll('.field .field-hint.sop-hint').forEach(hint => { if(hint.dataset.tipped) return; const field = hint.closest('.field'); const label = field && field.querySelector('label'); if(!label) return; hint.dataset.tipped = '1'; const chip = document.createElement('span'); chip.className = 'sop-chip'; chip.textContent = 'SOP'; chip.tabIndex = 0; // reachable by keyboard, not hover-only label.appendChild(chip); const sync = () => { const t = (hint.textContent || '').trim(); chip.dataset.tip = t ? 'From the project SOP — ' + t : 'Inherited from the project SOP.'; }; sync(); try { new MutationObserver(sync).observe(hint, {childList:true, characterData:true, subtree:true}); } catch(e){ /* no observer: the tooltip just won't track later edits */ } }); } function buildTypePicker(){ let types = enabledTypes(); if(bimSOP()) types = types.filter(t => isEwp() ? t.bim : !t.bim); // BIM types only for EWP, install types only for IWP const sel=document.getElementById('wp_type'); const cur=sel.value; sel.innerHTML=``+types.map(t=>``).join(''); if(types.some(t=>t.name===cur)) sel.value=cur; } function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=``+steps.map(s=>``).join(''); } function onTypeChange(){ updateNumber(); applySpecFromType(); track('type_selected'); } // Specification Section is authored once per WP type on the SOP (site comments // 8/3: "Spec section in general information can be removed" + "Add link to spec // section based on SOP"). The field is read-only here and follows the type, so it // can't drift package to package. A value stored on an older package is kept if // its type has no spec section on the SOP. function specForType(name){ const t = ((SOP && SOP.woTypes) || []).find(x => x && x.name === name); return (t && t.specSection) || ''; } function applySpecFromType(){ const el = document.getElementById('wp_spec'); if(!el) return; const fromSop = specForType(gv('wp_type')); if(fromSop) el.value = fromSop; else if(!el.dataset.legacy) el.value = ''; renderSpecFolderLink(); } // ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ─────────── function numberTokens(){ const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]'; return (fmt.match(/\[[^\]]+\]/g)||[]).map(t=>t.replace(/[\[\]]/g,'')).filter(t=>t.toUpperCase()!=='TYPE'); } function buildNumberDims(){ const wrap=document.getElementById('number-dims'); if(!wrap) return; const toks=numberTokens(); wrap.innerHTML = toks.length ? toks.map(t=>{ return `
`; }).join('') : '
SOP number format has no scope tokens.
'; } function typeNumberCode(name){ return (name||'').replace(/\b(install|installation)\b/ig,'').replace(/[^a-z0-9]+/gi,'').trim(); } function updateNumber(){ const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]'; const t=gv('wp_type'); let out=fmt.replace(/WO##|WP##/i,'WP'+pad2(editingSeq())); numberTokens().forEach(tok=>{ const v=(numberDims[tok]||'').trim(); out=out.split('['+tok+']').join(v||('['+tok+']')); }); out=out.split('[TYPE]').join(t?typeNumberCode(t):'[TYPE]'); document.getElementById('wp_number').value=out; } function editingSeq(){ // keep an existing package's sequence number if editing, else next if(editingId){ const ix=savedPackages.findIndex(p=>p.id===editingId); if(ix>=0) return ix+1; } return nextSeq(); } // ── MATERIAL LIST ──────────────────────────────────────────────────────────── function unitOptions(cur){ const list=ACU_UNITS.slice(); const c=(cur||'').toUpperCase(); if(c && !list.includes(c)) list.unshift(c); return ``+list.map(u=>``).join(''); } // Discipline column on the BOM appears only for multi-discipline packages, so a // split hands each instance just its own materials. Keeps a stale value as an // option (mirrors unitOptions) if the discipline was later removed. function matDisciplineOptions(cur){ const list=pkgDisciplines.slice(); const c=cur||''; if(c && !list.includes(c)) list.unshift(c); return ``+list.map(d=>``).join(''); } function buildMaterials(){ const multi=isMultiDiscipline(); const th=document.getElementById('mat-disc-th'); if(th) th.style.display=multi?'':'none'; const tb=document.getElementById('material-body'); tb.innerHTML=''; pkgMaterials.forEach((m,i)=>{ const tr=document.createElement('tr'); const discCell = multi ? `` : ''; tr.innerHTML=` ${discCell} `; tb.appendChild(tr); }); } function addMaterial(){ pkgMaterials.push({qty:'',unit:'',desc:''}); buildMaterials(); } function removeMaterial(i){ pkgMaterials.splice(i,1); if(!pkgMaterials.length)pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); } function downloadMaterialTemplate(){ const csv='Qty,Unit,Description\n10,FT,Single deep strut gold\n4,EA,2-hole strut base gold\n200,FT,3/4 EMT conduit\n'; const blob=new Blob([csv],{type:'text/csv'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='material-list-template.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('material_template'); } function parseCSV(text){ const rows=[]; text.replace(/\r\n/g,'\n').split('\n').forEach(line=>{ if(!line.trim()) return; const cells=[]; let cur='', q=false; for(let i=0;i{ try{ const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; } let start=0; const h=rows[0].map(c=>c.trim().toLowerCase()); const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description'); let cq=0,cu=1,cd=2; if(qi>-1||ui>-1||di>-1){ start=1; if(qi>-1)cq=qi; if(ui>-1)cu=ui; if(di>-1)cd=di; } const imported=[]; for(let i=start;i{ const row=document.createElement('div'); row.className='workstep-row'; row.innerHTML=`${i+1} `; tb.appendChild(row); }); } function addWorkStep(){ pkgWorkSteps.push(''); buildWorkSteps(); } function removeWorkStep(i){ pkgWorkSteps.splice(i,1); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); } // ── DISCIPLINES, PER-DISCIPLINE SCOPE & STATUS ─────────────────────────────── // A project's SOP decides how WPs use disciplines (governance.discMode): // 'single' one discipline per WP 'multi' one WP carries several disciplines // 'choice' planner decides per package (build big, split later) function sopDisciplines(){ const g=(SOP&&SOP.governance)||{}; return (g.disciplines&&g.disciplines.length)?g.disciplines:['Mechanical','Electrical','Tech']; } function sopDiscMode(){ return (SOP&&SOP.governance&&SOP.governance.discMode)||'choice'; } function disciplinesConfigured(){ return !!(SOP&&SOP.governance&&Array.isArray(SOP.governance.disciplines)&&SOP.governance.disciplines.length); } function isMultiDiscipline(){ return pkgDisciplines.length>1; } function instanceSuffixStyle(){ return (SOP&&SOP.governance&&SOP.governance.instanceSuffix)||'letter'; } function buildDisciplinePicker(){ const wrap=document.getElementById('discipline-picker'); if(!wrap) return; const card=document.getElementById('discipline-card'); const list=sopDisciplines(); if(!disciplinesConfigured() || !list.length){ if(card) card.style.display='none'; return; } if(card) card.style.display=''; const mode=sopDiscMode(); const note=document.getElementById('discipline-note'); if(note){ note.textContent = mode==='single' ? 'This project issues one discipline per package — choose the single discipline.' : mode==='multi' ? 'This project bundles disciplines into one package — select every discipline this package covers; each gets its own scope section.' : 'Select every discipline this package covers. Choose more than one to build a combined package you can split later.'; } wrap.innerHTML = list.map(d=>{ const on=pkgDisciplines.includes(d); return ``; }).join(''); } function toggleDiscipline(d,on){ if(sopDiscMode()==='single'){ pkgDisciplines = on?[d]:[]; } else { if(on){ if(!pkgDisciplines.includes(d)) pkgDisciplines.push(d); } else { pkgDisciplines=pkgDisciplines.filter(x=>x!==d); } } // keep per-discipline scope/status maps in sync with the selection pkgDisciplines.forEach(x=>{ if(!pkgScope[x]) pkgScope[x]=['']; if(!pkgDiscStatus[x]) pkgDiscStatus[x]=getRadio('status')||'Draft'; }); Object.keys(pkgScope).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgScope[x]; }); Object.keys(pkgDiscStatus).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgDiscStatus[x]; }); buildDisciplinePicker(); renderScope(); buildMaterials(); track('discipline_toggle',{count:pkgDisciplines.length}); } // Show the flat work-step list for 0–1 disciplines; per-discipline sections for 2+. function renderScope(){ const flat=document.getElementById('flat-scope'); const multi=document.getElementById('scope-by-discipline'); const splitBtn=document.getElementById('split-disc-btn'); if(!multi) return; if(isMultiDiscipline()){ if(flat) flat.style.display='none'; multi.style.display=''; if(splitBtn) splitBtn.style.display=''; buildScopeGroups(); } else { if(flat) flat.style.display=''; multi.style.display='none'; multi.innerHTML=''; if(splitBtn) splitBtn.style.display='none'; } } function buildScopeGroups(){ const multi=document.getElementById('scope-by-discipline'); if(!multi) return; multi.innerHTML = pkgDisciplines.map(d=>{ const steps=pkgScope[d]&&pkgScope[d].length?pkgScope[d]:['']; pkgScope[d]=steps; const st=pkgDiscStatus[d]||'Draft'; const rows=steps.map((s,i)=>`
${i+1}
`).join(''); const opts=STATUS_ORDER.map(o=>``).join(''); return `
${esc(d)} Status:
${rows}
`; }).join(''); } function setScopeStep(d,i,v){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d][i]=v; } function addScopeStep(d){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d].push(''); buildScopeGroups(); } function removeScopeStep(d,i){ if(!pkgScope[d])return; pkgScope[d].splice(i,1); if(!pkgScope[d].length)pkgScope[d]=['']; buildScopeGroups(); } function setDiscStatus(d,v){ pkgDiscStatus[d]=v; rollupDisciplineStatus(); track('disc_status',{discipline:d,status:v}); } // Overall WP status rolls up to the least-advanced discipline (so a WP isn't "Closed" while a discipline lags). function rollupDisciplineStatus(){ if(!isMultiDiscipline()) return; let minIdx=STATUS_ORDER.length-1; pkgDisciplines.forEach(d=>{ const ix=STATUS_ORDER.indexOf(pkgDiscStatus[d]||'Draft'); if(ix>=0&&ixmax){ el.innerHTML=`${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try Split by Discipline).':'.')+``; } else if(band || max){ el.innerHTML=`${esc(band)}${max?'Split threshold: '+max+' hrs.':''}`; } else { el.textContent=''; } } // ── SPLIT BY DISCIPLINE ────────────────────────────────────────────────────── // Turns a multi-discipline package into one instance per discipline: // WP01-… → WP01A-… (Mechanical), WP01B-… (Electrical), WP01C-… (Tech) // Each instance is a single-discipline package linked back to the master. function instanceSuffixFor(index, discipline){ if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); } return String.fromCharCode(65+index); // A, B, C… } function splitByDiscipline(){ if(!isMultiDiscipline()){ alert('Select two or more disciplines before splitting.'); return; } if(!gv('wp_subject')){ alert('Add a Subject before splitting.'); return; } const base=collectPackage(); const baseNumber=base.number||('WP'+pad2(editingSeq())); if(!confirm(`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`)) return; // Master: flagged as a split container, keeps all disciplines for roll-up tracking. const masterId = editingId || base.id; const master = {...base, id:masterId, split:true, children:[]}; const children = pkgDisciplines.map((d,i)=>{ const suffix=instanceSuffixFor(i,d); const steps=(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean); const childId='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i; master.children.push(childId); return { ...base, id: childId, number: baseNumber+suffix, disciplines:[d], scope:{[d]:steps.length?steps:['']}, discStatus:{[d]: (pkgDiscStatus[d]||base.status||'Draft')}, status: pkgDiscStatus[d]||base.status||'Draft', materials: (base.materials||[]).filter(m=>m.discipline===d), workSteps: steps, work: steps.join('\n'), instanceOf: masterId, instanceLabel: suffix, parentNumber: baseNumber, split:false, children:undefined, updatedAt:new Date().toISOString() }; }); // Upsert master + children into the saved store. const upsert=(p)=>{ const ix=savedPackages.findIndex(x=>x.id===p.id); if(ix>=0) savedPackages[ix]=p; else savedPackages.push(p); }; upsert(master); children.forEach(upsert); editingId=masterId; saveStore(); renderSavedList(); track('wp_split',{disciplines:pkgDisciplines.length}); toast('Split into '+children.length+' discipline instances'); const untagged=(base.materials||[]).filter(m=>!m.discipline).length; const matNote = untagged ? `\n\nNote: ${untagged} material line${untagged===1?'':'s'} had no discipline tag and stayed on the master only — tag them before splitting to route them to an instance.` : ''; alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote); } // ── ASSETS (Micron asset catalog) ──────────────────────────────────────────── // Assets are picked from the Micron asset catalog, a SQL Server database this app // reads through /api/assets. The lookup is strictly read-only — picking an asset // never writes to the catalog, and there is no endpoint that could. // // The catalog can be absent (not configured) or unreachable (VPN/host down), and // neither may block someone from writing a work package: in both cases the picker // says so and manual entry carries on. Manually entered assets are marked // source:'manual' so it stays visible which rows the catalog vouches for. // The whole catalog is fetched once when the page loads and searched in memory — // it is slow-moving reference data, so a request per keystroke would buy nothing // and cost latency on every one. let assetCatalog = []; // the full catalog, loaded once let assetCatalogIndex = new Map(); // lowercased id -> the Micron DB's own casing let assetCatalogState = 'loading'; // loading | ready | absent | error let assetResults = []; // current matches; the result list indexes into this const ASSET_RESULT_MAX = 500; // results shown at once — the box scrolls, not the search const ASSET_IMPORT_MAX = 1000; // rows accepted from one CSV — see importAssets() function assetKey(a){ return String((a && a.tag) || '').trim().toLowerCase(); } function assetAlreadyAdded(tag){ const k = String(tag||'').trim().toLowerCase(); return pkgAssets.some(a => assetKey(a) === k && k); } function buildAssets(){ const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML=''; if(!pkgAssets.length){ tb.innerHTML = `No assets yet — search the Micron DB above to add the assets this package covers.`; return; } pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr'); // The asset ID on a catalog row is shown as text, not an input: the catalog // is the source of truth for it and a locally edited copy would silently // disagree. The note is always the user's own, so it stays editable either // way. Every interpolation below is esc()'d text content or a numeric index // — never a raw string inside an inline handler, which is the bug pattern // recorded in KNOWN-ISSUES.md §1. const idCell = a.source === 'catalog' ? `${esc(a.tag)} Micron DB` : ``; tr.innerHTML = idCell + ` `; tb.appendChild(tr); }); } // Kept for saved packages written before the picker existed: their rows have no // `source`, so they would render as read-only catalog rows with no way to fix a // typo. Anything that didn't come from the catalog is treated as manual. function normaliseAsset(a){ const o = Object.assign({ tag:'', desc:'', link:'', source:'manual' }, a||{}); if(o.source !== 'catalog') o.source = 'manual'; return o; } function addManualAsset(){ pkgAssets.push(normaliseAsset({})); buildAssets(); track('asset_added',{source:'manual'}); } // ── CSV IMPORT ─────────────────────────────────────────────────────────────── // Bulk-add a list of asset ids. Each imported id is checked against the loaded // Micron DB: a hit is added as a catalog row (badge, id locked, stored with the // DB's own casing); a miss is added as a manual row so it is visibly NOT vouched // for rather than silently dropped. Nothing is ever written back to Micron. const ASSET_ID_HEADERS = ['asset id','assetid','asset_id','asset','asset tag','assettag','tag','id']; function importAssets(ev){ const f = ev.target.files && ev.target.files[0]; if(!f){ return; } const clear = () => { ev.target.value = ''; }; if(/\.xlsx?$/i.test(f.name)){ alert('Please save the workbook as CSV first (File → Save As → CSV), then load it here.'); clear(); return; } const r = new FileReader(); r.onload = () => { let rows; try { rows = parseCSV(r.result); } catch(e){ alert('Could not parse that CSV.'); clear(); return; } if(!rows.length){ alert('That file has no rows.'); clear(); return; } applyImportedAssets(rows); clear(); }; r.onerror = () => { alert('Could not read that file.'); clear(); }; r.readAsText(f); } // Which column holds the ids, and whether row 0 is a header. // - a recognised header name wins outright; // - otherwise pick the column with the most Micron DB hits, so an export with // the ids in column D works without the user rearranging it; // - failing both (nothing matches — e.g. the DB is offline), use column 0. function pickAssetColumn(rows){ const head = (rows[0] || []).map(c => String(c || '').trim().toLowerCase()); const named = head.findIndex(h => ASSET_ID_HEADERS.includes(h)); if(named >= 0) return { col: named, start: 1 }; const width = rows.slice(0, 200).reduce((w, r) => Math.max(w, r.length), 1); let best = 0, bestHits = 0; for(let c = 0; c < width; c++){ let hits = 0; for(let i = 0; i < Math.min(rows.length, 200); i++){ const v = String((rows[i] || [])[c] || '').trim(); if(v && assetCatalogIndex.has(v.toLowerCase())) hits++; } if(hits > bestHits){ bestHits = hits; best = c; } } return { col: best, start: 0 }; } function applyImportedAssets(rows){ const { col, start } = pickAssetColumn(rows); // Collect, trimmed and de-duplicated within the file itself. const seen = new Set(), ids = []; for(let i = start; i < rows.length; i++){ const v = String((rows[i] || [])[col] || '').trim(); if(!v) continue; const k = v.toLowerCase(); if(seen.has(k)) continue; seen.add(k); ids.push(v); } if(!ids.length){ alert('No asset ids found in that file.'); return; } // Cap the import rather than building a table with thousands of rows. Reported, // never silent — a truncated import that looked complete would be worse. const capped = ids.length > ASSET_IMPORT_MAX; const take = capped ? ids.slice(0, ASSET_IMPORT_MAX) : ids; let matched = 0, unmatched = 0, dupes = 0; take.forEach(id => { if(assetAlreadyAdded(id)){ dupes++; return; } const canonical = assetCatalogIndex.get(id.toLowerCase()); if(canonical){ pkgAssets.push({ tag: canonical, desc: '', link: '', source: 'catalog' }); matched++; } else { pkgAssets.push({ tag: id, desc: '', link: '', source: 'manual' }); unmatched++; } }); buildAssets(); renderAssetResults(); // rows just added should now read "added" track('asset_imported', { matched: matched, unmatched: unmatched }); // Every id matched, nothing skipped, nothing truncated: a toast is enough. // Anything the user needs to act on — unmatched ids, a silent-looking // truncation, an unchecked import — interrupts with the detail instead. const offline = assetCatalogState !== 'ready'; if(matched && !unmatched && !dupes && !capped && !offline){ toast('Added ' + matched + ' asset' + (matched === 1 ? '' : 's') + ' from the Micron DB'); return; } const parts = []; if(matched) parts.push(matched + ' found in the Micron DB'); if(unmatched) parts.push(unmatched + ' not in the Micron DB (added as manual rows)'); if(dupes) parts.push(dupes + ' already on this package (skipped)'); let msg = 'Imported ' + (matched + unmatched) + ' asset' + ((matched + unmatched) === 1 ? '' : 's') + (parts.length ? ':\n\n• ' + parts.join('\n• ') : ''); if(capped) msg += '\n\nThe list held ' + ids.length.toLocaleString() + ' ids — only the first ' + ASSET_IMPORT_MAX.toLocaleString() + ' were added.'; if(offline) msg += '\n\nNote: the Micron DB was not loaded, so nothing could be ' + 'checked against it — every row was added as manual.'; alert(msg); } function removeAsset(i){ pkgAssets.splice(i,1); buildAssets(); renderAssetResults(); // a removed asset becomes addable again } // ── Catalog lookup ─────────────────────────────────────────────────────────── function assetSourceNote(msg, tone){ const el = document.getElementById('asset-source-note'); if(!el) return; el.textContent = msg || ''; el.style.color = tone === 'warn' ? 'var(--red)' : ''; } function initAssetPicker(){ const box = document.getElementById('asset-search'); if(!box) return; box.addEventListener('input', () => runAssetSearch(box.value)); // Re-open on focus only when the list is actually closed. Adding an asset // returns focus to this box, and re-running the search there would rebuild the // list under the cursor and throw away the scroll position mid-multi-add. box.addEventListener('focus', () => { const results = document.getElementById('asset-results'); if(results && results.hidden && box.value.trim()) runAssetSearch(box.value); }); // Pasting a column of ids straight out of Excel adds them all, rather than // dropping a multi-line blob into a search box that can only match one thing. // Excel gives \r\n between rows and \t between columns — i.e. exactly the CSV // importer's row/cell shape, so it goes through the same matching path. // A single value is left alone: that is an ordinary search, not a bulk add. box.addEventListener('paste', e => { const cb = e.clipboardData || window.clipboardData; const text = cb ? cb.getData('text') : ''; if(!text) return; const lines = text.replace(/\r\n?/g, '\n').split('\n').filter(l => l.trim()); if(lines.length < 2) return; // one id — paste it and search as normal e.preventDefault(); applyImportedAssets(lines.map(l => l.split('\t'))); box.value = ''; assetResults = []; openAssetResults(false); }); box.addEventListener('keydown', e => { if(e.key === 'Escape'){ openAssetResults(false); box.blur(); } // Enter adds the first result that isn't already on the package — the common // case of typing an exact tag and taking it without reaching for the mouse. if(e.key === 'Enter'){ e.preventDefault(); const ix = assetResults.findIndex(tag => !assetAlreadyAdded(tag)); if(ix >= 0) addCatalogAsset(ix); } }); // Click-away closes, matching the .pp-menu pickers elsewhere on this form. // Tested against composedPath() rather than e.target: adding an asset can // re-render the row that was clicked, and a detached target reports itself as // outside every container, which would close the list on every add. document.addEventListener('click', e => { const path = typeof e.composedPath === 'function' ? e.composedPath() : null; const inside = path && path.length ? path.some(n => n && n.id === 'asset-pick') : !!(e.target.closest && e.target.closest('#asset-pick')); if(!inside) openAssetResults(false); }); // Delegated so result rows never need an inline handler carrying catalog text. const results = document.getElementById('asset-results'); if(results) results.addEventListener('click', e => { const row = e.target.closest('[data-asset-ix]'); if(!row) return; addCatalogAsset(parseInt(row.getAttribute('data-asset-ix'), 10)); }); box.disabled = true; box.placeholder = 'Loading asset IDs from the Micron DB…'; assetSourceNote('Loading asset IDs from the Micron DB…'); fetch('/api/assets', { headers:{ 'Accept':'application/json' } }) .then(r => r.ok ? r.json() : r.json().catch(() => ({})).then(b => Promise.reject(b.detail || 'The Micron DB could not be read.'))) .then(body => { if(!body.configured){ assetCatalogState = 'absent'; box.placeholder = 'Micron DB not configured — add assets manually below'; assetSourceNote('The Micron DB is not connected, so assets are entered by hand. Use “+ Add asset not in the Micron DB”.'); return; } assetCatalog = (body.assets || []).map(a => String(a.tag || '')); // Lowercased lookup for the CSV importer: it decides whether an imported id // is a real Micron asset, and maps it back to the DB's own casing so an // id typed as "ahu-2p-014" is stored exactly as Micron spells it. assetCatalogIndex = new Map(assetCatalog.map(t => [t.toLowerCase(), t])); assetCatalogState = 'ready'; box.disabled = false; box.placeholder = 'Search asset IDs, or paste a column from Excel…'; assetSourceNote(assetCatalog.length.toLocaleString() + ' asset IDs loaded from the Micron DB (read-only).'); }) .catch(err => { assetCatalogState = 'error'; box.placeholder = 'Micron DB unavailable — add assets manually below'; assetSourceNote(typeof err === 'string' ? err + ' You can still add assets manually.' : 'The Micron DB could not be reached. You can still add assets manually.', 'warn'); }); } // Filters the loaded catalog in memory. Exact match, then prefix, then contains — // so typing a full asset ID puts that asset first rather than whichever ID // happens to sort first. function runAssetSearch(q){ q = String(q||'').trim().toLowerCase(); if(!q || assetCatalogState !== 'ready'){ assetResults = []; renderAssetResults(); openAssetResults(false); return; } const exact=[], prefix=[], other=[]; for(const tag of assetCatalog){ const t = tag.toLowerCase(); if(t === q) exact.push(tag); else if(t.startsWith(q)) prefix.push(tag); else if(t.includes(q)) other.push(tag); if(exact.length + prefix.length + other.length >= ASSET_RESULT_MAX) break; } assetResults = exact.concat(prefix, other).slice(0, ASSET_RESULT_MAX); renderAssetResults(); openAssetResults(true); } function renderAssetResults(){ const box = document.getElementById('asset-results'); if(!box) return; if(!assetResults.length){ box.innerHTML = `
No matching asset IDs. Add it manually if it isn’t in the Micron DB yet.
`; return; } box.innerHTML = assetResults.map((tag,ix) => { const on = assetAlreadyAdded(tag); return ``; }).join(''); } function openAssetResults(open){ const box = document.getElementById('asset-results'); const inp = document.getElementById('asset-search'); if(box) box.hidden = !open; if(inp) inp.setAttribute('aria-expanded', open ? 'true' : 'false'); } function addCatalogAsset(ix){ const tag = assetResults[ix]; if(!tag) return; if(assetAlreadyAdded(tag)){ toast('That asset is already on this package'); return; } pkgAssets.push({ tag: tag, desc: '', link: '', source: 'catalog' }); buildAssets(); // Mark just this row instead of re-rendering the list: the results stay open // for the next pick, the scroll position holds, and the clicked element is // never detached mid-click (see the composedPath note in initAssetPicker). markAssetResultAdded(ix); const box = document.getElementById('asset-search'); if(box) box.focus(); // keep typing straight into the next search track('asset_added',{source:'catalog'}); } function markAssetResultAdded(ix){ const row = document.querySelector('#asset-results [data-asset-ix="' + ix + '"]'); if(!row) return; row.classList.add('is-added'); row.disabled = true; const label = row.querySelector('.asset-result-add'); if(label) label.textContent = 'added'; } // ── ATTACHMENTS ────────────────────────────────────────────────────────────── function buildAttach(){ const tb=document.getElementById('attach-body'); tb.innerHTML=''; pkgAttach.forEach((a,i)=>{ const tr=document.createElement('tr'); tr.innerHTML=` `; tb.appendChild(tr); }); } function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); } function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); } // ── ADD FILES FROM SOP FOLDER (no-auth interim) ────────────────────────────── // Opens the SOP source folders, then turns pasted SharePoint file links into // attachment rows with the file name parsed from the URL. (A true embedded // picker needs an Azure AD app registration — see DEPLOYMENT.md.) function fileNameFromUrl(url){ try{ const path=String(url).split('?')[0].split('#')[0]; const seg=decodeURIComponent(path.split('/').filter(Boolean).pop()||''); // Only use it as a doc name if it looks like a real file (has an extension); // short "/:f:/s/" sharing links have no filename, so leave doc blank. return /\.[a-z0-9]{2,6}$/i.test(seg) ? seg : ''; }catch(e){ return ''; } } function renderSopFileFolders(){ const box=document.getElementById('sop-file-folders'); if(!box) return; const srcs=sopLinkedSources(); box.innerHTML = srcs.length ? `
1) Open a folder, multi-select files in SharePoint, then use Copy link:
`+ `` : `
No SOP folders defined — load or import an SOP first.
`; } function toggleSopFilePanel(){ const p=document.getElementById('sop-file-panel'); if(!p) return; const show = !p.style.display || p.style.display==='none'; p.style.display = show ? 'block' : 'none'; if(show){ renderSopFileFolders(); track('sop_file_panel_opened'); } } function addPastedFileLinks(){ const ta=document.getElementById('sop-file-links'); if(!ta) return; const links=ta.value.split(/\r?\n/).map(s=>s.trim()).filter(Boolean); if(!links.length){ toast('Paste one or more file links first'); return; } // drop the single empty placeholder row if that's all there is if(pkgAttach.length===1 && !pkgAttach[0].doc && !pkgAttach[0].rev && !pkgAttach[0].link) pkgAttach=[]; links.forEach(link=>pkgAttach.push({doc:fileNameFromUrl(link), rev:'', link})); buildAttach(); ta.value=''; toggleSopFilePanel(); toast(links.length+' file'+(links.length>1?'s':'')+' added — set Rev as needed'); track('files_added_from_sop'); } // ── CONSTRAINTS + RELEASE GATE ─────────────────────────────────────────────── function buildConstraints(){ // Carry the SOP's definition through, not just the name: `critical` decides // whether reopening this constraint after release is emailed, and `description` // is the tooltip. Statuses already set on the package are preserved. const defs=constraintNames().map(n=> (typeof n==='string') ? {name:n} : (n||{})); const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c); pkgConstraints=defs.filter(d=>d.name).map(d=>{ const p=prev[d.name]; return { name: d.name, status: p ? p.status : 'open', comment: p ? (p.comment||'') : '', critical: !!d.critical, description: d.description || (p && p.description) || '' }; }); const tb=document.getElementById('constraint-body'); tb.innerHTML=''; pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr'); const critTag = c.critical ? ` ⚠ critical` : ''; tr.innerHTML=`${esc(c.name)}${critTag} `; tb.appendChild(tr); }); } function setConstraint(i,val){ const before=pkgConstraints[i].status; pkgConstraints[i].status=val; buildConstraints(); updateReleaseBanner(); // If a constraint is flagged Open after the package was released, require logging the issue if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){ prevStatus=getRadio('status'); holdContext={index:i, before}; openHoldModal(pkgConstraints[i].name, true); return; } // Clearing the LAST open constraint makes the package release-ready — offer to // issue it and scroll up to the status control so the change is visible. if(before==='open' && val!=='open' && readiness().open===0){ const st=getRadio('status'); // Don't offer to issue while a predecessor is still open — that would walk the // user straight into the override prompt they didn't ask for. if(readiness().blocking.length){ renderPredHint(); toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).'); } else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){ if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){ setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner(); track('status_change',{status:'Issued',via:'constraint_clear'}); } const sg=document.getElementById('status-group'); if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'}); } } } // Release readiness has two gates now: every constraint cleared/N-A, and every // predecessor package Closed. `ready` means both; `open`/`blocking` say which. function readiness(){ const open = pkgConstraints.filter(c=>c.status==='open').length; const blocking = blockingPredecessors(); return { open, blocking, total: pkgConstraints.length, cleared: pkgConstraints.filter(c=>c.status==='cleared').length, constraintsClear: open === 0, ready: open === 0 && blocking.length === 0 }; } function updateReleaseBanner(){ const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status'); let cls, txt; if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; } else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; } else if(r.constraintsClear){ // Constraints are done; what's left is upstream work. cls='rb-notready'; txt=`⚠ Not release-ready — waiting on ${r.blocking.map(p=>esc(p.number||p.id)+' ('+esc(p.status)+')').join(', ')}.`; } else { cls='rb-notready'; const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : ''; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`; } b.innerHTML=`
${txt}
`; updateStickyStatus(); } // Releasing with an unclosed predecessor is allowed but must be explained. The // reason rides on the package (data.gateOverride) and the server writes it to the // audit log. Returns false if the user backed out. function confirmEarlyRelease(blocking){ const list=blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'); const reason=prompt('These predecessor packages are not Closed yet:\n\n'+list+ '\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+ 'Why is it being released now? (Cancel to stop.)'); if(reason===null || !reason.trim()) return false; pkgGateOverride={ reason: reason.trim(), at: new Date().toISOString(), by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '', blocking: blocking.map(p=>p.number||p.id) }; track('predecessor_gate_overridden'); return true; } function onStatusChange(target){ const idx=STATUS_ORDER.indexOf(target); const r=readiness(); // Constraints are a hard gate: nothing releases with one open. if(idx>=ISSUED_IDX && r.open>0){ const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name); alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.'); setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; } // Predecessors are a gate you can refuse: planners genuinely need to release // ahead of upstream work closing out. Refusing it requires a reason, which is // stored on the package and written to the audit log by the server. if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){ if(!confirmEarlyRelease(r.blocking)){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return; } } if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts prevStatus=target; updateReleaseBanner(); track('status_change',{status:target}); } // ── HOLD LOG MODAL (comments 7) ────────────────────────────────────────────── let holdPhotoData='', holdContext=null; function openHoldModal(preselect, fromConstraint){ holdPhotoData=''; if(!fromConstraint) holdContext=null; const sel=document.getElementById('hold-constraint'); sel.innerHTML = pkgConstraints.map(c=>``).join('') + ''; const existing = fromConstraint ? (pkgConstraints.find(c=>c.name===preselect)||{}).comment||'' : ''; document.getElementById('hold-details').value=existing; document.getElementById('hold-doclink').value=''; document.getElementById('hold-photo-preview').innerHTML=''; document.getElementById('hold-modal').classList.add('open'); } function holdPhotoChange(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; const ok=/^data:image\//.test(holdPhotoData); document.getElementById('hold-photo-preview').innerHTML= ok?`supporting photo`:''; }; r.readAsDataURL(f); } function submitHold(){ const constraint=document.getElementById('hold-constraint').value; const details=document.getElementById('hold-details').value.trim(); const doclink=document.getElementById('hold-doclink').value.trim(); if(!details){ alert('A comment defining the issue is required.'); return; } pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'' }); const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; } buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null; document.getElementById('hold-modal').classList.remove('open'); updateReleaseBanner(); toast('Hold logged'); track('hold_logged',{constraint}); } function cancelHold(){ if(holdContext){ // came from flagging a constraint on a released package -> revert that constraint pkgConstraints[holdContext.index].status=holdContext.before; holdContext=null; buildConstraints(); updateReleaseBanner(); document.getElementById('hold-modal').classList.remove('open'); return; } closeHoldModal(false); } function closeHoldModal(committed){ document.getElementById('hold-modal').classList.remove('open'); if(!committed){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); } } // ── SIGN-OFFS ──────────────────────────────────────────────────────────────── function sopNameForSignoff(role){ if(!SOP) return ''; const roles=SOP.roles||[]; const proj=SOP.project||{}; const find=(re)=>{ const m=roles.find(r=>re.test(r.role||'')); return m&&m.name||''; }; switch(role){ case 'Planner': return find(/planner/i); case 'Superintendent': return find(/superintendent/i) || proj.cm || ''; case 'HSE Professional': return find(/safety|hse|ehs/i); case 'Quality Representative': return find(/quality/i) || proj.qm || ''; case 'Work Foreman': return find(/general foreman|foreman/i); } return ''; } function buildSignoffs(){ const prev={}; pkgSignoffs.forEach(s=>prev[s.role]=s); pkgSignoffs=SIGNOFF_ROLES.map(r=>{ if(prev[r]) return prev[r]; return {role:r, name:sopNameForSignoff(r), date:'', signed:false, fromSOP:!!sopNameForSignoff(r)}; }); const tb=document.getElementById('signoff-body'); tb.innerHTML=''; pkgSignoffs.forEach((s,i)=>{ const tr=document.createElement('tr'); tr.innerHTML=`${esc(s.role)} ${s.fromSOP?'from SOP':''} ${s.date?esc(s.date):'—'} ${s.dateReason?`overridden`:''} `; tb.appendChild(tr); }); } function todayStr(){ const d=new Date(); return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); } function onSignoffSigned(i,checked){ pkgSignoffs[i].signed=checked; if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; } buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked}); } function onSignoffDateOverride(i){ const s=pkgSignoffs[i]; const nd=prompt('Manual sign-off date for '+s.role+' (YYYY-MM-DD). The date is normally set automatically when Signed is checked.', s.date||todayStr()); if(nd===null) return; if(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; } const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:'); if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; } s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role}); } // ── SAVE / OUTPUT ──────────────────────────────────────────────────────────── function collectPackage(){ const steps=pkgWorkSteps.map(s=>s.trim()).filter(Boolean); const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits return { id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)), projectId: (prev&&prev.projectId) || activeProjectId || '', instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined, parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined, number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'), type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'), cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'), // Account ids behind those names — notification routing needs an account; // a display name can't be emailed. Typed-only names carry no id. assigneeIds:pkgPeople.assignees.map(x=>x.id).filter(Boolean), distributionIds:pkgPeople.distribution.map(x=>x.id).filter(Boolean), due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'), work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims}, disciplines:[...pkgDisciplines], scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined, discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined, hours:gv('wp_hours'), // seq is the SOP sequence phase (descriptive). predecessors are references to // other packages and are what actually gate release. seq:gv('wp_seq'), predecessors:pkgPredecessors.slice(), gateOverride:pkgGateOverride || undefined, assets:pkgAssets.filter(a=>a.tag||a.link||a.desc), materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc), kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'), mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'), constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})), qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'), qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides}, signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})), holds:pkgHolds.map(h=>({...h})), actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'), kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply // AWP traceability: which BIM/model package(s) enabled this install package. bimlink:gv('wp_bimlink'), // BIM/VDC package details (only meaningful on a BIM SOP). // LOD was removed from the form (site comment 8/3). Any value already stored on // the package is preserved rather than blanked on the next save. lod:(prev && prev.lod) || '', iff:gv('wp_iff'), modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'), project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'', // Project homepage links in the tracking / commissioning systems, copied from the // SOP so they travel with every Work Package created for this project. projectLinks:(SOP&&SOP.projectLinks)?SOP.projectLinks.map(l=>({...l})):[], updatedAt:new Date().toISOString() }; } function savePackage(view){ if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; } // A BIM package marked "Signed off (IFF)" without the number isn't traceable. // The status can also be set programmatically (the per-discipline roll-up), so // re-check the predecessor gate at the point of saving. const _r=readiness(); if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){ if(!confirmEarlyRelease(_r.blocking)) return; } if(isEwp() && iffRequired() && !gv('wp_iff').trim()){ alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.'); const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); } return; } const pkg=collectPackage(); const ix=savedPackages.findIndex(p=>p.id===pkg.id); if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg); editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status}); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server document.getElementById('loadingOverlay').classList.add('active'); setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400); } function renderPackage(pkg){ const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0}; const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS')); const kindLbl = pkg.kind==='ewp' ? 'BIM (EWP)' : 'IWP'; let h=`

${esc(pkg.number||'(no number)')} — Work Package

${esc(pkg.project)} · ${kindLbl} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}
`; const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1]; // Project system links travel with the WP; fall back to the live SOP for older packages. const plinks = (pkg.projectLinks&&pkg.projectLinks.length) ? pkg.projectLinks : ((SOP&&SOP.projectLinks)||[]); h+=`

1.0 General Information

${pkg.disciplines&&pkg.disciplines.length?``:''} ${plinks.length?``:''} ${pkg.bimlink?``:''} ${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?``:''}
WP Number${cell(pkg.number)}
Subject${cell(pkg.subject)}
Type${cell(pkg.type)}
Discipline(s)${esc(pkg.disciplines.join(', '))}${pkg.split?' [MASTER — split into instances]':''}${pkg.instanceOf?` [instance of ${esc(pkg.parentNumber||'')}]`:''}
System / Facility Code / UPN${cell(pkg.system)}
Location${cell(pkg.location)}
Cost Code${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}
Acumatica Task${cell(pkg.wbs)}
Assignees${cell(pkg.assignees)}
Distribution${cell(pkg.distribution)}
Due Date${cell(pkg.due)}
Specification Section${cell(pkg.spec)}
Description${cell(pkg.desc)}
Project Systems${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('
')}
Enabled by (BIM)${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}
BIM / Model${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('
')}
`; if(pkg.assets&&pkg.assets.length){ h+=`

2.0 Assets

`; pkg.assets.forEach(a=>h+=``); h+=`
Asset IDNote
${cell(a.tag)}${cell(a.desc)}
`; } let scopeHtml; if(pkg.scope && Object.keys(pkg.scope).length){ // per-discipline scope sections scopeHtml = Object.keys(pkg.scope).map(d=>{ const arr=(pkg.scope[d]||[]).filter(Boolean); const dst=pkg.discStatus&&pkg.discStatus[d]?` [${esc(pkg.discStatus[d])}]`:''; return `
${esc(d)}${dst}`+ (arr.length?'
    '+arr.map(s=>`
  1. ${esc(s)}
  2. `).join('')+'
':' — '+ns())+`
`; }).join(''); } else { const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]); scopeHtml = stepsArr.length ? '
    '+stepsArr.map(s=>`
  1. ${esc(s)}
  2. `).join('')+'
' : ns(); } h+=`

3.0 Scope & Work

${pkg.gateOverride?``:''}
Description of Work${scopeHtml}
Labor Est. Hrs.${cell(pkg.hours)}
Predecessor packages${ (pkg.predecessors && pkg.predecessors.length) ? pkg.predecessors.map(id=>{ const q=wpById(id); return q ? (esc(q.number||id)+' — '+esc(q.status)) : esc(id)+' (deleted)'; }).join('
') : 'None' }
Sequence phase${pkg.seq?esc(pkg.seq):ns()}
Released earlyPredecessor gate overridden.
${esc(pkg.gateOverride.reason||'')}
${esc(pkg.gateOverride.by||'')} · ${esc(pkg.gateOverride.at?wpFormatDateTime(pkg.gateOverride.at):'')}
`; if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline); h+=`

4.0 Material List

${showDisc?'':''}`; pkg.materials.forEach(m=>h+=`${showDisc?``:''}`); h+=`
QtyUnitDescriptionDiscipline
${cell(m.qty)}${cell(m.unit)}${cell(m.desc)}${cell(m.discipline)}
`; } if(pkg.attachments&&pkg.attachments.length){ h+=`

5.0 Drawings & Attachments

`; pkg.attachments.forEach(a=>h+=``); h+=`
DocumentRevLink / Note
${cell(a.doc)}${cell(a.rev)}${a.link?linkify(a.link):ns()}
`; } h+=`

6.0 Kitting & MIMO

Kitting Status${cell(pkg.kitStatus)}
Warehouse Owner${cell(pkg.kitOwner)}
Kitting Need Date${cell(pkg.kitDate)}
MIMO Sch. Time / Location${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}
`; h+=`

7.0 Constraints — Release Readiness

`; (pkg.constraints||[]).forEach(c=>{ const lbl=c.status==='cleared'?'Cleared':c.status==='na'?'N/A':'Open'; const col=c.status==='cleared'?'var(--accent-green)':c.status==='na'?'var(--text-dim)':'var(--red)'; h+=``; }); h+=`
ConstraintStatusComment
${esc(c.name)}${lbl}${cell(c.comment)}
`; h+=`

8.0 Quality & Hold Points

QC${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` (overridden: ${esc(pkg.overrides.wp_qc)})`:(pkg.qcFromSOP?' [from SOP]':'')}
Photo Documentation${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` (overridden: ${esc(pkg.overrides.wp_photo)})`:(pkg.photoFromSOP?' [from SOP]':'')}
Witness / Hold Points${cell(pkg.hold)}
`; if(pkg.holds&&pkg.holds.length){ h+=`

8.5 Hold Log

`; pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'photo attached':''].filter(Boolean).join('
')||ns(); h+=``; }); h+=`
LoggedConstraintDetailsSupport
${esc(when)}${cell(hd.constraint)}${cell(hd.details)}${sup}
`; } h+=`

9.0 Approvals & Sign-offs

`; (pkg.signoffs||[]).forEach(s=>h+=``); h+=`
RoleNameDateSigned
${esc(s.role)}${cell(s.name)}${cell(s.date)}${s.signed?'✓':'—'}
`; if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`

10.0 Closeout

Actual Hrs.${cell(pkg.actualHrs)}
Installed Quantity${cell(pkg.installedQty)}
Redlines / As-Built${cell(pkg.redlines)}
Lessons Learned${cell(pkg.lessons)}
`; } h+=`

Pushed to: ${cell(pkg.track)}

`; document.getElementById('pkg-doc').innerHTML=h; showOutput(); } function printPackage(){ const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank'); w.document.write(`Work Package${c}`); w.document.close(); w.print(); } // ── VIEWS ──────────────────────────────────────────────────────────────────── function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; } function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; window.scrollTo({top:0,behavior:'smooth'}); } function showForm(){ hideDashboard(); // This clears every card's inline display, which also clears the "hidden" set by // applyKind() — so the kind row and the BIM card would reappear on an // install-only project. Re-apply the kind visibility right after. document.querySelectorAll('.main > .card').forEach(e=>e.style.display=''); document.querySelector('.main > .nav-row').style.display='flex'; document.getElementById('pkg-output').style.display='none'; document.getElementById('saved-card').style.display = savedPackages.length?'':'none'; applyKindVisibility(); // visibility only — not a full applyKind() rebuild buildDisciplinePicker(); renderScope(); setFormChrome(true); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); } // Sticky save bar + section-nav chrome (shown only on the editable form view). function setFormChrome(on){ const nav=document.getElementById('section-nav'), save=document.getElementById('sticky-save'); if(nav) nav.style.display = on ? '' : 'none'; if(save) save.style.display = on ? 'flex' : 'none'; document.body.classList.toggle('has-sticky-save', !!on); if(on){ buildSectionNav(); updateStickyStatus(); makeCollapsible(); initSectionNavAutoHide(); } else positionSectionNav(); } // Make each form card collapsible by clicking its heading (idempotent). function makeCollapsible(){ document.querySelectorAll('.main > .card').forEach(card=>{ if(card.id==='saved-card') return; const head=card.querySelector('.section-header, .sub-heading'); if(!head || head.dataset.collapsible) return; head.dataset.collapsible='1'; head.style.cursor='pointer'; const chev=document.createElement('span'); chev.className='collapse-chev'; chev.textContent='▾'; head.insertBefore(chev, head.firstChild); head.addEventListener('click', e=>{ if(['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName) || e.target.classList.contains('help-tip')) return; const collapsed=card.classList.toggle('collapsed'); chev.textContent = collapsed ? '▸' : '▾'; }); }); } function buildSectionNav(){ const nav=document.getElementById('section-nav'); if(!nav) return; const chips=[]; document.querySelectorAll('.main > .card').forEach((card,i)=>{ if(card.id==='saved-card' || card.style.display==='none') return; const h=card.querySelector('.section-title, .sub-heading'); if(!h) return; const clone=h.cloneNode(true); clone.querySelectorAll('.help-tip').forEach(x=>x.remove()); const label=clone.textContent.trim().replace(/\s+/g,' '); if(!label) return; if(!card.id) card.id='sec-'+i; chips.push(`${esc(label)}`); }); nav.innerHTML=chips.join(''); } // Keep the section-nav pinned just below the sticky header (so it stays put while // scrolling instead of hiding behind the header), and let it slide out of the way // while reading (scroll down), snapping back the moment you scroll up. function positionSectionNav(){ const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header'); if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px'; // The navigator drawer + its handle hang below the page header. Deliberately NOT // including the section-nav height: that bar is sticky, so at scroll 0 it sits // further down the page and the handle would float over the chrome. document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px'); } let _snLastY=0, _snBound=false; function initSectionNavAutoHide(){ positionSectionNav(); if(_snBound) return; _snBound=true; window.addEventListener('resize', positionSectionNav, {passive:true}); window.addEventListener('scroll', ()=>{ const nav=document.getElementById('section-nav'); if(!nav || nav.style.display==='none') return; const y=window.scrollY||document.documentElement.scrollTop||0; if(y>_snLastY+4 && y>140) nav.classList.add('nav-hidden'); // scrolling down else if(y<_snLastY-4) nav.classList.remove('nav-hidden'); // scrolling up _snLastY=y; }, {passive:true}); } function updateStickyStatus(){ const el=document.getElementById('sticky-status'); if(!el) return; const r=readiness(); const st=getRadio('status'); if(st==='Issue'){ el.className='sticky-status ss-hold'; el.textContent=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened`; } else if(r.ready){ el.className='sticky-status ss-ready'; el.textContent=`✓ Release-ready — all ${r.total} constraints cleared`; } else if(r.constraintsClear){ el.className='sticky-status ss-notready'; el.textContent=`⚠ Waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`; } else { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`+(r.blocking.length?` · ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}`:''); } } // ── SAVED PACKAGES ─────────────────────────────────────────────────────────── const STORE_KEY='wp_iwp_v1'; // Per-project namespaced key so each project keeps its own packages in the browser. function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } } function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} } function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } } function renderSavedList(){ const card=document.getElementById('saved-card'), body=document.getElementById('saved-body'); renderWpNav(); renderPredPicker(); // candidates + blocking state change as packages are saved document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:''; if(!savedPackages.length){ card.style.display='none'; return; } if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display=''; body.innerHTML=savedPackages.map((p,i)=>{ const open=(p.constraints||[]).filter(c=>c.status==='open').length; const ready = p.status==='Issue' ? 'On Hold' : (open===0?'Ready':`${open} open`); const tag = p.split?' master':(p.instanceOf?` ${esc(p.instanceLabel||'instance')}`:''); const disc = (p.disciplines&&p.disciplines.length)?`
${esc(p.disciplines.join(', '))}
`:''; return `${esc(p.number||'—')}${tag}${disc}${esc(p.type||'')}${esc(p.subject||'')} ${statusPill(p.status)}${ready} ${canDeleteWP()?``:''}`; }).join(''); } function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } // ── WP NAVIGATOR (left rail) ───────────────────────────────────────────────── // Every saved package on this project, grouped by status, filterable. Clicking a // row opens it in the form (same path as the Saved table's "edit"). const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed']; // The panel's ESSENTIAL layout ships with this script rather than living only in // wp-creation-styles.css. The two files are cached independently, so a browser can // run new markup against an old stylesheet — and without these rules the panel is // not merely unstyled, it's broken: its title, filter and buttons drop into the // middle of the form as loose widgets (which is exactly what happened once). // Injecting the floor here means the panel is always a positioned side panel; the // stylesheet only refines its appearance. const WP_NAV_CRITICAL_CSS = ` body{--nav-w:288px;} body.wp-nav-collapsed{--nav-w:56px;} .wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w); z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc; border-right:1px solid #e0e0e0;} .wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;} .wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%; background:none;border:0;text-align:left;cursor:pointer;font:inherit;} .wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff; display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;} .wp-nav-body{min-width:0;flex:1 1 auto;} .wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;} .main{padding-left:calc(var(--nav-w) + 28px);} body.wp-nav-collapsed .wp-nav-body,body.wp-nav-collapsed .wp-nav-state, body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect, body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group, body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;} /* Modals are hidden by a stylesheet rule; without it their contents render inline in the middle of the form. Same reasoning as the panel: this is a floor. */ .modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;} /* The jump bar's sticky offset is set inline by JS; give it a sane default so a stale stylesheet can't park it behind the opaque header. */ .section-nav-bar{position:sticky;top:48px;z-index:30;background:#fff;} `; function injectWpNavCriticalCss(){ if(document.getElementById('wp-nav-critical')) return; const st = document.createElement('style'); st.id = 'wp-nav-critical'; st.textContent = WP_NAV_CRITICAL_CSS; // First in so the real stylesheet (loaded later) still wins on every // property it defines — this is a floor, not an override. const head = document.head || document.documentElement; head.insertBefore(st, head.firstChild); } // ── panel behaviour ──────────────────────────────────────────── // The panel is always present. The toggle collapses it to a 56px icon rail (badges // only) and back; that choice is remembered. No hover-to-open: a list you navigate // by shouldn't appear and disappear under the pointer. let wpNavView = 'all'; // all | mine | open function toggleWpNav(){ const collapsed = document.body.classList.toggle('wp-nav-collapsed'); try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){} const btn = document.getElementById('wp-nav-toggle'); if(btn){ btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel'; } closeWpNavMore(); track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded'); } // Kept so older callers (and the suite shell) don't break. function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); } function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); } function toggleWpNavMore(ev){ if(ev) ev.stopPropagation(); const m = document.getElementById('wp-nav-more'); const b = document.getElementById('wp-nav-more-btn'); if(!m) return; const open = m.hidden; m.hidden = !open; if(b) b.setAttribute('aria-expanded', open ? 'true' : 'false'); } function closeWpNavMore(){ const m = document.getElementById('wp-nav-more'); const b = document.getElementById('wp-nav-more-btn'); if(m) m.hidden = true; if(b) b.setAttribute('aria-expanded', 'false'); } document.addEventListener('click', e => { if(!e.target.closest || !e.target.closest('.wp-nav-primary')) closeWpNavMore(); }); document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNavMore(); }); function wpNavAction(what){ closeWpNavMore(); if(what === 'duplicate') duplicateWP(); else if(what === 'split') splitByDiscipline(); else if(what === 'export') exportPackages(); } // The three views are filters over the same list, like Planner's My tasks / My plans. function setWpNavView(view){ wpNavView = view; document.querySelectorAll('.wp-nav-link[data-view]').forEach(b => { b.classList.toggle('is-current', b.getAttribute('data-view') === view); }); renderWpNav(); track('wp_nav_view', {view}); } // A stable colour per package so the same one is always the same swatch — the cue // Planner gets from its plan avatars. Hashed from the WP number, not its index, so // it doesn't shuffle when a package is added or deleted. const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00', '#1192e8','#198038','#a56eff','#9f1853','#005d5d']; function wpBadgeColor(p){ const key = (p.number || p.id || '') + ''; let h = 0; for(let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 100000; return WP_BADGE_COLORS[h % WP_BADGE_COLORS.length]; } // Two characters for the badge: the package's own digits if it has them (WP-25 -> 25), // otherwise the initials of its type, otherwise WP. function wpBadgeText(p){ const digits = String(p.number || '').match(/(\d{1,2})(?!.*\d)/); if(digits) return digits[1]; const t = String(p.type || '').trim(); if(t){ const words = t.split(/\s+/).filter(Boolean); return (words.length > 1 ? words[0][0] + words[1][0] : t.slice(0, 2)); } return 'WP'; } function initWpNavDrawer(){ const nav = document.getElementById('wp-nav'); if(!nav || nav.dataset.bound) return; nav.dataset.bound = '1'; injectWpNavCriticalCss(); try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){} const btn = document.getElementById('wp-nav-toggle'); if(btn){ const collapsed = document.body.classList.contains('wp-nav-collapsed'); btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel'; } } function renderWpNav(){ const list = document.getElementById('wp-nav-list'); if(!list) return; const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase(); const me = myUserId(); // Keep the original index — edit/view act on savedPackages by position. const all = savedPackages.map((p, i) => ({p, i})); const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me); const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p)); const setN = (id, n) => { const e = document.getElementById(id); if(e) e.textContent = n || ''; }; setN('wp-nav-n-all', all.length); setN('wp-nav-n-mine', mine.length); setN('wp-nav-n-open', needs.length); let rows = wpNavView === 'mine' ? mine : (wpNavView === 'open' ? needs : all); const label = document.getElementById('wp-nav-sect-label'); if(label) label.textContent = wpNavView === 'mine' ? 'My packages' : (wpNavView === 'open' ? 'Needs attention' : 'Work packages'); if(q){ rows = rows.filter(r => { const p = r.p; return [p.number, p.subject, p.type, p.location, p.system] .some(v => String(v || '').toLowerCase().includes(q)); }); } const cnt = document.getElementById('wp-nav-count'); if(cnt) cnt.textContent = rows.length ? '(' + rows.length + ')' : ''; if(!rows.length){ list.innerHTML = ''; return; } const groups = {}; rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); }); const keys = Object.keys(groups).sort((a, b) => { const ia = WPNAV_ORDER.indexOf(a), ib = WPNAV_ORDER.indexOf(b); return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib); }); list.innerHTML = keys.map(k => { const heading = k === 'Issue' ? 'Issue (Hold)' : k; return '' + groups[k].map(r => { const p = r.p; const open = (p.constraints || []).filter(c => c.status === 'open').length; const waiting = wpWaitingOn(p).length; const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open'); const state = p.status === 'Issue' ? 'on hold' : (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready')); const active = (editingId && p.id === editingId) ? ' active' : ''; const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state; return ''; }).join(''); }).join(''); } // Open a package by id. The suite shell calls this instead of reloading the whole // document with a ?wp= parameter, so unsaved edits and scroll position survive. function openWpById(id){ const ix = savedPackages.findIndex(x => x.id === id); if(ix < 0) return false; wpNavOpen(ix); return true; } function wpNavOpen(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view renderWpNav(); track('wp_nav_open'); } // ── WP HISTORY (audit trail) ───────────────────────────────────────────────── function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); } function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); } function closeHistory(){ const m=document.getElementById('wp-history-modal'); if(m) m.remove(); } const HIST_LABEL={created:'Created',updated:'Updated',status_changed:'Status changed',issued:'Issued',deleted:'Deleted'}; async function showHistory(wpId, label){ if(!wpId){ toast('Save the package first — history is recorded as it changes.'); return; } if(!label){ const _p=savedPackages.find(x=>x.id===wpId)||(typeof dashArchived!=='undefined'&&dashArchived.find(x=>x.id===wpId)); label=_p?(_p.number||_p.subject):''; } closeHistory(); const ov=document.createElement('div'); ov.id='wp-history-modal'; ov.className='modal-overlay open'; ov.innerHTML=''; ov.addEventListener('click', e=>{ if(e.target===ov) closeHistory(); }); document.body.appendChild(ov); let rows=[]; try{ const r=await fetch('/api/audit?entity_type=wp&entity_id='+encodeURIComponent(wpId), {headers:{'Accept':'application/json'}}); if(r.ok) rows=await r.json(); }catch(e){} const body=document.getElementById('wp-history-body'); if(!body) return; if(!rows || !rows.length){ body.innerHTML='
No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.
'; return; } const fmt=s=>{ try{ return wpFormatDateTime(s); }catch(e){ return s||''; } }; const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; }; body.innerHTML='
'+rows.map(e=> '
'+esc(fmt(e.at))+'
'+ '
'+esc(HIST_LABEL[e.action]||(e.action||'').replace(/_/g,' '))+' '+ ''+det(e.detail)+'
'+ '
by '+esc(e.actor||'—')+'
').join('')+'
'; } // Deleting a work package is a Project Admin action (server: require_project_admin). // A project_user gets no delete button, and archiving is offered instead. function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; } function deletePackage(i){ const p=savedPackages[i]; if(!p) return; if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); } function clearSaved(){ if(!savedPackages.length) return; if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); } function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); } function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); } function loadPackageIntoForm(p){ pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location); set('wp_assignees',p.assignees); set('wp_distribution',p.distribution); loadPeopleFromPkg(p); set('wp_assignee',p.assigneeId); set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours); set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc); set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons); set('wp_bimlink',p.bimlink); set('wp_iff',p.iff); set('wp_model_area',p.modelArea); set('wp_clash',p.clash); set('wp_scan_link',p.scanLink); onClashChange(); applyKind(); buildTypePicker(); document.getElementById('wp_type').value=p.type||''; buildCostCodes(); document.getElementById('wp_cost').value=p.cost||''; set('wp_wbs',p.wbs); document.getElementById('wp_kit_status').value=p.kitStatus||''; buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||''; pkgPredecessors=Array.isArray(p.predecessors)?p.predecessors.slice():[]; pkgGateOverride=p.gateOverride||null; renderPredPicker(); setRadio('status',p.status||'Draft'); // number dimensions numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims(); // disciplines + per-discipline scope/status pkgDisciplines = Array.isArray(p.disciplines) ? [...p.disciplines] : []; pkgScope = p.scope ? JSON.parse(JSON.stringify(p.scope)) : {}; pkgDiscStatus = p.discStatus ? {...p.discStatus} : {}; buildDisciplinePicker(); renderScope(); onHoursChange(); // overrides + locked quality/hold pkgOverrides=p.overrides?{...p.overrides}:{}; set('wp_qc', p.qc!=null?p.qc:sopValueFor('wp_qc')); set('wp_photo', p.photo!=null?p.photo:sopValueFor('wp_photo')); set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold')); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); // collections pkgAssets=(p.assets||[]).map(normaliseAsset); buildAssets(); pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach(); pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows(); pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows(); pkgHolds=(p.holds||[]).map(h=>({...h})); updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); } function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints(); tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }}); buildConstraints(); } function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); } // Duplicate the current work package N times (asks how many). Each copy is a // fresh Draft with a unique number/subject and approvals/closeout cleared. function duplicateWP(){ if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; } const ans=prompt('How many copies of this work package do you want to create?','1'); if(ans===null) return; const n=parseInt(ans,10); if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; } const base=collectPackage(); const baseNum = base.number || ('WP'+pad2(editingSeq())); const made=[]; for(let i=1;i<=n;i++){ const c=JSON.parse(JSON.stringify(base)); c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i; c.number = baseNum + '-C' + i; c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)'); c.status='Draft'; c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined; if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''})); c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons=''; c.projectId = base.projectId || activeProjectId || ''; c.updatedAt=new Date().toISOString(); savedPackages.push(c); made.push(c); } editingId=null; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server toast('Created '+n+' duplicate'+(n>1?'s':'')); track('wp_duplicated',{count:n}); alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.'); } function newPackage(){ editingId=null; ['wp_subject','wp_system','wp_location','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value=''; ['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';}); onClashChange(); pkgKind='iwp'; applyKind(); resetPeopleForNewPackage(); pkgPredecessors=[]; pkgGateOverride=null; renderPredPicker(); setRadio('status','Draft'); numberDims={}; buildNumberDims(); pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange(); pkgAssets=[]; buildAssets(); pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); pkgWorkSteps=['']; buildWorkSteps(); pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs(); pkgHolds=[]; pkgOverrides={}; set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold'); prevStatus='Draft'; updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); renderWpNav(); track('new_package'); } function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); } function exportPackages(){ if(!savedPackages.length){ alert('No packages saved yet.'); return; } const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('packages_exported',{count:savedPackages.length}); } // ── DASHBOARD ──────────────────────────────────────────────────────────────── // Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus() // bodies for fetch() calls to /api/wps — the dashboard UI doesn't change. const WPData = { list(){ return savedPackages.slice(); }, // → GET /api/wps get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id} issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore(); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false; p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; }, }; let dashFilter={status:'',discipline:'',q:'',flag:''}; function dashToggleFlag(f){ if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:''}; } else { dashFilter.flag = dashFilter.flag===f ? '' : f; } dashPage=0; renderDashboard(); } function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); } // ── Phase 2: pagination, progress %, and archived view ────────────────────── let dashPage=0, dashShowArchived=false, dashArchived=[]; const DASH_PAGE_SIZE=25; // Weighted completion by status (0..1) so progress is smoother than done/not-done. const PROGRESS_W={'Draft':0,'Scheduled':0.25,'Issue':0.4,'Issued':0.5,'In Progress':0.75,'QC':0.9,'Closed':1}; function wpProgress(p){ const w=PROGRESS_W[p.status]; return w==null?0:w; } function dashGo(pg){ dashPage=pg; renderDashboard(); } function dashToggleArchived(on){ dashShowArchived=!!on; dashPage=0; if(dashShowArchived && !dashArchived.length && typeof ProjectData!=='undefined' && ProjectData.listArchived){ ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); }); } else { renderDashboard(); } } function dashArchive(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return; if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return; if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true); const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); } saveStore(); renderSavedList(); renderDashboard(); toast('Archived '+(p.number||'')); } function dashUnarchive(id){ const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return; if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false); p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p); saveStore(); renderSavedList(); renderDashboard(); toast('Restored '+(p.number||'')); } // Consistent colored status pill, reused by the dashboard board and the saved list. function statusPill(s){ const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'}; const label = s==='Issue' ? 'Issue (Hold)' : (s||'—'); return `${esc(label)}`; } function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } } function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); } // Predecessor packages of `p` that aren't Closed. Deleted ones don't block. function wpWaitingOn(p){ return (p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed'); } function wpReleaseBlocked(p){ return wpOpenConstraints(p).length>0 || wpWaitingOn(p).length>0; } function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); } // Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted. function countableWPs(){ return WPData.list().filter(p=>!p.split); } function showDashboard(){ setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display='none'; const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display=''; currentView='Dashboard'; cmtUpdateCurStep(); renderDashboard(); window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open'); } function renderDashboard(){ const all=countableWPs(); const byStatus={}; STATUS_ORDER.concat(['Issue']).forEach(s=>byStatus[s]=0); let estH=0, actH=0, ready=0, hold=0, overdue=0, mine=0; const byDisc={}; const meId=myUserId(); all.forEach(p=>{ byStatus[p.status]=(byStatus[p.status]||0)+1; estH+=parseFloat(p.hours)||0; actH+=parseFloat(p.actualHrs)||0; if(p.status==='Issue') hold++; if(meId && p.assigneeId===meId) mine++; if(!wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue') ready++; if(isOverdue(p)) overdue++; (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>byDisc[d]=(byDisc[d]||0)+1); }); // Clickable metric cards filter the board (flag-based); a card with no flag is static. const card=(label,val,cls,flag)=>{ const active = flag && dashFilter.flag===flag ? ' dm-active' : ''; const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : ''; return `
${val}
${esc(label)}
`; }; let h=`
${card('Total WPs', all.length, '', 'all')} ${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''} ${card('Release-ready', ready, ready?'dm-green':'', 'ready')} ${card('On hold', hold, hold?'dm-red':'', 'onhold')} ${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')} ${card('Est. hrs', Math.round(estH))} ${card('Actual hrs', Math.round(actH))}
`; // status + discipline breakdown chips (status chips also filter the board) const statusChip=(label,count,cls,status)=>`${esc(label)}: ${count}`; const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('') + (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):''); const discChips=Object.keys(byDisc).map(d=>`${esc(d)}: ${byDisc[d]}`).join('')||''; h+=`
By status
${statusChips||'—'}
By discipline
${discChips}
`; // progress by phase (discipline), weighted by status; archived excluded const overallPct = all.length ? Math.round(all.reduce((s,p)=>s+wpProgress(p),0)/all.length*100) : 0; const phaseGroups={}; all.forEach(p=>{ (p.disciplines&&p.disciplines.length?p.disciplines:['(none)']).forEach(d=>{ (phaseGroups[d]=phaseGroups[d]||[]).push(p); }); }); let prog=`
Progress by phase
`; prog+=`
Overall
${overallPct}%
`; Object.keys(phaseGroups).sort().forEach(d=>{ const g=phaseGroups[d]; const pct=g.length?Math.round(g.reduce((s,p)=>s+wpProgress(p),0)/g.length*100):0; const done=g.filter(p=>p.status==='Closed').length; prog+=`
${esc(d)}
${pct}% ${done}/${g.length}
`; }); prog+=`
Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.
`; h+=prog; // gating panel — what's blocking release const gated=all.filter(p=>wpOpenConstraints(p).length>0); h+=`
⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)
`; h+= gated.length ? ``+ gated.map(p=>``).join('')+ `
WP #SubjectBlocked by
${esc(p.number||'—')}${esc(p.subject||'')} ${wpOpenConstraints(p).map(c=>esc(c.name)+(c.comment?` (${esc(c.comment)})`:'')).join('
')}
` : `
No open constraints — every package is clear of gates.
`; h+=`
`; // filters const statusOpts=[''].concat(STATUS_ORDER.concat(['Issue']).map(s=>``)).join(''); const discList=Object.keys(byDisc); const discOpts=[''].concat(discList.map(d=>``)).join(''); h+=`
`; // main board (includes masters, marked; archived only when toggled on) const q=(dashFilter.q||'').toLowerCase(); const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list(); const rows=boardSource.filter(p=>{ if(dashFilter.status && p.status!==dashFilter.status) return false; if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false; if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false; if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false; if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false; if(dashFilter.flag==='onhold' && p.status!=='Issue') return false; if(dashFilter.flag==='overdue' && !isOverdue(p)) return false; return true; }); const totalRows=rows.length; const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE)); if(dashPage>=pages) dashPage=pages-1; if(dashPage<0) dashPage=0; const pageRows=rows.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE); h+=`
Work Packages (${totalRows})
`; if(!totalRows) h+=``; pageRows.forEach(p=>{ const ix=savedPackages.findIndex(x=>x.id===p.id); const open=wpOpenConstraints(p).length; const waiting=wpWaitingOn(p); const gates= p.split?'master' :(open?`${open} open` :(waiting.length?`waits on ${waiting.length}` :`clear`)); const due= p.due?`${esc(p.due)}`:ns(); const pid=esc(p.id); let actions; if(p.archived){ actions=``; } else { const canIssue = !p.split && open===0 && !waiting.length && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue'; const issueBtn = canIssue?` `:''; actions=`${issueBtn}`; } h+=``; }); h+=`
WP #SubjectTypeDisciplineStatusGatesDueHrs
No work packages match.
${esc(p.number||'—')}${p.instanceOf?` ${esc(p.instanceLabel||'')}`:''}${p.archived?' archived':''} ${esc(p.subject||'')}${esc(p.type||'')} ${esc((p.disciplines||[]).join(', '))||ns()} ${statusPill(p.status)}${gates}${due}${cell(p.hours)} ${actions}
`; if(pages>1){ h+=`
Page ${dashPage+1} of ${pages} · ${totalRows} packages
`; } h+=`
`; document.getElementById('dash-body').innerHTML=h; } function dashIssue(id){ const p=WPData.get(id); if(!p) return; if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; } const waiting=wpWaitingOn(p); if(waiting.length){ // Releasing early needs a reason, same as on the form — the dashboard must not // be the quiet way around the gate. alert('Cannot issue from here — waiting on:\n\n• '+ waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+ '\n\nOpen the package to release it early with a logged reason.'); return; } if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return; WPData.issue(id); renderDashboard(); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue'); } function dashView(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); } function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); } // ── VIEW SOP REFERENCE (comment 2) ─────────────────────────────────────────── function openSopModal(){ if(!SOP){ alert('No SOP loaded.'); return; } const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{}; const row=(k,v)=>`${esc(k)}${v||'—'}`; let h=``; h+=row('Project', esc(p.name)); h+=row('Number', esc(p.number)); h+=row('Client', esc(p.client)); h+=row('Division', esc(p.division)); h+=row('PM / CM / QM', [p.pm,p.cm,p.qm].filter(Boolean).map(esc).join(' / ')); h+=row('WP number format', `${esc(g.woFormat||'—')}`); h+=row('Issuance', esc((g.issuance||[]).join(', '))); h+=row('Target WP size', esc(g.woSize)); h+=row('WP types', enabledTypes().map(t=>esc(t.name)).join(', ')); h+=row('QC', esc([q.qcReq,q.qcScope].filter(Boolean).join(' — '))); h+=row('Photo', esc(q.photo)); h+=row('Hold points', esc(q.holdPoints)); h+=row('Tracking tool', esc((SOP.field&&SOP.field.trackPlatform))); h+=row('Sequence', ((SOP.sequence||[]).map(s=>esc(s.label)+(s.kind==='gate'?' (gate)':'')).join(' → '))); h+=row('Roles', (SOP.roles||[]).map(r=>esc(r.role)+': '+esc(r.name)).join('; ')); h+=row('Sources', (SOP.sources||[]).map(s=>esc(s.label)+' ('+esc(s.system)+')').join('; ')); h+=`
`; document.getElementById('sop-modal-body').innerHTML=h; document.getElementById('sop-modal').classList.add('open'); track('view_sop'); } function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); } const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } } function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} } function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} } function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);}); let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`; if(confirm(t)) downloadAnalytics(); } function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); } // ── COMMENTS ───────────────────────────────────────────────────────────────── const COMMENTS_KEY='wp_iwp_comments_v1'; function cmtLoad(){ let d; try{ d=JSON.parse(localStorage.getItem(COMMENTS_KEY)); }catch(e){} if(!d||typeof d!=='object')d={}; if(!d.clientId)d.clientId='c_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); if(typeof d.author!=='string')d.author=''; if(!Array.isArray(d.comments))d.comments=[]; return d; } function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d)); }catch(e){} } function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); } function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } } function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; } function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); } function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); } function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='
No comments yet.
'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?wpFormatDateTime(c.ts):''; return `
${esc(c.author||'Anonymous')}${esc(c.view||'')}${esc(when)}
${esc(c.text)}
${mine?`
`:''}
`; }).join(''); } function refreshCommentBadges(){ const d=cmtLoad(); const t=document.getElementById('cbadge-total'); if(t){ if(d.comments.length){ t.style.display=''; t.textContent=d.comments.length; } else t.style.display='none'; } } function exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); } function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); } function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; if(!mine){ alert('No comments to clear.'); return; } if(!confirm(`Delete your ${mine} comment(s)?`)) return; d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); } function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); } // ── STATUS PILLS ───────────────────────────────────────────────────────────── document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{ if(e.target.tagName==='INPUT') return; const v=p.dataset.val; prevStatus = getRadio('status') || 'Draft'; // capture before switching (for revert) document.querySelectorAll('#status-group .radio-pill').forEach(x=>x.classList.remove('selected')); p.classList.add('selected'); p.querySelector('input').checked=true; onStatusChange(v); })); // ── BOOT ───────────────────────────────────────────────────────────────────── // Resolve the active project BEFORE loading the store so namespaced keys resolve. (function seedProject(){ const params = new URLSearchParams(location.search); activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){ const cached = ProjectData.getActive && ProjectData.getActive(); ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId }); } })(); function bootSOP(){ // When embedded in the Suite, hide the SOP import/sample controls (SOP is injected) // and prefer the SOP the Suite just completed (persisted to localStorage, which // we've already hydrated from the server for this project). const params = new URLSearchParams(location.search); if(params.get('embedded')) document.body.classList.add('embedded'); try { const raw = localStorage.getItem(wpKey('wp_suite_sop')); if(raw){ const d = JSON.parse(raw); if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; } } } catch(e){} // No SOP found. Only show the Micron SAMPLE for a standalone preview (no project // context). For a real project, never substitute sample data — show the empty // state so it's clear the project's SOP must be completed first. if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); } else { loadSampleSOP(); } } // Populate the Owner picker with this project's members (+ admins). The list is // only used to pick an assignee; the server re-validates on save. // // The SOP's project team is listed first (they're the people this project has // actually named), with everyone else on the project after them — so the common // pick is at the top without hiding anyone who could legitimately own a package. let projectMembers = []; function sopTeamIds(){ const p = (SOP && SOP.project) || {}; const ids = [p.pmId, p.apmId, p.cmId, p.qmId]; (p.teamMembers || []).forEach(m => ids.push(m && m.userId)); return ids.filter(Boolean); } async function loadMembers(){ const sel=document.getElementById('wp_assignee'); if(!sel || !activeProjectId) return; try { const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'}); if(!r.ok) return; projectMembers=await r.json(); const cur=sel.value; const team=sopTeamIds(); const onTeam=projectMembers.filter(u=>team.includes(u.id)); const others=projectMembers.filter(u=>!team.includes(u.id)); const opt=u=>``; sel.innerHTML=''+ (onTeam.length?`${onTeam.map(opt).join('')}`:'')+ (others.length?`${others.map(opt).join('')}`:''); if(cur) sel.value=cur; else defaultOwnerToMe(); // The people pickers list the same accounts, so (re)render them now that the // member list has arrived. if(editingId){ const p=savedPackages.find(x=>x.id===editingId); if(p) loadPeopleFromPkg(p); } else resetPeopleForNewPackage(); } catch(e){} } // A new package defaults to the person creating it — they're accountable until // they hand it over. Only applies to an unsaved, unassigned package, and only if // they're actually assignable on this project. function defaultOwnerToMe(){ const sel=document.getElementById('wp_assignee'); if(!sel || sel.value || editingId) return; const me=(window.WP_USER||{}).id; if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me; } function bootData(){ loadStore(); // reads the localStorage cache (hydrated from the server below) bootSOP(); setRadio('status','Draft'); loadMembers(); initAssetPicker(); initWpNavDrawer(); renderSavedList(); positionSectionNav(); cmtInit(); // Deep-link: a specific package (?wp=, from the global search), or the dashboard. const p=new URLSearchParams(location.search); const wantWp=p.get('wp'); if(wantWp){ const ix=savedPackages.findIndex(x=>x.id===wantWp); if(ix>=0) wpNavOpen(ix); else toast('That work package is not on this project (it may have been deleted).'); } if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } track('app_open'); // The embedding shell needs to know when the packages are actually in hand: the // frame's `load` event fires long before pullProject() resolves, so anything that // acts on a specific package has to wait for this instead. window.wpCreatorReady = true; try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){} } window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); }); // Pull this project's shared SOP + Work Packages from the server first, then boot // off the refreshed cache. Falls back to whatever is cached locally if offline. if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData); } else { bootData(); }