Reintegrates C-West8's "storing data in DB instead of client only"
(commit e5102446 on shared-data) on top of the BIM / per-package work.
localStorage becomes a per-browser cache; the server is authoritative.
- project-data.js: pullProject() hydrates the apps' existing localStorage
keys from the API on load; pushSOP()/pushWP()/removeWP() write through
on save/delete. WPs store the whole flat object in `data`, so BIM
fields, kind, and projectLinks round-trip intact.
- index.html: home pulls the project before showing SOP status; feedback
loads from /api/comments (server-authoritative, local fallback).
- work-package-suite-app.js: pull-then-restore on boot; completeSOP
pushes the SOP to the server.
- wp-creation-app.js: save/duplicate/issue/setStatus push; delete/clear
remove; boot pulls from the server first, then boots off the cache.
- server/app.py: /api/sops and /api/wps take full=true to return the
data JSON for one-request hydration (list stays lean by default).
Co-Authored-By: C-West8 <125926137+C-West8@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1246 lines
98 KiB
JavaScript
1246 lines
98 KiB
JavaScript
/* 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=<id>; 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() || ''; }
|
||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
|
||
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=>`<a href="${u}" target="_blank" rel="noopener">${u}</a>`); }
|
||
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();
|
||
// 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){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||
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.
|
||
function bimSOP(){ return !!(SOP && SOP.bimEnabled); }
|
||
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});
|
||
}
|
||
function applyKind(){
|
||
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); // LOD / model area / clash / scan
|
||
show('asset-card', !ewp); // controls.dev 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
|
||
if(bimProj) setRadio('pkgkind', pkgKind);
|
||
buildTypePicker(); // filtered by kind
|
||
buildConstraints(); // filtered by kind
|
||
}
|
||
function buildCostCodes(){
|
||
const sel=document.getElementById('wp_cost'); const cur=sel.value;
|
||
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code} — ${esc(desc)}</option>`;}).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
|
||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
|
||
return;
|
||
}
|
||
const p=SOP.project||{}, g=SOP.governance||{};
|
||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
|
||
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
|
||
}
|
||
|
||
// ── 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=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||
}
|
||
function renderSpecFolderLink(){
|
||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
||
el.innerHTML = spec ? `<a href="${esc(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>` : '';
|
||
}
|
||
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=`<option value="">Select…</option>`+types.map(t=>`<option>${esc(t.name)}</option>`).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=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
|
||
function onTypeChange(){
|
||
updateNumber(); track('type_selected');
|
||
}
|
||
|
||
// ── 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 `<div class="field"><label>${esc(t)}</label>
|
||
<input type="text" id="ndim_${esc(t)}" value="${(numberDims[t]||'').replace(/"/g,'"')}" placeholder="${esc(t)} code" oninput="numberDims['${esc(t)}']=this.value;updateNumber()"></div>`;
|
||
}).join('') : '<div class="field-hint">SOP number format has no scope tokens.</div>';
|
||
}
|
||
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 `<option value="">Unit…</option>`+list.map(u=>`<option ${c===u?'selected':''}>${esc(u)}</option>`).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 `<option value="">—</option>`+list.map(d=>`<option ${c===d?'selected':''}>${esc(d)}</option>`).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 ? `<td><select onchange="pkgMaterials[${i}].discipline=this.value">${matDisciplineOptions(m.discipline)}</select></td>` : '';
|
||
tr.innerHTML=`<td><input type="text" value="${(m.qty||'').replace(/"/g,'"')}" placeholder="20" oninput="pkgMaterials[${i}].qty=this.value"></td>
|
||
<td><select onchange="pkgMaterials[${i}].unit=this.value">${unitOptions(m.unit)}</select></td>
|
||
<td><input type="text" value="${(m.desc||'').replace(/"/g,'"')}" placeholder="3/8 lock washers gold" oninput="pkgMaterials[${i}].desc=this.value"></td>
|
||
${discCell}
|
||
<td class="center"><button class="row-del" onclick="removeMaterial(${i})">✕</button></td>`;
|
||
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<line.length;i++){ const ch=line[i];
|
||
if(q){ if(ch==='"'){ if(line[i+1]==='"'){cur+='"';i++;} else q=false; } else cur+=ch; }
|
||
else { if(ch===','){cells.push(cur);cur='';} else if(ch==='"'){q=true;} else cur+=ch; } }
|
||
cells.push(cur); rows.push(cells);
|
||
});
|
||
return rows;
|
||
}
|
||
function importMaterials(ev){
|
||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
||
if(/\.xlsx?$/i.test(f.name)){ alert('Please save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.'); ev.target.value=''; return; }
|
||
const r=new FileReader(); r.onload=()=>{
|
||
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<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
|
||
if(!desc && !(c[cq]||'').trim()) continue;
|
||
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
|
||
if(!imported.length){ alert('No material rows found.'); return; }
|
||
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
|
||
}catch(e){ alert('Could not parse that CSV.'); }
|
||
ev.target.value='';
|
||
};
|
||
r.readAsText(f);
|
||
}
|
||
|
||
// ── DESCRIPTION OF WORK — STEP LIST (comment 16) ─────────────────────────────
|
||
function buildWorkSteps(){
|
||
const tb=document.getElementById('worksteps-body'); if(!tb) return; tb.innerHTML='';
|
||
pkgWorkSteps.forEach((s,i)=>{ const row=document.createElement('div'); row.className='workstep-row';
|
||
row.innerHTML=`<span class="ws-num">${i+1}</span>
|
||
<textarea rows="1" placeholder="Describe step ${i+1}…" oninput="pkgWorkSteps[${i}]=this.value">${esc(s)}</textarea>
|
||
<button class="row-del" onclick="removeWorkStep(${i})" title="Remove step">✕</button>`;
|
||
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 `<label class="disc-pill ${on?'selected':''}"><input type="${mode==='single'?'radio':'checkbox'}" name="wp_disc" ${on?'checked':''} onchange="toggleDiscipline('${esc(d)}',this.checked)"><span class="dot"></span>${esc(d)}</label>`;
|
||
}).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)=>`<div class="workstep-row"><span class="ws-num">${i+1}</span>
|
||
<textarea rows="1" placeholder="Describe ${esc(d)} step ${i+1}…" oninput="setScopeStep('${esc(d)}',${i},this.value)">${esc(s)}</textarea>
|
||
<button class="row-del" onclick="removeScopeStep('${esc(d)}',${i})" title="Remove step">✕</button></div>`).join('');
|
||
const opts=STATUS_ORDER.map(o=>`<option ${o===st?'selected':''}>${esc(o)}</option>`).join('');
|
||
return `<div class="disc-scope">
|
||
<div class="disc-scope-head"><span class="disc-tag">${esc(d)}</span>
|
||
<span class="disc-status">Status: <select onchange="setDiscStatus('${esc(d)}',this.value)">${opts}</select></span></div>
|
||
${rows}
|
||
<button class="add-btn" onclick="addScopeStep('${esc(d)}')">+ Add ${esc(d)} Step</button>
|
||
</div>`;
|
||
}).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&&ix<minIdx) minIdx=ix; });
|
||
setRadio('status', STATUS_ORDER[minIdx]); prevStatus=STATUS_ORDER[minIdx]; updateReleaseBanner();
|
||
}
|
||
|
||
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
||
function onHoursChange(){
|
||
const el=document.getElementById('size-check'); if(!el) return;
|
||
const g=(SOP&&SOP.governance)||{};
|
||
const band=g.woSize?('Target: '+g.woSize+'. '):'';
|
||
const max=parseFloat(g.sizeHoursMax||'');
|
||
const hrs=parseFloat(gv('wp_hours'));
|
||
if(max && hrs && hrs>max){
|
||
el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||
} else if(band || max){
|
||
el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
|
||
} 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 (controls.dev) ────────────────────────────────────────────────────
|
||
// Interim: assets are linked manually back to controls.dev. A future direct
|
||
// integration will let the user pick them from a list instead of pasting links.
|
||
function buildAssets(){
|
||
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
||
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
||
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||
<td><input type="url" value="${(a.link||'').replace(/"/g,'"')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
|
||
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
|
||
tb.appendChild(tr); });
|
||
}
|
||
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
|
||
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||
|
||
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
||
function buildAttach(){
|
||
const tb=document.getElementById('attach-body'); tb.innerHTML='';
|
||
pkgAttach.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||
tr.innerHTML=`<td><input type="text" value="${(a.doc||'').replace(/"/g,'"')}" placeholder="drawing / spec name" oninput="pkgAttach[${i}].doc=this.value"></td>
|
||
<td><input type="text" value="${(a.rev||'').replace(/"/g,'"')}" placeholder="0" oninput="pkgAttach[${i}].rev=this.value"></td>
|
||
<td><input type="text" value="${(a.link||'').replace(/"/g,'"')}" placeholder="link or note" oninput="pkgAttach[${i}].link=this.value"></td>
|
||
<td class="center"><button class="row-del" onclick="removeAttach(${i})">✕</button></td>`;
|
||
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/<guid>" 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
|
||
? `<div class="field-hint">1) Open a folder, multi-select files in SharePoint, then use <b>Copy link</b>:</div>`+
|
||
`<div class="ref-links">`+srcs.map(s=>`<a href="${esc(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`
|
||
: `<div class="field-hint">No SOP folders defined — load or import an SOP first.</div>`;
|
||
}
|
||
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(){
|
||
const names=constraintNames().map(n=> typeof n==='string' ? n : ((n&&n.name)||String(n)));
|
||
// preserve existing statuses if rebuilding
|
||
const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c);
|
||
pkgConstraints=names.map(n=> prev[n] || {name:n, status:'open', comment:''});
|
||
const tb=document.getElementById('constraint-body'); tb.innerHTML='';
|
||
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr');
|
||
tr.innerHTML=`<td class="row-label">${esc(c.name)}</td>
|
||
<td><span class="cstatus">
|
||
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button>
|
||
<button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button>
|
||
<button class="${c.status==='na'?'on-na':''}" onclick="setConstraint(${i},'na')">N/A</button>
|
||
</span></td>
|
||
<td><input type="text" value="${(c.comment||'').replace(/"/g,'"')}" placeholder="note" oninput="pkgConstraints[${i}].comment=this.value"></td>`;
|
||
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');
|
||
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'});
|
||
}
|
||
}
|
||
}
|
||
function readiness(){ const open=pkgConstraints.filter(c=>c.status==='open').length; return {open, total:pkgConstraints.length, cleared:pkgConstraints.filter(c=>c.status==='cleared').length, ready:open===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 { cls='rb-notready'; txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open.`; }
|
||
b.innerHTML=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||
updateStickyStatus();
|
||
}
|
||
function onStatusChange(target){
|
||
const idx=STATUS_ORDER.indexOf(target);
|
||
if(idx>=ISSUED_IDX && readiness().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;
|
||
}
|
||
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=>`<option ${c.name===preselect?'selected':''}>${esc(c.name)}</option>`).join('') + '<option>Other</option>';
|
||
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; document.getElementById('hold-photo-preview').innerHTML=`<img src="${holdPhotoData}" alt="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=`<td class="row-label">${esc(s.role)}</td>
|
||
<td><input type="text" value="${(s.name||'').replace(/"/g,'"')}" placeholder="name" oninput="pkgSignoffs[${i}].name=this.value">${s.fromSOP?'<span class="sop-tag">from SOP</span>':''}</td>
|
||
<td><span class="so-date">${s.date?esc(s.date):'—'}</span> <button class="lock-edit so-ovr" onclick="onSignoffDateOverride(${i})" title="Manual date override (reason required)">✎ override</button>${s.dateReason?`<span class="override-note" title="${esc(s.dateReason)}">overridden</span>`:''}</td>
|
||
<td class="center"><input type="checkbox" class="signoff-check" ${s.signed?'checked':''} onchange="onSignoffSigned(${i},this.checked)"></td>`;
|
||
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'), assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||
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:gv('wp_seq'),
|
||
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:gv('wp_lod'), 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; }
|
||
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=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1>
|
||
<div class="doc-subtitle">${esc(pkg.project)} · ${kindLbl} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`;
|
||
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+=`<h2>1.0 General Information</h2><table><tbody>
|
||
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
|
||
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
|
||
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
|
||
${pkg.disciplines&&pkg.disciplines.length?`<tr><th>Discipline(s)</th><td>${esc(pkg.disciplines.join(', '))}${pkg.split?' <span style="color:var(--accent);font-size:10px">[MASTER — split into instances]</span>':''}${pkg.instanceOf?` <span style="color:var(--accent);font-size:10px">[instance of ${esc(pkg.parentNumber||'')}]</span>`:''}</td></tr>`:''}
|
||
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
|
||
<tr><th>Location</th><td>${cell(pkg.location)}</td></tr>
|
||
<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>
|
||
<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>
|
||
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
|
||
<tr><th>Distribution</th><td>${cell(pkg.distribution)}</td></tr>
|
||
<tr><th>Due Date</th><td>${cell(pkg.due)}</td></tr>
|
||
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
|
||
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
|
||
${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''}
|
||
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
|
||
${(pkg.lod||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.lod?'LOD: '+esc(pkg.lod):'', pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):''].filter(Boolean).join('<br>')}</td></tr>`:''}
|
||
</tbody></table>`;
|
||
if(pkg.assets&&pkg.assets.length){ h+=`<h2>2.0 Assets (controls.dev)</h2><table><thead><tr><th style="width:180px">Asset Tag / ID</th><th>Description</th><th>controls.dev Link</th></tr></thead><tbody>`;
|
||
pkg.assets.forEach(a=>h+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
||
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]?` <span style="color:var(--accent);font-size:10px">[${esc(pkg.discStatus[d])}]</span>`:'';
|
||
return `<div style="margin-bottom:8px"><strong>${esc(d)}</strong>${dst}`+
|
||
(arr.length?'<ol style="margin:2px 0 0;padding-left:18px">'+arr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>':' — '+ns())+`</div>`;
|
||
}).join('');
|
||
} else {
|
||
const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]);
|
||
scopeHtml = stepsArr.length ? '<ol style="margin:0;padding-left:18px">'+stepsArr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>' : ns();
|
||
}
|
||
h+=`<h2>3.0 Scope & Work</h2><table><tbody>
|
||
<tr><th style="width:200px">Description of Work</th><td>${scopeHtml}</td></tr>
|
||
<tr><th>Labor Est. Hrs.</th><td>${cell(pkg.hours)}</td></tr>
|
||
<tr><th>Package Predecessor</th><td>${pkg.seq?('After: '+esc(pkg.seq)):'None (no predecessor)'}</td></tr>
|
||
</tbody></table>`;
|
||
if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline);
|
||
h+=`<h2>4.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th>${showDisc?'<th style="width:120px">Discipline</th>':''}</tr></thead><tbody>`;
|
||
pkg.materials.forEach(m=>h+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td>${showDisc?`<td>${cell(m.discipline)}</td>`:''}</tr>`); h+=`</tbody></table>`; }
|
||
if(pkg.attachments&&pkg.attachments.length){ h+=`<h2>5.0 Drawings & Attachments</h2><table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
|
||
pkg.attachments.forEach(a=>h+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`); h+=`</tbody></table>`; }
|
||
h+=`<h2>6.0 Kitting & MIMO</h2><table><tbody>
|
||
<tr><th style="width:200px">Kitting Status</th><td>${cell(pkg.kitStatus)}</td></tr>
|
||
<tr><th>Warehouse Owner</th><td>${cell(pkg.kitOwner)}</td></tr>
|
||
<tr><th>Kitting Need Date</th><td>${cell(pkg.kitDate)}</td></tr>
|
||
<tr><th>MIMO Sch. Time / Location</th><td>${cell(pkg.mimoTime)} ${pkg.mimoLoc?'· '+esc(pkg.mimoLoc):''}</td></tr>
|
||
</tbody></table>`;
|
||
h+=`<h2>7.0 Constraints — Release Readiness</h2><table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
|
||
(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+=`<tr><td>${esc(c.name)}</td><td style="color:${col};font-weight:700">${lbl}</td><td>${cell(c.comment)}</td></tr>`; });
|
||
h+=`</tbody></table>`;
|
||
h+=`<h2>8.0 Quality & Hold Points</h2><table><tbody>
|
||
<tr><th style="width:200px">QC</th><td>${cell(pkg.qc)}${pkg.overrides&&pkg.overrides.wp_qc?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_qc)})</span>`:(pkg.qcFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
|
||
<tr><th>Photo Documentation</th><td>${cell(pkg.photo)}${pkg.overrides&&pkg.overrides.wp_photo?` <span style="color:var(--accent-amber)">(overridden: ${esc(pkg.overrides.wp_photo)})</span>`:(pkg.photoFromSOP?' <span style="color:var(--accent);font-size:10px">[from SOP]</span>':'')}</td></tr>
|
||
<tr><th>Witness / Hold Points</th><td>${cell(pkg.hold)}</td></tr>
|
||
</tbody></table>`;
|
||
if(pkg.holds&&pkg.holds.length){
|
||
h+=`<h2>8.5 Hold Log</h2><table><thead><tr><th style="width:150px">Logged</th><th style="width:200px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||
pkg.holds.forEach(hd=>{ const when=hd.ts?new Date(hd.ts).toLocaleString():''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
||
h+=`<tr><td>${esc(when)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
||
h+=`</tbody></table>`;
|
||
}
|
||
h+=`<h2>9.0 Approvals & Sign-offs</h2><table><thead><tr><th>Role</th><th>Name</th><th style="width:120px">Date</th><th style="width:80px">Signed</th></tr></thead><tbody>`;
|
||
(pkg.signoffs||[]).forEach(s=>h+=`<tr><td>${esc(s.role)}</td><td>${cell(s.name)}</td><td>${cell(s.date)}</td><td>${s.signed?'✓':'—'}</td></tr>`);
|
||
h+=`</tbody></table>`;
|
||
if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){ h+=`<h2>10.0 Closeout</h2><table><tbody>
|
||
<tr><th style="width:200px">Actual Hrs.</th><td>${cell(pkg.actualHrs)}</td></tr>
|
||
<tr><th>Installed Quantity</th><td>${cell(pkg.installedQty)}</td></tr>
|
||
<tr><th>Redlines / As-Built</th><td>${cell(pkg.redlines)}</td></tr>
|
||
<tr><th>Lessons Learned</th><td>${cell(pkg.lessons)}</td></tr>
|
||
</tbody></table>`; }
|
||
h+=`<p style="margin-top:18px;color:var(--text-dim);font-size:11px">Pushed to: ${cell(pkg.track)}</p>`;
|
||
document.getElementById('pkg-doc').innerHTML=h; showOutput();
|
||
}
|
||
function printPackage(){
|
||
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
|
||
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
|
||
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(); 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'; 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(); }
|
||
}
|
||
// 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(`<span class="sec-chip" onclick="document.getElementById('${card.id}').scrollIntoView({behavior:'smooth',block:'start'})">${esc(label)}</span>`);
|
||
});
|
||
nav.innerHTML=chips.join('');
|
||
}
|
||
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 { el.className='sticky-status ss-notready'; el.textContent=`⚠ ${r.open} of ${r.total} constraint${r.open===1?'':'s'} open`; }
|
||
}
|
||
|
||
// ── 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');
|
||
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' ? '<span class="badge badge-N">On Hold</span>' : (open===0?'<span class="badge badge-Y">Ready</span>':`<span class="badge badge-O">${open} open</span>`);
|
||
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||
<td>${statusPill(p.status)}</td><td>${ready}</td>
|
||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="row-del" onclick="deletePackage(${i})">✕</button></td></tr>`;
|
||
}).join('');
|
||
}
|
||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||
function deletePackage(i){ const p=savedPackages[i]; if(!p) 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(!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); }
|
||
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);
|
||
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_lod',p.lod); set('wp_model_area',p.modelArea); set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
|
||
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||'';
|
||
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&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; 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_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_lod','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||
pkgKind='iwp'; applyKind();
|
||
setRadio('status','Draft');
|
||
numberDims={}; buildNumberDims();
|
||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||
pkgAssets=[{tag:'',desc:'',link:''}]; 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(); showForm(); 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; }
|
||
renderDashboard();
|
||
}
|
||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; renderDashboard(); }
|
||
// 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 `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||
}
|
||
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
||
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; const byDisc={};
|
||
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(wpOpenConstraints(p).length===0 && 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 `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||
};
|
||
let h=`<div class="dash-metrics">
|
||
${card('Total WPs', all.length, '', 'all')}
|
||
${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))}
|
||
</div>`;
|
||
|
||
// status + discipline breakdown chips (status chips also filter the board)
|
||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||
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=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||
|
||
// gating panel — what's blocking release
|
||
const gated=all.filter(p=>wpOpenConstraints(p).length>0);
|
||
h+=`<div class="dash-panel"><div class="dash-panel-title">⛔ Gating constraints (${gated.length} package${gated.length===1?'':'s'} blocked)</div>`;
|
||
h+= gated.length ? `<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Blocked by</th></tr></thead><tbody>`+
|
||
gated.map(p=>`<tr><td class="row-label">${esc(p.number||'—')}</td><td>${esc(p.subject||'')}</td>
|
||
<td>${wpOpenConstraints(p).map(c=>esc(c.name)+(c.comment?` <span style="color:var(--text-dim)">(${esc(c.comment)})</span>`:'')).join('<br>')}</td></tr>`).join('')+
|
||
`</tbody></table>` : `<div class="field-hint">No open constraints — every package is clear of gates.</div>`;
|
||
h+=`</div>`;
|
||
|
||
// filters
|
||
const statusOpts=['<option value="">All statuses</option>'].concat(STATUS_ORDER.concat(['Issue']).map(s=>`<option ${dashFilter.status===s?'selected':''}>${esc(s)}</option>`)).join('');
|
||
const discList=Object.keys(byDisc);
|
||
const discOpts=['<option value="">All disciplines</option>'].concat(discList.map(d=>`<option ${dashFilter.discipline===d?'selected':''}>${esc(d)}</option>`)).join('');
|
||
h+=`<div class="dash-filters">
|
||
<input type="search" placeholder="Search WP # / subject…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;renderDashboard()">
|
||
<select onchange="dashFilter.status=this.value;renderDashboard()">${statusOpts}</select>
|
||
<select onchange="dashFilter.discipline=this.value;renderDashboard()">${discOpts}</select>
|
||
</div>`;
|
||
|
||
// main board (includes masters, marked)
|
||
const q=(dashFilter.q||'').toLowerCase();
|
||
const rows=WPData.list().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||'')).toLowerCase().includes(q)) return false;
|
||
if(dashFilter.flag==='ready' && !(!p.split && wpOpenConstraints(p).length===0 && 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;
|
||
});
|
||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work Packages (${rows.length})</div>
|
||
<table class="dash-table"><thead><tr><th>WP #</th><th>Subject</th><th>Type</th><th>Discipline</th><th>Status</th><th>Gates</th><th>Due</th><th>Hrs</th><th></th></tr></thead><tbody>`;
|
||
if(!rows.length) h+=`<tr><td colspan="9" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||
rows.forEach(p=>{
|
||
const ix=savedPackages.findIndex(x=>x.id===p.id);
|
||
const open=wpOpenConstraints(p).length;
|
||
const gates= p.split?'<span class="badge badge-O">master</span>':(open?`<span class="badge badge-O">${open} open</span>`:`<span class="badge badge-Y">clear</span>`);
|
||
const due= p.due?`<span style="${isOverdue(p)?'color:var(--red);font-weight:700':''}">${esc(p.due)}</span>`:ns();
|
||
const canIssue = !p.split && open===0 && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
|
||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${p.id}')">issue</button>`:'';
|
||
h+=`<tr><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}</td>
|
||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||
<td class="center" style="white-space:nowrap">${issueBtn} <button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button></td></tr>`;
|
||
});
|
||
h+=`</tbody></table></div>`;
|
||
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; }
|
||
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)=>`<tr><th style="width:170px;text-align:left;padding:4px 8px;background:var(--surface2)">${esc(k)}</th><td style="padding:4px 8px">${v||'—'}</td></tr>`;
|
||
let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`;
|
||
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', `<code>${esc(g.woFormat||'—')}</code>`); 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+=`</table>`;
|
||
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='<div class="cmt-empty">No comments yet.</div>'; 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?new Date(c.ts).toLocaleString():''; return `<div class="cmt-item${mine?' mine':''}"><div class="cmt-meta"><span class="cmt-author">${esc(c.author||'Anonymous')}</span><span class="cmt-step">${esc(c.view||'')}</span><span class="cmt-time">${esc(when)}</span></div><div class="cmt-text">${esc(c.text)}</div>${mine?`<div><button class="cmt-del" style="float:right" onclick="deleteComment('${c.id}')">Delete</button></div>`:''}</div>`; }).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(); }
|
||
}
|
||
function bootData(){
|
||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||
bootSOP();
|
||
setRadio('status','Draft');
|
||
renderSavedList();
|
||
cmtInit();
|
||
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
|
||
const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); }
|
||
track('app_open');
|
||
}
|
||
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();
|
||
}
|