|
|
|
@@ -102,6 +102,126 @@ function toast(msg, kind){
|
|
|
|
t.textContent=msg; t.classList.add('show');
|
|
|
|
t.textContent=msg; t.classList.add('show');
|
|
|
|
clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200);
|
|
|
|
clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// ── DIALOG KIT (S1 / T7.9) ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
// The creator carried 43 native dialogs. One modal replaces them: a message, an
|
|
|
|
|
|
|
|
// optional input with an inline, announced error, and real buttons. Everything
|
|
|
|
|
|
|
|
// is promise-based, so a caller reads exactly like the confirm() it replaced -
|
|
|
|
|
|
|
|
// just awaited. Escape and the corner control both cancel.
|
|
|
|
|
|
|
|
let _dlgResolve=null;
|
|
|
|
|
|
|
|
function _openDialog(opts){
|
|
|
|
|
|
|
|
return new Promise(res=>{
|
|
|
|
|
|
|
|
_dlgResolve=res;
|
|
|
|
|
|
|
|
const ov=document.getElementById('wp-dialog'); ov._opts=opts||{};
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-title').textContent=opts.title||'Confirm';
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-msg').textContent=opts.message||'';
|
|
|
|
|
|
|
|
const wrap=document.getElementById('wp-dialog-input-wrap');
|
|
|
|
|
|
|
|
wrap.style.display=opts.input?'':'none';
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-label').textContent=opts.label||'';
|
|
|
|
|
|
|
|
const inp=document.getElementById('wp-dialog-input');
|
|
|
|
|
|
|
|
inp.value=(opts.value!=null?String(opts.value):'');
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-err').textContent='';
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-ok').textContent=opts.okLabel||'OK';
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-cancel').textContent=opts.cancelLabel||'Cancel';
|
|
|
|
|
|
|
|
ov.classList.add('open');
|
|
|
|
|
|
|
|
setTimeout(()=>{ (opts.input?inp:document.getElementById('wp-dialog-ok')).focus(); },0);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function wpDialogOk(){
|
|
|
|
|
|
|
|
const ov=document.getElementById('wp-dialog'); const opts=ov._opts||{};
|
|
|
|
|
|
|
|
if(opts.input){
|
|
|
|
|
|
|
|
const v=document.getElementById('wp-dialog-input').value;
|
|
|
|
|
|
|
|
if(opts.validate){
|
|
|
|
|
|
|
|
const err=opts.validate(v);
|
|
|
|
|
|
|
|
if(err){ document.getElementById('wp-dialog-err').textContent=err;
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog-input').focus(); return; }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
_closeDialog(v);
|
|
|
|
|
|
|
|
} else _closeDialog(true);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function wpDialogCancel(){
|
|
|
|
|
|
|
|
const opts=(document.getElementById('wp-dialog')||{})._opts||{};
|
|
|
|
|
|
|
|
_closeDialog(opts.input?null:false);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function _closeDialog(val){
|
|
|
|
|
|
|
|
document.getElementById('wp-dialog').classList.remove('open');
|
|
|
|
|
|
|
|
const r=_dlgResolve; _dlgResolve=null; if(r) r(val);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// confirm() said yes or no; this says true or false.
|
|
|
|
|
|
|
|
function wpConfirmDialog(opts){ return _openDialog({...opts, input:false}); }
|
|
|
|
|
|
|
|
// prompt() said string or null; so does this - and a validate() answer renders
|
|
|
|
|
|
|
|
// AT the input instead of round-tripping through another dialog.
|
|
|
|
|
|
|
|
function wpPromptDialog(opts){ return _openDialog({...opts, input:true}); }
|
|
|
|
|
|
|
|
document.addEventListener('keydown', e=>{
|
|
|
|
|
|
|
|
const ov=document.getElementById('wp-dialog');
|
|
|
|
|
|
|
|
if(e.key==='Escape' && ov && ov.classList.contains('open')) wpDialogCancel();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// ── INLINE VALIDATION (S1 / T7.9) ────────────────────────────────────────────
|
|
|
|
|
|
|
|
// One table: field id, the section card that holds it, the label the message
|
|
|
|
|
|
|
|
// uses. Adding a required field is one row; its error box, aria wiring and
|
|
|
|
|
|
|
|
// announcement all follow (the wizard's T5.8 pattern, applied to the creator).
|
|
|
|
|
|
|
|
const WP_REQUIRED = [
|
|
|
|
|
|
|
|
['wp_subject', 'general-card', 'Subject'],
|
|
|
|
|
|
|
|
['wp_type', 'general-card', 'WP type'],
|
|
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
function wpErrBox(id){
|
|
|
|
|
|
|
|
const el=document.getElementById(id); if(!el) return null;
|
|
|
|
|
|
|
|
let box=document.getElementById(id+'_err');
|
|
|
|
|
|
|
|
if(!box){
|
|
|
|
|
|
|
|
box=document.createElement('div');
|
|
|
|
|
|
|
|
box.className='field-error'; box.id=id+'_err';
|
|
|
|
|
|
|
|
box.setAttribute('role','alert');
|
|
|
|
|
|
|
|
(el.parentNode||document.body).insertBefore(box, el.nextSibling);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
const prior=(el.getAttribute('aria-describedby')||'').split(/\s+/).filter(Boolean);
|
|
|
|
|
|
|
|
if(prior.indexOf(box.id)<0){ prior.push(box.id); el.setAttribute('aria-describedby', prior.join(' ')); }
|
|
|
|
|
|
|
|
return box;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function wpSetFieldError(id, msg){
|
|
|
|
|
|
|
|
const el=document.getElementById(id); const box=wpErrBox(id);
|
|
|
|
|
|
|
|
if(box) box.textContent=msg||'';
|
|
|
|
|
|
|
|
if(el){ if(msg) el.setAttribute('aria-invalid','true'); else el.removeAttribute('aria-invalid'); }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
function wpMarkRailErrors(sectionIds){
|
|
|
|
|
|
|
|
document.querySelectorAll('.sec-rail-item').forEach(b=>{
|
|
|
|
|
|
|
|
const on=sectionIds.has(b.dataset.sec);
|
|
|
|
|
|
|
|
b.classList.toggle('has-error', on);
|
|
|
|
|
|
|
|
let mark=b.querySelector('.sec-err');
|
|
|
|
|
|
|
|
if(on && !mark){
|
|
|
|
|
|
|
|
mark=document.createElement('span'); mark.className='sec-err';
|
|
|
|
|
|
|
|
mark.textContent='!'; mark.setAttribute('aria-label','this section has errors');
|
|
|
|
|
|
|
|
b.insertBefore(mark, b.firstChild);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if(!on && mark) mark.remove();
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// Validate the whole form: mark every failing field, mark the rail entries of
|
|
|
|
|
|
|
|
// the sections that hold them, then go to the FIRST one - switching sections if
|
|
|
|
|
|
|
|
// needed - and put focus in it. Returns whether the form may save.
|
|
|
|
|
|
|
|
function wpValidateForm(){
|
|
|
|
|
|
|
|
const bad=[];
|
|
|
|
|
|
|
|
WP_REQUIRED.forEach(([id, section, label])=>{
|
|
|
|
|
|
|
|
const ok=!!gv(id);
|
|
|
|
|
|
|
|
wpSetFieldError(id, ok?'':label+' is required.');
|
|
|
|
|
|
|
|
if(!ok) bad.push([id, section]);
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
if(typeof isEwp==='function' && isEwp() && typeof iffRequired==='function'
|
|
|
|
|
|
|
|
&& iffRequired() && !gv('wp_iff').trim()){
|
|
|
|
|
|
|
|
wpSetFieldError('wp_iff','Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
|
|
|
|
|
|
|
|
bad.push(['wp_iff','bim-card']);
|
|
|
|
|
|
|
|
} else if(document.getElementById('wp_iff')) wpSetFieldError('wp_iff','');
|
|
|
|
|
|
|
|
wpMarkRailErrors(new Set(bad.map(b=>b[1])));
|
|
|
|
|
|
|
|
if(bad.length){
|
|
|
|
|
|
|
|
const [firstId, firstSection]=bad[0];
|
|
|
|
|
|
|
|
if(typeof gotoSection==='function') gotoSection(firstSection);
|
|
|
|
|
|
|
|
setTimeout(()=>{ const el=document.getElementById(firstId);
|
|
|
|
|
|
|
|
if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); } }, 150);
|
|
|
|
|
|
|
|
toast((bad.length===1?'1 required field needs':bad.length+' required fields need')
|
|
|
|
|
|
|
|
+' attention — the first is focused.', 'alert');
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
return !bad.length;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
|
|
|
|
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 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 enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
|
|
|
|
@@ -117,9 +237,9 @@ function nextSeq(){ return savedPackages.length+1; }
|
|
|
|
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 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){
|
|
|
|
function importSOP(ev){
|
|
|
|
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader();
|
|
|
|
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; }
|
|
|
|
r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ toast('That file does not look like an SOP export from the Configuration tool.', 'alert'); return; }
|
|
|
|
SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported');
|
|
|
|
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=''; };
|
|
|
|
}catch(e){ toast('Could not read that file.', 'alert'); } ev.target.value=''; };
|
|
|
|
r.readAsText(f);
|
|
|
|
r.readAsText(f);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function applySOP(){
|
|
|
|
function applySOP(){
|
|
|
|
@@ -231,12 +351,17 @@ function editQuality(id){
|
|
|
|
document.getElementById(id).value=sopValueFor(id);
|
|
|
|
document.getElementById(id).value=sopValueFor(id);
|
|
|
|
lockQuality(id); track('quality_reverted',{field:id}); return;
|
|
|
|
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:');
|
|
|
|
wpPromptDialog({
|
|
|
|
|
|
|
|
title:'Override an SOP field',
|
|
|
|
|
|
|
|
message:'This field is set by the project SOP. The override and its reason stay on the package.',
|
|
|
|
|
|
|
|
label:'Reason for overriding it', okLabel:'Override',
|
|
|
|
|
|
|
|
validate:v=>v.trim()?'':'A reason is required to override an SOP field.',
|
|
|
|
|
|
|
|
}).then(reason=>{
|
|
|
|
if(reason===null) return;
|
|
|
|
if(reason===null) return;
|
|
|
|
if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; }
|
|
|
|
|
|
|
|
pkgOverrides[id]=reason.trim();
|
|
|
|
pkgOverrides[id]=reason.trim();
|
|
|
|
const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus();
|
|
|
|
const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus();
|
|
|
|
lockQuality(id); track('quality_overridden',{field:id});
|
|
|
|
lockQuality(id); track('quality_overridden',{field:id});
|
|
|
|
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function renderCtxBar(){
|
|
|
|
function renderCtxBar(){
|
|
|
|
const bar=document.getElementById('ctx-bar');
|
|
|
|
const bar=document.getElementById('ctx-bar');
|
|
|
|
@@ -658,10 +783,10 @@ function parseCSV(text){
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function importMaterials(ev){
|
|
|
|
function importMaterials(ev){
|
|
|
|
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
|
|
|
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; }
|
|
|
|
if(/\.xlsx?$/i.test(f.name)){ toast('Save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.', 'alert'); ev.target.value=''; return; }
|
|
|
|
const r=new FileReader(); r.onload=()=>{
|
|
|
|
const r=new FileReader(); r.onload=()=>{
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; }
|
|
|
|
const rows=parseCSV(r.result); if(!rows.length){ toast('No rows found in that file.', 'alert'); return; }
|
|
|
|
let start=0; const h=rows[0].map(c=>c.trim().toLowerCase());
|
|
|
|
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');
|
|
|
|
const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description');
|
|
|
|
let cq=0,cu=1,cd=2;
|
|
|
|
let cq=0,cu=1,cd=2;
|
|
|
|
@@ -670,9 +795,9 @@ function importMaterials(ev){
|
|
|
|
for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
|
|
|
|
for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
|
|
|
|
if(!desc && !(c[cq]||'').trim()) continue;
|
|
|
|
if(!desc && !(c[cq]||'').trim()) continue;
|
|
|
|
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
|
|
|
|
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
|
|
|
|
if(!imported.length){ alert('No material rows found.'); return; }
|
|
|
|
if(!imported.length){ toast('No material rows found in that file.', 'alert'); return; }
|
|
|
|
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
|
|
|
|
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
|
|
|
|
}catch(e){ alert('Could not parse that CSV.'); }
|
|
|
|
}catch(e){ toast('Could not parse that CSV.', 'alert'); }
|
|
|
|
ev.target.value='';
|
|
|
|
ev.target.value='';
|
|
|
|
};
|
|
|
|
};
|
|
|
|
r.readAsText(f);
|
|
|
|
r.readAsText(f);
|
|
|
|
@@ -800,12 +925,20 @@ function instanceSuffixFor(index, discipline){
|
|
|
|
if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); }
|
|
|
|
if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); }
|
|
|
|
return String.fromCharCode(65+index); // A, B, C…
|
|
|
|
return String.fromCharCode(65+index); // A, B, C…
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function splitByDiscipline(){
|
|
|
|
async function splitByDiscipline(){
|
|
|
|
if(!isMultiDiscipline()){ alert('Select two or more disciplines before splitting.'); return; }
|
|
|
|
if(!isMultiDiscipline()){ toast('Select two or more disciplines before splitting.', 'alert'); return; }
|
|
|
|
if(!gv('wp_subject')){ alert('Add a Subject before splitting.'); return; }
|
|
|
|
if(!gv('wp_subject')){
|
|
|
|
|
|
|
|
wpSetFieldError('wp_subject','Add a Subject before splitting.');
|
|
|
|
|
|
|
|
wpMarkRailErrors(new Set(['general-card'])); gotoSection('general-card');
|
|
|
|
|
|
|
|
setTimeout(()=>{ const el=document.getElementById('wp_subject'); if(el) el.focus(); },150);
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
const base=collectPackage();
|
|
|
|
const base=collectPackage();
|
|
|
|
const baseNumber=base.number||('WP'+pad2(editingSeq()));
|
|
|
|
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;
|
|
|
|
if(!(await wpConfirmDialog({
|
|
|
|
|
|
|
|
title:'Split by discipline',
|
|
|
|
|
|
|
|
message:`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.`,
|
|
|
|
|
|
|
|
okLabel:'Split it'}))) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Master: flagged as a split container, keeps all disciplines for roll-up tracking.
|
|
|
|
// Master: flagged as a split container, keeps all disciplines for roll-up tracking.
|
|
|
|
const masterId = editingId || base.id;
|
|
|
|
const masterId = editingId || base.id;
|
|
|
|
@@ -839,8 +972,7 @@ function splitByDiscipline(){
|
|
|
|
track('wp_split',{disciplines:pkgDisciplines.length});
|
|
|
|
track('wp_split',{disciplines:pkgDisciplines.length});
|
|
|
|
toast('Split into '+children.length+' discipline instances');
|
|
|
|
toast('Split into '+children.length+' discipline instances');
|
|
|
|
const untagged=(base.materials||[]).filter(m=>!m.discipline).length;
|
|
|
|
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.` : '';
|
|
|
|
if(untagged) toast(untagged+' material line'+(untagged===1?'':'s')+' had no discipline tag and stayed on the master only.', 'alert');
|
|
|
|
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) ────────────────────────────────────────────────────
|
|
|
|
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
|
|
|
|
@@ -949,7 +1081,9 @@ async function wpFileDescSave(id, v){
|
|
|
|
}catch(e){ toast('Could not save the description — it is shown but not saved yet.'); }
|
|
|
|
}catch(e){ toast('Could not save the description — it is shown but not saved yet.'); }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function wpFileDelete(id){
|
|
|
|
async function wpFileDelete(id){
|
|
|
|
if(!confirm('Remove this drawing from the package?')) return;
|
|
|
|
if(!(await wpConfirmDialog({title:'Remove drawing',
|
|
|
|
|
|
|
|
message:'Remove this drawing from the package? The bytes are deleted from the project storage.',
|
|
|
|
|
|
|
|
okLabel:'Remove'}))) return;
|
|
|
|
try{
|
|
|
|
try{
|
|
|
|
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'});
|
|
|
|
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'});
|
|
|
|
if(!r.ok) throw 0;
|
|
|
|
if(!r.ok) throw 0;
|
|
|
|
@@ -1077,10 +1211,14 @@ function setConstraint(i,val){
|
|
|
|
toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
|
|
|
|
toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
|
|
|
|
else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
|
|
|
|
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
|
|
|
|
wpConfirmDialog({title:'Release-ready',
|
|
|
|
|
|
|
|
message:'All constraints are cleared — this Work Package is release-ready.',
|
|
|
|
|
|
|
|
okLabel:'Mark it as Issued', cancelLabel:'Not yet'}).then(ok=>{
|
|
|
|
|
|
|
|
if(ok){
|
|
|
|
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
|
|
|
|
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
|
|
|
|
track('status_change',{status:'Issued',via:'constraint_clear'});
|
|
|
|
track('status_change',{status:'Issued',via:'constraint_clear'});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
});
|
|
|
|
const sg=document.getElementById('status-group');
|
|
|
|
const sg=document.getElementById('status-group');
|
|
|
|
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
|
|
|
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1138,7 +1276,7 @@ function updateReleaseBanner(){
|
|
|
|
// Releasing with an unclosed predecessor is allowed but must be explained. The
|
|
|
|
// Releasing with an unclosed predecessor is allowed but must be explained. The
|
|
|
|
// reason rides on the package (data.gateOverride) and the server writes it to the
|
|
|
|
// reason rides on the package (data.gateOverride) and the server writes it to the
|
|
|
|
// audit log. Returns false if the user backed out.
|
|
|
|
// audit log. Returns false if the user backed out.
|
|
|
|
function confirmEarlyRelease(blocking, openConstraints){
|
|
|
|
async function confirmEarlyRelease(blocking, openConstraints){
|
|
|
|
// D4 extended this to open constraints on an URGENT package - the same audited
|
|
|
|
// D4 extended this to open constraints on an URGENT package - the same audited
|
|
|
|
// path, not a new one. A silent bypass would destroy the delay-documentation
|
|
|
|
// path, not a new one. A silent bypass would destroy the delay-documentation
|
|
|
|
// use case that justifies the constraint workflow, so the reason is mandatory
|
|
|
|
// use case that justifies the constraint workflow, so the reason is mandatory
|
|
|
|
@@ -1148,10 +1286,13 @@ function confirmEarlyRelease(blocking, openConstraints){
|
|
|
|
if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n'));
|
|
|
|
if(open.length) parts.push('These constraints are still OPEN:\n\n'+open.map(n=>'• '+n).join('\n'));
|
|
|
|
if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+
|
|
|
|
if(blocking.length) parts.push('These predecessor packages are not Closed yet:\n\n'+
|
|
|
|
blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'));
|
|
|
|
blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'));
|
|
|
|
const reason=prompt(parts.join('\n\n')+
|
|
|
|
const reason=await wpPromptDialog({
|
|
|
|
'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+
|
|
|
|
title:'Release with a logged override',
|
|
|
|
'Why is it being released now? (Cancel to stop.)');
|
|
|
|
message:parts.join('\n\n')+'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.',
|
|
|
|
if(reason===null || !reason.trim()) return false;
|
|
|
|
label:'Why is it being released now?', okLabel:'Release — log the reason',
|
|
|
|
|
|
|
|
validate:v=>v.trim()?'':'A reason is required — it goes on the package and in the audit log.',
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
if(reason===null) return false;
|
|
|
|
pkgGateOverride={
|
|
|
|
pkgGateOverride={
|
|
|
|
reason: reason.trim(),
|
|
|
|
reason: reason.trim(),
|
|
|
|
at: new Date().toISOString(),
|
|
|
|
at: new Date().toISOString(),
|
|
|
|
@@ -1183,11 +1324,11 @@ function holdReturnStatus(){
|
|
|
|
// timestamp, user, what cleared it - and returns to the recorded prior status.
|
|
|
|
// timestamp, user, what cleared it - and returns to the recorded prior status.
|
|
|
|
// Predecessors stay a refusable gate on the way back out (A1): if one reopened
|
|
|
|
// Predecessors stay a refusable gate on the way back out (A1): if one reopened
|
|
|
|
// while the package sat on hold, the audited override is offered, never skipped.
|
|
|
|
// while the package sat on hold, the audited override is offered, never skipped.
|
|
|
|
function releaseHold(clearedName){
|
|
|
|
async function releaseHold(clearedName){
|
|
|
|
const back=holdReturnStatus();
|
|
|
|
const back=holdReturnStatus();
|
|
|
|
const r=readiness();
|
|
|
|
const r=readiness();
|
|
|
|
if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
|
|
|
if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
|
|
|
if(!confirmEarlyRelease(r.blocking)){
|
|
|
|
if(!(await confirmEarlyRelease(r.blocking))){
|
|
|
|
toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.');
|
|
|
|
toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.');
|
|
|
|
updateReleaseBanner(); return;
|
|
|
|
updateReleaseBanner(); return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1220,7 +1361,14 @@ function qaRejectOpen(){
|
|
|
|
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
|
|
|
|
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
|
|
|
|
function qaRejectSubmit(){
|
|
|
|
function qaRejectSubmit(){
|
|
|
|
const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim();
|
|
|
|
const c=(document.getElementById('qa-reject-comment')||{value:''}).value.trim();
|
|
|
|
if(!c){ alert('A comment is required — the crew needs to know what to fix.'); return; }
|
|
|
|
const errBox=document.getElementById('qa-reject-comment_err');
|
|
|
|
|
|
|
|
if(!c){
|
|
|
|
|
|
|
|
if(errBox) errBox.textContent='A comment is required — the crew needs to know what to fix.';
|
|
|
|
|
|
|
|
const el=document.getElementById('qa-reject-comment'); if(el){ el.setAttribute('aria-invalid','true'); el.focus(); }
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if(errBox) errBox.textContent='';
|
|
|
|
|
|
|
|
const qel=document.getElementById('qa-reject-comment'); if(qel) qel.removeAttribute('aria-invalid');
|
|
|
|
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
|
|
|
|
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
|
|
|
|
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
|
|
|
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
|
|
|
from:'Ready for QA' });
|
|
|
|
from:'Ready for QA' });
|
|
|
|
@@ -1232,16 +1380,16 @@ function qaRejectSubmit(){
|
|
|
|
|
|
|
|
|
|
|
|
// D4: the banner's primary action for an Urgent package blocked by constraints.
|
|
|
|
// D4: the banner's primary action for an Urgent package blocked by constraints.
|
|
|
|
// It runs the SAME audited path the status control runs - one override, one log.
|
|
|
|
// It runs the SAME audited path the status control runs - one override, one log.
|
|
|
|
function urgentOverrideRelease(){
|
|
|
|
async function urgentOverrideRelease(){
|
|
|
|
const r=readiness();
|
|
|
|
const r=readiness();
|
|
|
|
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
|
|
|
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
|
|
|
if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; }
|
|
|
|
if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; }
|
|
|
|
if(!confirmEarlyRelease(r.blocking, open)) return;
|
|
|
|
if(!(await confirmEarlyRelease(r.blocking, open))) return;
|
|
|
|
setRadio('status','Issued'); prevStatus='Issued';
|
|
|
|
setRadio('status','Issued'); prevStatus='Issued';
|
|
|
|
updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'});
|
|
|
|
updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function onStatusChange(target){
|
|
|
|
async function onStatusChange(target){
|
|
|
|
const idx=STATUS_ORDER.indexOf(target);
|
|
|
|
const idx=STATUS_ORDER.indexOf(target);
|
|
|
|
const r=readiness();
|
|
|
|
const r=readiness();
|
|
|
|
// Constraints are a hard gate for Normal and High: nothing releases with one
|
|
|
|
// Constraints are a hard gate for Normal and High: nothing releases with one
|
|
|
|
@@ -1250,11 +1398,12 @@ function onStatusChange(target){
|
|
|
|
if(idx>=ISSUED_IDX && r.open>0){
|
|
|
|
if(idx>=ISSUED_IDX && r.open>0){
|
|
|
|
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
|
|
|
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
|
|
|
if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
|
|
|
|
if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
|
|
|
|
if(!confirmEarlyRelease(r.blocking, open)){
|
|
|
|
if(!(await confirmEarlyRelease(r.blocking, open))){
|
|
|
|
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
|
|
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
|
|
|
|
toast('Cannot move to "'+target+'" — '+open.length+' constraint'+(open.length===1?' is':'s are')+' still open. Clear or mark N/A first.', 'alert');
|
|
|
|
|
|
|
|
gotoSection('constraint-card');
|
|
|
|
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
|
|
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1262,7 +1411,7 @@ function onStatusChange(target){
|
|
|
|
// ahead of upstream work closing out. Refusing it requires a reason, which is
|
|
|
|
// ahead of upstream work closing out. Refusing it requires a reason, which is
|
|
|
|
// stored on the package and written to the audit log by the server.
|
|
|
|
// stored on the package and written to the audit log by the server.
|
|
|
|
if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
|
|
|
if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
|
|
|
if(!confirmEarlyRelease(r.blocking)){
|
|
|
|
if(!(await confirmEarlyRelease(r.blocking))){
|
|
|
|
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
|
|
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1290,7 +1439,14 @@ function submitHold(){
|
|
|
|
const constraint=document.getElementById('hold-constraint').value;
|
|
|
|
const constraint=document.getElementById('hold-constraint').value;
|
|
|
|
const details=document.getElementById('hold-details').value.trim();
|
|
|
|
const details=document.getElementById('hold-details').value.trim();
|
|
|
|
const doclink=document.getElementById('hold-doclink').value.trim();
|
|
|
|
const doclink=document.getElementById('hold-doclink').value.trim();
|
|
|
|
if(!details){ alert('A comment defining the issue is required.'); return; }
|
|
|
|
const hErr=document.getElementById('hold-details_err');
|
|
|
|
|
|
|
|
if(!details){
|
|
|
|
|
|
|
|
if(hErr) hErr.textContent='A comment defining the issue is required.';
|
|
|
|
|
|
|
|
const hel=document.getElementById('hold-details'); if(hel){ hel.setAttribute('aria-invalid','true'); hel.focus(); }
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if(hErr) hErr.textContent='';
|
|
|
|
|
|
|
|
const hel2=document.getElementById('hold-details'); if(hel2) hel2.removeAttribute('aria-invalid');
|
|
|
|
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'',
|
|
|
|
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'',
|
|
|
|
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
|
|
|
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
|
|
|
// What the release returns to. prevStatus is captured before the status pill
|
|
|
|
// What the release returns to. prevStatus is captured before the status pill
|
|
|
|
@@ -1347,13 +1503,22 @@ function onSignoffSigned(i,checked){
|
|
|
|
if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; }
|
|
|
|
if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; }
|
|
|
|
buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked});
|
|
|
|
buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function onSignoffDateOverride(i){
|
|
|
|
async function onSignoffDateOverride(i){
|
|
|
|
const s=pkgSignoffs[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());
|
|
|
|
const nd=await wpPromptDialog({
|
|
|
|
|
|
|
|
title:'Manual sign-off date — '+s.role,
|
|
|
|
|
|
|
|
message:'The date is normally set automatically when Signed is checked.',
|
|
|
|
|
|
|
|
label:'Sign-off date (YYYY-MM-DD)', value:s.date||todayStr(), okLabel:'Next',
|
|
|
|
|
|
|
|
validate:v=>/^\d{4}-\d{2}-\d{2}$/.test(v.trim())?'':'Enter the date as YYYY-MM-DD.',
|
|
|
|
|
|
|
|
});
|
|
|
|
if(nd===null) return;
|
|
|
|
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=await wpPromptDialog({
|
|
|
|
const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:');
|
|
|
|
title:'Manual sign-off date — '+s.role,
|
|
|
|
if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; }
|
|
|
|
message:'The override and its reason stay on the package.',
|
|
|
|
|
|
|
|
label:'Reason for the manual date', okLabel:'Override the date',
|
|
|
|
|
|
|
|
validate:v=>v.trim()?'':'A reason is required for a manual date override.',
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
if(reason===null) return;
|
|
|
|
s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role});
|
|
|
|
s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1420,20 +1585,16 @@ function collectPackage(){
|
|
|
|
updatedAt:new Date().toISOString()
|
|
|
|
updatedAt:new Date().toISOString()
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function savePackage(view){
|
|
|
|
async function savePackage(view){
|
|
|
|
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
|
|
|
|
if(!wpValidateForm()) return;
|
|
|
|
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
|
|
|
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
|
|
|
// The status can also be set programmatically (the per-discipline roll-up), so
|
|
|
|
// The status can also be set programmatically (the per-discipline roll-up), so
|
|
|
|
// re-check the predecessor gate at the point of saving.
|
|
|
|
// re-check the predecessor gate at the point of saving.
|
|
|
|
const _r=readiness();
|
|
|
|
const _r=readiness();
|
|
|
|
if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
|
|
|
|
if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
|
|
|
|
if(!confirmEarlyRelease(_r.blocking)) return;
|
|
|
|
if(!(await confirmEarlyRelease(_r.blocking))) return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
|
|
|
|
|
|
|
|
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
|
|
|
|
|
|
|
|
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// The IFF rule is part of wpValidateForm() now - checked above with the rest.
|
|
|
|
const pkg=collectPackage();
|
|
|
|
const pkg=collectPackage();
|
|
|
|
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
|
|
|
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
|
|
|
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
|
|
|
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
|
|
|
@@ -2490,10 +2651,20 @@ async function showHistory(wpId, label){
|
|
|
|
// A project_user gets no delete button, and archiving is offered instead.
|
|
|
|
// A project_user gets no delete button, and archiving is offered instead.
|
|
|
|
function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; }
|
|
|
|
function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; }
|
|
|
|
|
|
|
|
|
|
|
|
function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
|
|
|
|
async function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
|
|
|
|
if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
|
|
|
if(!canDeleteWP()){ toast('Deleting a work package needs the Project Admin role — archive it instead.', 'alert'); return; }
|
|
|
|
function clearSaved(){ if(!savedPackages.length) return;
|
|
|
|
if(!(await wpConfirmDialog({title:'Delete work package',
|
|
|
|
if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
|
|
|
message:'Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.',
|
|
|
|
|
|
|
|
okLabel:'Delete it'}))) return;
|
|
|
|
|
|
|
|
const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted');
|
|
|
|
|
|
|
|
if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
|
|
|
|
|
|
|
async function clearSaved(){ if(!savedPackages.length) return;
|
|
|
|
|
|
|
|
if(!canDeleteWP()){ toast('Deleting work packages needs the Project Admin role.', 'alert'); return; }
|
|
|
|
|
|
|
|
if(!(await wpConfirmDialog({title:'Delete every saved package',
|
|
|
|
|
|
|
|
message:'Delete all '+savedPackages.length+' saved packages? This cannot be undone.',
|
|
|
|
|
|
|
|
okLabel:'Delete them all'}))) return;
|
|
|
|
|
|
|
|
const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList();
|
|
|
|
|
|
|
|
if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
|
|
|
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); }
|
|
|
|
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); }
|
|
|
|
|
|
|
|
|
|
|
|
// S3: the open package IS the page's state, so it belongs in the URL. This is the
|
|
|
|
// S3: the open package IS the page's state, so it belongs in the URL. This is the
|
|
|
|
@@ -2565,12 +2736,15 @@ function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignof
|
|
|
|
|
|
|
|
|
|
|
|
// Duplicate the current work package N times (asks how many). Each copy is a
|
|
|
|
// 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.
|
|
|
|
// fresh Draft with a unique number/subject and approvals/closeout cleared.
|
|
|
|
function duplicateWP(){
|
|
|
|
async function duplicateWP(){
|
|
|
|
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
|
|
|
|
if(!gv('wp_subject')){ toast('Open or fill in a work package first, then Duplicate.', 'alert'); return; }
|
|
|
|
const ans=prompt('How many copies of this work package do you want to create?','1');
|
|
|
|
const ans=await wpPromptDialog({
|
|
|
|
|
|
|
|
title:'Duplicate this work package',
|
|
|
|
|
|
|
|
label:'How many copies?', value:'1', okLabel:'Create copies',
|
|
|
|
|
|
|
|
validate:v=>{ const k=parseInt(v,10); return (k>=1&&k<=50&&String(k)===v.trim())?'':'Enter a whole number between 1 and 50.'; },
|
|
|
|
|
|
|
|
});
|
|
|
|
if(ans===null) return;
|
|
|
|
if(ans===null) return;
|
|
|
|
const n=parseInt(ans,10);
|
|
|
|
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 base=collectPackage();
|
|
|
|
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
|
|
|
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
|
|
|
const made=[];
|
|
|
|
const made=[];
|
|
|
|
@@ -2589,9 +2763,8 @@ function duplicateWP(){
|
|
|
|
}
|
|
|
|
}
|
|
|
|
editingId=null; saveStore(); renderSavedList();
|
|
|
|
editingId=null; saveStore(); renderSavedList();
|
|
|
|
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
|
|
|
|
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
|
|
|
|
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
|
|
|
toast('Created '+n+' duplicate'+(n>1?'s':'')+' — in the Saved Work Packages list, ready to edit.');
|
|
|
|
track('wp_duplicated',{count:n});
|
|
|
|
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(){
|
|
|
|
function newPackage(){
|
|
|
|
@@ -2625,7 +2798,7 @@ function newPackage(){
|
|
|
|
}
|
|
|
|
}
|
|
|
|
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 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(){
|
|
|
|
function exportPackages(){
|
|
|
|
if(!savedPackages.length){ alert('No packages saved yet.'); return; }
|
|
|
|
if(!savedPackages.length){ toast('No packages saved yet.', 'alert'); return; }
|
|
|
|
const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages};
|
|
|
|
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');
|
|
|
|
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';
|
|
|
|
a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json';
|
|
|
|
@@ -2879,9 +3052,11 @@ function dashToggleArchived(on){
|
|
|
|
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
|
|
|
|
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
|
|
|
|
} else { renderDashboard(); }
|
|
|
|
} else { renderDashboard(); }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function dashArchive(id){
|
|
|
|
async function dashArchive(id){
|
|
|
|
const p=savedPackages.find(x=>x.id===id); if(!p) return;
|
|
|
|
const p=savedPackages.find(x=>x.id===id); if(!p) return;
|
|
|
|
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return;
|
|
|
|
if(!(await wpConfirmDialog({title:'Archive work package',
|
|
|
|
|
|
|
|
message:'Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.',
|
|
|
|
|
|
|
|
okLabel:'Archive'}))) return;
|
|
|
|
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
|
|
|
|
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
|
|
|
|
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
|
|
|
|
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
|
|
|
|
saveStore(); renderSavedList(); toast('Archived '+(p.number||'')); dashRefreshAfterWrite();
|
|
|
|
saveStore(); renderSavedList(); toast('Archived '+(p.number||'')); dashRefreshAfterWrite();
|
|
|
|
@@ -3133,19 +3308,20 @@ function renderDashboard(){
|
|
|
|
h+=`</div>`;
|
|
|
|
h+=`</div>`;
|
|
|
|
document.getElementById('dash-body').innerHTML=h;
|
|
|
|
document.getElementById('dash-body').innerHTML=h;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
function dashIssue(id){
|
|
|
|
async function dashIssue(id){
|
|
|
|
const p=WPData.get(id); if(!p) return;
|
|
|
|
const p=WPData.get(id); if(!p) return;
|
|
|
|
if(wpOpenConstraints(p).length>0){ alert('Cannot issue — open constraints remain.'); return; }
|
|
|
|
if(wpOpenConstraints(p).length>0){ toast('Cannot issue — open constraints remain.', 'alert'); return; }
|
|
|
|
const waiting=wpWaitingOn(p);
|
|
|
|
const waiting=wpWaitingOn(p);
|
|
|
|
if(waiting.length){
|
|
|
|
if(waiting.length){
|
|
|
|
// Releasing early needs a reason, same as on the form — the dashboard must not
|
|
|
|
// Releasing early needs a reason, same as on the form — the dashboard must not
|
|
|
|
// be the quiet way around the gate.
|
|
|
|
// be the quiet way around the gate.
|
|
|
|
alert('Cannot issue from here — waiting on:\n\n• '+
|
|
|
|
toast('Cannot issue from here — waiting on '+waiting.map(w=>(w.number||w.id)).join(', ')
|
|
|
|
waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+
|
|
|
|
+'. Open the package to release it early with a logged reason.', 'alert');
|
|
|
|
'\n\nOpen the package to release it early with a logged reason.');
|
|
|
|
|
|
|
|
return;
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return;
|
|
|
|
if(!(await wpConfirmDialog({title:'Issue work package',
|
|
|
|
|
|
|
|
message:'Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.',
|
|
|
|
|
|
|
|
okLabel:'Issue it'}))) return;
|
|
|
|
WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
|
|
|
|
WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
|
|
|
|
dashRefreshAfterWrite();
|
|
|
|
dashRefreshAfterWrite();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -3154,7 +3330,7 @@ function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; l
|
|
|
|
|
|
|
|
|
|
|
|
// ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
|
|
|
|
// ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
|
|
|
|
function openSopModal(){
|
|
|
|
function openSopModal(){
|
|
|
|
if(!SOP){ alert('No SOP loaded.'); return; }
|
|
|
|
if(!SOP){ toast('No SOP loaded.', 'alert'); return; }
|
|
|
|
const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{};
|
|
|
|
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>`;
|
|
|
|
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">`;
|
|
|
|
let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`;
|
|
|
|
@@ -3186,13 +3362,16 @@ function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d));
|
|
|
|
function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); }
|
|
|
|
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 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 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 addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ wpSetFieldError('cmt-author','Add your name first.'); document.getElementById('cmt-author').focus(); return; } wpSetFieldError('cmt-author',''); 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 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?wpFormatDateTime(c.ts):''; 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 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?wpFormatDateTime(c.ts):''; 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 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 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 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){ toast('No comments found in that file.', 'alert'); 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(); toast(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ toast('Could not read that file.', 'alert'); } 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(); }
|
|
|
|
async function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length;
|
|
|
|
|
|
|
|
if(!mine){ toast('No comments to clear.', 'alert'); return; }
|
|
|
|
|
|
|
|
if(!(await wpConfirmDialog({title:'Clear my comments', message:`Delete your ${mine} comment(s)?`, okLabel:'Delete them'}))) 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(); }
|
|
|
|
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
|
|
|
|
|
|
|
|
|
|
|
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
|
|
|
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
|
|
|
|