Add discipline strategy, WP sizing, Split-by-Discipline & dashboard

SOP config (Governance step) now sets project discipline policy:
- disciplines list, discipline strategy (single/multi/planner-choice),
  letter instance-suffix style, and a max-hours split threshold.

WP Creator becomes discipline-aware:
- discipline picker; selecting 2+ turns the flat scope into per-discipline
  scope sections, each with its own status (rolls up to least-advanced).
- "Split by Discipline" turns a multi-discipline WP into WP01A/B/C instances
  linked to a kept master (instanceOf/parentNumber/split/children).
- est-hours warning against the SOP split threshold.

New WP Dashboard (header button, home card, ?view=dashboard deep-link):
metrics, status/discipline breakdowns, a gating panel, and a filterable
board with view/edit/issue. Reads localStorage via an API-ready WPData
adapter; masters excluded from counts.

Backend: POST /api/wps/{id}/issue (enforces the constraint gate),
POST /api/wps/{id}/status, GET /api/wps/metrics; work_packages gains
parent_id + issued_at.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 13:29:41 -07:00
parent 108a690f66
commit d1f0901897
9 changed files with 671 additions and 44 deletions

View File

@@ -8,15 +8,16 @@ 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:'35 days', woFormat:'WP##-[Sector]-[TYPE]' },
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'35 days', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'120' },
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:'Procore',notes:'',link:'https://us02.procore.com/562949954073428/project/documents/folders/drawings'},
{label:'Specifications',system:'Procore',notes:'',link:'https://us02.procore.com/562949954073428/project/documents/folders/specs'},
{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'},
],
@@ -38,13 +39,16 @@ const COST_CODES = ['1000|Project Management','2000|Design and Development','210
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":"Bill Clarida (Prime Controls), 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","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"};
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":"Bill Clarida (Prime Controls), 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 pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[];
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=[];
@@ -76,6 +80,7 @@ function importSOP(ev){
}
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){
@@ -88,6 +93,7 @@ function applySOP(){
buildConstraints(); buildSignoffs();
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();
}
@@ -262,6 +268,168 @@ function buildWorkSteps(){
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(); track('discipline_toggle',{count:pkgDisciplines.length});
}
// Show the flat work-step list for 01 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 max=parseFloat((SOP&&SOP.governance&&SOP.governance.sizeHoursMax)||'');
const hrs=parseFloat(gv('wp_hours'));
if(max && hrs && hrs>max){
el.innerHTML=`<span style="color:var(--accent-amber)">⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
} else if(max){ el.textContent=`Split threshold: ${max} hrs (from SOP).`; }
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',
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');
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up. Edit each instance from the Saved Work Packages list.');
}
// ── 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,'&quot;')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
<td><input type="text" value="${(a.desc||'').replace(/"/g,'&quot;')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
<td><input type="url" value="${(a.link||'').replace(/"/g,'&quot;')}" 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='';
@@ -275,6 +443,44 @@ function buildAttach(){
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)));
@@ -407,14 +613,21 @@ function onSignoffDateOverride(i){
// ── 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)),
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'),
@@ -447,6 +660,7 @@ function renderPackage(pkg){
<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>
@@ -457,42 +671,54 @@ function renderPackage(pkg){
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
</tbody></table>`;
const stepsArr = pkg.workSteps && pkg.workSteps.length ? pkg.workSteps : (pkg.work?String(pkg.work).split('\n').filter(Boolean):[]);
const stepsHtml = stepsArr.length ? '<ol style="margin:0;padding-left:18px">'+stepsArr.map(s=>`<li>${esc(s)}</li>`).join('')+'</ol>' : ns();
h+=`<h2>2.0 Scope & Work</h2><table><tbody>
<tr><th style="width:200px">Description of Work</th><td>${stepsHtml}</td></tr>
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){ h+=`<h2>3.0 Material List</h2><table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th></tr></thead><tbody>`;
if(pkg.materials&&pkg.materials.length){ 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></tr></thead><tbody>`;
pkg.materials.forEach(m=>h+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td></tr>`); h+=`</tbody></table>`; }
if(pkg.attachments&&pkg.attachments.length){ h+=`<h2>4.0 Drawings & Attachments</h2><table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note</th></tr></thead><tbody>`;
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>5.0 Kitting & MIMO</h2><table><tbody>
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>6.0 Constraints — Release Readiness</h2><table><thead><tr><th>Constraint</th><th style="width:90px">Status</th><th>Comment</th></tr></thead><tbody>`;
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>7.0 Quality & Hold Points</h2><table><tbody>
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>7.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>`;
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>8.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>`;
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>9.0 Closeout</h2><table><tbody>
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>
@@ -508,8 +734,9 @@ function printPackage(){
}
// ── VIEWS ────────────────────────────────────────────────────────────────────
function showOutput(){ 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(){ 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'; currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
function showOutput(){ hideDashboard(); 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(); currentView='Work Package Form'; window.scrollTo({top:0,behavior:'smooth'}); }
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
const STORE_KEY='wp_iwp_v1';
@@ -522,7 +749,9 @@ function renderSavedList(){
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>`);
return `<tr><td class="row-label">${esc(p.number||'—')}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
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>${esc(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('');
@@ -547,6 +776,11 @@ function loadPackageIntoForm(p){
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'));
@@ -554,6 +788,7 @@ function loadPackageIntoForm(p){
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();
@@ -573,6 +808,8 @@ function newPackage(){
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
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();
@@ -591,6 +828,115 @@ function exportPackages(){
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(); return true; }, // → POST /api/wps/{id}/issue
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
p.status=status; p.updatedAt=new Date().toISOString(); saveStore(); return true; }, // → POST /api/wps/{id}/status
};
let dashFilter={status:'',discipline:'',q:''};
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(){
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);
});
const card=(label,val,cls)=>`<div class="dash-metric ${cls||''}"><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)}
${card('Release-ready', ready, ready?'dm-green':'')}
${card('On hold', hold, hold?'dm-red':'')}
${card('Overdue', overdue, overdue?'dm-red':'')}
${card('Est. hrs', Math.round(estH))}
${card('Actual hrs', Math.round(actH))}
</div>`;
// status + discipline breakdown chips
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>`<span class="dash-chip">${esc(s)}: <b>${byStatus[s]}</b></span>`).join('')
+ (byStatus['Issue']?`<span class="dash-chip chip-red">On Hold: <b>${byStatus['Issue']}</b></span>`:'');
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,'&quot;')}" 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;
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>${esc(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; }
@@ -666,4 +1012,7 @@ loadStore();
setRadio('status','Draft');
renderSavedList();
cmtInit();
// Deep-link: open straight to the dashboard when requested (?view=dashboard or #dashboard).
(function(){ const p=new URLSearchParams(location.search); if(p.get('view')==='dashboard' || location.hash==='#dashboard'){ showDashboard(); } })();
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
track('app_open');