`;
}
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 ? `↗ Open spec folder (${esc(spec.system||'SOP')})` : '';
}
function buildTypePicker(){ document.getElementById('wp_type').innerHTML=``+enabledTypes().map(t=>``).join(''); }
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=``+steps.map(s=>``).join(''); }
function onTypeChange(){
updateNumber(); 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 `
`;
}).join('') : '
SOP number format has no scope tokens.
';
}
function typeNumberCode(name){ return (name||'').replace(/\b(install|installation)\b/ig,'').replace(/[^a-z0-9]+/gi,'').trim(); }
function updateNumber(){
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
const t=gv('wp_type');
let out=fmt.replace(/WO##|WP##/i,'WP'+pad2(editingSeq()));
numberTokens().forEach(tok=>{
const v=(numberDims[tok]||'').trim();
out=out.split('['+tok+']').join(v||('['+tok+']'));
});
out=out.split('[TYPE]').join(t?typeNumberCode(t):'[TYPE]');
document.getElementById('wp_number').value=out;
}
function editingSeq(){ // keep an existing package's sequence number if editing, else next
if(editingId){ const ix=savedPackages.findIndex(p=>p.id===editingId); if(ix>=0) return ix+1; }
return nextSeq();
}
// ── MATERIAL LIST ────────────────────────────────────────────────────────────
function unitOptions(cur){
const list=ACU_UNITS.slice(); const c=(cur||'').toUpperCase();
if(c && !list.includes(c)) list.unshift(c);
return ``+list.map(u=>``).join('');
}
// Discipline column on the BOM appears only for multi-discipline packages, so a
// split hands each instance just its own materials. Keeps a stale value as an
// option (mirrors unitOptions) if the discipline was later removed.
function matDisciplineOptions(cur){
const list=pkgDisciplines.slice(); const c=cur||'';
if(c && !list.includes(c)) list.unshift(c);
return ``+list.map(d=>``).join('');
}
function buildMaterials(){
const multi=isMultiDiscipline();
const th=document.getElementById('mat-disc-th'); if(th) th.style.display=multi?'':'none';
const tb=document.getElementById('material-body'); tb.innerHTML='';
pkgMaterials.forEach((m,i)=>{ const tr=document.createElement('tr');
const discCell = multi ? `
` : '';
tr.innerHTML=`
${discCell}
`;
tb.appendChild(tr); });
}
function addMaterial(){ pkgMaterials.push({qty:'',unit:'',desc:''}); buildMaterials(); }
function removeMaterial(i){ pkgMaterials.splice(i,1); if(!pkgMaterials.length)pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
function downloadMaterialTemplate(){
const csv='Qty,Unit,Description\n10,FT,Single deep strut gold\n4,EA,2-hole strut base gold\n200,FT,3/4 EMT conduit\n';
const blob=new Blob([csv],{type:'text/csv'}); const a=document.createElement('a');
a.href=URL.createObjectURL(blob); a.download='material-list-template.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('material_template');
}
function parseCSV(text){
const rows=[]; text.replace(/\r\n/g,'\n').split('\n').forEach(line=>{
if(!line.trim()) return;
const cells=[]; let cur='', q=false;
for(let i=0;i{
try{
const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; }
let start=0; const h=rows[0].map(c=>c.trim().toLowerCase());
const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description');
let cq=0,cu=1,cd=2;
if(qi>-1||ui>-1||di>-1){ start=1; if(qi>-1)cq=qi; if(ui>-1)cu=ui; if(di>-1)cd=di; }
const imported=[];
for(let i=start;i{ const row=document.createElement('div'); row.className='workstep-row';
row.innerHTML=`${i+1}
`;
tb.appendChild(row); });
}
function addWorkStep(){ pkgWorkSteps.push(''); buildWorkSteps(); }
function removeWorkStep(i){ pkgWorkSteps.splice(i,1); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); }
// ── DISCIPLINES, PER-DISCIPLINE SCOPE & STATUS ───────────────────────────────
// A project's SOP decides how WPs use disciplines (governance.discMode):
// 'single' one discipline per WP 'multi' one WP carries several disciplines
// 'choice' planner decides per package (build big, split later)
function sopDisciplines(){ const g=(SOP&&SOP.governance)||{}; return (g.disciplines&&g.disciplines.length)?g.disciplines:['Mechanical','Electrical','Tech']; }
function sopDiscMode(){ return (SOP&&SOP.governance&&SOP.governance.discMode)||'choice'; }
function disciplinesConfigured(){ return !!(SOP&&SOP.governance&&Array.isArray(SOP.governance.disciplines)&&SOP.governance.disciplines.length); }
function isMultiDiscipline(){ return pkgDisciplines.length>1; }
function instanceSuffixStyle(){ return (SOP&&SOP.governance&&SOP.governance.instanceSuffix)||'letter'; }
function buildDisciplinePicker(){
const wrap=document.getElementById('discipline-picker'); if(!wrap) return;
const card=document.getElementById('discipline-card');
const list=sopDisciplines();
if(!disciplinesConfigured() || !list.length){ if(card) card.style.display='none'; return; }
if(card) card.style.display='';
const mode=sopDiscMode();
const note=document.getElementById('discipline-note');
if(note){
note.textContent = mode==='single'
? 'This project issues one discipline per package — choose the single discipline.'
: mode==='multi'
? 'This project bundles disciplines into one package — select every discipline this package covers; each gets its own scope section.'
: 'Select every discipline this package covers. Choose more than one to build a combined package you can split later.';
}
wrap.innerHTML = list.map(d=>{
const on=pkgDisciplines.includes(d);
return ``;
}).join('');
}
function toggleDiscipline(d,on){
if(sopDiscMode()==='single'){ pkgDisciplines = on?[d]:[]; }
else { if(on){ if(!pkgDisciplines.includes(d)) pkgDisciplines.push(d); } else { pkgDisciplines=pkgDisciplines.filter(x=>x!==d); } }
// keep per-discipline scope/status maps in sync with the selection
pkgDisciplines.forEach(x=>{ if(!pkgScope[x]) pkgScope[x]=['']; if(!pkgDiscStatus[x]) pkgDiscStatus[x]=getRadio('status')||'Draft'; });
Object.keys(pkgScope).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgScope[x]; });
Object.keys(pkgDiscStatus).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgDiscStatus[x]; });
buildDisciplinePicker(); renderScope(); buildMaterials(); track('discipline_toggle',{count:pkgDisciplines.length});
}
// Show the flat work-step list for 0–1 disciplines; per-discipline sections for 2+.
function renderScope(){
const flat=document.getElementById('flat-scope');
const multi=document.getElementById('scope-by-discipline');
const splitBtn=document.getElementById('split-disc-btn');
if(!multi) return;
if(isMultiDiscipline()){
if(flat) flat.style.display='none';
multi.style.display='';
if(splitBtn) splitBtn.style.display='';
buildScopeGroups();
} else {
if(flat) flat.style.display='';
multi.style.display='none';
multi.innerHTML='';
if(splitBtn) splitBtn.style.display='none';
}
}
function buildScopeGroups(){
const multi=document.getElementById('scope-by-discipline'); if(!multi) return;
multi.innerHTML = pkgDisciplines.map(d=>{
const steps=pkgScope[d]&&pkgScope[d].length?pkgScope[d]:[''];
pkgScope[d]=steps;
const st=pkgDiscStatus[d]||'Draft';
const rows=steps.map((s,i)=>`
`;
}).join('');
}
function setScopeStep(d,i,v){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d][i]=v; }
function addScopeStep(d){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d].push(''); buildScopeGroups(); }
function removeScopeStep(d,i){ if(!pkgScope[d])return; pkgScope[d].splice(i,1); if(!pkgScope[d].length)pkgScope[d]=['']; buildScopeGroups(); }
function setDiscStatus(d,v){ pkgDiscStatus[d]=v; rollupDisciplineStatus(); track('disc_status',{discipline:d,status:v}); }
// Overall WP status rolls up to the least-advanced discipline (so a WP isn't "Closed" while a discipline lags).
function rollupDisciplineStatus(){
if(!isMultiDiscipline()) return;
let minIdx=STATUS_ORDER.length-1;
pkgDisciplines.forEach(d=>{ const ix=STATUS_ORDER.indexOf(pkgDiscStatus[d]||'Draft'); if(ix>=0&&ixmax){
el.innerHTML=`⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try Split by Discipline).':'.')+``;
} 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',
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=`
`;
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=`
`;
tb.appendChild(tr); });
}
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
function removeAttach(i){ pkgAttach.splice(i,1); if(!pkgAttach.length)pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
// ── ADD FILES FROM SOP FOLDER (no-auth interim) ──────────────────────────────
// Opens the SOP source folders, then turns pasted SharePoint file links into
// attachment rows with the file name parsed from the URL. (A true embedded
// picker needs an Azure AD app registration — see DEPLOYMENT.md.)
function fileNameFromUrl(url){
try{
const path=String(url).split('?')[0].split('#')[0];
const seg=decodeURIComponent(path.split('/').filter(Boolean).pop()||'');
// Only use it as a doc name if it looks like a real file (has an extension);
// short "/:f:/s/" sharing links have no filename, so leave doc blank.
return /\.[a-z0-9]{2,6}$/i.test(seg) ? seg : '';
}catch(e){ return ''; }
}
function renderSopFileFolders(){
const box=document.getElementById('sop-file-folders'); if(!box) return;
const srcs=sopLinkedSources();
box.innerHTML = srcs.length
? `
1) Open a folder, multi-select files in SharePoint, then use Copy link:
No SOP folders defined — load or import an SOP first.
`;
}
function toggleSopFilePanel(){
const p=document.getElementById('sop-file-panel'); if(!p) return;
const show = !p.style.display || p.style.display==='none';
p.style.display = show ? 'block' : 'none';
if(show){ renderSopFileFolders(); track('sop_file_panel_opened'); }
}
function addPastedFileLinks(){
const ta=document.getElementById('sop-file-links'); if(!ta) return;
const links=ta.value.split(/\r?\n/).map(s=>s.trim()).filter(Boolean);
if(!links.length){ toast('Paste one or more file links first'); return; }
// drop the single empty placeholder row if that's all there is
if(pkgAttach.length===1 && !pkgAttach[0].doc && !pkgAttach[0].rev && !pkgAttach[0].link) pkgAttach=[];
links.forEach(link=>pkgAttach.push({doc:fileNameFromUrl(link), rev:'', link}));
buildAttach(); ta.value=''; toggleSopFilePanel();
toast(links.length+' file'+(links.length>1?'s':'')+' added — set Rev as needed'); track('files_added_from_sop');
}
// ── CONSTRAINTS + RELEASE GATE ───────────────────────────────────────────────
function buildConstraints(){
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=`
${esc(c.name)}
`;
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);
}
}
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=`
${txt}
`;
}
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=>``).join('') + '';
const existing = fromConstraint ? (pkgConstraints.find(c=>c.name===preselect)||{}).comment||'' : '';
document.getElementById('hold-details').value=existing;
document.getElementById('hold-doclink').value='';
document.getElementById('hold-photo-preview').innerHTML='';
document.getElementById('hold-modal').classList.add('open');
}
function holdPhotoChange(ev){
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; document.getElementById('hold-photo-preview').innerHTML=``; }; 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=`