The field wants the specific PDF attached, not a link to a Bluebeam session.
Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.
The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.
Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).
Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.
Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.
Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.
Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.
Items: CR-007, D8 (X8 honored)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3467 lines
208 KiB
JavaScript
3467 lines
208 KiB
JavaScript
/* Work Package (IWP) form — the downstream installation work package record.
|
||
Parameters (numbering, types, QC, sources) are inherited from the project SOP
|
||
exported by the Configuration tool. Implements the AWP constraint checklist +
|
||
release gate from IR 272-2 Vol II. */
|
||
|
||
// ── SAMPLE SOP (same shape the Configuration tool exports) ───────────────────
|
||
const SAMPLE_SOP = {
|
||
meta:{tool:'Work Package Configuration', sample:true},
|
||
// CR-016 / T5.7. This sample IS the Micron EUV configuration as far as the
|
||
// repository is concerned — it is what the standalone creator shows when there
|
||
// is no project SOP to read. Assets is off because its content duplicates the
|
||
// database Clinton's team maintains and that integration is deferred; the
|
||
// section, its data model and every value already captured stay exactly where
|
||
// they are, which is the whole point of expressing this as a toggle.
|
||
//
|
||
// Written as a partial map, not a full one: WPSections.normalize fills the rest
|
||
// in as ON, so a section added later is not silently off for this SOP.
|
||
sections:{assets:false},
|
||
project:{name:'Micron — INC Construction Work Packages', number:'26-67-008', client:'Micron Technology, Inc.', division:'Semiconductor', pm:'Nick Siegfried', cm:'K. Boyd', qm:'D. Nguyen', site:'Boise, ID — Fab'},
|
||
roles:[{role:'General Foreman',name:'M. Torres'},{role:'Superintendent',name:'K. Boyd'},{role:'Safety Manager / Lead',name:'A. Reyes'},{role:'Quality Manager',name:'D. Nguyen'},{role:'Planner',name:'L. Graver'}],
|
||
governance:{ issuance:['By Sector / Area','By Discipline'], woSize:'Standard — 3–5 days (≈40–80 hrs)', woFormat:'WP##-[Sector]-[TYPE]', disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:'80' },
|
||
woTypes:[
|
||
{name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true},
|
||
{name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true},
|
||
{name:'Instrument Install', enabled:true}, {name:'Panel Install', enabled:true},
|
||
],
|
||
sources:[
|
||
{label:'Design Drawings',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/Drawings?csf=1&web=1&e=Hx4lZF'},
|
||
{label:'Data Sheets',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/DataSheets?csf=1&web=1&e=PkPrXc'},
|
||
{label:'Specifications',system:'SharePoint',notes:'',link:'https://primecontrolsdallas.sharepoint.com/:f:/r/sites/BusinessTechnologyGroup/Shared%20Documents/Current%20projects/Project%20SDE/Piloting/Test%20Files/Specs?csf=1&web=1&e=ZQ19ai'},
|
||
{label:'IO List',system:'controls.dev',notes:'',link:'https://controls.dev/io'},
|
||
{label:'Cable Schedule',system:'SharePoint',notes:'',link:'https://primecontrols.sharepoint.com/cable-schedule'},
|
||
],
|
||
field:{ trackPlatform:'CxAlloy' },
|
||
quality:{ qcReq:'Yes', qcScope:'Detailed inspection items', photo:'Key checkpoints only', cxTool:'CxAlloy', holdPoints:'HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.' },
|
||
sequence:[{label:'Layout',kind:'step'},{label:'Conduit Install',kind:'step'},{label:'Tray Install',kind:'step'},{label:'Wire Pull',kind:'step'},{label:'Terminations',kind:'step'},{label:'Commissioning',kind:'step'}],
|
||
costCodes:['5100 - Rough In','5200 - Wire Pull','5300 - Terminations','5400 - Instrumentation'],
|
||
};
|
||
|
||
// Standard AWP constraint set (Vol I §2.3.2; Vol II IWP checklists)
|
||
const DEFAULT_CONSTRAINTS = ['Safety & Permitting','Quality Control / Inspection','IFC Drawings & Specs','Schedule','Materials (on site, bagged & tagged)','Prefabrication','Work Access & Laydown','Craft Availability','Construction Equipment & Tools','Scaffolding / Access Equipment'];
|
||
const SIGNOFF_ROLES = ['Planner','Superintendent','HSE Professional','Quality Representative','Work Foreman'];
|
||
// CR-014: 'Ready for QA' is a rung between In Progress and QC - the queue QA
|
||
// plans their week from. 'Issue' (hold) stays OUT of this ladder: it is a
|
||
// branch, not a rung (T7.3).
|
||
const STATUS_ORDER = ['Draft','Scheduled','Issued','In Progress','Ready for QA','QC','Closed'];
|
||
const ISSUED_IDX = STATUS_ORDER.indexOf('Issued');
|
||
|
||
// Acumatica cost codes (comment 10) — code|description
|
||
// CR-003. Exactly three, in escalating order — the order is the sort order, and
|
||
// it is why priority does not sort alphabetically (High, Normal, Urgent would put
|
||
// the most urgent last). Do not add a fourth: the meeting settled on three, and a
|
||
// fourth level is how a priority scale stops meaning anything.
|
||
const WP_PRIORITIES = ['Normal', 'High', 'Urgent'];
|
||
const WP_PRIORITY_DEFAULT = 'Normal';
|
||
|
||
const COST_CODES = ['1000|Project Management','2000|Design and Development','2100|Design','2110|Control System Design','2120|Instrument Design','2130|Electrical Design','2140|Panel Design','2141|Panel Design Rework','2150|BIM','2151|BIM Rework','2160|Documentation','2200|Development','2210|PLC Programming','2220|OIT Programming','2230|SCADA Programming','2240|Simulation Development','2290|Programming Subcontract','2300|Customer Training','3000|Operational Technology','3100|OT Design','3200|Rack Assembly','3300|Network Configuration','3400|Computer Configuration','4000|Construction','4010|Instruments Install','4020|Network & Computers Install','4040|PLC Install','4050|Panel Install','4060|Electrical Install','4070|Mechanical Install','4080|Security Install','4090|Radio Install','4100|Commissioning','4940|Contract Labor','4960|Electrical Subcontract','4970|Mechanical Subcontract','4980|Security Subcontract','4990|Other/Radio Subcontract'];
|
||
// Acumatica allowed units of measure (comment 15) — common first
|
||
const ACU_UNITS = ['EA','EACH','FT','M','METER','HR','DAYS','MINUTE','KG','LITER','CASE','LOT','LS','PK','PACK','PALLET','PIECE','BOTTLE','CAN'];
|
||
|
||
// Example built work package (comment 4 / "Load Example") — WP02 export
|
||
const EXAMPLE_PKG = {"number":"WP02-1P-ELEC-Conduit","status":"Issue","subject":"FAB 1P Horn Strobe Conduit","type":"Conduit Install","system":"Chilled Water","location":"FAB / LVL 1 / Sect P","cost":"4060","wbs":"2001","assignees":"David Velazquez (Catapult Solutions Group), Jesus Casiano-Figueroa (Prime Controls)","distribution":"Sean Tolley (Prime Controls), Jefferson Dufriend (Prime Controls)","due":"2026-06-18","spec":"26_05_33_31 - Conduit","desc":"1P Conduit run for horns and strobes","assets":[{"tag":"1P-HS-NORTH","desc":"1P North horn/strobe circuit","link":"https://controls.dev/assets/1P-HS-NORTH"}],"work":"Layout conduit route\nUsing ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.\nWhen complete initiate inspection with Prime QAQC","workSteps":["Layout conduit route","Using ladders and MEWP install conduit and boxes with proper strapping, supports, and pull points according to 2D drawings and 3D model. Reference package drawings included in attachments.","When complete initiate inspection with Prime QAQC"],"numberDims":{"Sector":"1P","Discipline":"ELEC"},"hours":"60","seq":"Layout","materials":[{"qty":"400","unit":"FT","desc":"3/4\" EMT"},{"qty":"300","unit":"FT","desc":"1\" EMT"},{"qty":"50","unit":"FT","desc":"7/8\" strut"},{"qty":"50","unit":"FT","desc":"1-5/8\" strut"},{"qty":"8","unit":"EA","desc":"4x4x4 NEMA 3 Box"},{"qty":"2","unit":"EA","desc":"1\" C-Type Conduit Body"},{"qty":"1","unit":"EA","desc":"1\" T-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" C-Type Conduit Body"},{"qty":"2","unit":"EA","desc":"3/4\" T-Type Conduit Body"},{"qty":"3","unit":"EA","desc":"3/4\" EMT LB & Cover"},{"qty":"6","unit":"EA","desc":"Hoffman F44GCPNK Horn Strobe Box"},{"qty":"2","unit":"EA","desc":"EMO 2x4 Box"},{"qty":"4","unit":"EA","desc":"1\" Bond Bushing w/ Lug"},{"qty":"4","unit":"EA","desc":"3/4\" Bond Bushing w/ Lug"},{"qty":"50","unit":"EA","desc":"1/4\"x3\" Toggle Bolt"},{"qty":"100","unit":"EA","desc":"Drywall Anchor"},{"qty":"5","unit":"EA","desc":"1\" to 3/4\" threaded reducer"}],"attachments":[{"doc":"EE-1YA-2P-5_ HPM PANEL CALLOUT 05","rev":"0","link":"https://us02.procore.com/webclients/host/companies/562949953431440/projects/562949954073428/tools/document-viewer/prostore/562950644886790"}],"kitStatus":"Open","kitOwner":"Ian Spielburg","kitDate":"2026-06-15","mimoTime":"2026-06-11T10:30","mimoLoc":"04","constraints":[{"name":"Safety & Permitting","status":"cleared","comment":""},{"name":"Quality Control / Inspection","status":"cleared","comment":""},{"name":"IFC Drawings & Specs","status":"cleared","comment":""},{"name":"Schedule","status":"cleared","comment":""},{"name":"Materials (on site, bagged & tagged)","status":"cleared","comment":""},{"name":"Prefabrication","status":"cleared","comment":""},{"name":"Work Access & Laydown","status":"cleared","comment":""},{"name":"Craft Availability","status":"cleared","comment":""},{"name":"Construction Equipment & Tools","status":"open","comment":"Boom lift not yet on site"},{"name":"Scaffolding / Access Equipment","status":"cleared","comment":""}],"qc":"Yes — Detailed inspection items","photo":"Key checkpoints only","hold":"HOLD: Prime QAQC to inspect rough-in before cover/cover-up. WITNESS: client QC to observe megger / insulation-resistance test before energization.","overrides":{"wp_photo":"Change Order"},"signoffs":[{"role":"Planner","name":"L. Graver","date":"2026-06-10","signed":true,"dateReason":""},{"role":"Superintendent","name":"K. Boyd","date":"2026-06-10","signed":true,"dateReason":""},{"role":"HSE Professional","name":"A. Reyes","date":"","signed":false,"dateReason":""},{"role":"Quality Representative","name":"D. Nguyen","date":"","signed":false,"dateReason":""},{"role":"Work Foreman","name":"M. Torres","date":"","signed":false,"dateReason":""}],"holds":[],"actualHrs":"54","installedQty":"200 ft","redlines":"Conduit size changed. need to update model","lessons":"Prepping trapeze hangers with conduit straps saved time","project":"Micron — INC Construction Work Packages","track":"CxAlloy"};
|
||
|
||
// ── STATE ────────────────────────────────────────────────────────────────────
|
||
let SOP=null, editingId=null, numberDirty=false;
|
||
let pkgKind='iwp'; // 'iwp' (install) | 'ewp' (BIM) — per-package, only relevant when SOP.bimEnabled
|
||
let activeProjectId=''; // set at boot from ?project=<id>; stamped onto saved WPs for the API
|
||
let pkgMaterials=[], pkgAttach=[], pkgConstraints=[], pkgSignoffs=[], pkgHolds=[], pkgWorkSteps=[], pkgAssets=[], pkgQaRejections=[];
|
||
let pkgFiles=[]; // CR-007: uploaded drawing METAS - bytes live on the server, data['files'] is server-owned
|
||
let pkgOverrides={}; // {fieldId: reason} for SOP-locked fields that were edited
|
||
let numberDims={}; // {Sector:'', Discipline:''} dimensions that build the WP number (comment 3)
|
||
let pkgDisciplines=[]; // disciplines this WP covers (from SOP governance.disciplines)
|
||
let pkgScope={}; // {discipline:[steps]} — per-discipline scope when multi-discipline
|
||
let pkgDiscStatus={}; // {discipline:status} — per-discipline phase tracking (e.g. Issued-Electrical)
|
||
let prevStatus='Draft';
|
||
let devMode=false; // comment 7: pauses usage tracking during review
|
||
let savedPackages=[];
|
||
let currentView='Work Package Form';
|
||
|
||
// ── HELPERS ──────────────────────────────────────────────────────────────────
|
||
function gv(id){ return document.getElementById(id)?.value?.trim() || ''; }
|
||
// Attribute-safe HTML escaper (also escapes " and ' so values are safe inside
|
||
// href="…" / src="…" attributes, not just element text).
|
||
function esc(v){ if(v==null) return ''; return String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||
// Only allow http(s) URLs into an href; anything else (e.g. javascript:) → '#'.
|
||
function hrefAttr(u){ return /^https?:\/\//i.test(String(u||'')) ? esc(u) : '#'; }
|
||
function ns(){ return '<span style="color:var(--text-dim)">—</span>'; }
|
||
function cell(v){ return v ? esc(v) : ns(); }
|
||
function pad2(n){ return n<10?'0'+n:''+n; }
|
||
function typeCode(name){ return (name||'').replace(/[^a-z0-9]+/gi,''); }
|
||
function linkify(v){ if(!v) return ''; return esc(v).replace(/(https?:\/\/[^\s]+)/g,u=>`<a href="${u}" target="_blank" rel="noopener">${u}</a>`); }
|
||
// S10: a toast that nobody hears is not a notification. role="status" by default
|
||
// so a confirmation waits its turn; toast(msg, 'alert') for anything the user has
|
||
// to act on, which interrupts. Same vocabulary as login.html, which was the only
|
||
// place in the app already doing this correctly.
|
||
function toast(msg, kind){
|
||
let t=document.getElementById('toast');
|
||
if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
|
||
// Set the role BEFORE the text: assistive technology announces on the content
|
||
// change, so a role applied afterwards describes the next message, not this one.
|
||
t.setAttribute('role', kind === 'alert' ? 'alert' : 'status');
|
||
t.textContent=msg; t.classList.add('show');
|
||
clearTimeout(toast._t); toast._t=setTimeout(()=>t.classList.remove('show'),2200);
|
||
}
|
||
function getRadio(name){ const s=document.querySelector(`.radio-pill.selected input[name="${name}"]`); return s?.closest('.radio-pill')?.dataset?.val||''; }
|
||
function setRadio(name,val){ document.querySelectorAll(`.radio-pill input[name="${name}"]`).forEach(i=>{const p=i.closest('.radio-pill'); const on=p.dataset.val===val; p.classList.toggle('selected',on); i.checked=on;}); }
|
||
function enabledTypes(){ return ((SOP&&SOP.woTypes)||[]).filter(t=>t.enabled!==false); }
|
||
function constraintNames(){
|
||
let cs = (SOP&&Array.isArray(SOP.constraints)&&SOP.constraints.length)?SOP.constraints:DEFAULT_CONSTRAINTS;
|
||
// On a BIM-enabled project, EWPs use the BIM gates and IWPs use the install gates.
|
||
if(bimSOP()) cs = cs.filter(c => (c && typeof c==='object') ? (isEwp() ? c.bim : !c.bim) : !isEwp());
|
||
return cs;
|
||
}
|
||
function nextSeq(){ return savedPackages.length+1; }
|
||
|
||
// ── SOP LOADING ──────────────────────────────────────────────────────────────
|
||
function loadSampleSOP(){ SOP=JSON.parse(JSON.stringify(SAMPLE_SOP)); applySOP(); newPackage(); toast('Sample SOP loaded — ' + (SOP.project&&SOP.project.name||'project')); track('sample_loaded'); }
|
||
function importSOP(ev){
|
||
const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader();
|
||
r.onload=()=>{ try{ const d=JSON.parse(r.result); if(!d.woTypes){ alert('That file does not look like an SOP export from the Configuration tool.'); return; }
|
||
SOP=d; applySOP(); newPackage(); toast('Loaded SOP — '+((d.project&&d.project.name)||'project')); track('sop_imported');
|
||
}catch(e){ alert('Could not read that file.'); } ev.target.value=''; };
|
||
r.readAsText(f);
|
||
}
|
||
function applySOP(){
|
||
renderCtxBar(); buildTypePicker(); buildCostCodes(); buildSequencePicker(); buildNumberDims();
|
||
buildDisciplinePicker(); renderScope(); onHoursChange();
|
||
renderSopRefLinks(); renderSpecFolderLink(); applySpecFromType(); initSopHintTips();
|
||
// SOP-inherited Quality fields — populated then locked (editable only with a logged reason)
|
||
if(SOP.quality){
|
||
document.getElementById('wp_qc').value=[SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
|
||
document.getElementById('wp_photo').value=SOP.quality.photo||'';
|
||
document.getElementById('wp_hold').value=SOP.quality.holdPoints||'';
|
||
}
|
||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||
const hint=document.getElementById('wp_number_hint'); hint.textContent = SOP.governance&&SOP.governance.woFormat ? 'auto-built · format: '+SOP.governance.woFormat : '';
|
||
buildConstraints(); buildSignoffs();
|
||
applyKind();
|
||
if(!pkgMaterials.length){ pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||
if(!pkgAttach.length){ pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach(); }
|
||
if(!pkgAssets.length){ pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||
if(!pkgWorkSteps.length){ pkgWorkSteps=['']; buildWorkSteps(); }
|
||
updateNumber(); updateReleaseBanner();
|
||
// CR-006. Last, after every builder has rendered its card — applying it earlier
|
||
// would be undone by whichever builder touched the same card afterwards.
|
||
applySopSections(SOP && SOP.sections, SOP && SOP.fields);
|
||
}
|
||
// Per-package kind. A project whose SOP has bimEnabled produces both install (IWP)
|
||
// and BIM (EWP) packages; the kind selector tailors which fields, WP types, and
|
||
// release gates apply. Install-only projects never show the selector.
|
||
// A project is on the BIM path only if its SOP enabled it AND an administrator
|
||
// has the BIM/VDC tooling switched on app-wide (Admin Console → Features). With
|
||
// the flag off, a SOP that already has BIM keeps its data but every package here
|
||
// behaves as install-only — no IWP/EWP choice, no BIM fields or gates.
|
||
function bimSOP(){ return !!(SOP && SOP.bimEnabled) && (typeof wpBimEnabled === 'function' ? wpBimEnabled() : false); }
|
||
function isEwp(){ return bimSOP() && pkgKind === 'ewp'; }
|
||
function setKind(k){
|
||
if(pkgKind === k) return;
|
||
pkgKind = (k === 'ewp') ? 'ewp' : 'iwp';
|
||
const tEl = document.getElementById('wp_type'); if(tEl) tEl.value = ''; // type list changes with kind
|
||
applyKind();
|
||
numberDirty = false; updateNumber(); updateReleaseBanner();
|
||
track('kind_changed', {kind: pkgKind});
|
||
}
|
||
// Which cards this package kind uses. Split out from applyKind() because
|
||
// showForm() clears every card's inline display and has to restore just the
|
||
// visibility — without rebuilding the type picker and constraint rows.
|
||
function applyKindVisibility(){
|
||
const bimProj = bimSOP(), ewp = isEwp();
|
||
const show = (id, on) => { const el = document.getElementById(id); if(el) el.style.display = on ? '' : 'none'; };
|
||
show('kind-row', bimProj);
|
||
show('bim-card', ewp); // model area / clash + IFF # / scan
|
||
show('asset-card', !ewp); // controls.dev assets
|
||
show('material-card', !ewp); // bill of materials
|
||
show('mimo-card', !ewp); // kitting / MIMO
|
||
show('bimlink-wrap', bimProj && !ewp); // an IWP references the BIM package that enabled it
|
||
}
|
||
|
||
function applyKind(){
|
||
const bimProj = bimSOP();
|
||
applyKindVisibility();
|
||
if(bimProj) setRadio('pkgkind', pkgKind);
|
||
buildTypePicker(); // filtered by kind
|
||
buildConstraints(); // filtered by kind
|
||
}
|
||
// The IFF number is the GC's sign-off reference, so it only becomes meaningful
|
||
// once coordination reaches "Signed off (IFF)" — at which point it's required.
|
||
function iffRequired(){ return (gv('wp_clash') || '') === 'Signed off (IFF)'; }
|
||
function onClashChange(){
|
||
const hint = document.getElementById('iff-hint');
|
||
const inp = document.getElementById('wp_iff');
|
||
if(!hint || !inp) return;
|
||
const need = iffRequired();
|
||
if(need && !inp.value.trim()){
|
||
hint.textContent = 'Required — coordination is signed off, so record the IFF number.';
|
||
hint.style.color = 'var(--accent-amber)';
|
||
} else if(need){
|
||
hint.textContent = '';
|
||
} else {
|
||
hint.textContent = 'Recorded when the GC signs the model package off.';
|
||
hint.style.color = '';
|
||
}
|
||
}
|
||
|
||
function buildCostCodes(){
|
||
const sel=document.getElementById('wp_cost'); const cur=sel.value;
|
||
sel.innerHTML=`<option value="">Select cost code…</option>`+COST_CODES.map(c=>{const [code,desc]=c.split('|'); return `<option value="${code}">${code} — ${esc(desc)}</option>`;}).join('');
|
||
if(cur) sel.value=cur;
|
||
}
|
||
// ── SOP-LOCKED QUALITY FIELDS (comment 3) ────────────────────────────────────
|
||
function sopValueFor(id){
|
||
if(!SOP||!SOP.quality) return '';
|
||
if(id==='wp_qc') return [SOP.quality.qcReq,SOP.quality.qcScope].filter(Boolean).join(' — ');
|
||
if(id==='wp_photo') return SOP.quality.photo||'';
|
||
if(id==='wp_hold') return SOP.quality.holdPoints||'';
|
||
return '';
|
||
}
|
||
function lockQuality(id){
|
||
const el=document.getElementById(id); if(!el) return;
|
||
const fromSOP = !!(SOP&&SOP.quality) && !pkgOverrides[id];
|
||
el.readOnly = fromSOP;
|
||
el.classList.toggle('sop-inherited', fromSOP); // comment 8: blue-tinted SOP field
|
||
el.classList.toggle('locked-field', false);
|
||
const wrap=el.closest('.field'); const btn=wrap&&wrap.querySelector('.lock-edit'); const note=wrap&&wrap.querySelector('.override-note');
|
||
if(btn) btn.textContent = fromSOP ? '🔒 Edit (reason required)' : '↺ Revert to SOP';
|
||
if(note) note.innerHTML = pkgOverrides[id] ? `Overridden: ${esc(pkgOverrides[id])}` : '';
|
||
}
|
||
function editQuality(id){
|
||
if(pkgOverrides[id]){ // currently overridden -> revert to SOP value
|
||
delete pkgOverrides[id];
|
||
document.getElementById(id).value=sopValueFor(id);
|
||
lockQuality(id); track('quality_reverted',{field:id}); return;
|
||
}
|
||
const reason=prompt('This field is set by the project SOP. Enter the reason for overriding it on this package:');
|
||
if(reason===null) return;
|
||
if(!reason.trim()){ alert('A reason is required to override an SOP field.'); return; }
|
||
pkgOverrides[id]=reason.trim();
|
||
const el=document.getElementById(id); el.readOnly=false; el.classList.remove('locked-field'); el.focus();
|
||
lockQuality(id); track('quality_overridden',{field:id});
|
||
}
|
||
function renderCtxBar(){
|
||
const bar=document.getElementById('ctx-bar');
|
||
if(!SOP){
|
||
bar.innerHTML = activeProjectId
|
||
? `<div class="ctx-empty">No SOP found for this project yet — complete the <strong>SOP Configuration</strong> first, then return here.</div>`
|
||
: `<div class="ctx-empty">No SOP loaded — <button class="link-btn" onclick="loadSampleSOP()">load the sample</button> or import one from the Configuration tool.</div>`;
|
||
return;
|
||
}
|
||
const p=SOP.project||{}, g=SOP.governance||{};
|
||
const sample=SOP.meta&&SOP.meta.sample?`<span class="ctx-sample">SAMPLE</span>`:'';
|
||
bar.innerHTML=`<div class="ctx-main"><div class="ctx-proj">${esc(p.name||'Untitled')} ${sample}</div>
|
||
<div class="ctx-sub">${esc(p.number||'')}${p.division?' · '+esc(p.division):''}</div></div>
|
||
<div class="ctx-meta"><span><b>${enabledTypes().length}</b> types</span><span>format <code>${esc(g.woFormat||'—')}</code></span><span>track: <b>${esc((SOP.field&&SOP.field.trackPlatform)||'—')}</b></span></div>`;
|
||
}
|
||
|
||
// ── DEV MODE (comment 7) ─────────────────────────────────────────────────────
|
||
function toggleDevMode(){
|
||
devMode=!devMode;
|
||
document.body.classList.toggle('dev-mode', devMode);
|
||
document.getElementById('dev-banner').style.display = devMode ? '' : 'none';
|
||
toast(devMode ? 'DEV MODE on — usage tracking paused' : 'DEV MODE off — tracking resumed');
|
||
}
|
||
|
||
// ── SOP REFERENCE LINKS (comments 1, 3, 4) ───────────────────────────────────
|
||
function sopLinkedSources(){ return ((SOP&&SOP.sources)||[]).filter(s=>s.link); }
|
||
function renderSopRefLinks(){
|
||
const box=document.getElementById('sop-ref-links'); if(!box) return;
|
||
const srcs=sopLinkedSources();
|
||
if(!srcs.length){ box.innerHTML=''; return; }
|
||
box.innerHTML=`<div class="ref-links-title">Reference folders (from SOP) — navigate to find & copy the specific file link:</div>`+
|
||
`<div class="ref-links">`+srcs.map(s=>`<a href="${hrefAttr(s.link)}" target="_blank" rel="noopener" class="ref-link">📁 ${esc(s.label)} <span class="ref-sys">${esc(s.system||'')}</span></a>`).join('')+`</div>`;
|
||
}
|
||
function renderSpecFolderLink(){
|
||
const el=document.getElementById('spec-folder-link'); if(!el) return;
|
||
const spec=sopLinkedSources().find(s=>/spec/i.test(s.label));
|
||
const link = spec
|
||
? `<a href="${hrefAttr(spec.link)}" target="_blank" rel="noopener" class="ref-link-sm">↗ Open spec folder (${esc(spec.system||'SOP')})</a>`
|
||
: '';
|
||
// Say where the value came from, since the field itself is read-only now.
|
||
const src = gv('wp_spec')
|
||
? `<span style="color:var(--text-dim)">from the SOP’s WP type${link ? ' · ' : ''}</span>`
|
||
: `<span style="color:var(--text-dim)">no spec section set on this WP type in the SOP</span>`;
|
||
el.innerHTML = src + link;
|
||
}
|
||
|
||
// ── PEOPLE PICKERS: Assignees + Distribution ──────────────────────────────────
|
||
// Both were free-text lists. They're now multi-selects over the project team named
|
||
// on the SOP (site comments 8/3), while still accepting a typed name for someone
|
||
// with no user account — subcontractors and GC contacts have to stay addable.
|
||
//
|
||
// Stored shape keeps BOTH: `assignees`/`distribution` remain comma-joined display
|
||
// strings (print, export and the dashboard already read those), and
|
||
// `assigneeIds`/`distributionIds` carry the account ids that notification routing
|
||
// will need. The hidden inputs keep gv()/set() working unchanged.
|
||
let pkgPeople = { assignees: [], distribution: [] }; // [{id,name}] — id '' = typed name
|
||
|
||
function peopleFieldId(kind){ return kind === 'assignees' ? 'wp_assignees' : 'wp_distribution'; }
|
||
|
||
// The SOP team first (they're this project's named people), then anyone else on
|
||
// the project. Mirrors the Owner picker's ordering.
|
||
function peopleOptions(){
|
||
const team = sopTeamIds();
|
||
const onTeam = projectMembers.filter(u => team.includes(u.id));
|
||
const others = projectMembers.filter(u => !team.includes(u.id));
|
||
return { onTeam, others };
|
||
}
|
||
|
||
function cmMember(){
|
||
const cmId = (SOP && SOP.project && SOP.project.cmId) || '';
|
||
return cmId ? (projectMembers.find(u => u.id === cmId) || null) : null;
|
||
}
|
||
|
||
function syncPeopleField(kind){
|
||
const el = document.getElementById(peopleFieldId(kind));
|
||
if(el) el.value = pkgPeople[kind].map(p => p.name).filter(Boolean).join(', ');
|
||
}
|
||
|
||
function renderPeoplePicker(kind){
|
||
const box = document.getElementById('pick_' + kind);
|
||
if(!box) return;
|
||
const cm = cmMember();
|
||
const isCm = p => !!(cm && p.id === cm.id && kind === 'distribution');
|
||
const chips = pkgPeople[kind].map((p, i) => {
|
||
// The CM stays on distribution by default but can be dropped per package, so
|
||
// the chip is marked rather than locked.
|
||
const tag = isCm(p) ? ' pp-locked' : '';
|
||
const title = isCm(p) ? 'Construction Manager — included by default' : (p.id ? '' : 'Typed name (no user account)');
|
||
return `<span class="pp-chip${tag}" title="${esc(title)}"><span class="pp-name">${esc(p.name)}</span>` +
|
||
`<button type="button" class="pp-x" title="Remove" onclick="removePerson('${kind}',${i})">✕</button></span>`;
|
||
}).join('');
|
||
const { onTeam, others } = peopleOptions();
|
||
const opt = u => {
|
||
const on = pkgPeople[kind].some(p => p.id === u.id);
|
||
return `<label class="pp-opt"><input type="checkbox" ${on?'checked':''} onchange="togglePerson('${kind}','${esc(u.id)}',this.checked)">` +
|
||
`<span>${esc(u.full_name||u.username)}${u.project_role?` <span class="pp-role">${esc(u.project_role)}</span>`:''}</span></label>`;
|
||
};
|
||
const menu =
|
||
(onTeam.length ? `<div class="pp-group">Project team (from SOP)</div>${onTeam.map(opt).join('')}` : '') +
|
||
(others.length ? `<div class="pp-group">${onTeam.length?'Others on this project':'On this project'}</div>${others.map(opt).join('')}` : '') +
|
||
(!onTeam.length && !others.length ? `<div class="pp-group">No project members found</div>` : '') +
|
||
`<div class="pp-free">
|
||
<input type="text" placeholder="Add a name not on the list…" onkeydown="if(event.key==='Enter'){event.preventDefault();addTypedPerson('${kind}',this);}">
|
||
<div class="field-hint">Someone with no user account — they can't be emailed by the suite.</div>
|
||
</div>`;
|
||
box.innerHTML = chips +
|
||
`<span class="pp-add">
|
||
<button type="button" class="pp-add-btn" onclick="togglePeopleMenu('${kind}')">+ Add</button>
|
||
<div class="pp-menu" id="ppmenu_${kind}" hidden>${menu}</div>
|
||
</span>`;
|
||
syncPeopleField(kind);
|
||
}
|
||
|
||
function togglePeopleMenu(kind){
|
||
const m = document.getElementById('ppmenu_' + kind);
|
||
if(!m) return;
|
||
const open = m.hidden;
|
||
document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; });
|
||
m.hidden = !open;
|
||
}
|
||
document.addEventListener('click', e => {
|
||
if(!e.target.closest || !e.target.closest('.pp-add')){
|
||
document.querySelectorAll('.pp-menu').forEach(x => { x.hidden = true; });
|
||
}
|
||
});
|
||
|
||
function togglePerson(kind, userId, on){
|
||
const u = projectMembers.find(x => x.id === userId);
|
||
if(!u) return;
|
||
const list = pkgPeople[kind];
|
||
const ix = list.findIndex(p => p.id === userId);
|
||
if(on && ix < 0) list.push({ id: u.id, name: u.full_name || u.username });
|
||
else if(!on && ix >= 0) list.splice(ix, 1);
|
||
renderPeoplePicker(kind);
|
||
}
|
||
|
||
function addTypedPerson(kind, input){
|
||
const name = (input.value || '').trim();
|
||
if(!name) return;
|
||
if(!pkgPeople[kind].some(p => p.name.toLowerCase() === name.toLowerCase())){
|
||
pkgPeople[kind].push({ id: '', name });
|
||
}
|
||
input.value = '';
|
||
renderPeoplePicker(kind);
|
||
}
|
||
|
||
function removePerson(kind, i){
|
||
pkgPeople[kind].splice(i, 1);
|
||
renderPeoplePicker(kind);
|
||
}
|
||
|
||
// Parse whatever is stored on a package back into chips. Names that match a
|
||
// project member are re-linked to that account; the rest stay as typed names.
|
||
function loadPeopleFromPkg(p){
|
||
const parse = (str, ids) => {
|
||
const names = String(str || '').split(',').map(x => x.trim()).filter(Boolean);
|
||
const byId = (ids || []).map(id => projectMembers.find(u => u.id === id)).filter(Boolean)
|
||
.map(u => ({ id: u.id, name: u.full_name || u.username }));
|
||
const out = byId.slice();
|
||
names.forEach(n => {
|
||
if(out.some(x => x.name.toLowerCase() === n.toLowerCase())) return;
|
||
const u = projectMembers.find(m => (m.full_name || m.username || '').toLowerCase() === n.toLowerCase());
|
||
out.push(u ? { id: u.id, name: u.full_name || u.username } : { id: '', name: n });
|
||
});
|
||
return out;
|
||
};
|
||
pkgPeople.assignees = parse(p && p.assignees, p && p.assigneeIds);
|
||
pkgPeople.distribution = parse(p && p.distribution, p && p.distributionIds);
|
||
renderPeoplePicker('assignees');
|
||
renderPeoplePicker('distribution');
|
||
}
|
||
|
||
// A new package starts with the project's CM on distribution (site comment 8/3).
|
||
function resetPeopleForNewPackage(){
|
||
pkgPeople = { assignees: [], distribution: [] };
|
||
const cm = cmMember();
|
||
if(cm) pkgPeople.distribution.push({ id: cm.id, name: cm.full_name || cm.username });
|
||
renderPeoplePicker('assignees');
|
||
renderPeoplePicker('distribution');
|
||
}
|
||
|
||
// ── PREDECESSOR WORK PACKAGES ─────────────────────────────────────────────────
|
||
// "Package Predecessor" used to be a free-text SOP phase label, which couldn't
|
||
// express "WP04 waits on WP02" and gated nothing. It's now a set of references to
|
||
// other packages on the project, and an unclosed predecessor makes a package not
|
||
// release-ready (server: enforce_release_gates). The SOP phase survives as the
|
||
// descriptive "Sequence phase" field beside it.
|
||
let pkgPredecessors = []; // [wpId]
|
||
let pkgGateOverride = null; // {reason, at, by} once a planner releases early
|
||
|
||
function wpById(id){ return savedPackages.find(p => p.id === id) || null; }
|
||
|
||
// Everything this package already blocks, directly or through a chain — offering
|
||
// any of them as a predecessor would create a cycle, so they're excluded.
|
||
function descendantsOf(id){
|
||
const out = new Set();
|
||
const stack = [id];
|
||
while(stack.length){
|
||
const cur = stack.pop();
|
||
savedPackages.forEach(p => {
|
||
if((p.predecessors || []).includes(cur) && !out.has(p.id)){
|
||
out.add(p.id);
|
||
stack.push(p.id);
|
||
}
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function predCandidates(){
|
||
const self = editingId || '';
|
||
const banned = self ? descendantsOf(self) : new Set();
|
||
return savedPackages.filter(p => p.id !== self && !banned.has(p.id));
|
||
}
|
||
|
||
// Predecessors that aren't Closed yet. Missing ones (deleted since) don't block —
|
||
// a deleted package must not freeze everything downstream of it.
|
||
function blockingPredecessors(){
|
||
return pkgPredecessors.map(id => wpById(id)).filter(p => p && p.status !== 'Closed');
|
||
}
|
||
|
||
function predLabel(p){ return (p.number || '(unnumbered)') + ' — ' + (p.subject || 'untitled'); }
|
||
|
||
function renderPredPicker(){
|
||
const box = document.getElementById('pick_predecessors');
|
||
if(!box) return;
|
||
const chips = pkgPredecessors.map((id, i) => {
|
||
const p = wpById(id);
|
||
const gone = !p;
|
||
const done = p && p.status === 'Closed';
|
||
const title = gone ? 'This package no longer exists — it does not block release.'
|
||
: (done ? 'Closed — cleared' : p.status + ' — blocks release until Closed');
|
||
const cls = done ? ' pp-locked' : '';
|
||
const name = gone ? '(deleted package)' : (p.number || p.subject || id);
|
||
return `<span class="pp-chip${cls}" title="${esc(title)}"><span class="pp-name">${done?'✓ ':''}${esc(name)}</span>` +
|
||
`<button type="button" class="pp-x" title="Remove" onclick="removePredecessor(${i})">✕</button></span>`;
|
||
}).join('');
|
||
const cands = predCandidates();
|
||
const opts = cands.map(p => {
|
||
const on = pkgPredecessors.includes(p.id);
|
||
return `<label class="pp-opt"><input type="checkbox" ${on?'checked':''} onchange="togglePredecessor('${esc(p.id)}',this.checked)">` +
|
||
`<span>${esc(p.number || '(unnumbered)')} <span class="pp-role">${esc(p.status || 'Draft')}</span><br>` +
|
||
`<span class="pp-role">${esc((p.subject||'').slice(0,60))}</span></span></label>`;
|
||
}).join('');
|
||
box.innerHTML = chips +
|
||
`<span class="pp-add">
|
||
<button type="button" class="pp-add-btn" onclick="togglePeopleMenu('predecessors')">+ Add</button>
|
||
<div class="pp-menu" id="ppmenu_predecessors" hidden>
|
||
${cands.length ? `<div class="pp-group">Work packages on this project</div>${opts}`
|
||
: `<div class="pp-group">No other packages saved yet</div>`}
|
||
</div>
|
||
</span>`;
|
||
renderPredHint();
|
||
}
|
||
|
||
function renderPredHint(){
|
||
const el = document.getElementById('pred-hint');
|
||
if(!el) return;
|
||
const blocking = blockingPredecessors();
|
||
if(!pkgPredecessors.length){
|
||
el.textContent = 'None — this package can be released as soon as its constraints are cleared.';
|
||
el.style.color = '';
|
||
} else if(blocking.length){
|
||
el.innerHTML = '⛔ Waiting on ' + blocking.map(p => esc(p.number || p.id) + ' (' + esc(p.status) + ')').join(', ') +
|
||
'. Release is gated until they are Closed.';
|
||
el.style.color = 'var(--red)';
|
||
} else {
|
||
el.textContent = '✓ All predecessors are Closed.';
|
||
el.style.color = 'var(--accent-green)';
|
||
}
|
||
}
|
||
|
||
function togglePredecessor(id, on){
|
||
const ix = pkgPredecessors.indexOf(id);
|
||
if(on && ix < 0) pkgPredecessors.push(id);
|
||
else if(!on && ix >= 0) pkgPredecessors.splice(ix, 1);
|
||
// The set changed, so a previously-granted override no longer describes reality.
|
||
pkgGateOverride = null;
|
||
renderPredPicker();
|
||
updateReleaseBanner();
|
||
}
|
||
|
||
function removePredecessor(i){
|
||
pkgPredecessors.splice(i, 1);
|
||
pkgGateOverride = null;
|
||
renderPredPicker();
|
||
updateReleaseBanner();
|
||
}
|
||
|
||
// ── SOP-inherited hints → label tooltips (site comment 8/3) ───────────────────
|
||
// The blue "from SOP types" subtext under a field becomes a small SOP chip on the
|
||
// label, with the detail on hover. The hint elements stay in the DOM (hidden) so
|
||
// the code that writes into them keeps working; an observer mirrors their text
|
||
// into the chip's tooltip.
|
||
function initSopHintTips(){
|
||
document.querySelectorAll('.field .field-hint.sop-hint').forEach(hint => {
|
||
if(hint.dataset.tipped) return;
|
||
const field = hint.closest('.field');
|
||
const label = field && field.querySelector('label');
|
||
if(!label) return;
|
||
hint.dataset.tipped = '1';
|
||
const chip = document.createElement('span');
|
||
chip.className = 'sop-chip';
|
||
chip.textContent = 'SOP';
|
||
chip.tabIndex = 0; // reachable by keyboard, not hover-only
|
||
label.appendChild(chip);
|
||
const sync = () => {
|
||
const t = (hint.textContent || '').trim();
|
||
chip.dataset.tip = t ? 'From the project SOP — ' + t : 'Inherited from the project SOP.';
|
||
};
|
||
sync();
|
||
try { new MutationObserver(sync).observe(hint, {childList:true, characterData:true, subtree:true}); }
|
||
catch(e){ /* no observer: the tooltip just won't track later edits */ }
|
||
});
|
||
}
|
||
function buildTypePicker(){
|
||
let types = enabledTypes();
|
||
if(bimSOP()) types = types.filter(t => isEwp() ? t.bim : !t.bim); // BIM types only for EWP, install types only for IWP
|
||
const sel=document.getElementById('wp_type'); const cur=sel.value;
|
||
sel.innerHTML=`<option value="">Select…</option>`+types.map(t=>`<option>${esc(t.name)}</option>`).join('');
|
||
if(types.some(t=>t.name===cur)) sel.value=cur;
|
||
}
|
||
function buildSequencePicker(){ const steps=((SOP&&SOP.sequence)||[]).filter(s=>s.kind!=='gate'&&(s.label||'').trim()); document.getElementById('wp_seq').innerHTML=`<option value="">None (no predecessor)</option>`+steps.map(s=>`<option>${esc(s.label)}</option>`).join(''); }
|
||
function onTypeChange(){
|
||
updateNumber(); applySpecFromType(); track('type_selected');
|
||
}
|
||
|
||
// Specification Section is authored once per WP type on the SOP (site comments
|
||
// 8/3: "Spec section in general information can be removed" + "Add link to spec
|
||
// section based on SOP"). The field is read-only here and follows the type, so it
|
||
// can't drift package to package. A value stored on an older package is kept if
|
||
// its type has no spec section on the SOP.
|
||
function specForType(name){
|
||
const t = ((SOP && SOP.woTypes) || []).find(x => x && x.name === name);
|
||
return (t && t.specSection) || '';
|
||
}
|
||
function applySpecFromType(){
|
||
const el = document.getElementById('wp_spec'); if(!el) return;
|
||
const fromSop = specForType(gv('wp_type'));
|
||
if(fromSop) el.value = fromSop;
|
||
else if(!el.dataset.legacy) el.value = '';
|
||
renderSpecFolderLink();
|
||
}
|
||
|
||
// ── WP NUMBER (auto-built from per-WP dimensions + type + sequence) ───────────
|
||
function numberTokens(){
|
||
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
|
||
return (fmt.match(/\[[^\]]+\]/g)||[]).map(t=>t.replace(/[\[\]]/g,'')).filter(t=>t.toUpperCase()!=='TYPE');
|
||
}
|
||
function buildNumberDims(){
|
||
const wrap=document.getElementById('number-dims'); if(!wrap) return;
|
||
const toks=numberTokens();
|
||
wrap.innerHTML = toks.length ? toks.map(t=>{
|
||
return `<div class="field"><label>${esc(t)}</label>
|
||
<input type="text" id="ndim_${esc(t)}" value="${(numberDims[t]||'').replace(/"/g,'"')}" placeholder="${esc(t)} code" oninput="numberDims['${esc(t)}']=this.value;updateNumber()"></div>`;
|
||
}).join('') : '<div class="field-hint">SOP number format has no scope tokens.</div>';
|
||
}
|
||
function typeNumberCode(name){ return (name||'').replace(/\b(install|installation)\b/ig,'').replace(/[^a-z0-9]+/gi,'').trim(); }
|
||
function updateNumber(){
|
||
const fmt=(SOP&&SOP.governance&&SOP.governance.woFormat)||'WP##-[TYPE]';
|
||
const t=gv('wp_type');
|
||
let out=fmt.replace(/WO##|WP##/i,'WP'+pad2(editingSeq()));
|
||
numberTokens().forEach(tok=>{
|
||
const v=(numberDims[tok]||'').trim();
|
||
out=out.split('['+tok+']').join(v||('['+tok+']'));
|
||
});
|
||
out=out.split('[TYPE]').join(t?typeNumberCode(t):'[TYPE]');
|
||
document.getElementById('wp_number').value=out;
|
||
}
|
||
function editingSeq(){ // keep an existing package's sequence number if editing, else next
|
||
if(editingId){ const ix=savedPackages.findIndex(p=>p.id===editingId); if(ix>=0) return ix+1; }
|
||
return nextSeq();
|
||
}
|
||
|
||
// ── MATERIAL LIST ────────────────────────────────────────────────────────────
|
||
function unitOptions(cur){
|
||
const list=ACU_UNITS.slice(); const c=(cur||'').toUpperCase();
|
||
if(c && !list.includes(c)) list.unshift(c);
|
||
return `<option value="">Unit…</option>`+list.map(u=>`<option ${c===u?'selected':''}>${esc(u)}</option>`).join('');
|
||
}
|
||
// Discipline column on the BOM appears only for multi-discipline packages, so a
|
||
// split hands each instance just its own materials. Keeps a stale value as an
|
||
// option (mirrors unitOptions) if the discipline was later removed.
|
||
function matDisciplineOptions(cur){
|
||
const list=pkgDisciplines.slice(); const c=cur||'';
|
||
if(c && !list.includes(c)) list.unshift(c);
|
||
return `<option value="">—</option>`+list.map(d=>`<option ${c===d?'selected':''}>${esc(d)}</option>`).join('');
|
||
}
|
||
function buildMaterials(){
|
||
const multi=isMultiDiscipline();
|
||
const th=document.getElementById('mat-disc-th'); if(th) th.style.display=multi?'':'none';
|
||
const tb=document.getElementById('material-body'); tb.innerHTML='';
|
||
pkgMaterials.forEach((m,i)=>{ const tr=document.createElement('tr');
|
||
const discCell = multi ? `<td><select onchange="pkgMaterials[${i}].discipline=this.value">${matDisciplineOptions(m.discipline)}</select></td>` : '';
|
||
tr.innerHTML=`<td><input type="text" value="${(m.qty||'').replace(/"/g,'"')}" placeholder="20" oninput="pkgMaterials[${i}].qty=this.value"></td>
|
||
<td><select onchange="pkgMaterials[${i}].unit=this.value">${unitOptions(m.unit)}</select></td>
|
||
<td><input type="text" value="${(m.desc||'').replace(/"/g,'"')}" placeholder="3/8 lock washers gold" oninput="pkgMaterials[${i}].desc=this.value"></td>
|
||
${discCell}
|
||
<td class="center"><button class="row-del" onclick="removeMaterial(${i})">✕</button></td>`;
|
||
tb.appendChild(tr); });
|
||
}
|
||
function addMaterial(){ pkgMaterials.push({qty:'',unit:'',desc:''}); buildMaterials(); }
|
||
function removeMaterial(i){ pkgMaterials.splice(i,1); if(!pkgMaterials.length)pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials(); }
|
||
function downloadMaterialTemplate(){
|
||
const csv='Qty,Unit,Description\n10,FT,Single deep strut gold\n4,EA,2-hole strut base gold\n200,FT,3/4 EMT conduit\n';
|
||
const blob=new Blob([csv],{type:'text/csv'}); const a=document.createElement('a');
|
||
a.href=URL.createObjectURL(blob); a.download='material-list-template.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('material_template');
|
||
}
|
||
function parseCSV(text){
|
||
const rows=[]; text.replace(/\r\n/g,'\n').split('\n').forEach(line=>{
|
||
if(!line.trim()) return;
|
||
const cells=[]; let cur='', q=false;
|
||
for(let i=0;i<line.length;i++){ const ch=line[i];
|
||
if(q){ if(ch==='"'){ if(line[i+1]==='"'){cur+='"';i++;} else q=false; } else cur+=ch; }
|
||
else { if(ch===','){cells.push(cur);cur='';} else if(ch==='"'){q=true;} else cur+=ch; } }
|
||
cells.push(cur); rows.push(cells);
|
||
});
|
||
return rows;
|
||
}
|
||
function importMaterials(ev){
|
||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
||
if(/\.xlsx?$/i.test(f.name)){ alert('Please save the Excel file as CSV first (File → Save As → CSV), then import. The template download is already CSV.'); ev.target.value=''; return; }
|
||
const r=new FileReader(); r.onload=()=>{
|
||
try{
|
||
const rows=parseCSV(r.result); if(!rows.length){ alert('No rows found.'); return; }
|
||
let start=0; const h=rows[0].map(c=>c.trim().toLowerCase());
|
||
const qi=h.indexOf('qty'), ui=h.indexOf('unit'), di=h.indexOf('description');
|
||
let cq=0,cu=1,cd=2;
|
||
if(qi>-1||ui>-1||di>-1){ start=1; if(qi>-1)cq=qi; if(ui>-1)cu=ui; if(di>-1)cd=di; }
|
||
const imported=[];
|
||
for(let i=start;i<rows.length;i++){ const c=rows[i]; const desc=(c[cd]||'').trim();
|
||
if(!desc && !(c[cq]||'').trim()) continue;
|
||
imported.push({qty:(c[cq]||'').trim(), unit:(c[cu]||'').trim().toUpperCase(), desc}); }
|
||
if(!imported.length){ alert('No material rows found.'); return; }
|
||
pkgMaterials=imported; buildMaterials(); toast('Imported '+imported.length+' material rows'); track('material_imported',{count:imported.length});
|
||
}catch(e){ alert('Could not parse that CSV.'); }
|
||
ev.target.value='';
|
||
};
|
||
r.readAsText(f);
|
||
}
|
||
|
||
// ── DESCRIPTION OF WORK — STEP LIST (comment 16) ─────────────────────────────
|
||
function buildWorkSteps(){
|
||
const tb=document.getElementById('worksteps-body'); if(!tb) return; tb.innerHTML='';
|
||
pkgWorkSteps.forEach((s,i)=>{ const row=document.createElement('div'); row.className='workstep-row';
|
||
row.innerHTML=`<span class="ws-num">${i+1}</span>
|
||
<textarea rows="1" placeholder="Describe step ${i+1}…" oninput="pkgWorkSteps[${i}]=this.value">${esc(s)}</textarea>
|
||
<button class="row-del" onclick="removeWorkStep(${i})" title="Remove step">✕</button>`;
|
||
tb.appendChild(row); });
|
||
}
|
||
function addWorkStep(){ pkgWorkSteps.push(''); buildWorkSteps(); }
|
||
function removeWorkStep(i){ pkgWorkSteps.splice(i,1); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps(); }
|
||
|
||
// ── DISCIPLINES, PER-DISCIPLINE SCOPE & STATUS ───────────────────────────────
|
||
// A project's SOP decides how WPs use disciplines (governance.discMode):
|
||
// 'single' one discipline per WP 'multi' one WP carries several disciplines
|
||
// 'choice' planner decides per package (build big, split later)
|
||
function sopDisciplines(){ const g=(SOP&&SOP.governance)||{}; return (g.disciplines&&g.disciplines.length)?g.disciplines:['Mechanical','Electrical','Tech']; }
|
||
function sopDiscMode(){ return (SOP&&SOP.governance&&SOP.governance.discMode)||'choice'; }
|
||
function disciplinesConfigured(){ return !!(SOP&&SOP.governance&&Array.isArray(SOP.governance.disciplines)&&SOP.governance.disciplines.length); }
|
||
function isMultiDiscipline(){ return pkgDisciplines.length>1; }
|
||
function instanceSuffixStyle(){ return (SOP&&SOP.governance&&SOP.governance.instanceSuffix)||'letter'; }
|
||
|
||
function buildDisciplinePicker(){
|
||
const wrap=document.getElementById('discipline-picker'); if(!wrap) return;
|
||
const card=document.getElementById('discipline-card');
|
||
const list=sopDisciplines();
|
||
if(!disciplinesConfigured() || !list.length){ if(card) card.style.display='none'; return; }
|
||
if(card) card.style.display='';
|
||
const mode=sopDiscMode();
|
||
const note=document.getElementById('discipline-note');
|
||
if(note){
|
||
note.textContent = mode==='single'
|
||
? 'This project issues one discipline per package — choose the single discipline.'
|
||
: mode==='multi'
|
||
? 'This project bundles disciplines into one package — select every discipline this package covers; each gets its own scope section.'
|
||
: 'Select every discipline this package covers. Choose more than one to build a combined package you can split later.';
|
||
}
|
||
wrap.innerHTML = list.map(d=>{
|
||
const on=pkgDisciplines.includes(d);
|
||
return `<label class="disc-pill ${on?'selected':''}"><input type="${mode==='single'?'radio':'checkbox'}" name="wp_disc" ${on?'checked':''} onchange="toggleDiscipline('${esc(d)}',this.checked)"><span class="dot"></span>${esc(d)}</label>`;
|
||
}).join('');
|
||
}
|
||
function toggleDiscipline(d,on){
|
||
if(sopDiscMode()==='single'){ pkgDisciplines = on?[d]:[]; }
|
||
else { if(on){ if(!pkgDisciplines.includes(d)) pkgDisciplines.push(d); } else { pkgDisciplines=pkgDisciplines.filter(x=>x!==d); } }
|
||
// keep per-discipline scope/status maps in sync with the selection
|
||
pkgDisciplines.forEach(x=>{ if(!pkgScope[x]) pkgScope[x]=['']; if(!pkgDiscStatus[x]) pkgDiscStatus[x]=getRadio('status')||'Draft'; });
|
||
Object.keys(pkgScope).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgScope[x]; });
|
||
Object.keys(pkgDiscStatus).forEach(x=>{ if(!pkgDisciplines.includes(x)) delete pkgDiscStatus[x]; });
|
||
buildDisciplinePicker(); renderScope(); buildMaterials(); track('discipline_toggle',{count:pkgDisciplines.length});
|
||
}
|
||
|
||
// Show the flat work-step list for 0–1 disciplines; per-discipline sections for 2+.
|
||
function renderScope(){
|
||
const flat=document.getElementById('flat-scope');
|
||
const multi=document.getElementById('scope-by-discipline');
|
||
const splitBtn=document.getElementById('split-disc-btn');
|
||
if(!multi) return;
|
||
if(isMultiDiscipline()){
|
||
if(flat) flat.style.display='none';
|
||
multi.style.display='';
|
||
if(splitBtn) splitBtn.style.display='';
|
||
buildScopeGroups();
|
||
} else {
|
||
if(flat) flat.style.display='';
|
||
multi.style.display='none';
|
||
multi.innerHTML='';
|
||
if(splitBtn) splitBtn.style.display='none';
|
||
}
|
||
}
|
||
function buildScopeGroups(){
|
||
const multi=document.getElementById('scope-by-discipline'); if(!multi) return;
|
||
multi.innerHTML = pkgDisciplines.map(d=>{
|
||
const steps=pkgScope[d]&&pkgScope[d].length?pkgScope[d]:[''];
|
||
pkgScope[d]=steps;
|
||
const st=pkgDiscStatus[d]||'Draft';
|
||
const rows=steps.map((s,i)=>`<div class="workstep-row"><span class="ws-num">${i+1}</span>
|
||
<textarea rows="1" placeholder="Describe ${esc(d)} step ${i+1}…" oninput="setScopeStep('${esc(d)}',${i},this.value)">${esc(s)}</textarea>
|
||
<button class="row-del" onclick="removeScopeStep('${esc(d)}',${i})" title="Remove step">✕</button></div>`).join('');
|
||
const opts=STATUS_ORDER.map(o=>`<option ${o===st?'selected':''}>${esc(o)}</option>`).join('');
|
||
return `<div class="disc-scope">
|
||
<div class="disc-scope-head"><span class="disc-tag">${esc(d)}</span>
|
||
<span class="disc-status">Status: <select onchange="setDiscStatus('${esc(d)}',this.value)">${opts}</select></span></div>
|
||
${rows}
|
||
<button class="add-btn" onclick="addScopeStep('${esc(d)}')">+ Add ${esc(d)} step</button>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
function setScopeStep(d,i,v){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d][i]=v; }
|
||
function addScopeStep(d){ if(!pkgScope[d])pkgScope[d]=['']; pkgScope[d].push(''); buildScopeGroups(); }
|
||
function removeScopeStep(d,i){ if(!pkgScope[d])return; pkgScope[d].splice(i,1); if(!pkgScope[d].length)pkgScope[d]=['']; buildScopeGroups(); }
|
||
function setDiscStatus(d,v){ pkgDiscStatus[d]=v; rollupDisciplineStatus(); track('disc_status',{discipline:d,status:v}); }
|
||
// Overall WP status rolls up to the least-advanced discipline (so a WP isn't "Closed" while a discipline lags).
|
||
function rollupDisciplineStatus(){
|
||
if(!isMultiDiscipline()) return;
|
||
let minIdx=STATUS_ORDER.length-1;
|
||
pkgDisciplines.forEach(d=>{ const ix=STATUS_ORDER.indexOf(pkgDiscStatus[d]||'Draft'); if(ix>=0&&ix<minIdx) minIdx=ix; });
|
||
setRadio('status', STATUS_ORDER[minIdx]); prevStatus=STATUS_ORDER[minIdx]; updateReleaseBanner();
|
||
}
|
||
|
||
// ── WP SIZING WARNING (governance.sizeHoursMax) ──────────────────────────────
|
||
function onHoursChange(){
|
||
const el=document.getElementById('size-check'); if(!el) return;
|
||
const g=(SOP&&SOP.governance)||{};
|
||
const band=g.woSize?('Target: '+g.woSize+'. '):'';
|
||
const max=parseFloat(g.sizeHoursMax||'');
|
||
const hrs=parseFloat(gv('wp_hours'));
|
||
if(max && hrs && hrs>max){
|
||
el.innerHTML=`<span style="color:var(--accent-amber)">${esc(band)}⚠ ${hrs} hrs exceeds the ${max}-hr split threshold — consider breaking this package down`+(isMultiDiscipline()?' (try <strong>Split by Discipline</strong>).':'.')+`</span>`;
|
||
} else if(band || max){
|
||
el.innerHTML=`<span>${esc(band)}${max?'Split threshold: '+max+' hrs.':''}</span>`;
|
||
} else { el.textContent=''; }
|
||
}
|
||
|
||
// ── SPLIT BY DISCIPLINE ──────────────────────────────────────────────────────
|
||
// Turns a multi-discipline package into one instance per discipline:
|
||
// WP01-… → WP01A-… (Mechanical), WP01B-… (Electrical), WP01C-… (Tech)
|
||
// Each instance is a single-discipline package linked back to the master.
|
||
function instanceSuffixFor(index, discipline){
|
||
if(instanceSuffixStyle()==='discipline'){ return '_'+typeNumberCode(discipline); }
|
||
return String.fromCharCode(65+index); // A, B, C…
|
||
}
|
||
function splitByDiscipline(){
|
||
if(!isMultiDiscipline()){ alert('Select two or more disciplines before splitting.'); return; }
|
||
if(!gv('wp_subject')){ alert('Add a Subject before splitting.'); return; }
|
||
const base=collectPackage();
|
||
const baseNumber=base.number||('WP'+pad2(editingSeq()));
|
||
if(!confirm(`Split "${baseNumber}" into ${pkgDisciplines.length} discipline instances (${pkgDisciplines.map((d,i)=>baseNumber+instanceSuffixFor(i,d)).join(', ')})?\n\nThe master package is kept as a roll-up; each instance becomes its own single-discipline package.`)) return;
|
||
|
||
// Master: flagged as a split container, keeps all disciplines for roll-up tracking.
|
||
const masterId = editingId || base.id;
|
||
const master = {...base, id:masterId, split:true, children:[]};
|
||
|
||
const children = pkgDisciplines.map((d,i)=>{
|
||
const suffix=instanceSuffixFor(i,d);
|
||
const steps=(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean);
|
||
const childId='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
|
||
master.children.push(childId);
|
||
return {
|
||
...base,
|
||
id: childId,
|
||
number: baseNumber+suffix,
|
||
disciplines:[d],
|
||
scope:{[d]:steps.length?steps:['']},
|
||
discStatus:{[d]: (pkgDiscStatus[d]||base.status||'Draft')},
|
||
status: pkgDiscStatus[d]||base.status||'Draft',
|
||
materials: (base.materials||[]).filter(m=>m.discipline===d),
|
||
workSteps: steps, work: steps.join('\n'),
|
||
instanceOf: masterId, instanceLabel: suffix, parentNumber: baseNumber,
|
||
split:false, children:undefined,
|
||
updatedAt:new Date().toISOString()
|
||
};
|
||
});
|
||
|
||
// Upsert master + children into the saved store.
|
||
const upsert=(p)=>{ const ix=savedPackages.findIndex(x=>x.id===p.id); if(ix>=0) savedPackages[ix]=p; else savedPackages.push(p); };
|
||
upsert(master); children.forEach(upsert);
|
||
editingId=masterId; saveStore(); renderSavedList();
|
||
track('wp_split',{disciplines:pkgDisciplines.length});
|
||
toast('Split into '+children.length+' discipline instances');
|
||
const untagged=(base.materials||[]).filter(m=>!m.discipline).length;
|
||
const matNote = untagged ? `\n\nNote: ${untagged} material line${untagged===1?'':'s'} had no discipline tag and stayed on the master only — tag them before splitting to route them to an instance.` : '';
|
||
alert('Created '+children.length+' instances:\n\n• '+children.map(c=>c.number+' ('+c.disciplines[0]+', '+(c.materials?c.materials.length:0)+' material line'+((c.materials&&c.materials.length===1)?'':'s')+')').join('\n• ')+'\n\nThe master '+baseNumber+' is kept as a roll-up.'+matNote);
|
||
}
|
||
|
||
// ── ASSETS (controls.dev) ────────────────────────────────────────────────────
|
||
// Interim: assets are linked manually back to controls.dev. A future direct
|
||
// integration will let the user pick them from a list instead of pasting links.
|
||
function buildAssets(){
|
||
const tb=document.getElementById('asset-body'); if(!tb) return; tb.innerHTML='';
|
||
pkgAssets.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||
tr.innerHTML=`<td><input type="text" value="${(a.tag||'').replace(/"/g,'"')}" placeholder="controls.dev asset tag / ID" oninput="pkgAssets[${i}].tag=this.value"></td>
|
||
<td><input type="text" value="${(a.desc||'').replace(/"/g,'"')}" placeholder="what it is (optional)" oninput="pkgAssets[${i}].desc=this.value"></td>
|
||
<td><input type="url" value="${(a.link||'').replace(/"/g,'"')}" placeholder="https://controls.dev/..." oninput="pkgAssets[${i}].link=this.value"></td>
|
||
<td class="center"><button class="row-del" onclick="removeAsset(${i})">✕</button></td>`;
|
||
tb.appendChild(tr); });
|
||
}
|
||
function addAsset(){ pkgAssets.push({tag:'',desc:'',link:''}); buildAssets(); track('asset_added'); }
|
||
function removeAsset(i){ pkgAssets.splice(i,1); if(!pkgAssets.length)pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets(); }
|
||
|
||
// ── ATTACHMENTS ──────────────────────────────────────────────────────────────
|
||
function buildAttach(){
|
||
const tb=document.getElementById('attach-body'); tb.innerHTML='';
|
||
pkgAttach.forEach((a,i)=>{ const tr=document.createElement('tr');
|
||
tr.innerHTML=`<td><input type="text" value="${(a.doc||'').replace(/"/g,'"')}" placeholder="drawing / spec name" oninput="pkgAttach[${i}].doc=this.value"></td>
|
||
<td><input type="text" value="${(a.rev||'').replace(/"/g,'"')}" placeholder="0" oninput="pkgAttach[${i}].rev=this.value"></td>
|
||
<td><input type="text" value="${(a.link||'').replace(/"/g,'"')}" placeholder="link or note" oninput="pkgAttach[${i}].link=this.value"></td>
|
||
<td class="center"><button class="row-del" onclick="removeAttach(${i})">✕</button></td>`;
|
||
tb.appendChild(tr); });
|
||
}
|
||
function addAttach(){ pkgAttach.push({doc:'',rev:'',link:''}); buildAttach(); }
|
||
|
||
// ── CR-007 / D8: drawing uploads ─────────────────────────────────────────────
|
||
// Files attach to the SAVED record (they are rows, not form state), so the form
|
||
// around an upload is never at risk: a refused or failed upload changes nothing
|
||
// but the toast. Both limits are ALSO enforced by the server - these checks are
|
||
// the courtesy of refusing before the bytes travel.
|
||
function wpFileSize(n){
|
||
if(n>=1024*1024) return (n/1024/1024).toFixed(1)+'MB';
|
||
if(n>=1024) return Math.round(n/1024)+'KB';
|
||
return n+'B';
|
||
}
|
||
async function wpFileUpload(ev){
|
||
const inp=ev.target; const f=inp.files&&inp.files[0]; inp.value='';
|
||
if(!f) return;
|
||
if(!/^(application\/pdf|image\/)/.test(f.type||'')){
|
||
toast('Only PDF and image files are accepted — "'+(f.type||f.name.split('.').pop()||'that')+'" is neither.');
|
||
return;
|
||
}
|
||
if(f.size>5*1024*1024){
|
||
toast('Files are limited to 5MB each — this one is '+wpFileSize(f.size)+'.');
|
||
return;
|
||
}
|
||
if(!editingId){
|
||
toast('Save the package first — uploads attach to the saved record.');
|
||
return;
|
||
}
|
||
const desc=(document.getElementById('wp-file-desc')||{value:''}).value.trim();
|
||
let b64='';
|
||
try{
|
||
b64=await new Promise((res,rej)=>{ const r=new FileReader();
|
||
r.onload=()=>res(String(r.result).split(',')[1]||''); r.onerror=rej; r.readAsDataURL(f); });
|
||
}catch(e){ toast('Could not read that file — the form is untouched.'); return; }
|
||
let resp, out={};
|
||
try{
|
||
resp=await fetch('/api/wps/'+encodeURIComponent(editingId)+'/files',{
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body:JSON.stringify({name:f.name, mime:f.type, description:desc, data_base64:b64})});
|
||
out=await resp.json().catch(()=>({}));
|
||
}catch(e){
|
||
toast('Upload failed — the network dropped. Nothing else was lost; try again.');
|
||
return;
|
||
}
|
||
if(!resp.ok){
|
||
const d=out&&out.detail;
|
||
const msg=(d&&d.message)||(typeof d==='string'?d:'Upload failed ('+resp.status+')');
|
||
toast(msg);
|
||
return;
|
||
}
|
||
pkgFiles.push(out.file);
|
||
wpFilesMirror();
|
||
const de=document.getElementById('wp-file-desc'); if(de) de.value='';
|
||
wpFilesRender(); wpFileUsageSet(out.used, out.ceiling);
|
||
toast('Uploaded '+f.name+'.'); track('file_uploaded');
|
||
}
|
||
// Keep the local saved-package copy in step so exports and the offline cache
|
||
// see the list without another fetch. The server copy is authoritative.
|
||
function wpFilesMirror(){
|
||
const ix=savedPackages.findIndex(x=>x.id===editingId);
|
||
if(ix>=0){ savedPackages[ix].files=pkgFiles.map(x=>({...x})); saveStore(); }
|
||
}
|
||
function wpFilesRender(){
|
||
const box=document.getElementById('wp-file-list'); if(!box) return;
|
||
box.innerHTML=pkgFiles.map(f=>`<div class="wp-file-item">
|
||
<a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${f.mime==='application/pdf'?'📄':'🖼'} ${esc(f.name||'drawing')}</a>
|
||
<span class="wf-size">${wpFileSize(f.size||0)}</span>
|
||
<input type="text" value="${(f.description||'').replace(/"/g,'"')}" placeholder="focus area, e.g. Tray section, Level 3 east only"
|
||
aria-label="Description of ${esc(f.name||'drawing')}" onchange="wpFileDescSave('${f.id}', this.value)">
|
||
<button type="button" class="btn btn-ghost wp-file-x" onclick="wpFileDelete('${f.id}')">Remove</button>
|
||
</div>`).join('');
|
||
}
|
||
async function wpFileDescSave(id, v){
|
||
try{
|
||
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'PATCH',
|
||
headers:{'Content-Type':'application/json'}, body:JSON.stringify({description:v})});
|
||
if(!r.ok) throw 0;
|
||
const f=pkgFiles.find(x=>x.id===id); if(f) f.description=v;
|
||
wpFilesMirror();
|
||
}catch(e){ toast('Could not save the description — it is shown but not saved yet.'); }
|
||
}
|
||
async function wpFileDelete(id){
|
||
if(!confirm('Remove this drawing from the package?')) return;
|
||
try{
|
||
const r=await fetch('/api/files/'+encodeURIComponent(id),{method:'DELETE'});
|
||
if(!r.ok) throw 0;
|
||
const out=await r.json();
|
||
pkgFiles=pkgFiles.filter(f=>f.id!==id);
|
||
wpFilesMirror(); wpFilesRender(); wpFileUsageSet(out.used, out.ceiling);
|
||
toast('Drawing removed.');
|
||
}catch(e){ toast('Could not remove the drawing — try again.'); }
|
||
}
|
||
// The running total, on the same line as the rules (D8: warn from 80%, refuse at
|
||
// the ceiling - the refusal itself comes from the server and names the number).
|
||
function wpFileUsageSet(used, ceiling){
|
||
const el=document.getElementById('file-usage'); if(el==null) return;
|
||
if(typeof used!=='number' || !ceiling){ el.textContent=''; return; }
|
||
const pct=used/ceiling;
|
||
const txt=`Project storage: ${wpFileSize(used)} of ${wpFileSize(ceiling)} used.`;
|
||
el.innerHTML = pct>=1 ? `<span class="fr-full">${txt} Full — remove a drawing to make room.</span>`
|
||
: pct>=0.8 ? `<span class="fr-warn">${txt} Approaching the ceiling.</span>`
|
||
: esc(txt);
|
||
}
|
||
async function wpFilesRefreshUsage(){
|
||
if(!activeProjectId) return;
|
||
try{
|
||
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/storage');
|
||
if(!r.ok) return;
|
||
const out=await r.json();
|
||
wpFileUsageSet(out.used, out.ceiling);
|
||
}catch(e){}
|
||
}
|
||
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="${hrefAttr(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(){
|
||
// Carry the SOP's definition through, not just the name: `critical` decides
|
||
// whether reopening this constraint after release is emailed, and `description`
|
||
// is the tooltip. Statuses already set on the package are preserved.
|
||
const defs=constraintNames().map(n=> (typeof n==='string') ? {name:n} : (n||{}));
|
||
const prev={}; pkgConstraints.forEach(c=>prev[c.name]=c);
|
||
pkgConstraints=defs.filter(d=>d.name).map(d=>{
|
||
const p=prev[d.name];
|
||
return {
|
||
name: d.name,
|
||
status: p ? p.status : 'open',
|
||
comment: p ? (p.comment||'') : '',
|
||
critical: !!d.critical,
|
||
description: d.description || (p && p.description) || ''
|
||
};
|
||
});
|
||
const tb=document.getElementById('constraint-body'); tb.innerHTML='';
|
||
pkgConstraints.forEach((c,i)=>{ const tr=document.createElement('tr');
|
||
const critTag = c.critical
|
||
? ` <span class="crit-tag" title="Critical constraint — if this reopens after release the PM, CM and owner are notified by email.">⚠ critical</span>`
|
||
: '';
|
||
tr.innerHTML=`<td class="row-label">${esc(c.name)}${critTag}</td>
|
||
<td><span class="cstatus">
|
||
<button class="${c.status==='open'?'on-open':''}" onclick="setConstraint(${i},'open')">Open</button>
|
||
<button class="${c.status==='cleared'?'on-cleared':''}" onclick="setConstraint(${i},'cleared')">Cleared</button>
|
||
<button class="${c.status==='na'?'on-na':''}" onclick="setConstraint(${i},'na')">N/A</button>
|
||
</span></td>
|
||
<td><input type="text" value="${(c.comment||'').replace(/"/g,'"')}" placeholder="note" oninput="pkgConstraints[${i}].comment=this.value"></td>`;
|
||
tb.appendChild(tr); });
|
||
}
|
||
function setConstraint(i,val){
|
||
const before=pkgConstraints[i].status;
|
||
pkgConstraints[i].status=val; buildConstraints(); updateReleaseBanner();
|
||
// If a constraint is flagged Open after the package was released, require logging the issue
|
||
if(val==='open' && STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX){
|
||
prevStatus=getRadio('status'); holdContext={index:i, before};
|
||
openHoldModal(pkgConstraints[i].name, true);
|
||
return;
|
||
}
|
||
// Clearing the LAST open constraint makes the package release-ready — offer to
|
||
// issue it and scroll up to the status control so the change is visible.
|
||
if(before==='open' && val!=='open' && readiness().open===0){
|
||
const st=getRadio('status');
|
||
// CR-015: hold state is recalculated on EVERY constraint change, never read
|
||
// once and left to go stale. Clearing the last open constraint releases the
|
||
// hold right here - no refresh, no dialog - and returns the package to the
|
||
// status it held when the hold was placed. The old code fell through to the
|
||
// "Mark it as Issued now?" offer because STATUS_ORDER.indexOf('Issue') is -1,
|
||
// and declining it left the package on hold with nothing open.
|
||
if(st==='Issue'){ releaseHold(pkgConstraints[i].name); return; }
|
||
// Don't offer to issue while a predecessor is still open — that would walk the
|
||
// user straight into the override prompt they didn't ask for.
|
||
if(readiness().blocking.length){
|
||
renderPredHint();
|
||
toast('All constraints cleared — still waiting on '+readiness().blocking.length+' predecessor package(s).');
|
||
}
|
||
else if(STATUS_ORDER.indexOf(st) < ISSUED_IDX){
|
||
if(confirm('All constraints are cleared — this Work Package is release-ready.\n\nMark it as Issued now?')){
|
||
setRadio('status','Issued'); prevStatus='Issued'; updateReleaseBanner();
|
||
track('status_change',{status:'Issued',via:'constraint_clear'});
|
||
}
|
||
const sg=document.getElementById('status-group');
|
||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||
}
|
||
}
|
||
}
|
||
// Release readiness has two gates now: every constraint cleared/N-A, and every
|
||
// predecessor package Closed. `ready` means both; `open`/`blocking` say which.
|
||
function readiness(){
|
||
const open = pkgConstraints.filter(c=>c.status==='open').length;
|
||
const blocking = blockingPredecessors();
|
||
return {
|
||
open, blocking,
|
||
total: pkgConstraints.length,
|
||
cleared: pkgConstraints.filter(c=>c.status==='cleared').length,
|
||
constraintsClear: open === 0,
|
||
ready: open === 0 && blocking.length === 0
|
||
};
|
||
}
|
||
function updateReleaseBanner(){
|
||
const b=document.getElementById('release-banner'); const r=readiness(); const st=getRadio('status');
|
||
let cls, txt;
|
||
if(st==='Issue'){ cls='rb-hold'; txt=`⛔ On hold — ${r.open} constraint${r.open===1?'':'s'} reopened. Resolve to resume.`; }
|
||
else if(st==='Ready for QA'){
|
||
// CR-014: the QA decision lives where the state is announced. Accept moves to
|
||
// QC; reject returns to the crew and REQUIRES a comment (the modal enforces
|
||
// it, and the server refuses the transition without one).
|
||
cls='rb-qa';
|
||
txt=`◉ In the QA queue — accept to begin QC, or return it to the crew with a comment.`
|
||
+` <button type="button" class="rb-act" onclick="qaAccept()">Accept — start QC</button>`
|
||
+`<button type="button" class="rb-act rb-act-ghost" onclick="qaRejectOpen()">Reject…</button>`;
|
||
}
|
||
else if(r.ready){ cls='rb-ready'; txt=`✓ Release-ready — all ${r.total} constraints cleared or N/A.`; }
|
||
else if(r.constraintsClear){
|
||
// Constraints are done; what's left is upstream work.
|
||
cls='rb-notready';
|
||
txt=`⚠ Not release-ready — waiting on ${r.blocking.map(p=>esc(p.number||p.id)+' ('+esc(p.status)+')').join(', ')}.`;
|
||
}
|
||
else {
|
||
cls='rb-notready';
|
||
const extra = r.blocking.length ? ` · also waiting on ${r.blocking.length} predecessor${r.blocking.length===1?'':'s'}` : '';
|
||
txt=`⚠ Not release-ready — ${r.open} of ${r.total} constraint${r.open===1?'':'s'} still open${extra}.`;
|
||
// D4: an Urgent package gets the audited override as the banner's primary
|
||
// action - not a hidden menu item, and not on Normal or High, whose gate is
|
||
// exactly as prominent as it was before this change.
|
||
if(STATUS_ORDER.indexOf(st)<ISSUED_IDX && wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
|
||
txt+=` <button type="button" class="rb-act" onclick="urgentOverrideRelease()">Release now — audited override</button>`;
|
||
}
|
||
}
|
||
// A live region announces every rewrite, and this runs on saves and loads
|
||
// that change nothing - only touch the DOM when the message actually moved.
|
||
const html=`<div class="rb-inner ${cls}">${txt}</div>`;
|
||
if(b._rbLast!==html){ b._rbLast=html; b.innerHTML=html; }
|
||
updateConstraintBadge();
|
||
}
|
||
// Releasing with an unclosed predecessor is allowed but must be explained. The
|
||
// reason rides on the package (data.gateOverride) and the server writes it to the
|
||
// audit log. Returns false if the user backed out.
|
||
function confirmEarlyRelease(blocking, openConstraints){
|
||
// 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
|
||
// use case that justifies the constraint workflow, so the reason is mandatory
|
||
// and the override records exactly which gates it crossed.
|
||
const open=(openConstraints||[]).slice();
|
||
const parts=[];
|
||
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'+
|
||
blocking.map(p=>'• '+(p.number||p.id)+' — '+p.status).join('\n'));
|
||
const reason=prompt(parts.join('\n\n')+
|
||
'\n\nYou can still release this package, but the reason is recorded on it and in the audit log.\n\n'+
|
||
'Why is it being released now? (Cancel to stop.)');
|
||
if(reason===null || !reason.trim()) return false;
|
||
pkgGateOverride={
|
||
reason: reason.trim(),
|
||
at: new Date().toISOString(),
|
||
by: (window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||
blocking: blocking.map(p=>p.number||p.id)
|
||
};
|
||
// The server only honours a constraint override that NAMES what it covers, so
|
||
// a constraint opened after the override cannot ride through on an old reason.
|
||
if(open.length) pkgGateOverride.constraints=open;
|
||
track('predecessor_gate_overridden');
|
||
return true;
|
||
}
|
||
|
||
// Which status an on-hold package returns to. The hold entry records it at the
|
||
// moment the hold is placed (`from`); the newest hold entry wins. Falls back to
|
||
// 'Issued' for a package held before this field existed - it must have been
|
||
// released to be flagged, and 'Issued' is the most conservative released state.
|
||
function holdReturnStatus(){
|
||
for(let i=pkgHolds.length-1;i>=0;i--){
|
||
const h=pkgHolds[i];
|
||
if(!h || h.released) continue;
|
||
if(h.from && STATUS_ORDER.indexOf(h.from)>=0) return h.from;
|
||
}
|
||
if(prevStatus && STATUS_ORDER.indexOf(prevStatus)>=0) return prevStatus;
|
||
return 'Issued';
|
||
}
|
||
|
||
// CR-015: leaving hold. Writes the release to the same history the hold went to -
|
||
// 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
|
||
// while the package sat on hold, the audited override is offered, never skipped.
|
||
function releaseHold(clearedName){
|
||
const back=holdReturnStatus();
|
||
const r=readiness();
|
||
if(STATUS_ORDER.indexOf(back)>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
||
if(!confirmEarlyRelease(r.blocking)){
|
||
toast('Constraint cleared — still on hold: predecessor package(s) are not Closed.');
|
||
updateReleaseBanner(); return;
|
||
}
|
||
}
|
||
pkgHolds.push({ ts:new Date().toISOString(), released:true,
|
||
constraint:clearedName||'',
|
||
details:'Hold released — last open constraint cleared'+(clearedName?': '+clearedName:''),
|
||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||
to:back });
|
||
setRadio('status', back); prevStatus=back;
|
||
updateReleaseBanner(); toast('Hold released — back to '+back+'.');
|
||
track('hold_released',{to:back});
|
||
const sg=document.getElementById('status-group');
|
||
if(sg) sg.scrollIntoView({behavior:'smooth', block:'center'});
|
||
}
|
||
|
||
// CR-014: accept / reject, from the banner. Accept is a plain forward step.
|
||
// Reject is the audited return: a comment is mandatory, rides on the package
|
||
// (data.qaRejections), and the server emails the owner and the QA group.
|
||
function qaAccept(){
|
||
setRadio('status','QC'); prevStatus='QC';
|
||
updateReleaseBanner(); toast('Accepted — package is in QC.');
|
||
track('qa_accepted');
|
||
}
|
||
function qaRejectOpen(){
|
||
const el=document.getElementById('qa-reject-comment'); if(el) el.value='';
|
||
document.getElementById('qa-reject-modal').classList.add('open');
|
||
if(el) el.focus();
|
||
}
|
||
function qaRejectCancel(){ document.getElementById('qa-reject-modal').classList.remove('open'); }
|
||
function qaRejectSubmit(){
|
||
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; }
|
||
pkgQaRejections.push({ ts:new Date().toISOString(), comment:c,
|
||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||
from:'Ready for QA' });
|
||
document.getElementById('qa-reject-modal').classList.remove('open');
|
||
setRadio('status','In Progress'); prevStatus='In Progress';
|
||
updateReleaseBanner(); toast('Returned to In Progress — the comment is on the record.');
|
||
track('qa_rejected');
|
||
}
|
||
|
||
// 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.
|
||
function urgentOverrideRelease(){
|
||
const r=readiness();
|
||
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
||
if(!open.length && !r.blocking.length){ updateReleaseBanner(); return; }
|
||
if(!confirmEarlyRelease(r.blocking, open)) return;
|
||
setRadio('status','Issued'); prevStatus='Issued';
|
||
updateReleaseBanner(); track('status_change',{status:'Issued', via:'urgent_override'});
|
||
}
|
||
|
||
function onStatusChange(target){
|
||
const idx=STATUS_ORDER.indexOf(target);
|
||
const r=readiness();
|
||
// Constraints are a hard gate for Normal and High: nothing releases with one
|
||
// open. For an URGENT package the gate is refusable through the audited
|
||
// override (D4) - the same confirmEarlyRelease() path, never a silent bypass.
|
||
if(idx>=ISSUED_IDX && r.open>0){
|
||
const open=pkgConstraints.filter(c=>c.status==='open').map(c=>c.name);
|
||
if(wpPriorityOf({priority: gv('wp_priority')})==='Urgent'){
|
||
if(!confirmEarlyRelease(r.blocking, open)){
|
||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
||
}
|
||
} else {
|
||
alert('Cannot move to "'+target+'" — these constraints are still open:\n\n• '+open.join('\n• ')+'\n\nClear or mark N/A first.');
|
||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
||
}
|
||
}
|
||
// Predecessors are a gate you can refuse: planners genuinely need to release
|
||
// ahead of upstream work closing out. Refusing it requires a reason, which is
|
||
// stored on the package and written to the audit log by the server.
|
||
if(idx>=ISSUED_IDX && r.blocking.length && !pkgGateOverride){
|
||
if(!confirmEarlyRelease(r.blocking)){
|
||
setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); return;
|
||
}
|
||
}
|
||
if(target==='Issue'){ openHoldModal(); return; } // modal commits or reverts
|
||
prevStatus=target; updateReleaseBanner(); track('status_change',{status:target});
|
||
}
|
||
|
||
// ── HOLD LOG MODAL (comments 7) ──────────────────────────────────────────────
|
||
let holdPhotoData='', holdContext=null;
|
||
function openHoldModal(preselect, fromConstraint){
|
||
holdPhotoData=''; if(!fromConstraint) holdContext=null;
|
||
const sel=document.getElementById('hold-constraint');
|
||
sel.innerHTML = pkgConstraints.map(c=>`<option ${c.name===preselect?'selected':''}>${esc(c.name)}</option>`).join('') + '<option>Other</option>';
|
||
const existing = fromConstraint ? (pkgConstraints.find(c=>c.name===preselect)||{}).comment||'' : '';
|
||
document.getElementById('hold-details').value=existing;
|
||
document.getElementById('hold-doclink').value='';
|
||
document.getElementById('hold-photo-preview').innerHTML='';
|
||
document.getElementById('hold-modal').classList.add('open');
|
||
}
|
||
function holdPhotoChange(ev){
|
||
const f=ev.target.files&&ev.target.files[0]; if(!f) return;
|
||
const r=new FileReader(); r.onload=()=>{ holdPhotoData=r.result; const ok=/^data:image\//.test(holdPhotoData); document.getElementById('hold-photo-preview').innerHTML= ok?`<img src="${esc(holdPhotoData)}" alt="supporting photo">`:''; }; r.readAsDataURL(f);
|
||
}
|
||
function submitHold(){
|
||
const constraint=document.getElementById('hold-constraint').value;
|
||
const details=document.getElementById('hold-details').value.trim();
|
||
const doclink=document.getElementById('hold-doclink').value.trim();
|
||
if(!details){ alert('A comment defining the issue is required.'); return; }
|
||
pkgHolds.push({ ts:new Date().toISOString(), constraint, details, doc:doclink, photo:holdPhotoData||'',
|
||
by:(window.WP_USER && (WP_USER.full_name||WP_USER.username)) || '',
|
||
// What the release returns to. prevStatus is captured before the status pill
|
||
// switches; guard against re-holding while already on hold.
|
||
from:(prevStatus && prevStatus!=='Issue') ? prevStatus : holdReturnStatus() });
|
||
const c=pkgConstraints.find(x=>x.name===constraint); if(c){ c.status='open'; c.comment=details; }
|
||
buildConstraints(); setRadio('status','Issue'); prevStatus='Issue'; holdContext=null;
|
||
document.getElementById('hold-modal').classList.remove('open');
|
||
updateReleaseBanner(); toast('Hold logged'); track('hold_logged',{constraint});
|
||
}
|
||
function cancelHold(){
|
||
if(holdContext){ // came from flagging a constraint on a released package -> revert that constraint
|
||
pkgConstraints[holdContext.index].status=holdContext.before; holdContext=null; buildConstraints(); updateReleaseBanner();
|
||
document.getElementById('hold-modal').classList.remove('open'); return;
|
||
}
|
||
closeHoldModal(false);
|
||
}
|
||
function closeHoldModal(committed){
|
||
document.getElementById('hold-modal').classList.remove('open');
|
||
if(!committed){ setRadio('status', prevStatus||'Scheduled'); updateReleaseBanner(); }
|
||
}
|
||
|
||
// ── SIGN-OFFS ────────────────────────────────────────────────────────────────
|
||
function sopNameForSignoff(role){
|
||
if(!SOP) return '';
|
||
const roles=SOP.roles||[]; const proj=SOP.project||{};
|
||
const find=(re)=>{ const m=roles.find(r=>re.test(r.role||'')); return m&&m.name||''; };
|
||
switch(role){
|
||
case 'Planner': return find(/planner/i);
|
||
case 'Superintendent': return find(/superintendent/i) || proj.cm || '';
|
||
case 'HSE Professional': return find(/safety|hse|ehs/i);
|
||
case 'Quality Representative': return find(/quality/i) || proj.qm || '';
|
||
case 'Work Foreman': return find(/general foreman|foreman/i);
|
||
}
|
||
return '';
|
||
}
|
||
function buildSignoffs(){
|
||
const prev={}; pkgSignoffs.forEach(s=>prev[s.role]=s);
|
||
pkgSignoffs=SIGNOFF_ROLES.map(r=>{
|
||
if(prev[r]) return prev[r];
|
||
return {role:r, name:sopNameForSignoff(r), date:'', signed:false, fromSOP:!!sopNameForSignoff(r)};
|
||
});
|
||
const tb=document.getElementById('signoff-body'); tb.innerHTML='';
|
||
pkgSignoffs.forEach((s,i)=>{ const tr=document.createElement('tr');
|
||
tr.innerHTML=`<td class="row-label">${esc(s.role)}</td>
|
||
<td><input type="text" value="${(s.name||'').replace(/"/g,'"')}" placeholder="name" oninput="pkgSignoffs[${i}].name=this.value">${s.fromSOP?'<span class="sop-tag">from SOP</span>':''}</td>
|
||
<td><span class="so-date">${s.date?esc(s.date):'—'}</span> <button class="lock-edit so-ovr" onclick="onSignoffDateOverride(${i})" title="Manual date override (reason required)">✎ override</button>${s.dateReason?`<span class="override-note" title="${esc(s.dateReason)}">overridden</span>`:''}</td>
|
||
<td class="center"><input type="checkbox" class="signoff-check" ${s.signed?'checked':''} onchange="onSignoffSigned(${i},this.checked)"></td>`;
|
||
tb.appendChild(tr); });
|
||
}
|
||
function todayStr(){ const d=new Date(); return d.getFullYear()+'-'+pad2(d.getMonth()+1)+'-'+pad2(d.getDate()); }
|
||
function onSignoffSigned(i,checked){
|
||
pkgSignoffs[i].signed=checked;
|
||
if(checked && !pkgSignoffs[i].date){ pkgSignoffs[i].date=todayStr(); pkgSignoffs[i].dateReason=''; }
|
||
buildSignoffs(); track('signoff',{role:pkgSignoffs[i].role, signed:checked});
|
||
}
|
||
function onSignoffDateOverride(i){
|
||
const s=pkgSignoffs[i];
|
||
const nd=prompt('Manual sign-off date for '+s.role+' (YYYY-MM-DD). The date is normally set automatically when Signed is checked.', s.date||todayStr());
|
||
if(nd===null) return;
|
||
if(!/^\d{4}-\d{2}-\d{2}$/.test(nd.trim())){ alert('Enter the date as YYYY-MM-DD.'); return; }
|
||
const reason=prompt('Reason for manually overriding the '+s.role+' sign-off date:');
|
||
if(reason===null || !reason.trim()){ alert('A reason is required for a manual date override.'); return; }
|
||
s.date=nd.trim(); s.dateReason=reason.trim(); buildSignoffs(); track('signoff_date_override',{role:s.role});
|
||
}
|
||
|
||
// ── SAVE / OUTPUT ────────────────────────────────────────────────────────────
|
||
function collectPackage(){
|
||
const steps=pkgWorkSteps.map(s=>s.trim()).filter(Boolean);
|
||
const prev=editingId?savedPackages.find(p=>p.id===editingId):null; // carry instance/split linkage across edits
|
||
return {
|
||
id: editingId || ('wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)),
|
||
projectId: (prev&&prev.projectId) || activeProjectId || '',
|
||
instanceOf: prev?prev.instanceOf:undefined, instanceLabel: prev?prev.instanceLabel:undefined,
|
||
parentNumber: prev?prev.parentNumber:undefined, split: prev?prev.split:undefined, children: prev?prev.children:undefined,
|
||
number:gv('wp_number'), status:getRadio('status')||'Draft', subject:gv('wp_subject'),
|
||
type:gv('wp_type'), system:gv('wp_system'), location:gv('wp_location'),
|
||
p6Id:gv('wp_p6_id'), p6Desc:gv('wp_p6_desc'), // CR-001
|
||
// CR-004: codes (paths), not display strings. `location` keeps whatever
|
||
// free text the package already had — retained, never overwritten.
|
||
building:gv('wp_building'), floor:gv('wp_floor'), sector:gv('wp_sector'),
|
||
|
||
priority:wpPriorityOf({priority: gv('wp_priority')}), // CR-003
|
||
|
||
|
||
cost:gv('wp_cost'), wbs:gv('wp_wbs'), assigneeId:gv('wp_assignee'),
|
||
assignees:gv('wp_assignees'), distribution:gv('wp_distribution'),
|
||
// Account ids behind those names — notification routing needs an account;
|
||
// a display name can't be emailed. Typed-only names carry no id.
|
||
assigneeIds:pkgPeople.assignees.map(x=>x.id).filter(Boolean),
|
||
distributionIds:pkgPeople.distribution.map(x=>x.id).filter(Boolean),
|
||
due:gv('wp_due'), spec:gv('wp_spec'), desc:gv('wp_desc'),
|
||
work:steps.join('\n'), workSteps:steps, numberDims:{...numberDims},
|
||
disciplines:[...pkgDisciplines],
|
||
scope: isMultiDiscipline() ? Object.fromEntries(pkgDisciplines.map(d=>[d,(pkgScope[d]||[]).map(s=>s.trim()).filter(Boolean)])) : undefined,
|
||
discStatus: isMultiDiscipline() ? {...pkgDiscStatus} : undefined,
|
||
hours:gv('wp_hours'),
|
||
// seq is the SOP sequence phase (descriptive). predecessors are references to
|
||
// other packages and are what actually gate release.
|
||
seq:gv('wp_seq'),
|
||
predecessors:pkgPredecessors.slice(),
|
||
gateOverride:pkgGateOverride || undefined,
|
||
assets:pkgAssets.filter(a=>a.tag||a.link||a.desc),
|
||
materials:pkgMaterials.filter(m=>m.qty||m.desc), attachments:pkgAttach.filter(a=>a.doc),
|
||
kitStatus:gv('wp_kit_status'), kitOwner:gv('wp_kit_owner'), kitDate:gv('wp_kit_date'),
|
||
mimoTime:gv('wp_mimo_time'), mimoLoc:gv('wp_mimo_loc'),
|
||
constraints:pkgConstraints.map(c=>({name:c.name,status:c.status,comment:c.comment})),
|
||
qc:gv('wp_qc'), photo:gv('wp_photo'), hold:gv('wp_hold'),
|
||
qcFromSOP: !pkgOverrides['wp_qc'], photoFromSOP: !pkgOverrides['wp_photo'], holdFromSOP: !pkgOverrides['wp_hold'], overrides:{...pkgOverrides},
|
||
signoffs:pkgSignoffs.map(s=>({role:s.role,name:s.name,date:s.date,signed:s.signed,dateReason:s.dateReason||''})),
|
||
holds:pkgHolds.map(h=>({...h})),
|
||
qaRejections:pkgQaRejections.map(x=>({...x})),
|
||
files:pkgFiles.map(x=>({...x})), // metas only; the server re-asserts this key on save
|
||
actualHrs:gv('wp_actual_hrs'), installedQty:gv('wp_installed_qty'), redlines:gv('wp_redlines'), lessons:gv('wp_lessons'),
|
||
kind: pkgKind, // 'iwp' (install) | 'ewp' (BIM) — drives which fields/types/gates apply
|
||
// AWP traceability: which BIM/model package(s) enabled this install package.
|
||
bimlink:gv('wp_bimlink'),
|
||
// BIM/VDC package details (only meaningful on a BIM SOP).
|
||
// LOD was removed from the form (site comment 8/3). Any value already stored on
|
||
// the package is preserved rather than blanked on the next save.
|
||
lod:(prev && prev.lod) || '', iff:gv('wp_iff'),
|
||
modelArea:gv('wp_model_area'), clash:gv('wp_clash'), scanLink:gv('wp_scan_link'),
|
||
project:(SOP&&SOP.project&&SOP.project.name)||'', track:(SOP&&SOP.field&&SOP.field.trackPlatform)||'',
|
||
// Project homepage links in the tracking / commissioning systems, copied from the
|
||
// SOP so they travel with every Work Package created for this project.
|
||
projectLinks:(SOP&&SOP.projectLinks)?SOP.projectLinks.map(l=>({...l})):[],
|
||
updatedAt:new Date().toISOString()
|
||
};
|
||
}
|
||
function savePackage(view){
|
||
if(!gv('wp_subject') || !gv('wp_type')){ alert('Subject and WP Type are required.'); return; }
|
||
// A BIM package marked "Signed off (IFF)" without the number isn't traceable.
|
||
// The status can also be set programmatically (the per-discipline roll-up), so
|
||
// re-check the predecessor gate at the point of saving.
|
||
const _r=readiness();
|
||
if(STATUS_ORDER.indexOf(getRadio('status'))>=ISSUED_IDX && _r.blocking.length && !pkgGateOverride){
|
||
if(!confirmEarlyRelease(_r.blocking)) return;
|
||
}
|
||
if(isEwp() && iffRequired() && !gv('wp_iff').trim()){
|
||
alert('Coordination is set to "Signed off (IFF)" — enter the IFF number so the sign-off is traceable.');
|
||
const el=document.getElementById('wp_iff'); if(el){ el.focus(); el.scrollIntoView({behavior:'smooth',block:'center'}); }
|
||
return;
|
||
}
|
||
const pkg=collectPackage();
|
||
const ix=savedPackages.findIndex(p=>p.id===pkg.id);
|
||
if(ix>=0) savedPackages[ix]=pkg; else savedPackages.push(pkg);
|
||
editingId=pkg.id; saveStore(); renderSavedList(); track('package_saved',{status:pkg.status});
|
||
wpMarkFormClean();
|
||
// The record now holds this work, so the draft is no longer protecting anything.
|
||
// Left in place it would make the next load offer to "recover" work already saved.
|
||
if(typeof WPAutosave!=='undefined') WPAutosave.settled(wpDraftId());
|
||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(pkg, activeProjectId); // share to server
|
||
document.getElementById('loadingOverlay').classList.add('active');
|
||
setTimeout(()=>{ document.getElementById('loadingOverlay').classList.remove('active'); if(view) renderPackage(pkg); }, 400);
|
||
}
|
||
// The package as a document — this is both the detail view and, via
|
||
// printPackage(), the PDF export: the print window reuses #pkg-doc's HTML, so a
|
||
// section suppressed here is suppressed in both. That is why CR-006 needed one
|
||
// change rather than two.
|
||
//
|
||
// Built as a LIST of (section id, html) blocks rather than one long string, so a
|
||
// section this project has turned off is dropped and what survives is numbered
|
||
// 1.0, 2.0, 3.0 — not 1.0, 3.0, 4.0 with a hole where Assets used to be.
|
||
let _lastRenderedPkg = null;
|
||
|
||
function renderPackage(pkg){
|
||
_lastRenderedPkg = pkg;
|
||
const r = pkg.constraints ? {open:pkg.constraints.filter(c=>c.status==='open').length,total:pkg.constraints.length} : {open:0,total:0};
|
||
const readyTxt = pkg.status==='Issue' ? 'ON HOLD' : (r.open===0?'RELEASE-READY':(r.open+' OPEN CONSTRAINTS'));
|
||
const kindLbl = pkg.kind==='ewp' ? 'BIM (EWP)' : 'IWP';
|
||
let head=`<h1>${esc(pkg.number||'(no number)')} — Work Package</h1>
|
||
<div class="doc-subtitle">${esc(pkg.project)} · ${kindLbl} · TYPE: ${esc(pkg.type).toUpperCase()} · STATUS: ${esc(pkg.status).toUpperCase()} · ${readyTxt}</div>`;
|
||
const costDesc = (COST_CODES.find(c=>c.split('|')[0]===pkg.cost)||'').split('|')[1];
|
||
// Project system links travel with the WP; fall back to the live SOP for older packages.
|
||
const plinks = (pkg.projectLinks&&pkg.projectLinks.length) ? pkg.projectLinks : ((SOP&&SOP.projectLinks)||[]);
|
||
|
||
// Each entry is [section id, title, body]. `null` bodies are skipped, which is
|
||
// how a section that simply has no content behaves — different from a section
|
||
// this project does not use, which never reaches the list at all.
|
||
const blocks = [];
|
||
const add = (id, title, body) => { if(body != null && sectionOn(id)) blocks.push([title, body]); };
|
||
|
||
add('general', 'General Information', `<table><tbody>
|
||
<tr><th style="width:200px">WP Number</th><td>${cell(pkg.number)}</td></tr>
|
||
<tr><th>Subject</th><td>${cell(pkg.subject)}</td></tr>
|
||
<tr><th>Type</th><td>${cell(pkg.type)}</td></tr>
|
||
${pkg.disciplines&&pkg.disciplines.length?`<tr><th>Discipline(s)</th><td>${esc(pkg.disciplines.join(', '))}${pkg.split?' <span style="color:var(--accent);font-size:10px">[MASTER — split into instances]</span>':''}${pkg.instanceOf?` <span style="color:var(--accent);font-size:10px">[instance of ${esc(pkg.parentNumber||'')}]</span>`:''}</td></tr>`:''}
|
||
<tr><th>System / Facility Code / UPN</th><td>${cell(pkg.system)}</td></tr>
|
||
${sectionOn('location')?`<tr><th>Location</th><td>${cell(wpLocationText(pkg))}</td></tr>`:''}
|
||
${fieldOn('costCode')?`<tr><th>Cost Code</th><td>${pkg.cost?esc(pkg.cost)+(costDesc?' — '+esc(costDesc):''):ns()}</td></tr>`:''}
|
||
${fieldOn('acumaticaTask')?`<tr><th>Acumatica Task</th><td>${cell(pkg.wbs)}</td></tr>`:''}
|
||
<tr><th>Assignees</th><td>${cell(pkg.assignees)}</td></tr>
|
||
<tr><th>Distribution</th><td>${cell(pkg.distribution)}</td></tr>
|
||
<tr><th>Priority</th><td>${esc(wpPriorityOf(pkg))}</td></tr>
|
||
<tr><th>Due Date</th><td>${cell(pkg.due)}${pkg.p6Id?` <span style="color:var(--text-dim)">· P6 activity ${esc(pkg.p6Id)}</span>`:''}</td></tr>
|
||
<tr><th>P6 Activity</th><td>${pkg.p6Id?esc(pkg.p6Id)+(pkg.p6Desc?' — '+esc(pkg.p6Desc):''):ns()}</td></tr>
|
||
<tr><th>Specification Section</th><td>${cell(pkg.spec)}</td></tr>
|
||
<tr><th>Description</th><td>${cell(pkg.desc)}</td></tr>
|
||
${plinks.length?`<tr><th>Project Systems</th><td>${plinks.map(l=>esc(l.label)+': '+linkify(l.url)).join('<br>')}</td></tr>`:''}
|
||
${pkg.bimlink?`<tr><th>Enabled by (BIM)</th><td>${/^https?:\/\//i.test(pkg.bimlink)?linkify(pkg.bimlink):cell(pkg.bimlink)}</td></tr>`:''}
|
||
${(pkg.lod||pkg.iff||pkg.modelArea||pkg.clash||pkg.scanLink)?`<tr><th>BIM / Model</th><td>${[pkg.modelArea?'Area: '+esc(pkg.modelArea):'', pkg.clash?'Coordination: '+esc(pkg.clash):'', pkg.iff?'IFF #: '+esc(pkg.iff):'', pkg.scanLink?'Scan: '+linkify(pkg.scanLink):'', pkg.lod?'LOD: '+esc(pkg.lod)+' (legacy)':''].filter(Boolean).join('<br>')}</td></tr>`:''}
|
||
</tbody></table>`);
|
||
|
||
// Location has no block of its own yet — it is a row inside General
|
||
// Information, suppressed above. CR-004 gives it structured fields in wave 6
|
||
// and this is where its own block will go. Recorded rather than left implicit,
|
||
// because "the toggle does nothing" and "the toggle governs one row" look the
|
||
// same from outside.
|
||
|
||
if(pkg.assets&&pkg.assets.length){
|
||
let t=`<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=>t+=`<tr><td>${cell(a.tag)}</td><td>${cell(a.desc)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
|
||
add('assets', 'Assets (controls.dev)', t+`</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();
|
||
}
|
||
add('scope', 'Scope & Work', `<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>Predecessor packages</th><td>${
|
||
(pkg.predecessors && pkg.predecessors.length)
|
||
? pkg.predecessors.map(id=>{ const q=wpById(id); return q ? (esc(q.number||id)+' — '+esc(q.status)) : esc(id)+' (deleted)'; }).join('<br>')
|
||
: 'None'
|
||
}</td></tr>
|
||
<tr><th>Sequence phase</th><td>${pkg.seq?esc(pkg.seq):ns()}</td></tr>
|
||
${pkg.gateOverride?`<tr><th>Released early</th><td><strong>Predecessor gate overridden.</strong><br>${esc(pkg.gateOverride.reason||'')}<br><span style="color:var(--text-dim)">${esc(pkg.gateOverride.by||'')} · ${esc(pkg.gateOverride.at?wpFormatDateTime(pkg.gateOverride.at):'')}</span></td></tr>`:''}
|
||
</tbody></table>`);
|
||
|
||
if(pkg.materials&&pkg.materials.length){ const showDisc=pkg.materials.some(m=>m.discipline);
|
||
let t=`<table><thead><tr><th style="width:80px">Qty</th><th style="width:80px">Unit</th><th>Description</th>${showDisc?'<th style="width:120px">Discipline</th>':''}</tr></thead><tbody>`;
|
||
pkg.materials.forEach(m=>t+=`<tr><td>${cell(m.qty)}</td><td>${cell(m.unit)}</td><td>${cell(m.desc)}</td>${showDisc?`<td>${cell(m.discipline)}</td>`:''}</tr>`);
|
||
add('materials', 'Material List', t+`</tbody></table>`); }
|
||
|
||
if((pkg.attachments&&pkg.attachments.length)||(pkg.files&&pkg.files.length)){
|
||
let t=`<table><thead><tr><th>Document</th><th style="width:60px">Rev</th><th>Link / Note / Focus area</th></tr></thead><tbody>`;
|
||
(pkg.attachments||[]).forEach(a=>t+=`<tr><td>${cell(a.doc)}</td><td>${cell(a.rev)}</td><td>${a.link?linkify(a.link):ns()}</td></tr>`);
|
||
// CR-007: uploaded drawings print WITH the package - the row for every file,
|
||
// the image itself inline (it is the sheet the crew needs), PDFs as links.
|
||
(pkg.files||[]).forEach(f=>t+=`<tr><td><a href="/api/files/${encodeURIComponent(f.id)}" target="_blank" rel="noopener">${esc(f.name||'drawing')}</a> <span style="font-size:10px;color:var(--text-dim)">[uploaded, ${wpFileSize(f.size||0)}]</span></td><td>${ns()}</td><td>${cell(f.description)}</td></tr>`);
|
||
t+=`</tbody></table>`;
|
||
(pkg.files||[]).filter(f=>/^image\//.test(f.mime||'')).forEach(f=>{
|
||
t+=`<figure style="margin:10px 0"><img src="/api/files/${encodeURIComponent(f.id)}" alt="${(f.name||'').replace(/"/g,'"')}" style="max-width:100%">`
|
||
+(f.description?`<figcaption style="font-size:11px;color:var(--text-muted)">${esc(f.description)}</figcaption>`:'')+`</figure>`;
|
||
});
|
||
add('drawings', 'Drawings & Attachments', t); }
|
||
|
||
add('kitting', 'Kitting & MIMO', `<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>`);
|
||
|
||
let ct=`<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)';
|
||
ct+=`<tr><td>${esc(c.name)}</td><td style="color:${col};font-weight:700">${lbl}</td><td>${cell(c.comment)}</td></tr>`; });
|
||
add('constraints', 'Constraints — Release Readiness', ct+`</tbody></table>`);
|
||
|
||
add('qaqc', 'Quality & Hold Points', `<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){
|
||
let t=`<table><thead><tr><th style="width:150px">Logged</th><th style="width:130px">By</th><th style="width:180px">Constraint</th><th>Details</th><th style="width:120px">Support</th></tr></thead><tbody>`;
|
||
pkg.holds.forEach(hd=>{ const when=hd.ts?wpFormatDateTime(hd.ts):''; const sup=[hd.doc?linkify(hd.doc):'', hd.photo?'<span style="color:var(--accent-green)">photo attached</span>':''].filter(Boolean).join('<br>')||ns();
|
||
t+=`<tr><td>${esc(when)}</td><td>${cell(hd.by)}</td><td>${cell(hd.constraint)}</td><td>${cell(hd.details)}</td><td>${sup}</td></tr>`; });
|
||
// The hold LOG belongs to QA/QC — it is the record of the hold points that
|
||
// section defines, so it goes with it rather than surviving on its own.
|
||
add('qaqc', 'Hold Log', t+`</tbody></table>`);
|
||
}
|
||
|
||
// Approvals are not one of CR-006's ten sections and are not toggleable: a
|
||
// package nobody signed is not a shorter package, it is an unapproved one.
|
||
let st=`<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=>st+=`<tr><td>${esc(s.role)}</td><td>${cell(s.name)}</td><td>${cell(s.date)}</td><td>${s.signed?'✓':'—'}</td></tr>`);
|
||
blocks.push(['Approvals & Sign-offs', st+`</tbody></table>`]);
|
||
|
||
if(pkg.actualHrs||pkg.installedQty||pkg.redlines||pkg.lessons){
|
||
add('closeout', 'Closeout', `<table><tbody>
|
||
<tr><th style="width:200px">Actual Hrs.</th><td>${cell(pkg.actualHrs)}</td></tr>
|
||
<tr><th>Installed Quantity</th><td>${cell(pkg.installedQty)}</td></tr>
|
||
<tr><th>Redlines / As-Built</th><td>${cell(pkg.redlines)}</td></tr>
|
||
<tr><th>Lessons Learned</th><td>${cell(pkg.lessons)}</td></tr>
|
||
</tbody></table>`); }
|
||
|
||
let h = head + blocks.map(([title, body], i) => `<h2>${i+1}.0 ${esc(title)}</h2>${body}`).join('');
|
||
h+=`<p style="margin-top:18px;color:var(--text-dim);font-size:11px">Pushed to: ${cell(pkg.track)}</p>`;
|
||
document.getElementById('pkg-doc').innerHTML=h; showOutput();
|
||
}
|
||
function printPackage(){
|
||
const c=document.getElementById('pkg-doc').innerHTML; const w=window.open('','_blank');
|
||
w.document.write(`<!DOCTYPE html><html><head><title>Work Package</title><style>body{font-family:Arial,sans-serif;font-size:12px;line-height:1.6;color:#1a2230;padding:40px;max-width:820px;margin:0 auto}h1{font-size:20px;margin-bottom:4px}h2{font-size:13px;font-weight:700;text-transform:uppercase;border-bottom:2px solid #cdd9f2;padding-bottom:4px;margin-top:22px;color:#2563d6}table{width:100%;border-collapse:collapse;margin:10px 0;font-size:11px}th{background:#f0f2f5;border:1px solid #ccc;padding:5px 8px;text-align:left}td{border:1px solid #ccc;padding:5px 8px}@page{margin:18mm}</style></head><body>${c}</body></html>`);
|
||
w.document.close(); w.print();
|
||
}
|
||
|
||
// ── VIEWS ────────────────────────────────────────────────────────────────────
|
||
function hideDashboard(){ const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='none'; }
|
||
function showOutput(){ hideDashboard(); setFormChrome(false); document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none'); document.getElementById('pkg-output').style.display=''; renderSavedList(); currentView='Package View'; syncToolTabs(); window.scrollTo({top:0,behavior:'smooth'}); }
|
||
// Populating the form establishes the clean state it is later compared against.
|
||
function wpFormPopulated(){ setTimeout(wpMarkFormClean, 0); }
|
||
function showForm(){
|
||
hideDashboard();
|
||
// This clears every card's inline display, which also clears the "hidden" set by
|
||
// applyKind() — so the kind row and the BIM card would reappear on an
|
||
// install-only project. Re-apply the kind visibility right after.
|
||
document.querySelectorAll('.main > .card').forEach(e=>e.style.display='');
|
||
document.querySelector('.main > .nav-row').style.display='flex';
|
||
document.getElementById('pkg-output').style.display='none';
|
||
document.getElementById('saved-card').style.display = savedPackages.length?'':'none';
|
||
applyKindVisibility(); // visibility only — not a full applyKind() rebuild
|
||
buildDisciplinePicker(); renderScope();
|
||
setFormChrome(true);
|
||
currentView='Work Package Form';
|
||
syncToolTabs();
|
||
window.scrollTo({top:0,behavior:'smooth'});
|
||
}
|
||
|
||
// ── TOOL TABS (B7 / T7.1) ─────────────────────────────────────────
|
||
// The strip the suite page used to own alone. Two of its three tabs switch view
|
||
// inside this document; the third goes back to the wizard, and carries the
|
||
// project so the wizard opens on the same one.
|
||
//
|
||
// `aria-current` rather than a class alone: the active tab has to be announceable,
|
||
// and the 3px underline is a colour cue that a screen reader cannot see.
|
||
function syncToolTabs(){
|
||
const link = document.querySelector('a.nav-tab[data-nav-href]');
|
||
if(link){
|
||
const base = link.getAttribute('data-nav-href') || '';
|
||
link.setAttribute('href', base + (activeProjectId
|
||
? (base.indexOf('?') >= 0 ? '&' : '?') + 'project=' + encodeURIComponent(activeProjectId)
|
||
: ''));
|
||
}
|
||
const current = (currentView === 'Dashboard') ? 'dashboard' : 'wp';
|
||
document.querySelectorAll('.nav-tab').forEach(t => {
|
||
const on = t.getAttribute('data-tab') === current;
|
||
t.classList.toggle('active', on);
|
||
if(on) t.setAttribute('aria-current', 'page'); else t.removeAttribute('aria-current');
|
||
});
|
||
}
|
||
|
||
// Sticky save bar + section-nav chrome (shown only on the editable form view).
|
||
function setFormChrome(on){
|
||
const rail=document.getElementById('section-rail'), save=document.getElementById('sticky-save');
|
||
if(rail) rail.style.display = on ? '' : 'none';
|
||
if(save) save.style.display = on ? 'flex' : 'none';
|
||
document.body.classList.toggle('has-sticky-save', !!on);
|
||
document.body.classList.toggle('has-sec-rail', !!on);
|
||
if(on){ initSectionNavAutoHide(); buildSectionRail(); updateConstraintBadge(); }
|
||
else positionSectionNav();
|
||
}
|
||
// ── SECTION STRUCTURE (F6 / D3) ───────────────────────────────────────────────
|
||
// F6 measured this form at 11 cards in one 5,399px scroll, with a strip of jump
|
||
// chips standing in for structure. D3 settled the shape, and it is not tabs:
|
||
// one page, navigation down the side, sections collapsible, only the current one
|
||
// open, plus an `Expand all` for people who would rather scroll straight through.
|
||
//
|
||
// Tabs were rejected because they hide sections a first-time author does not know
|
||
// exist. The uncollapsed long form was rejected because it is the page F6 exists
|
||
// to fix. So the height criterion became "at rest" - the state the page loads in -
|
||
// which is what D3 amended it to, in writing, rather than quietly failing the old
|
||
// one.
|
||
//
|
||
// Two accessibility defects come out with the old strip, both named in CLAUDE.md:
|
||
// - the jump chips were `<span onclick>`; there were two left in the app
|
||
// - collapsing was a click listener on a heading `<div>`, so it was mouse-only
|
||
// Every control here is a real button. The rail is built FROM THE CARDS, so a
|
||
// section added later, or suppressed by a CR-006 toggle, changes the rail without
|
||
// anyone remembering to maintain a second list.
|
||
|
||
const SEC_EXPAND_KEY = 'wp_iwp_expand_all';
|
||
let secExpandAll = false;
|
||
let secCurrent = '';
|
||
let _secSpyBound = false;
|
||
|
||
// The cards that are sections: rendered, and carrying a heading to name them.
|
||
// `hidden` and `display:none` are how CR-006 and applyKindVisibility() suppress
|
||
// one, and a table of contents has to lose exactly what the form lost.
|
||
function secCards(){
|
||
return [...document.querySelectorAll('.main > .card')].filter(card => {
|
||
// `Saved work packages` is a list of OTHER packages, sitting at the bottom of
|
||
// the form for editing THIS one - and the navigator panel already lists exactly
|
||
// the same thing. It is not a section of the package, so it gets no rail entry.
|
||
// It is still collapsed by default (secApplyOpenState), because 310px of a
|
||
// duplicate list is not what the page should open on.
|
||
if(card.id === 'saved-card') return false;
|
||
if(card.hidden || card.style.display === 'none') return false;
|
||
return !!card.querySelector('.section-title, .sub-heading');
|
||
});
|
||
}
|
||
|
||
function secLabel(card){
|
||
const h = card.querySelector('.section-title, .sub-heading');
|
||
if(!h) return '';
|
||
const clone = h.cloneNode(true);
|
||
clone.querySelectorAll('.help-tip, .auto-tag, .collapse-chev').forEach(x => x.remove());
|
||
return clone.textContent.trim().replace(/\s+/g, ' ');
|
||
}
|
||
|
||
// Everything below the heading, wrapped once so `aria-controls` has a real target
|
||
// and collapsing is `hidden` on one element rather than a `:not()` rule listing
|
||
// the heading classes. The old CSS did the latter, which meant the disclosure
|
||
// state lived nowhere a screen reader could read it.
|
||
function secBody(card){
|
||
let body = card.querySelector(':scope > .card-body');
|
||
if(body) return body;
|
||
const head = card.querySelector(':scope > .section-header, :scope > .sub-heading');
|
||
if(!head) return null;
|
||
body = document.createElement('div');
|
||
body.className = 'card-body';
|
||
body.id = (card.id || 'sec') + '-body';
|
||
const rest = [];
|
||
for(let n = head.nextSibling; n; n = n.nextSibling) rest.push(n);
|
||
rest.forEach(n => body.appendChild(n));
|
||
card.appendChild(body);
|
||
return body;
|
||
}
|
||
|
||
// Turn the heading into a disclosure button, in place and once. The label goes
|
||
// inside the button; the help tip and the description stay OUTSIDE it, because a
|
||
// tooltip trigger nested in a button is two controls in one hit area.
|
||
function secMakeToggle(card){
|
||
if(card.dataset.secReady) return;
|
||
const body = secBody(card);
|
||
if(!body) return;
|
||
const title = card.querySelector(':scope > .section-header > .section-title')
|
||
|| card.querySelector(':scope > .sub-heading');
|
||
if(!title) return;
|
||
|
||
const label = secLabel(card);
|
||
// Every ELEMENT in the heading has to survive, not only the text. `saved-card`'s
|
||
// heading carries <span id="saved-count">, which renderSavedList() writes to on
|
||
// every save - clearing the heading destroyed it, renderSavedList() then threw on
|
||
// a null, and boot stopped one line short of setting wpCreatorReady. The page
|
||
// still rendered, so it looked like a slow load rather than a crash.
|
||
//
|
||
// Help tips go OUTSIDE the button: a tooltip trigger nested inside a button is
|
||
// two controls sharing one hit area. Everything else goes inside, because it is
|
||
// part of the heading's own text - a count, a badge, a tag.
|
||
const kids = [...title.children];
|
||
const tips = kids.filter(el => el.classList.contains('help-tip'));
|
||
const inside = kids.filter(el => !el.classList.contains('help-tip'));
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'card-toggle';
|
||
btn.setAttribute('aria-expanded', 'true');
|
||
btn.setAttribute('aria-controls', body.id);
|
||
btn.innerHTML = '<span class="collapse-chev" aria-hidden="true"></span>'
|
||
+ '<span class="card-toggle-label"></span>';
|
||
const labelEl = btn.querySelector('.card-toggle-label');
|
||
labelEl.textContent = label;
|
||
inside.forEach(el => labelEl.appendChild(el));
|
||
title.textContent = '';
|
||
title.appendChild(btn);
|
||
tips.forEach(t => title.appendChild(t));
|
||
|
||
btn.addEventListener('click', () => {
|
||
const open = card.classList.contains('collapsed');
|
||
secSetOpen(card, open);
|
||
// Opening a section by its own header makes it the one you are in, which the
|
||
// rail has to agree with - otherwise the rail marks a section you left.
|
||
if(open){ secCurrent = card.id; secMarkRail(); secSyncUrl(); }
|
||
});
|
||
card.dataset.secReady = '1';
|
||
}
|
||
|
||
function secSetOpen(card, open){
|
||
const btn = card.querySelector(':scope .card-toggle');
|
||
const body = card.querySelector(':scope > .card-body');
|
||
card.classList.toggle('collapsed', !open);
|
||
if(btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
if(body) body.hidden = !open;
|
||
}
|
||
|
||
function secIsOpen(card){ return !card.classList.contains('collapsed'); }
|
||
|
||
// ── the rail ─────────────────────────────────────────────────────────────────
|
||
function buildSectionRail(){
|
||
const rail = document.getElementById('section-rail');
|
||
const list = document.getElementById('sec-rail-list');
|
||
if(!rail || !list) return;
|
||
let cards = secCards();
|
||
// Every section carries a real id in the markup. A positional fallback was here
|
||
// and it was a bug waiting to be shipped: the id it invents goes into ?section=
|
||
// as a shareable address, and the INDEX moves whenever the set of visible
|
||
// sections changes - a CR-006 toggle, the BIM flag, or a card being inserted
|
||
// ahead of it. A link somebody sent then opens a different section, silently and
|
||
// with no error. Name the card in the markup instead; complain loudly if not.
|
||
cards.forEach(card => {
|
||
if(!card.id){
|
||
// Not a toast: this is a defect in the page, not something a user can act on.
|
||
try { console.error('T7.2: a form section has no id; its rail entry cannot be'
|
||
+ ' addressed. Give it one in wp-creation-index.html.', card); } catch(e){}
|
||
return;
|
||
}
|
||
secMakeToggle(card);
|
||
});
|
||
cards = cards.filter(c => c.id);
|
||
|
||
list.innerHTML = cards.map(card =>
|
||
'<li><button type="button" class="sec-rail-item" data-sec="' + esc(card.id) + '">'
|
||
+ esc(secLabel(card)) + '</button></li>').join('');
|
||
list.querySelectorAll('.sec-rail-item').forEach(b => {
|
||
b.addEventListener('click', () => gotoSection(b.dataset.sec));
|
||
});
|
||
rail.hidden = cards.length < 2;
|
||
|
||
// Whatever the URL asks for, else the first section. Never "all of them", which
|
||
// is the state F6 is about.
|
||
const want = (typeof WPUrl !== 'undefined' && WPUrl.get && WPUrl.get('section')) || '';
|
||
const target = cards.some(c => c.id === want) ? want : (cards[0] && cards[0].id) || '';
|
||
secApplyOpenState(target);
|
||
updateConstraintBadge();
|
||
secBindSpy();
|
||
}
|
||
|
||
// One section open, or all of them. Called on every render, so it is also what
|
||
// keeps a suppressed section from being left expanded behind the scenes.
|
||
function secApplyOpenState(currentId){
|
||
const cards = secCards();
|
||
secCurrent = currentId || secCurrent || (cards[0] && cards[0].id) || '';
|
||
cards.forEach(card => secSetOpen(card, secExpandAll || card.id === secCurrent));
|
||
// The saved-package list is not in `cards` - it is not a section - but it is on
|
||
// the page and it is 310px tall, so it collapses like everything else. It gets
|
||
// its disclosure button here because the rail loop no longer reaches it.
|
||
const saved = document.getElementById('saved-card');
|
||
if(saved && saved.style.display !== 'none'){
|
||
secMakeToggle(saved);
|
||
secSetOpen(saved, secExpandAll);
|
||
}
|
||
secMarkRail();
|
||
}
|
||
|
||
function secMarkRail(){
|
||
document.querySelectorAll('.sec-rail-item').forEach(b => {
|
||
const on = b.dataset.sec === secCurrent;
|
||
b.classList.toggle('is-current', on);
|
||
// aria-current="true", not "page": these are places in a page, not pages.
|
||
if(on) b.setAttribute('aria-current', 'true');
|
||
else b.removeAttribute('aria-current');
|
||
});
|
||
}
|
||
|
||
function secSyncUrl(opts){
|
||
if(typeof WPUrl === 'undefined') return;
|
||
const patch = { section: secCurrent || '' };
|
||
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
|
||
}
|
||
|
||
// Open a section and go to it. Focus moves to the section's own heading button,
|
||
// not just the scroll position: a keyboard user who activates a rail item has to
|
||
// land somewhere, and landing nowhere is why jump links are not navigation.
|
||
function gotoSection(id, opts){
|
||
const card = document.getElementById(id);
|
||
if(!card) return false;
|
||
// Moving section is a "you have visibly moved on" moment, so flush the draft
|
||
// rather than wait out the debounce. This was bound to the old jump chips at
|
||
// initAutosave() time, which only worked because the chips were built before it
|
||
// ran; it belongs here, where every section change goes through one door -
|
||
// rail click, keyboard, deep link and Back alike.
|
||
if(typeof WPAutosave !== 'undefined' && WPAutosave.flush) WPAutosave.flush('section');
|
||
secApplyOpenState(id);
|
||
const btn = card.querySelector('.card-toggle');
|
||
if(btn){
|
||
btn.focus({preventScroll:true});
|
||
card.scrollIntoView({behavior:'smooth', block:'start'});
|
||
}
|
||
if(!(opts && opts.fromUrl)) secSyncUrl();
|
||
return true;
|
||
}
|
||
|
||
function toggleExpandAll(){
|
||
setExpandAll(!secExpandAll);
|
||
}
|
||
|
||
function setExpandAll(on){
|
||
secExpandAll = !!on;
|
||
try { localStorage.setItem(SEC_EXPAND_KEY, secExpandAll ? '1' : '0'); } catch(e){}
|
||
const btn = document.getElementById('sec-expand-all');
|
||
if(btn){
|
||
btn.setAttribute('aria-pressed', secExpandAll ? 'true' : 'false');
|
||
btn.textContent = secExpandAll ? 'Collapse all' : 'Expand all';
|
||
}
|
||
secApplyOpenState(secCurrent);
|
||
track(secExpandAll ? 'sections_expand_all' : 'sections_collapse_all');
|
||
}
|
||
|
||
// With one section open the current one is whatever you opened. With Expand all
|
||
// on, that answer is wrong the moment you scroll, so the rail follows the form.
|
||
function secBindSpy(){
|
||
if(_secSpyBound) return;
|
||
_secSpyBound = true;
|
||
window.addEventListener('scroll', () => {
|
||
if(!secExpandAll) return;
|
||
const rail = document.getElementById('section-rail');
|
||
if(!rail || rail.hidden) return;
|
||
const line = (document.querySelector('.header') || {offsetHeight:0}).offsetHeight + 80;
|
||
let seen = '';
|
||
secCards().forEach(card => {
|
||
if(card.getBoundingClientRect().top <= line) seen = card.id;
|
||
});
|
||
if(seen && seen !== secCurrent){ secCurrent = seen; secMarkRail(); }
|
||
}, {passive:true});
|
||
}
|
||
|
||
// The drawer and the comments panel hang below the page chrome, and the chrome is
|
||
// taller since T7.1 - app bar, tab strip, toolbar. Measure it rather than assume.
|
||
function positionSectionNav(){
|
||
const parts = ['.header', '.main-nav', '.wp-toolbar']
|
||
.map(sel => document.querySelector(sel))
|
||
.filter(Boolean);
|
||
const h = parts.reduce((n, el) => n + el.offsetHeight, 0);
|
||
document.documentElement.style.setProperty('--rail-top', h + 'px');
|
||
const rail = document.getElementById('section-rail');
|
||
if(rail) rail.style.setProperty('--sec-rail-top', h + 'px');
|
||
}
|
||
|
||
let _secChromeBound = false;
|
||
function initSectionNavAutoHide(){
|
||
positionSectionNav();
|
||
if(_secChromeBound) return;
|
||
_secChromeBound = true;
|
||
window.addEventListener('resize', positionSectionNav, {passive:true});
|
||
try { setExpandAll(localStorage.getItem(SEC_EXPAND_KEY) === '1'); } catch(e){}
|
||
}
|
||
|
||
// A2: the count badge on the Constraints rail entry. The rail is sticky at every
|
||
// width, so this is the thing a user can see from ANY section - the top banner
|
||
// is the warning, this is the pointer to it. It replaced updateStickyStatus(),
|
||
// which wrote the same warning a second time into the sticky save bar - and
|
||
// destroyed the B5 autosave indicator mounted in the same span every time it did.
|
||
function updateConstraintBadge(){
|
||
const btn=document.querySelector('.sec-rail-item[data-sec="constraint-card"]');
|
||
if(!btn) return;
|
||
let badge=btn.querySelector('.sec-badge');
|
||
if(!badge){
|
||
badge=document.createElement('span');
|
||
badge.className='sec-badge';
|
||
btn.appendChild(badge);
|
||
}
|
||
const open=readiness().open;
|
||
badge.hidden = open===0;
|
||
badge.textContent = open || '';
|
||
btn.setAttribute('aria-label', 'Constraints'+(open?` — ${open} open`:''));
|
||
}
|
||
|
||
// ── LOCATION (CR-004 / T6.3) ─────────────────────────────────────────────────
|
||
// Three dependent dropdowns off the project's own taxonomy (CR-005 / T5.4). The
|
||
// values stored are PATHS — 'B-ONE/L1/S-A' — not display strings, because CR-018
|
||
// rolls cost up by them and a rollup keyed on a label breaks the day somebody
|
||
// fixes a typo in it. A floor's own code is not unique across buildings; its path
|
||
// is.
|
||
//
|
||
// Loaded with include_inactive=true, which is not a contradiction of CR-005's
|
||
// "deactivating hides it from new work packages". Two different questions:
|
||
//
|
||
// what may be CHOSEN active only — the option lists below filter on it
|
||
// what may be SHOWN everything, because a package already referencing a
|
||
// deactivated value still has to render its label
|
||
//
|
||
// Nothing here reads localStorage. X5 is explicit: the option lists cannot come
|
||
// from the browser's own copy.
|
||
let wpLocations = [];
|
||
let wpLocationsLoaded = false;
|
||
|
||
const LOC_LEVELS = ['building', 'floor', 'sector'];
|
||
const LOC_FIELD = {building: 'wp_building', floor: 'wp_floor', sector: 'wp_sector'};
|
||
|
||
function locNode(path){
|
||
return wpLocations.find(n => n.path === path) || null;
|
||
}
|
||
function locLabel(path){
|
||
const n = locNode(path);
|
||
return n ? n.name : (path || '');
|
||
}
|
||
// The label as it should READ on a package — a value that has since been
|
||
// deactivated says so, rather than looking like any other choice.
|
||
function locLabelFull(path){
|
||
const n = locNode(path);
|
||
if(!n) return path || '';
|
||
return n.active ? n.name : n.name + ' (no longer offered)';
|
||
}
|
||
|
||
async function loadLocations(){
|
||
if(!activeProjectId){ wpLocations = []; wpLocationsLoaded = true; buildLocationPickers(); return; }
|
||
try {
|
||
const r = await fetch('/api/projects/' + encodeURIComponent(activeProjectId)
|
||
+ '/locations?include_inactive=true',
|
||
{credentials:'same-origin', headers:{'Accept':'application/json'}});
|
||
if(!r.ok) throw new Error('HTTP ' + r.status);
|
||
const data = await r.json();
|
||
wpLocations = data.nodes || [];
|
||
} catch(e){
|
||
// No cache fallback, deliberately: an option list remembered from last time
|
||
// can offer a value this project no longer has, and B4's whole objection is
|
||
// to a per-browser answer that looks authoritative.
|
||
wpLocations = [];
|
||
}
|
||
wpLocationsLoaded = true;
|
||
buildLocationPickers();
|
||
}
|
||
|
||
/* Fill one level's <select>, filtered to the children of `parent`.
|
||
|
||
`keep` is the value already on the package. It is offered even when it is
|
||
inactive — otherwise opening an old package would silently blank its location
|
||
and the next save would write that blank back. */
|
||
function fillLocSelect(level, parent, keep){
|
||
const sel = document.getElementById(LOC_FIELD[level]);
|
||
if(!sel) return;
|
||
const wanted = LOC_LEVELS.indexOf(level);
|
||
const options = wpLocations.filter(n =>
|
||
n.level === level
|
||
&& (wanted === 0 ? !n.parent_id : (n.path.indexOf(parent + '/') === 0
|
||
&& n.path.split('/').length === wanted + 1))
|
||
&& (n.active || n.path === keep));
|
||
const placeholder = wanted === 0 ? 'Building…' : (level === 'floor' ? 'Floor…' : 'Sector…');
|
||
sel.innerHTML = `<option value="">${esc(placeholder)}</option>`
|
||
+ options.map(n => `<option value="${esc(n.path)}"${n.path === keep ? ' selected' : ''}>`
|
||
+ `${esc(locLabelFull(n.path))}</option>`).join('');
|
||
// A level with nothing under it is disabled rather than an empty dropdown that
|
||
// looks broken — and a level whose parent is unset is disabled for the same
|
||
// reason: there is nothing it could sensibly offer yet.
|
||
const noParent = wanted > 0 && !parent;
|
||
sel.disabled = noParent || (!options.length && !keep);
|
||
sel.title = noParent
|
||
? 'Choose a ' + LOC_LEVELS[wanted - 1] + ' first'
|
||
: (!options.length ? 'This project has no ' + level + ' values configured here' : '');
|
||
if(keep) sel.value = keep;
|
||
}
|
||
|
||
function buildLocationPickers(keepValues){
|
||
const cur = keepValues || {
|
||
building: (document.getElementById('wp_building') || {}).value || '',
|
||
floor: (document.getElementById('wp_floor') || {}).value || '',
|
||
sector: (document.getElementById('wp_sector') || {}).value || '',
|
||
};
|
||
fillLocSelect('building', '', cur.building);
|
||
fillLocSelect('floor', cur.building, cur.building ? cur.floor : '');
|
||
fillLocSelect('sector', cur.floor, cur.floor ? cur.sector : '');
|
||
renderLocationNote();
|
||
}
|
||
|
||
// Clearing a parent clears its children. Not doing this is how a package ends up
|
||
// filed under a floor that is not in the building it claims.
|
||
function onLocationChange(level){
|
||
const b = (document.getElementById('wp_building') || {}).value || '';
|
||
const f = (document.getElementById('wp_floor') || {}).value || '';
|
||
const s = (document.getElementById('wp_sector') || {}).value || '';
|
||
if(level === 'building') buildLocationPickers({building: b, floor: '', sector: ''});
|
||
else if(level === 'floor') buildLocationPickers({building: b, floor: f, sector: ''});
|
||
else buildLocationPickers({building: b, floor: f, sector: s});
|
||
}
|
||
|
||
function renderLocationNote(){
|
||
const note = document.getElementById('wp_location_note');
|
||
if(!note) return;
|
||
const legacy = (document.getElementById('wp_location') || {}).value || '';
|
||
if(!wpLocationsLoaded){ note.textContent = 'Loading this project\'s location list…'; return; }
|
||
if(!wpLocations.length){
|
||
note.textContent = 'This project has no location list yet — configure it on step 11 of the SOP.';
|
||
return;
|
||
}
|
||
// A package written before CR-004 has free text and no codes. It is SHOWN
|
||
// rather than dropped: the data is retained, which is the rule everywhere else
|
||
// in this plan too.
|
||
note.textContent = legacy && !(document.getElementById('wp_building') || {}).value
|
||
? 'Previously recorded as free text: “' + legacy + '”. Pick the structured values to replace it.'
|
||
: '';
|
||
}
|
||
|
||
function wpLocationOf(p){
|
||
return {building: p.building || '', floor: p.floor || '', sector: p.sector || ''};
|
||
}
|
||
// One readable line from whatever a package holds — structured if it has it,
|
||
// the old free text if that is all there is.
|
||
function wpLocationText(p){
|
||
const parts = LOC_LEVELS.map(l => p[l]).filter(Boolean).map(locLabelFull);
|
||
if(parts.length) return parts.join(' / ');
|
||
return p.location || '';
|
||
}
|
||
|
||
// ── SECTION TOGGLES (CR-006 / T5.5) ──────────────────────────────────────────
|
||
// Which sections this project uses. The list is wp-sections.js, shared with the
|
||
// SOP wizard; this file owns the mapping from a section id to the DOM it governs
|
||
// and to the blocks renderPackage() emits.
|
||
//
|
||
// NOTHING HERE WRITES TO A PACKAGE. A section that is off is not rendered — in
|
||
// the form, in the package view or in the export. Everything captured while it
|
||
// was on stays on the package, and turning it back on shows it again. That is
|
||
// the whole of CR-006's contract, and the reason CR-002 and CR-016 are toggles.
|
||
|
||
// Location is a field inside General Information's grid today rather than a card
|
||
// of its own; CR-004 gives it structured fields in wave 6 and only this line
|
||
// changes then.
|
||
const WP_SECTION_NODES = {
|
||
// Two cards, one section. T7.2 split General Information because it was 1,288px
|
||
// on its own; the split is presentational and CR-006 still governs both through
|
||
// the single `general` id, so wp-sections.js and the SOP wizard are untouched.
|
||
general: ['#general-card', '#assign-card'],
|
||
location: ['#location-card'],
|
||
scope: ['#scope-card'],
|
||
assets: ['#asset-card'],
|
||
materials: ['#material-card'],
|
||
kitting: ['#mimo-card'],
|
||
drawings: ['#drawings-card'],
|
||
constraints: ['#constraint-card'],
|
||
qaqc: ['#quality-card'],
|
||
closeout: ['#closeout-card'],
|
||
};
|
||
|
||
// CR-002: two fields inside General Information, off-switchable on their own.
|
||
// Hiding the whole .field wrapper rather than the input, so the label goes with
|
||
// it — a bare label over nothing is worse than either state.
|
||
const WP_FIELD_NODES = {
|
||
costCode: ['#field-costCode'],
|
||
acumaticaTask: ['#field-acumaticaTask'],
|
||
};
|
||
|
||
let wpSections = (typeof WPSections !== 'undefined') ? WPSections.defaults() : {};
|
||
let wpFields = (typeof WPSections !== 'undefined') ? WPSections.fieldDefaults() : {};
|
||
|
||
function sectionOn(id){
|
||
if(typeof WPSections === 'undefined') return true;
|
||
return WPSections.isOn(wpSections, id);
|
||
}
|
||
function fieldOn(id){
|
||
if(typeof WPSections === 'undefined') return true;
|
||
return WPSections.fieldOn(wpSections, wpFields, id);
|
||
}
|
||
|
||
/* Apply a section map to the form. Called from applySOP() on boot — which covers
|
||
a reload, a fresh tab and the standalone page — and from the SOP wizard next
|
||
door while the frame is already open (X4). Exposed on window deliberately: the
|
||
wizard is a different document and can only reach a global. */
|
||
function applySopSections(sections, fields){
|
||
if(typeof WPSections === 'undefined') return wpSections;
|
||
wpSections = WPSections.normalize(sections);
|
||
wpFields = WPSections.normalizeFields(fields);
|
||
Object.keys(WP_FIELD_NODES).forEach(id => {
|
||
const on = fieldOn(id);
|
||
WP_FIELD_NODES[id].forEach(sel => {
|
||
document.querySelectorAll(sel).forEach(el => { el.hidden = !on; });
|
||
});
|
||
});
|
||
Object.keys(WP_SECTION_NODES).forEach(id => {
|
||
const on = wpSections[id];
|
||
WP_SECTION_NODES[id].forEach(sel => {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
// `hidden` rather than display:none — several of these cards are shown
|
||
// and hidden by other code (showOutput, showDashboard, the BIM flag) with
|
||
// inline display, and two mechanisms fighting over one property is how a
|
||
// section comes back on its own.
|
||
el.hidden = !on;
|
||
el.dataset.sectionOff = on ? '' : '1';
|
||
});
|
||
});
|
||
});
|
||
// The section chips are a table of contents for the form, so they have to lose
|
||
// the same entries.
|
||
if(typeof buildSectionRail === 'function') buildSectionRail();
|
||
// A package already on screen is re-rendered, or the view keeps showing a
|
||
// section the form no longer has.
|
||
if(currentView === 'Package View' && _lastRenderedPkg) renderPackage(_lastRenderedPkg);
|
||
return wpSections;
|
||
}
|
||
// Exposed for the suite page to call across the frame while T5.5 and T7.1 were
|
||
// both in flight. Nothing calls it from outside this file any more - applySOP()
|
||
// calls it at boot, which is the path T5.5 proved and the only one left (X4).
|
||
// Kept on window because the section probes drive it directly.
|
||
window.applySopSections = applySopSections;
|
||
|
||
// ── SAVED PACKAGES ───────────────────────────────────────────────────────────
|
||
const STORE_KEY='wp_iwp_v1';
|
||
// Per-project namespaced key so each project keeps its own packages in the browser.
|
||
function wpKey(base){ try{ return (typeof ProjectData!=='undefined'&&ProjectData.key)?ProjectData.key(base):base; }catch(e){ return base; } }
|
||
function saveStore(){ try{ localStorage.setItem(wpKey(STORE_KEY), JSON.stringify(savedPackages)); }catch(e){} }
|
||
function loadStore(){ try{ const d=JSON.parse(localStorage.getItem(wpKey(STORE_KEY))); savedPackages=Array.isArray(d)?d:[]; }catch(e){ savedPackages=[]; } }
|
||
function renderSavedList(){
|
||
const card=document.getElementById('saved-card'), body=document.getElementById('saved-body');
|
||
renderWpNav();
|
||
renderPredPicker(); // candidates + blocking state change as packages are saved
|
||
document.getElementById('saved-count').textContent=savedPackages.length?`(${savedPackages.length})`:'';
|
||
if(!savedPackages.length){ card.style.display='none'; return; }
|
||
if(document.getElementById('pkg-output').style.display==='none' || document.getElementById('pkg-output').style.display==='') card.style.display='';
|
||
body.innerHTML=savedPackages.map((p,i)=>{ const open=(p.constraints||[]).filter(c=>c.status==='open').length;
|
||
const ready = p.status==='Issue' ? '<span class="badge badge-N">On Hold</span>' : (open===0?'<span class="badge badge-Y">Ready</span>':`<span class="badge badge-O">${open} open</span>`);
|
||
const tag = p.split?' <span class="badge badge-O">master</span>':(p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'instance')}</span>`:'');
|
||
const disc = (p.disciplines&&p.disciplines.length)?`<div style="font-size:10px;color:var(--text-dim)">${esc(p.disciplines.join(', '))}</div>`:'';
|
||
return `<tr><td class="row-label">${esc(p.number||'—')}${tag}${disc}</td><td>${esc(p.type||'')}</td><td>${esc(p.subject||'')}</td>
|
||
<td>${statusPill(p.status)}</td><td>${ready}</td>
|
||
<td class="center"><button class="link-btn" onclick="editPackage(${i})">edit</button> <button class="link-btn" onclick="viewPackage(${i})">view</button> <button class="link-btn" onclick="showHistoryRow(${i})">history</button> ${canDeleteWP()?`<button class="row-del" onclick="deletePackage(${i})">✕</button>`:''}</td></tr>`;
|
||
}).join('');
|
||
}
|
||
function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
|
||
|
||
// ── WP NAVIGATOR (left rail) ─────────────────────────────────────────────────
|
||
// Every saved package on this project, grouped by status, filterable. Clicking a
|
||
// row opens it in the form (same path as the Saved table's "edit").
|
||
const WPNAV_ORDER=['Issue','Ready for QA','In Progress','Issued','QC','Scheduled','Draft','Closed'];
|
||
// The panel's ESSENTIAL layout ships with this script rather than living only in
|
||
// wp-creation-styles.css. The two files are cached independently, so a browser can
|
||
// run new markup against an old stylesheet — and without these rules the panel is
|
||
// not merely unstyled, it's broken: its title, filter and buttons drop into the
|
||
// middle of the form as loose widgets (which is exactly what happened once).
|
||
// Injecting the floor here means the panel is always a positioned side panel; the
|
||
// stylesheet only refines its appearance.
|
||
const WP_NAV_CRITICAL_CSS = `
|
||
body{--nav-w:288px;}
|
||
body.wp-nav-collapsed{--nav-w:56px;}
|
||
.wp-nav{position:fixed;top:var(--rail-top,48px);left:0;bottom:0;width:var(--nav-w);
|
||
z-index:120;display:flex;flex-direction:column;overflow:hidden;background:#fbfbfc;
|
||
border-right:1px solid #e0e0e0;}
|
||
.wp-nav-list{flex:1 1 auto;overflow-y:auto;overflow-x:hidden;}
|
||
.wp-nav-item,.wp-nav-link{display:flex;align-items:center;gap:11px;width:100%;
|
||
background:none;border:0;text-align:left;cursor:pointer;font:inherit;}
|
||
.wp-nav-badge{flex:0 0 28px;width:28px;height:28px;border-radius:5px;color:#fff;
|
||
display:inline-flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;}
|
||
.wp-nav-body{min-width:0;flex:1 1 auto;}
|
||
.wp-nav-num,.wp-nav-subj{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||
.main{padding-left:calc(var(--nav-w) + 28px);}
|
||
body.wp-nav-collapsed .wp-nav-body,body.wp-nav-collapsed .wp-nav-state,
|
||
body.wp-nav-collapsed .wp-nav-link-label,body.wp-nav-collapsed .wp-nav-sect,
|
||
body.wp-nav-collapsed .wp-nav-filter,body.wp-nav-collapsed .wp-nav-group,
|
||
body.wp-nav-collapsed .wp-nav-cta-label,body.wp-nav-collapsed .wp-nav-cta-more{display:none;}
|
||
/* Modals are hidden by a stylesheet rule; without it their contents render inline
|
||
in the middle of the form. Same reasoning as the panel: this is a floor. */
|
||
.modal-overlay:not(.open),.cmt-overlay:not(.open){display:none!important;}
|
||
/* F6/D3: the rail must not paint over the form before the real sheet lands,
|
||
and a section must never be stuck collapsed if that sheet never arrives.
|
||
Same reasoning as the panel above: this is a floor, not an override. */
|
||
.sec-rail[hidden]{display:none;}
|
||
.card.collapsed > .card-body[hidden]{display:none;}
|
||
`;
|
||
|
||
function injectWpNavCriticalCss(){
|
||
if(document.getElementById('wp-nav-critical')) return;
|
||
const st = document.createElement('style');
|
||
st.id = 'wp-nav-critical';
|
||
st.textContent = WP_NAV_CRITICAL_CSS;
|
||
// First in <head> so the real stylesheet (loaded later) still wins on every
|
||
// property it defines — this is a floor, not an override.
|
||
const head = document.head || document.documentElement;
|
||
head.insertBefore(st, head.firstChild);
|
||
}
|
||
|
||
// ── panel behaviour ────────────────────────────────────────────
|
||
// The panel is always present. The toggle collapses it to a 56px icon rail (badges
|
||
// only) and back; that choice is remembered. No hover-to-open: a list you navigate
|
||
// by shouldn't appear and disappear under the pointer.
|
||
let wpNavView = 'all'; // all | mine | open
|
||
|
||
function toggleWpNav(){
|
||
const collapsed = document.body.classList.toggle('wp-nav-collapsed');
|
||
try{ localStorage.setItem('wp_nav_collapsed', collapsed ? '1' : ''); }catch(e){}
|
||
const btn = document.getElementById('wp-nav-toggle');
|
||
if(btn){
|
||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
|
||
}
|
||
closeWpNavMore();
|
||
track(collapsed ? 'wp_nav_collapsed' : 'wp_nav_expanded');
|
||
}
|
||
|
||
// Kept so older callers (and the suite shell) don't break.
|
||
function openWpNav(){ document.body.classList.remove('wp-nav-collapsed'); }
|
||
function closeWpNav(){ document.body.classList.add('wp-nav-collapsed'); }
|
||
|
||
function toggleWpNavMore(ev){
|
||
if(ev) ev.stopPropagation();
|
||
const m = document.getElementById('wp-nav-more');
|
||
const b = document.getElementById('wp-nav-more-btn');
|
||
if(!m) return;
|
||
const open = m.hidden;
|
||
m.hidden = !open;
|
||
if(b) b.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
}
|
||
function closeWpNavMore(){
|
||
const m = document.getElementById('wp-nav-more');
|
||
const b = document.getElementById('wp-nav-more-btn');
|
||
if(m) m.hidden = true;
|
||
if(b) b.setAttribute('aria-expanded', 'false');
|
||
}
|
||
document.addEventListener('click', e => {
|
||
if(!e.target.closest || !e.target.closest('.wp-nav-primary')) closeWpNavMore();
|
||
});
|
||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNavMore(); });
|
||
|
||
function wpNavAction(what){
|
||
closeWpNavMore();
|
||
if(what === 'duplicate') duplicateWP();
|
||
else if(what === 'split') splitByDiscipline();
|
||
else if(what === 'export') exportPackages();
|
||
}
|
||
|
||
// The three views are filters over the same list, like Planner's My tasks / My plans.
|
||
function setWpNavView(view){
|
||
wpNavView = view;
|
||
document.querySelectorAll('.wp-nav-link[data-view]').forEach(b => {
|
||
b.classList.toggle('is-current', b.getAttribute('data-view') === view);
|
||
});
|
||
renderWpNav();
|
||
track('wp_nav_view', {view});
|
||
}
|
||
|
||
// A stable colour per package so the same one is always the same swatch — the cue
|
||
// Planner gets from its plan avatars. Hashed from the WP number, not its index, so
|
||
// it doesn't shuffle when a package is added or deleted.
|
||
const WP_BADGE_COLORS = ['#0f62fe','#8a3ffc','#007d79','#d02670','#ba4e00',
|
||
'#1192e8','#198038','#a56eff','#9f1853','#005d5d'];
|
||
function wpBadgeColor(p){
|
||
const key = (p.number || p.id || '') + '';
|
||
let h = 0;
|
||
for(let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 100000;
|
||
return WP_BADGE_COLORS[h % WP_BADGE_COLORS.length];
|
||
}
|
||
// Two characters for the badge: the package's own digits if it has them (WP-25 -> 25),
|
||
// otherwise the initials of its type, otherwise WP.
|
||
function wpBadgeText(p){
|
||
const digits = String(p.number || '').match(/(\d{1,2})(?!.*\d)/);
|
||
if(digits) return digits[1];
|
||
const t = String(p.type || '').trim();
|
||
if(t){
|
||
const words = t.split(/\s+/).filter(Boolean);
|
||
return (words.length > 1 ? words[0][0] + words[1][0] : t.slice(0, 2));
|
||
}
|
||
return 'WP';
|
||
}
|
||
|
||
function initWpNavDrawer(){
|
||
const nav = document.getElementById('wp-nav');
|
||
if(!nav || nav.dataset.bound) return;
|
||
nav.dataset.bound = '1';
|
||
injectWpNavCriticalCss();
|
||
try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
|
||
const btn = document.getElementById('wp-nav-toggle');
|
||
if(btn){
|
||
const collapsed = document.body.classList.contains('wp-nav-collapsed');
|
||
btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||
btn.title = collapsed ? 'Expand the panel' : 'Collapse the panel';
|
||
}
|
||
}
|
||
|
||
function renderWpNav(){
|
||
const list = document.getElementById('wp-nav-list');
|
||
if(!list) return;
|
||
const q = ((document.getElementById('wp-nav-search') || {}).value || '').trim().toLowerCase();
|
||
const me = myUserId();
|
||
|
||
// Keep the original index — edit/view act on savedPackages by position.
|
||
const all = savedPackages.map((p, i) => ({p, i}));
|
||
const mine = all.filter(r => r.p.assigneeId && r.p.assigneeId === me);
|
||
const needs = all.filter(r => r.p.status === 'Issue' || wpReleaseBlocked(r.p));
|
||
|
||
const setN = (id, n) => { const e = document.getElementById(id); if(e) e.textContent = n || ''; };
|
||
setN('wp-nav-n-all', all.length);
|
||
setN('wp-nav-n-mine', mine.length);
|
||
setN('wp-nav-n-open', needs.length);
|
||
|
||
let rows = wpNavView === 'mine' ? mine : (wpNavView === 'open' ? needs : all);
|
||
const label = document.getElementById('wp-nav-sect-label');
|
||
if(label) label.textContent = wpNavView === 'mine' ? 'My packages'
|
||
: (wpNavView === 'open' ? 'Needs attention' : 'Work packages');
|
||
|
||
if(q){
|
||
rows = rows.filter(r => {
|
||
const p = r.p;
|
||
return [p.number, p.subject, p.type, p.location, p.system]
|
||
.some(v => String(v || '').toLowerCase().includes(q));
|
||
});
|
||
}
|
||
|
||
const cnt = document.getElementById('wp-nav-count');
|
||
if(cnt) cnt.textContent = rows.length ? '(' + rows.length + ')' : '';
|
||
|
||
if(!rows.length){
|
||
list.innerHTML = '<div class="wp-nav-empty">' + (
|
||
savedPackages.length
|
||
? (q ? 'Nothing matches “' + esc(q) + '”.' : 'No packages in this view.')
|
||
: 'No work packages yet. Fill the form and Save Draft.'
|
||
) + '</div>';
|
||
return;
|
||
}
|
||
|
||
const groups = {};
|
||
rows.forEach(r => { const st = r.p.status || 'Draft'; (groups[st] = groups[st] || []).push(r); });
|
||
const keys = Object.keys(groups).sort((a, b) => {
|
||
const ia = WPNAV_ORDER.indexOf(a), ib = WPNAV_ORDER.indexOf(b);
|
||
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib);
|
||
});
|
||
|
||
list.innerHTML = keys.map(k => {
|
||
const heading = k === 'Issue' ? 'Issue (Hold)' : k;
|
||
return '<div class="wp-nav-group">' + esc(heading) + ' · ' + groups[k].length + '</div>' +
|
||
groups[k].map(r => {
|
||
const p = r.p;
|
||
const open = (p.constraints || []).filter(c => c.status === 'open').length;
|
||
const waiting = wpWaitingOn(p).length;
|
||
const dot = p.status === 'Issue' ? 'hold' : ((open === 0 && !waiting) ? 'ok' : 'open');
|
||
const state = p.status === 'Issue' ? 'on hold'
|
||
: (open ? open + ' open' : (waiting ? 'waits on ' + waiting : 'ready'));
|
||
const active = (editingId && p.id === editingId) ? ' active' : '';
|
||
const title = (p.number || '') + ' — ' + (p.subject || '') + ' · ' + (p.status || '') + ' · ' + state;
|
||
// A6: the stand-up answer, inline. Status, priority, due date, P6 activity
|
||
// and the open count all on the row; a held package says WHY it is held,
|
||
// from the newest live hold entry - the modal captured it, so display it.
|
||
// The title attribute above keeps the hover summary, but hover is never
|
||
// the only path (C1: Field View runs on tablets).
|
||
const triage = esc(p.status || 'Draft') + ' · ' + esc(wpPriorityOf(p))
|
||
+ ' · due ' + esc(p.due || '—') + ' · P6 ' + esc(p.p6Id || '—');
|
||
let holdLine = '';
|
||
if (p.status === 'Issue') {
|
||
const live = (p.holds || []).filter(h => h && !h.released);
|
||
const h = live[live.length - 1];
|
||
const why = h ? (h.constraint ? h.constraint + ': ' : '') + (h.details || '')
|
||
: 'no reason recorded — log it from the status control';
|
||
holdLine = '<span class="wp-nav-hold">⛔ ' + (open ? open + ' open — ' : '')
|
||
+ esc(why) + '</span>';
|
||
}
|
||
return '<button type="button" class="wp-nav-item' + active + '" onclick="wpNavOpen(' + r.i + ')"' +
|
||
' title="' + esc(title) + '">' +
|
||
'<span class="wp-nav-badge" style="background:' + wpBadgeColor(p) + '">' + esc(wpBadgeText(p)) + '</span>' +
|
||
'<span class="wp-nav-body">' +
|
||
'<span class="wp-nav-num">' + esc(p.number || '(unnumbered)') + '</span>' +
|
||
'<span class="wp-nav-subj">' + esc(p.subject || 'untitled') + '</span>' +
|
||
'<span class="wp-nav-triage">' + triage + '</span>' +
|
||
holdLine +
|
||
'</span>' +
|
||
'<span class="wp-nav-state"><span class="wp-nav-dot ' + dot + '"></span>' + esc(state) + '</span>' +
|
||
'</button>';
|
||
}).join('');
|
||
}).join('');
|
||
}
|
||
|
||
// Open a package by id. The suite shell calls this instead of reloading the whole
|
||
// document with a ?wp= parameter, so unsaved edits and scroll position survive.
|
||
function openWpById(id){
|
||
const ix = savedPackages.findIndex(x => x.id === id);
|
||
if(ix < 0) return false;
|
||
wpNavOpen(ix);
|
||
return true;
|
||
}
|
||
|
||
function wpNavOpen(i){
|
||
const p=savedPackages[i]; if(!p) return;
|
||
editingId=p.id;
|
||
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
|
||
renderWpNav();
|
||
track('wp_nav_open');
|
||
}
|
||
|
||
// ── WP HISTORY (audit trail) ─────────────────────────────────────────────────
|
||
function showHistoryRow(i){ const p=savedPackages[i]; if(p) showHistory(p.id, p.number||p.subject); }
|
||
function showHistoryCurrent(){ showHistory(editingId, (document.getElementById('wp_number')||{}).value); }
|
||
function closeHistory(){ const m=document.getElementById('wp-history-modal'); if(m) m.remove(); }
|
||
const HIST_LABEL={created:'Created',updated:'Updated',status_changed:'Status changed',issued:'Issued',deleted:'Deleted'};
|
||
async function showHistory(wpId, label){
|
||
if(!wpId){ toast('Save the package first — history is recorded as it changes.'); return; }
|
||
if(!label){ const _p=savedPackages.find(x=>x.id===wpId)||(typeof dashArchived!=='undefined'&&dashArchived.find(x=>x.id===wpId)); label=_p?(_p.number||_p.subject):''; }
|
||
closeHistory();
|
||
const ov=document.createElement('div');
|
||
ov.id='wp-history-modal'; ov.className='modal-overlay open';
|
||
ov.innerHTML='<div class="modal" style="max-width:640px"><div class="modal-head"><div class="modal-title">History — '+esc(label||wpId)+'</div>'+
|
||
'<button class="cmt-x" onclick="closeHistory()" title="Close">✕</button></div>'+
|
||
'<div class="modal-body" id="wp-history-body"><div class="empty-hint">Loading…</div></div>'+
|
||
'<div class="modal-foot"><button class="btn btn-primary" onclick="closeHistory()">Close</button></div></div>';
|
||
ov.addEventListener('click', e=>{ if(e.target===ov) closeHistory(); });
|
||
document.body.appendChild(ov);
|
||
let rows=[];
|
||
try{ const r=await fetch('/api/audit?entity_type=wp&entity_id='+encodeURIComponent(wpId), {headers:{'Accept':'application/json'}}); if(r.ok) rows=await r.json(); }catch(e){}
|
||
const body=document.getElementById('wp-history-body'); if(!body) return;
|
||
if(!rows || !rows.length){
|
||
body.innerHTML='<div class="empty-hint">No history on the server yet. Changes are recorded as the package is saved and its status changes — if this package was just created it may still be syncing.</div>';
|
||
return;
|
||
}
|
||
const fmt=s=>{ try{ return wpFormatDateTime(s); }catch(e){ return s||''; } };
|
||
const det=d=>{ d=d||{}; if(d.from!=null||d.to!=null) return esc((d.from==null?'—':d.from)+' → '+(d.to==null?'—':d.to)); if(d.status) return 'status: '+esc(d.status); return ''; };
|
||
body.innerHTML='<div class="hist-list">'+rows.map(e=>
|
||
'<div class="hist-item"><div class="hist-when">'+esc(fmt(e.at))+'</div>'+
|
||
'<div class="hist-main"><span class="hist-action">'+esc(HIST_LABEL[e.action]||(e.action||'').replace(/_/g,' '))+'</span> '+
|
||
'<span class="hist-detail">'+det(e.detail)+'</span></div>'+
|
||
'<div class="hist-actor">by '+esc(e.actor||'—')+'</div></div>').join('')+'</div>';
|
||
}
|
||
// Deleting a work package is a Project Admin action (server: require_project_admin).
|
||
// A project_user gets no delete button, and archiving is offered instead.
|
||
function canDeleteWP(){ return (typeof wpCanDeleteWP === 'function') ? wpCanDeleteWP() : true; }
|
||
|
||
function deletePackage(i){ const p=savedPackages[i]; if(!p) return;
|
||
if(!canDeleteWP()){ alert('Deleting a work package needs the Project Admin role.\n\nYou can archive it instead — it disappears from the lists and dashboard but stays on the record.'); return; } if(!confirm('Delete work package "'+(p.number||p.subject||'untitled')+'"? This cannot be undone.')) return; const delId=p.id; savedPackages.splice(i,1); saveStore(); renderSavedList(); track('package_deleted'); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ProjectData.removeWP(delId); }
|
||
function clearSaved(){ if(!savedPackages.length) return;
|
||
if(!canDeleteWP()){ alert('Deleting work packages needs the Project Admin role.'); return; } if(!confirm('Delete all '+savedPackages.length+' saved packages?')) return; const ids=savedPackages.map(p=>p.id); savedPackages=[]; saveStore(); renderSavedList(); if(typeof ProjectData!=='undefined' && ProjectData.removeWP) ids.forEach(id=>ProjectData.removeWP(id)); }
|
||
function editPackage(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); renderWpNav(); urlSyncPackage(p.id); }
|
||
|
||
// S3: the open package IS the page's state, so it belongs in the URL. This is the
|
||
// address CR-011 and CR-014 send people to (X1), so it has to survive being pasted
|
||
// into an email and opened by someone whose browser has never seen this project -
|
||
// which is why the project id rides along.
|
||
function urlSyncPackage(id, opts){
|
||
if(typeof WPUrl === 'undefined') return;
|
||
const patch = { wp: id || '', view: '' };
|
||
if(activeProjectId) patch.project = activeProjectId;
|
||
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
|
||
}
|
||
function loadExample(){ loadPackageIntoForm(JSON.parse(JSON.stringify(EXAMPLE_PKG))); editingId=null; toast('Example work package loaded'); track('example_loaded'); }
|
||
function loadPackageIntoForm(p){
|
||
pkgKind = (p.kind === 'ewp') ? 'ewp' : 'iwp'; // set before type/constraint pickers so they filter correctly
|
||
const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';};
|
||
set('wp_subject',p.subject); set('wp_system',p.system); set('wp_location',p.location);
|
||
set('wp_p6_id',p.p6Id); set('wp_p6_desc',p.p6Desc); // CR-001
|
||
buildLocationPickers(wpLocationOf(p)); // CR-004
|
||
set('wp_priority', wpPriorityOf(p)); // CR-003
|
||
set('wp_assignees',p.assignees); set('wp_distribution',p.distribution);
|
||
loadPeopleFromPkg(p);
|
||
set('wp_assignee',p.assigneeId);
|
||
set('wp_due',p.due); set('wp_spec',p.spec); set('wp_desc',p.desc); set('wp_hours',p.hours);
|
||
set('wp_kit_owner',p.kitOwner); set('wp_kit_date',p.kitDate); set('wp_mimo_time',p.mimoTime); set('wp_mimo_loc',p.mimoLoc);
|
||
set('wp_actual_hrs',p.actualHrs); set('wp_installed_qty',p.installedQty); set('wp_redlines',p.redlines); set('wp_lessons',p.lessons);
|
||
set('wp_bimlink',p.bimlink); set('wp_iff',p.iff); set('wp_model_area',p.modelArea);
|
||
set('wp_clash',p.clash); set('wp_scan_link',p.scanLink);
|
||
onClashChange();
|
||
applyKind();
|
||
buildTypePicker(); document.getElementById('wp_type').value=p.type||'';
|
||
buildCostCodes(); document.getElementById('wp_cost').value=p.cost||'';
|
||
set('wp_wbs',p.wbs);
|
||
document.getElementById('wp_kit_status').value=p.kitStatus||'';
|
||
buildSequencePicker(); document.getElementById('wp_seq').value=p.seq||'';
|
||
pkgPredecessors=Array.isArray(p.predecessors)?p.predecessors.slice():[];
|
||
pkgGateOverride=p.gateOverride||null;
|
||
renderPredPicker();
|
||
setRadio('status',p.status||'Draft');
|
||
// number dimensions
|
||
numberDims = p.numberDims ? {...p.numberDims} : {}; buildNumberDims();
|
||
// disciplines + per-discipline scope/status
|
||
pkgDisciplines = Array.isArray(p.disciplines) ? [...p.disciplines] : [];
|
||
pkgScope = p.scope ? JSON.parse(JSON.stringify(p.scope)) : {};
|
||
pkgDiscStatus = p.discStatus ? {...p.discStatus} : {};
|
||
buildDisciplinePicker(); renderScope(); onHoursChange();
|
||
// overrides + locked quality/hold
|
||
pkgOverrides=p.overrides?{...p.overrides}:{};
|
||
set('wp_qc', p.qc!=null?p.qc:sopValueFor('wp_qc'));
|
||
set('wp_photo', p.photo!=null?p.photo:sopValueFor('wp_photo'));
|
||
set('wp_hold', (p.hold&&p.hold.trim())?p.hold:sopValueFor('wp_hold'));
|
||
lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||
// collections
|
||
pkgAssets=(p.assets&&p.assets.length)?p.assets.map(a=>({...a})):[{tag:'',desc:'',link:''}]; buildAssets();
|
||
pkgMaterials=(p.materials&&p.materials.length)?p.materials.map(m=>({...m,unit:(m.unit||'').toUpperCase()})):[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||
pkgAttach=(p.attachments&&p.attachments.length)?p.attachments.map(a=>({...a})):[{doc:'',rev:'',link:''}]; buildAttach();
|
||
pkgWorkSteps=(p.workSteps&&p.workSteps.length)?p.workSteps.slice():(p.work?String(p.work).split('\n').filter(Boolean):['']); if(!pkgWorkSteps.length)pkgWorkSteps=['']; buildWorkSteps();
|
||
pkgConstraints=(p.constraints||[]).map(c=>({...c})); if(!pkgConstraints.length) buildConstraints(); else renderConstraintRows();
|
||
pkgSignoffs=(p.signoffs||[]).map(s=>({...s, fromSOP:!!s.name})); if(!pkgSignoffs.length) buildSignoffs(); else renderSignoffRows();
|
||
pkgHolds=(p.holds||[]).map(h=>({...h}));
|
||
pkgQaRejections=(p.qaRejections||[]).map(x=>({...x}));
|
||
pkgFiles=(p.files||[]).map(x=>({...x})); wpFilesRender(); wpFilesRefreshUsage();
|
||
updateNumber(); updateReleaseBanner(); prevStatus=p.status||'Draft'; showForm(); wpFormPopulated();
|
||
}
|
||
function renderConstraintRows(){ const tmp=pkgConstraints; pkgConstraints=[]; buildConstraints();
|
||
tmp.forEach(s=>{ const c=pkgConstraints.find(x=>x.name===s.name); if(c){ c.status=s.status; c.comment=s.comment; }});
|
||
buildConstraints(); }
|
||
function renderSignoffRows(){ const tmp=pkgSignoffs; pkgSignoffs=[]; buildSignoffs(); tmp.forEach(s=>{ const c=pkgSignoffs.find(x=>x.role===s.role); if(c){ c.name=s.name; c.date=s.date; c.signed=s.signed; c.dateReason=s.dateReason||''; }}); buildSignoffs(); }
|
||
|
||
// Duplicate the current work package N times (asks how many). Each copy is a
|
||
// fresh Draft with a unique number/subject and approvals/closeout cleared.
|
||
function duplicateWP(){
|
||
if(!gv('wp_subject')){ alert('Open or fill in a work package first, then Duplicate.'); return; }
|
||
const ans=prompt('How many copies of this work package do you want to create?','1');
|
||
if(ans===null) return;
|
||
const n=parseInt(ans,10);
|
||
if(!n || n<1 || n>50){ alert('Enter a whole number between 1 and 50.'); return; }
|
||
const base=collectPackage();
|
||
const baseNum = base.number || ('WP'+pad2(editingSeq()));
|
||
const made=[];
|
||
for(let i=1;i<=n;i++){
|
||
const c=JSON.parse(JSON.stringify(base));
|
||
c.id='wp_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5)+i;
|
||
c.number = baseNum + '-C' + i;
|
||
c.subject = base.subject + (n>1 ? ' (copy '+i+')' : ' (copy)');
|
||
c.status='Draft';
|
||
c.instanceOf=undefined; c.instanceLabel=undefined; c.parentNumber=undefined; c.split=undefined; c.children=undefined;
|
||
if(Array.isArray(c.signoffs)) c.signoffs=c.signoffs.map(s=>({...s, signed:false, date:'', dateReason:''}));
|
||
c.holds=[]; c.actualHrs=''; c.installedQty=''; c.redlines=''; c.lessons='';
|
||
c.projectId = base.projectId || activeProjectId || '';
|
||
c.updatedAt=new Date().toISOString();
|
||
savedPackages.push(c); made.push(c);
|
||
}
|
||
editingId=null; saveStore(); renderSavedList();
|
||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) made.forEach(m=>ProjectData.pushWP(m, activeProjectId)); // share to server
|
||
toast('Created '+n+' duplicate'+(n>1?'s':''));
|
||
track('wp_duplicated',{count:n});
|
||
alert('Created '+n+' duplicate'+(n>1?'s':'')+':\n\n• '+made.map(m=>m.number).join('\n• ')+'\n\nThey are in the Saved Work Packages list — edit each as needed.');
|
||
}
|
||
|
||
function newPackage(){
|
||
// Only clear the address once the app is up. bootSOP() calls this during boot, so
|
||
// without the guard a deep link to ?wp=<id> had its own parameter deleted by the
|
||
// page it was opening - the link worked, then erased itself, and Back had nothing
|
||
// to return to.
|
||
if(typeof WPUrl !== 'undefined' && window.wpCreatorReady && WPUrl.get('wp')) urlSyncPackage('', {replace:true});
|
||
editingId=null;
|
||
['wp_subject','wp_system','wp_location','wp_p6_id','wp_p6_desc','wp_wbs','wp_assignee','wp_assignees','wp_distribution','wp_due','wp_spec','wp_desc','wp_hours','wp_kit_owner','wp_kit_date','wp_mimo_time','wp_mimo_loc','wp_actual_hrs','wp_installed_qty','wp_redlines','wp_lessons','wp_bimlink','wp_model_area','wp_scan_link'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||
document.getElementById('wp_type').value=''; document.getElementById('wp_kit_status').value=''; document.getElementById('wp_cost').value='';
|
||
const prio=document.getElementById('wp_priority'); if(prio) prio.value=WP_PRIORITY_DEFAULT; // CR-003
|
||
buildLocationPickers({building:'', floor:'', sector:''}); // CR-004
|
||
['wp_iff','wp_clash'].forEach(id=>{const el=document.getElementById(id); if(el) el.value='';});
|
||
onClashChange();
|
||
pkgKind='iwp'; applyKind();
|
||
resetPeopleForNewPackage();
|
||
pkgPredecessors=[]; pkgGateOverride=null; renderPredPicker();
|
||
setRadio('status','Draft');
|
||
numberDims={}; buildNumberDims();
|
||
pkgDisciplines=[]; pkgScope={}; pkgDiscStatus={}; buildDisciplinePicker(); renderScope(); onHoursChange();
|
||
pkgAssets=[{tag:'',desc:'',link:''}]; buildAssets();
|
||
pkgMaterials=[{qty:'',unit:'',desc:''}]; buildMaterials();
|
||
pkgAttach=[{doc:'',rev:'',link:''}]; buildAttach();
|
||
pkgWorkSteps=['']; buildWorkSteps();
|
||
pkgConstraints=[]; buildConstraints(); pkgSignoffs=[]; buildSignoffs();
|
||
pkgHolds=[]; pkgQaRejections=[]; pkgFiles=[]; wpFilesRender(); pkgOverrides={};
|
||
set_quality_from_sop(); lockQuality('wp_qc'); lockQuality('wp_photo'); lockQuality('wp_hold');
|
||
prevStatus='Draft';
|
||
updateNumber(); updateReleaseBanner(); defaultOwnerToMe(); showForm(); wpFormPopulated(); renderWpNav(); track('new_package');
|
||
}
|
||
function set_quality_from_sop(){ const set=(id,v)=>{const el=document.getElementById(id); if(el) el.value=v||'';}; set('wp_qc',sopValueFor('wp_qc')); set('wp_photo',sopValueFor('wp_photo')); set('wp_hold',sopValueFor('wp_hold')); }
|
||
function exportPackages(){
|
||
if(!savedPackages.length){ alert('No packages saved yet.'); return; }
|
||
const payload={tool:'Work Package (IWP)', project:(SOP&&SOP.project&&SOP.project.name)||'', exportedAt:new Date().toISOString(), packages:savedPackages};
|
||
const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const a=document.createElement('a');
|
||
a.href=URL.createObjectURL(blob); a.download='work-packages-'+new Date().toISOString().slice(0,10)+'.json';
|
||
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('packages_exported',{count:savedPackages.length});
|
||
}
|
||
|
||
// ── DASHBOARD ────────────────────────────────────────────────────────────────
|
||
// Data adapter: localStorage today. In Phase 2 swap list()/issue()/setStatus()
|
||
// bodies for fetch() calls to /api/wps — the dashboard UI doesn't change.
|
||
const WPData = {
|
||
list(){ return savedPackages.slice(); }, // → GET /api/wps
|
||
get(id){ return savedPackages.find(p=>p.id===id); }, // → GET /api/wps/{id}
|
||
issue(id){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||
p.status='Issued'; p.issuedAt=new Date().toISOString(); p.updatedAt=p.issuedAt; saveStore();
|
||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||
setStatus(id,status){ const p=savedPackages.find(x=>x.id===id); if(!p) return false;
|
||
p.status=status; p.updatedAt=new Date().toISOString(); saveStore();
|
||
if(typeof ProjectData!=='undefined' && ProjectData.pushWP) ProjectData.pushWP(p, activeProjectId); return true; },
|
||
};
|
||
|
||
let dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''};
|
||
// A write has to reach the server before the server can count it. The outbox is
|
||
// the only path writes take, so flush it and then re-read - otherwise the refresh
|
||
// races the push and shows the pre-write totals, which is the same stale number
|
||
// B4 removed, just arriving a different way.
|
||
function dashRefreshAfterWrite(){
|
||
const done = () => loadDashMetrics();
|
||
try {
|
||
if(typeof ProjectData!=='undefined' && ProjectData.flushSync){
|
||
Promise.resolve(ProjectData.flushSync()).then(done, done);
|
||
return;
|
||
}
|
||
} catch(e){}
|
||
done();
|
||
}
|
||
|
||
// The four flags the dashboard filters on. Named here because the launcher's
|
||
// pipeline strip (T5.3 / B4) links straight to them, and a cell that links to a
|
||
// filter this file does not recognise is a dead link that still looks live.
|
||
const DASH_FLAGS = ['ready', 'onhold', 'overdue', 'mine'];
|
||
|
||
function dashToggleFlag(f, opts){
|
||
if(f==='all'){ dashFilter={status:'',discipline:'',q:'',flag:'',priority:'',building:'',floor:'',sector:''}; }
|
||
else { dashFilter.flag = dashFilter.flag===f ? '' : f; }
|
||
dashPage=0;
|
||
// S3: which slice of the board you are looking at is state, so it belongs in
|
||
// the URL. Without this a pipeline cell could open the filter but nobody could
|
||
// send anyone the result — which is the half of T5.3 that is about sharing.
|
||
if(!(opts && opts.fromUrl)) urlSyncDashFlag();
|
||
renderDashboard();
|
||
}
|
||
|
||
function urlSyncDashFlag(opts){
|
||
if(typeof WPUrl === 'undefined') return;
|
||
const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' };
|
||
if(activeProjectId) patch.project = activeProjectId;
|
||
(opts && opts.replace ? WPUrl.replace : WPUrl.push).call(WPUrl, patch);
|
||
}
|
||
|
||
// Apply a flag arriving from outside. Until T7.1 that meant two things - this
|
||
// page's own URL, or the embedding shell forwarding one across the frame because
|
||
// the frame's src carried only the project. There is one source now: the URL.
|
||
// Never pushes history; the caller's own navigation is what put us here.
|
||
function dashApplyFlag(f){
|
||
const next = DASH_FLAGS.indexOf(f) >= 0 ? f : '';
|
||
if(dashFilter.flag === next) return next;
|
||
dashFilter.flag = next;
|
||
dashPage = 0;
|
||
if(currentView === 'Dashboard') renderDashboard();
|
||
return next;
|
||
}
|
||
function dashSetStatus(s){ dashFilter.status = dashFilter.status===s ? '' : s; dashPage=0; renderDashboard(); }
|
||
|
||
// ── Board sorting (CR-001 / T6.1) ────────────────────────────────────────────
|
||
// The board had no sorting at all. CR-001 asks for a sortable P6 activity column
|
||
// and CR-003 asks for a sortable priority one, so the mechanism is built once and
|
||
// declared as data — a column added later is a row in this table, not another
|
||
// hand-written <th> and another comparator.
|
||
//
|
||
// EMPTIES SORT LAST, in both directions. That is the whole of "sorts correctly,
|
||
// including with empty values": ascending by P6 activity means "the ones with an
|
||
// activity, in order, then the ones without", because nobody sorts by a column in
|
||
// order to look at the rows that have nothing in it. Reversing the direction
|
||
// reverses the filled rows and leaves the blanks where they are.
|
||
const DASH_COLUMNS = [
|
||
{key:'number', label:'WP #', get:p => p.number || ''},
|
||
{key:'subject', label:'Subject', get:p => p.subject || ''},
|
||
{key:'type', label:'Type', get:p => p.type || ''},
|
||
{key:'disciplines', label:'Discipline', get:p => (p.disciplines || []).join(', ')},
|
||
{key:'p6Id', label:'P6 activity',get:p => p.p6Id || ''},
|
||
// Sorted by the ESCALATION order, not alphabetically: High, Normal, Urgent
|
||
// would put the most urgent last, which is the one thing the column is for.
|
||
{key:'priority', label:'Priority', numeric:true,
|
||
get:p => WP_PRIORITIES.indexOf(wpPriorityOf(p))},
|
||
{key:'status', label:'Status', get:p => p.status || ''},
|
||
{key:'gates', label:'Gates', sortable:false},
|
||
{key:'due', label:'Due', get:p => p.due || ''},
|
||
{key:'hours', label:'Hrs', numeric:true, get:p => p.hours},
|
||
{key:'actions', label:'', sortable:false},
|
||
];
|
||
|
||
let dashSort = {key:'', dir:1};
|
||
|
||
function dashSetSort(key){
|
||
const col = DASH_COLUMNS.find(c => c.key === key);
|
||
if(!col || col.sortable === false) return;
|
||
dashSort = (dashSort.key === key) ? {key: key, dir: -dashSort.dir} : {key: key, dir: 1};
|
||
dashPage = 0;
|
||
renderDashboard();
|
||
}
|
||
|
||
function dashSortRows(rows){
|
||
const col = DASH_COLUMNS.find(c => c.key === dashSort.key);
|
||
if(!col || col.sortable === false) return rows;
|
||
const blank = v => v === null || v === undefined || String(v).trim() === '';
|
||
// A copy: `rows` is derived from the live list and sorting it in place would
|
||
// reorder the board's own source.
|
||
return rows.slice().sort((a, b) => {
|
||
const va = col.get(a), vb = col.get(b);
|
||
const ea = blank(va), eb = blank(vb);
|
||
if(ea && eb) return 0;
|
||
if(ea) return 1; // empties last, whichever way the arrow points
|
||
if(eb) return -1;
|
||
if(col.numeric){
|
||
const na = parseFloat(va), nb = parseFloat(vb);
|
||
// A non-numeric value in a numeric column is not an empty and is not a
|
||
// number either; it sorts after the numbers rather than as NaN, which
|
||
// compares false against everything and leaves the order undefined.
|
||
if(isNaN(na) && isNaN(nb)) return 0;
|
||
if(isNaN(na)) return 1;
|
||
if(isNaN(nb)) return -1;
|
||
return (na - nb) * dashSort.dir;
|
||
}
|
||
return String(va).localeCompare(String(vb), undefined, {numeric:true, sensitivity:'base'})
|
||
* dashSort.dir;
|
||
});
|
||
}
|
||
|
||
// CR-004: one filter per level, dependent the same way the form's are — picking
|
||
// a building narrows the floors offered. Rendered only when the project has a
|
||
// taxonomy: three empty dropdowns on a project that has not configured one are
|
||
// three controls that cannot do anything.
|
||
function locFilterSelects(){
|
||
if(!wpLocations.length) return '';
|
||
const out = [];
|
||
LOC_LEVELS.forEach((level, i) => {
|
||
const parent = i === 0 ? '' : dashFilter[LOC_LEVELS[i - 1]];
|
||
if(i > 0 && !parent) return; // nothing to narrow to yet
|
||
const opts = wpLocations.filter(n =>
|
||
n.level === level
|
||
&& (i === 0 ? !n.parent_id : (n.path.indexOf(parent + '/') === 0
|
||
&& n.path.split('/').length === i + 1)));
|
||
if(!opts.length) return;
|
||
const label = level.charAt(0).toUpperCase() + level.slice(1);
|
||
out.push(`<select aria-label="Filter by ${esc(label.toLowerCase())}"`
|
||
+ ` onchange="dashSetLocationFilter('${level}', this.value)">`
|
||
+ `<option value="">All ${esc(level)}s</option>`
|
||
+ opts.map(n => `<option value="${esc(n.path)}"${dashFilter[level] === n.path ? ' selected' : ''}>`
|
||
+ `${esc(locLabelFull(n.path))}</option>`).join('')
|
||
+ `</select>`);
|
||
});
|
||
return out.join('');
|
||
}
|
||
|
||
// Clearing a parent clears its children here too, for the same reason as on the
|
||
// form: a floor filter under a different building filters to nothing and looks
|
||
// like an empty project.
|
||
function dashSetLocationFilter(level, value){
|
||
const i = LOC_LEVELS.indexOf(level);
|
||
dashFilter[level] = value;
|
||
for(let k = i + 1; k < LOC_LEVELS.length; k++) dashFilter[LOC_LEVELS[k]] = '';
|
||
dashPage = 0;
|
||
renderDashboard();
|
||
}
|
||
|
||
// ── CR-018 / T6.4: rollup by building, floor and sector ─────────────────────
|
||
// This is why the Acumatica cost code was removed rather than relabelled: the
|
||
// tracking dimension the team wants is floor and area, not an accounting code.
|
||
//
|
||
// Every number here is the SERVER's. The browser does not sum the leaf groups to
|
||
// get a floor total — that would be the same per-browser arithmetic B4 removed,
|
||
// and it would silently disagree with the header count the moment this browser's
|
||
// copy is behind. server/app.py::_location_levels does it once, for everyone.
|
||
function renderLocationRollup(m){
|
||
const loc = m && m.by_location;
|
||
if(!loc || !loc.levels) return '';
|
||
const unset = loc.unassigned_key || '(unassigned)';
|
||
const levels = (loc.dimensions || []).filter(d => (loc.levels[d] || []).length);
|
||
if(!levels.length) return '';
|
||
|
||
// Each row's `path` is a full location path — a floor is `B-ONE/L1` — so it
|
||
// resolves straight to a name. A group whose value at this level is unassigned
|
||
// is SHOWN, not hidden: it is the row that makes the totals add up, and hiding
|
||
// it is how a rollup ends up quietly missing work.
|
||
const label = path => path === unset ? 'Unassigned' : locLabelFull(path);
|
||
|
||
let h = `<div class="dash-panel"><div class="dash-panel-title">By location</div>`;
|
||
h += `<div class="field-hint">Totals come from the server and are computed at every level, `
|
||
+ `so each table adds up to the project on its own. Packages with no location assigned `
|
||
+ `appear as <strong>Unassigned</strong> rather than being left out.</div>`;
|
||
levels.forEach(dim => {
|
||
const rows = loc.levels[dim] || [];
|
||
const sum = k => rows.reduce((a, r) => a + (r[k] || 0), 0);
|
||
h += `<div class="loc-rollup"><div class="dash-bd-title">By ${esc(dim)}</div>`
|
||
+ `<table class="dash-table"><thead><tr>`
|
||
+ `<th>${esc(dim.charAt(0).toUpperCase() + dim.slice(1))}</th>`
|
||
+ `<th>WPs</th><th>Ready</th><th>On hold</th><th>Overdue</th>`
|
||
+ `<th>Est. hrs</th><th>Actual hrs</th></tr></thead><tbody>`;
|
||
rows.forEach(r => {
|
||
const isUnset = r.path === unset;
|
||
h += `<tr${isUnset ? ' class="loc-unassigned"' : ''}>`
|
||
+ `<td class="row-label">${esc(label(r.path))}</td>`
|
||
+ `<td>${r.total}</td><td>${r.release_ready}</td><td>${r.on_hold}</td>`
|
||
+ `<td>${r.overdue}</td><td>${r.est_hours}</td><td>${r.actual_hours}</td></tr>`;
|
||
});
|
||
// The total row is what makes "reconciles against an unfiltered count"
|
||
// checkable by looking rather than by trusting.
|
||
h += `<tr class="loc-total"><td class="row-label">All ${esc(dim)}s</td>`
|
||
+ `<td>${sum('total')}</td><td>${sum('release_ready')}</td><td>${sum('on_hold')}</td>`
|
||
+ `<td>${sum('overdue')}</td><td>${sum('est_hours')}</td><td>${sum('actual_hours')}</td></tr>`;
|
||
h += `</tbody></table></div>`;
|
||
});
|
||
return h + `</div>`;
|
||
}
|
||
|
||
function dashHeaderCells(){
|
||
return DASH_COLUMNS.map(c => {
|
||
if(c.sortable === false) return `<th>${esc(c.label)}</th>`;
|
||
const on = dashSort.key === c.key;
|
||
const aria = on ? (dashSort.dir > 0 ? 'ascending' : 'descending') : 'none';
|
||
const arrow = on ? (dashSort.dir > 0 ? ' ▲' : ' ▼') : '';
|
||
// A real button inside the th, so it is in the tab order and Enter/Space work
|
||
// for free (C1). The direction is said by aria-sort as well as by the arrow.
|
||
return `<th aria-sort="${aria}"><button type="button" class="dash-sort${on ? ' is-sorted' : ''}"`
|
||
+ ` onclick="dashSetSort('${c.key}')">${esc(c.label)}<span aria-hidden="true">${arrow}</span></button></th>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ── Phase 2: pagination, progress %, and archived view ──────────────────────
|
||
let dashPage=0, dashShowArchived=false, dashArchived=[];
|
||
const DASH_PAGE_SIZE=25;
|
||
// Weighted completion by status (0..1) so progress is smoother than done/not-done.
|
||
// The progress weights moved to server/app.py PROGRESS_WEIGHT at T4.1 (B4), so the
|
||
// bar is computed once for everyone instead of once per browser. Deleted here
|
||
// rather than left in place: a second copy of a weighting table is how the two
|
||
// drift apart, and nothing in this file reads it any more.
|
||
function dashGo(pg){ dashPage=pg; renderDashboard(); }
|
||
function dashToggleArchived(on){
|
||
dashShowArchived=!!on; dashPage=0;
|
||
if(dashShowArchived && !dashArchived.length && typeof ProjectData!=='undefined' && ProjectData.listArchived){
|
||
ProjectData.listArchived(activeProjectId).then(rows=>{ dashArchived=rows||[]; renderDashboard(); });
|
||
} else { renderDashboard(); }
|
||
}
|
||
function dashArchive(id){
|
||
const p=savedPackages.find(x=>x.id===id); if(!p) return;
|
||
if(!confirm('Archive "'+(p.number||p.subject||'this package')+'"? It will be hidden from the active board but kept for the record.')) return;
|
||
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,true);
|
||
const ix=savedPackages.findIndex(x=>x.id===id); if(ix>=0){ p.archived=true; dashArchived.unshift(p); savedPackages.splice(ix,1); }
|
||
saveStore(); renderSavedList(); toast('Archived '+(p.number||'')); dashRefreshAfterWrite();
|
||
}
|
||
function dashUnarchive(id){
|
||
const ix=dashArchived.findIndex(x=>x.id===id); const p=ix>=0?dashArchived[ix]:null; if(!p) return;
|
||
if(typeof ProjectData!=='undefined' && ProjectData.archiveWP) ProjectData.archiveWP(id,false);
|
||
p.archived=false; dashArchived.splice(ix,1); if(!savedPackages.some(x=>x.id===id)) savedPackages.push(p);
|
||
saveStore(); renderSavedList(); toast('Restored '+(p.number||'')); dashRefreshAfterWrite();
|
||
}
|
||
// Consistent colored status pill, reused by the dashboard board and the saved list.
|
||
function statusPill(s){
|
||
const map={'Draft':'badge-NA','Scheduled':'badge-O','Issued':'badge-Y','In Progress':'badge-O','QC':'badge-O','Closed':'badge-Y','Issue':'badge-N'};
|
||
const label = s==='Issue' ? 'Issue (Hold)' : (s||'—');
|
||
return `<span class="badge ${map[s]||'badge-NA'}">${esc(label)}</span>`;
|
||
}
|
||
// A package saved before CR-003 has no priority at all, and so does one whose
|
||
// value has been hand-edited to something not on the list. Both mean Normal —
|
||
// the baseline — rather than blank, which would sort and filter as its own
|
||
// invisible fourth level.
|
||
function wpPriorityOf(p){
|
||
const v = (p && p.priority) || '';
|
||
return WP_PRIORITIES.indexOf(v) >= 0 ? v : WP_PRIORITY_DEFAULT;
|
||
}
|
||
|
||
function priorityPill(p){
|
||
const v = wpPriorityOf(p);
|
||
// The LABEL is always present. The colour is a second channel, never the only
|
||
// one (C1 / X7), and every one of them is a canonical token.
|
||
return `<span class="prio prio-${v.toLowerCase()}">${esc(v)}</span>`;
|
||
}
|
||
|
||
function myUserId(){ try { return (window.WP_USER && window.WP_USER.id) || ''; } catch(e){ return ''; } }
|
||
function wpOpenConstraints(p){ return (p.constraints||[]).filter(c=>c.status==='open'); }
|
||
// Predecessor packages of `p` that aren't Closed. Deleted ones don't block.
|
||
function wpWaitingOn(p){ return (p.predecessors||[]).map(id=>wpById(id)).filter(x=>x&&x.status!=='Closed'); }
|
||
function wpReleaseBlocked(p){ return wpOpenConstraints(p).length>0 || wpWaitingOn(p).length>0; }
|
||
function isOverdue(p){ return !!(p.due && p.status!=='Closed' && p.due < todayStr()); }
|
||
// Masters are roll-ups of their instances — exclude them from counts so work isn't double-counted.
|
||
function countableWPs(){ return WPData.list().filter(p=>!p.split); }
|
||
|
||
// Restoring the dashboard because the user pressed Back: same rendering, no new
|
||
// history entry.
|
||
function showDashboardFromUrl(){ showDashboard({fromUrl:true}); }
|
||
function showDashboard(opts){
|
||
const fromUrl = !!(opts && opts.fromUrl);
|
||
setFormChrome(false);
|
||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='none');
|
||
document.getElementById('pkg-output').style.display='none';
|
||
const dv=document.getElementById('dashboard-view'); if(dv) dv.style.display='';
|
||
currentView='Dashboard'; cmtUpdateCurStep();
|
||
if(typeof WPUrl !== 'undefined' && !fromUrl){
|
||
const patch = { view: 'dashboard', wp: '', flag: dashFilter.flag || '' };
|
||
if(activeProjectId) patch.project = activeProjectId;
|
||
WPUrl.push(patch);
|
||
}
|
||
// Ask the server every time the dashboard is opened. Counts that were correct
|
||
// when you last looked are not evidence that they are correct now.
|
||
dashMetrics=null; loadDashMetrics();
|
||
syncToolTabs();
|
||
window.scrollTo({top:0,behavior:'smooth'}); track('dashboard_open');
|
||
}
|
||
// ── B4: dashboard counts come from the server ────────────────────────────────
|
||
// Every number on this dashboard used to be summed in the browser from
|
||
// savedPackages, which is this browser's localStorage. Two people on the same
|
||
// project saw different totals and neither was told. /api/wps/metrics computes
|
||
// them from the database instead, so the answer is the project's answer.
|
||
//
|
||
// There is no localStorage fallback. A silently-stale number that looks
|
||
// authoritative is the failure being fixed, so a failed fetch renders an error
|
||
// panel and a retry - not a zero, and not the last good answer.
|
||
let dashMetrics = null; // last successful server response
|
||
let dashMetricsErr = null; // Error, if the last fetch failed
|
||
let dashMetricsLoading = false;
|
||
|
||
function dashMetricsUrl(){
|
||
const q = [];
|
||
if(activeProjectId) q.push('project_id=' + encodeURIComponent(activeProjectId));
|
||
return '/api/wps/metrics' + (q.length ? '?' + q.join('&') : '');
|
||
}
|
||
|
||
function loadDashMetrics(){
|
||
dashMetricsLoading = true; dashMetricsErr = null;
|
||
return fetch(dashMetricsUrl(), { headers: { 'Accept': 'application/json' } })
|
||
.then(r => { if(!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||
.then(m => { dashMetrics = m; dashMetricsErr = null; })
|
||
.catch(e => { dashMetricsErr = e; dashMetrics = null; })
|
||
.then(() => { dashMetricsLoading = false; renderDashboard(); });
|
||
}
|
||
|
||
function renderDashboard(){
|
||
const all=countableWPs();
|
||
const m = dashMetrics;
|
||
const byStatus = m ? Object.assign({}, m.by_status) : {};
|
||
const byDisc = m ? Object.assign({}, m.by_discipline) : {};
|
||
if(m) STATUS_ORDER.concat(['Issue']).forEach(s=>{ if(byStatus[s]==null) byStatus[s]=0; });
|
||
const estH=m?m.est_hours:0, actH=m?m.actual_hours:0, ready=m?m.release_ready:0,
|
||
hold=m?m.on_hold:0, overdue=m?m.overdue:0, mine=m?m.mine:0;
|
||
const meId=myUserId();
|
||
// Clickable metric cards filter the board (flag-based); a card with no flag is static.
|
||
const card=(label,val,cls,flag)=>{
|
||
const active = flag && dashFilter.flag===flag ? ' dm-active' : '';
|
||
const attr = flag ? ` onclick="dashToggleFlag('${flag}')" title="Click to filter the board"` : '';
|
||
return `<div class="dash-metric ${cls||''}${active}"${attr}><div class="dm-val">${val}</div><div class="dm-label">${esc(label)}</div></div>`;
|
||
};
|
||
// The counts panel is server-derived; if that request failed, say so instead of
|
||
// rendering a row of zeros that reads as "this project is empty".
|
||
if(dashMetricsErr){
|
||
let eh = `<div class="dash-panel" role="alert"><div class="dash-panel-title">⚠ Counts unavailable</div>`;
|
||
eh += `<div class="field-hint">The dashboard totals are computed by the server so that everyone on this project sees the same numbers. That request failed (${esc(String(dashMetricsErr.message||dashMetricsErr))}), and this browser's own copy is not shown in its place because it can be out of date without looking it.</div>`;
|
||
eh += `<div class="material-actions"><button class="btn btn-ghost" onclick="loadDashMetrics()">Retry</button></div></div>`;
|
||
const host0=document.getElementById('dashboard-view');
|
||
if(host0){ host0.innerHTML = eh; }
|
||
return;
|
||
}
|
||
if(!m && dashMetricsLoading){
|
||
const host1=document.getElementById('dashboard-view');
|
||
if(host1){ host1.innerHTML = `<div class="dash-panel" role="status"><div class="field-hint">Loading project totals…</div></div>`; }
|
||
return;
|
||
}
|
||
if(!m){ loadDashMetrics(); return; }
|
||
|
||
let h=`<div class="dash-metrics">
|
||
${card('Total WPs', m.total, '', 'all')}
|
||
${meId ? card('My WPs', mine, mine?'dm-blue':'', 'mine') : ''}
|
||
${card('Release-ready', ready, ready?'dm-green':'', 'ready')}
|
||
${card('On hold', hold, hold?'dm-red':'', 'onhold')}
|
||
${card('Overdue', overdue, overdue?'dm-red':'', 'overdue')}
|
||
${card('Est. hrs', Math.round(estH))}
|
||
${card('Actual hrs', Math.round(actH))}
|
||
</div>`;
|
||
|
||
// status + discipline breakdown chips (status chips also filter the board)
|
||
const statusChip=(label,count,cls,status)=>`<span class="dash-chip${cls?' '+cls:''}${dashFilter.status===status?' chip-active':''}" onclick="dashSetStatus('${status}')" title="Click to filter the board">${esc(label)}: <b>${count}</b></span>`;
|
||
const statusChips=STATUS_ORDER.filter(s=>byStatus[s]).map(s=>statusChip(s,byStatus[s],'',s)).join('')
|
||
+ (byStatus['Issue']?statusChip('On Hold',byStatus['Issue'],'chip-red','Issue'):'');
|
||
const discChips=Object.keys(byDisc).map(d=>`<span class="dash-chip">${esc(d)}: <b>${byDisc[d]}</b></span>`).join('')||'<span class="dash-chip">—</span>';
|
||
h+=`<div class="dash-breakdown"><div><div class="dash-bd-title">By status</div>${statusChips||'—'}</div>
|
||
<div><div class="dash-bd-title">By discipline</div>${discChips}</div></div>`;
|
||
|
||
// progress by phase (discipline), weighted by status; archived excluded.
|
||
// The weights live in server/app.py PROGRESS_WEIGHT now, so one definition
|
||
// produces the bar for everyone rather than one per browser.
|
||
const overallPct = m.progress.overall_pct;
|
||
let prog=`<div class="dash-panel"><div class="dash-panel-title">Progress by phase</div>`;
|
||
prog+=`<div class="prog-row"><div class="prog-name"><strong>Overall</strong></div><div class="prog-bar"><div class="prog-fill" style="width:${overallPct}%"></div></div><div class="prog-pct">${overallPct}%</div></div>`;
|
||
m.progress.by_discipline.forEach(g=>{
|
||
prog+=`<div class="prog-row"><div class="prog-name">${esc(g.name)}</div><div class="prog-bar"><div class="prog-fill" style="width:${g.pct}%"></div></div><div class="prog-pct">${g.pct}% <span class="prog-sub">${g.done}/${g.total}</span></div></div>`; });
|
||
prog+=`<div class="field-hint" style="margin-top:8px">Weighted by status (Draft 0 · Scheduled 25 · Issued 50 · In Progress 75 · QC 90 · Closed 100%). Archived packages excluded.</div></div>`;
|
||
h+=prog;
|
||
h+=renderLocationRollup(m);
|
||
|
||
// gating panel — what's blocking release, from the server
|
||
const gated=m.gating||[];
|
||
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(g=>`<tr><td class="row-label">${esc(g.number||'—')}</td><td>${esc(g.subject||'')}</td>
|
||
<td>${(g.blocked_by||[]).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('');
|
||
const prioOpts=['<option value="">All priorities</option>'].concat(WP_PRIORITIES.map(v=>`<option ${dashFilter.priority===v?'selected':''}>${esc(v)}</option>`)).join('');
|
||
h+=`<div class="dash-filters">
|
||
<input type="search" placeholder="Search WP # / subject / type…" value="${(dashFilter.q||'').replace(/"/g,'"')}" oninput="dashFilter.q=this.value;dashPage=0;renderDashboard()">
|
||
<select onchange="dashFilter.status=this.value;dashPage=0;renderDashboard()">${statusOpts}</select>
|
||
<select onchange="dashFilter.discipline=this.value;dashPage=0;renderDashboard()">${discOpts}</select>
|
||
<select aria-label="Filter by priority" onchange="dashFilter.priority=this.value;dashPage=0;renderDashboard()">${prioOpts}</select>
|
||
${locFilterSelects()}
|
||
<label class="dash-arch-toggle"><input type="checkbox" ${dashShowArchived?'checked':''} onchange="dashToggleArchived(this.checked)"> Show archived${dashShowArchived?' ('+dashArchived.length+')':''}</label>
|
||
</div>`;
|
||
|
||
// main board (includes masters, marked; archived only when toggled on)
|
||
const q=(dashFilter.q||'').toLowerCase();
|
||
const boardSource = dashShowArchived ? WPData.list().concat(dashArchived) : WPData.list();
|
||
const rows=boardSource.filter(p=>{
|
||
if(dashFilter.status && p.status!==dashFilter.status) return false;
|
||
if(dashFilter.discipline && !((p.disciplines||[]).includes(dashFilter.discipline))) return false;
|
||
if(dashFilter.priority && wpPriorityOf(p)!==dashFilter.priority) return false;
|
||
// CR-004: filtering by a BUILDING matches every package under it, because the
|
||
// stored value is a path. Filtering by the floor code alone could not do that.
|
||
if(dashFilter.building && !((p.building||'')===dashFilter.building
|
||
|| (p.floor||'').indexOf(dashFilter.building+'/')===0)) return false;
|
||
if(dashFilter.floor && !((p.floor||'')===dashFilter.floor
|
||
|| (p.sector||'').indexOf(dashFilter.floor+'/')===0)) return false;
|
||
if(dashFilter.sector && (p.sector||'')!==dashFilter.sector) return false;
|
||
if(q && !((p.number||'')+' '+(p.subject||'')+' '+(p.type||'')).toLowerCase().includes(q)) return false;
|
||
if(dashFilter.flag==='mine' && p.assigneeId!==myUserId()) return false;
|
||
if(dashFilter.flag==='ready' && !(!p.split && !wpReleaseBlocked(p) && p.status!=='Closed' && p.status!=='Issue')) return false;
|
||
if(dashFilter.flag==='onhold' && p.status!=='Issue') return false;
|
||
if(dashFilter.flag==='overdue' && !isOverdue(p)) return false;
|
||
return true;
|
||
});
|
||
const sorted=dashSortRows(rows);
|
||
const totalRows=sorted.length;
|
||
const pages=Math.max(1, Math.ceil(totalRows/DASH_PAGE_SIZE));
|
||
if(dashPage>=pages) dashPage=pages-1;
|
||
if(dashPage<0) dashPage=0;
|
||
const pageRows=sorted.slice(dashPage*DASH_PAGE_SIZE, dashPage*DASH_PAGE_SIZE+DASH_PAGE_SIZE);
|
||
// The board is a LIST, not a rollup: it renders the packages this browser holds,
|
||
// which is what keeps the field view usable offline. Its header is therefore the
|
||
// only count on this page not computed by the server — so it is reconciled against
|
||
// the server's instead of being left to disagree in silence, which is the whole of
|
||
// what B4 objects to. A divergence means this browser's copy is behind (a pending
|
||
// outbox write, a stale tab), and saying so is more useful than hiding it.
|
||
const localCountable = (dashShowArchived ? WPData.list() : WPData.list().filter(p=>!p.archived))
|
||
.filter(p=>!p.split).length;
|
||
const drift = (!dashShowArchived && m && localCountable !== m.total)
|
||
? ` <span class="dash-chip chip-red" title="The server counts ${m.total} package(s) on this project; this browser is holding ${localCountable}. Usually a save that has not finished syncing.">this browser has ${localCountable} of ${m.total}</span>`
|
||
: '';
|
||
h+=`<div class="dash-panel"><div class="dash-panel-title">Work packages (${totalRows})${drift}</div>
|
||
<table class="dash-table"><thead><tr>${dashHeaderCells()}</tr></thead><tbody>`;
|
||
if(!totalRows) h+=`<tr><td colspan="${DASH_COLUMNS.length}" class="field-hint" style="padding:14px">No work packages match.</td></tr>`;
|
||
pageRows.forEach(p=>{
|
||
const ix=savedPackages.findIndex(x=>x.id===p.id);
|
||
const open=wpOpenConstraints(p).length;
|
||
const waiting=wpWaitingOn(p);
|
||
const gates= p.split?'<span class="badge badge-O">master</span>'
|
||
:(open?`<span class="badge badge-O">${open} open</span>`
|
||
:(waiting.length?`<span class="badge badge-O" title="${esc(waiting.map(w=>(w.number||w.id)+' — '+w.status).join(', '))}">waits on ${waiting.length}</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 pid=esc(p.id);
|
||
let actions;
|
||
if(p.archived){
|
||
actions=`<button class="link-btn" onclick="showHistory('${pid}')">history</button> <button class="link-btn" onclick="dashUnarchive('${pid}')">restore</button>`;
|
||
} else {
|
||
const canIssue = !p.split && open===0 && !waiting.length && p.status!=='Closed' && p.status!=='Issued' && p.status!=='Issue';
|
||
const issueBtn = canIssue?`<button class="link-btn" onclick="dashIssue('${pid}')">issue</button> `:'';
|
||
actions=`${issueBtn}<button class="link-btn" onclick="dashView(${ix})">view</button> <button class="link-btn" onclick="dashEdit(${ix})">edit</button> <button class="link-btn" onclick="dashArchive('${pid}')">archive</button>`;
|
||
}
|
||
h+=`<tr${p.archived?' style="opacity:.6"':''}><td class="row-label">${esc(p.number||'—')}${p.instanceOf?` <span class="badge badge-Y">${esc(p.instanceLabel||'')}</span>`:''}${p.archived?' <span class="badge badge-NA">archived</span>':''}</td>
|
||
<td>${esc(p.subject||'')}</td><td>${esc(p.type||'')}</td>
|
||
<td style="font-size:11px">${esc((p.disciplines||[]).join(', '))||ns()}</td>
|
||
<td style="font-size:11px" title="${esc(p.p6Desc||'')}">${cell(p.p6Id)}</td>
|
||
<td>${priorityPill(p)}</td>
|
||
<td>${statusPill(p.status)}</td><td>${gates}</td><td>${due}</td><td>${cell(p.hours)}</td>
|
||
<td class="center" style="white-space:nowrap">${actions}</td></tr>`;
|
||
});
|
||
h+=`</tbody></table>`;
|
||
if(pages>1){
|
||
h+=`<div class="dash-pager"><span>Page ${dashPage+1} of ${pages} · ${totalRows} packages</span>
|
||
<span class="dash-pager-btns"><button class="btn btn-ghost" ${dashPage===0?'disabled':''} onclick="dashGo(${dashPage-1})">‹ Prev</button>
|
||
<button class="btn btn-ghost" ${dashPage>=pages-1?'disabled':''} onclick="dashGo(${dashPage+1})">Next ›</button></span></div>`;
|
||
}
|
||
h+=`</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; }
|
||
const waiting=wpWaitingOn(p);
|
||
if(waiting.length){
|
||
// Releasing early needs a reason, same as on the form — the dashboard must not
|
||
// be the quiet way around the gate.
|
||
alert('Cannot issue from here — waiting on:\n\n• '+
|
||
waiting.map(w=>(w.number||w.id)+' — '+w.status).join('\n• ')+
|
||
'\n\nOpen the package to release it early with a logged reason.');
|
||
return;
|
||
}
|
||
if(!confirm('Issue work package "'+(p.number||p.subject)+'"? This marks it released to the field.')) return;
|
||
WPData.issue(id); renderSavedList(); toast('Issued '+(p.number||'')); track('dashboard_issue');
|
||
dashRefreshAfterWrite();
|
||
}
|
||
function dashView(i){ if(savedPackages[i]){ renderPackage(savedPackages[i]); urlSyncPackage(savedPackages[i].id); } }
|
||
function dashEdit(i){ const p=savedPackages[i]; if(!p) return; editingId=p.id; loadPackageIntoForm(p); urlSyncPackage(p.id); }
|
||
|
||
// ── VIEW SOP REFERENCE (comment 2) ───────────────────────────────────────────
|
||
function openSopModal(){
|
||
if(!SOP){ alert('No SOP loaded.'); return; }
|
||
const p=SOP.project||{}, g=SOP.governance||{}, q=SOP.quality||{};
|
||
const row=(k,v)=>`<tr><th style="width:170px;text-align:left;padding:4px 8px;background:var(--surface2)">${esc(k)}</th><td style="padding:4px 8px">${v||'—'}</td></tr>`;
|
||
let h=`<table style="width:100%;border-collapse:collapse;font-size:13px">`;
|
||
h+=row('Project', esc(p.name)); h+=row('Number', esc(p.number)); h+=row('Client', esc(p.client));
|
||
h+=row('Division', esc(p.division)); h+=row('PM / CM / QM', [p.pm,p.cm,p.qm].filter(Boolean).map(esc).join(' / '));
|
||
h+=row('WP number format', `<code>${esc(g.woFormat||'—')}</code>`); h+=row('Issuance', esc((g.issuance||[]).join(', ')));
|
||
h+=row('Target WP size', esc(g.woSize));
|
||
h+=row('WP types', enabledTypes().map(t=>esc(t.name)).join(', '));
|
||
h+=row('QC', esc([q.qcReq,q.qcScope].filter(Boolean).join(' — '))); h+=row('Photo', esc(q.photo));
|
||
h+=row('Hold points', esc(q.holdPoints)); h+=row('Tracking tool', esc((SOP.field&&SOP.field.trackPlatform)));
|
||
h+=row('Sequence', ((SOP.sequence||[]).map(s=>esc(s.label)+(s.kind==='gate'?' (gate)':'')).join(' → ')));
|
||
h+=row('Roles', (SOP.roles||[]).map(r=>esc(r.role)+': '+esc(r.name)).join('; '));
|
||
h+=row('Sources', (SOP.sources||[]).map(s=>esc(s.label)+' ('+esc(s.system)+')').join('; '));
|
||
h+=`</table>`;
|
||
document.getElementById('sop-modal-body').innerHTML=h;
|
||
document.getElementById('sop-modal').classList.add('open'); track('view_sop');
|
||
}
|
||
function closeSopModal(){ document.getElementById('sop-modal').classList.remove('open'); }
|
||
const ANALYTICS_KEY='wp_iwp_analytics_v1'; const _session='s_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6);
|
||
function analyticsLoad(){ try{ return JSON.parse(localStorage.getItem(ANALYTICS_KEY))||{events:[]}; }catch(e){ return {events:[]}; } }
|
||
function analyticsSave(d){ try{ localStorage.setItem(ANALYTICS_KEY, JSON.stringify(d)); }catch(e){} }
|
||
function track(event,detail){ if(devMode) return; try{ const d=analyticsLoad(); d.events.push({ts:new Date().toISOString(),session:_session,event,detail:detail||null}); if(d.events.length>5000)d.events=d.events.slice(-5000); analyticsSave(d); }catch(e){} }
|
||
function showAnalytics(){ const d=analyticsLoad(); const by={}; const ses=new Set(); d.events.forEach(e=>{by[e.event]=(by[e.event]||0)+1;ses.add(e.session);});
|
||
let t=`USAGE ANALYTICS\n\nSessions: ${ses.size} Events: ${d.events.length}\n\nActions:\n`; Object.keys(by).forEach(k=>t+=` ${k}: ${by[k]}\n`); t+=`\nSaved packages (this device): ${savedPackages.length}\n\nDownload full log as JSON?`;
|
||
if(confirm(t)) downloadAnalytics(); }
|
||
function downloadAnalytics(){ const d=analyticsLoad(); const blob=new Blob([JSON.stringify(d,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='wp-iwp-usage-'+new Date().toISOString().slice(0,10)+'.json'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('analytics_exported'); }
|
||
|
||
// ── COMMENTS ─────────────────────────────────────────────────────────────────
|
||
const COMMENTS_KEY='wp_iwp_comments_v1';
|
||
function cmtLoad(){ let d; try{ d=JSON.parse(localStorage.getItem(COMMENTS_KEY)); }catch(e){} if(!d||typeof d!=='object')d={}; if(!d.clientId)d.clientId='c_'+Date.now().toString(36)+Math.random().toString(36).slice(2,6); if(typeof d.author!=='string')d.author=''; if(!Array.isArray(d.comments))d.comments=[]; return d; }
|
||
function cmtSave(d){ try{ localStorage.setItem(COMMENTS_KEY, JSON.stringify(d)); }catch(e){} }
|
||
function cmtSaveAuthor(v){ const d=cmtLoad(); d.author=v; cmtSave(d); }
|
||
function toggleComments(){ const dr=document.getElementById('cmt-drawer'),ov=document.getElementById('cmt-overlay'); const open=!dr.classList.contains('open'); dr.classList.toggle('open',open); ov.classList.toggle('open',open); dr.setAttribute('aria-hidden',open?'false':'true'); if(open){ cmtUpdateCurStep(); renderComments(); const a=document.getElementById('cmt-author'); if(a&&!a.value)a.focus(); else document.getElementById('cmt-input')?.focus(); } }
|
||
function cmtUpdateCurStep(){ const el=document.getElementById('cmt-cur-step'); if(el) el.textContent=currentView; }
|
||
function addComment(){ const d=cmtLoad(); const author=(document.getElementById('cmt-author').value||'').trim(); const text=(document.getElementById('cmt-input').value||'').trim(); if(!author){ alert('Please add your name first.'); document.getElementById('cmt-author').focus(); return; } if(!text){ document.getElementById('cmt-input').focus(); return; } const entry={id:'m_'+Date.now().toString(36)+Math.random().toString(36).slice(2,5), view:currentView, author, clientId:d.clientId, text, ts:new Date().toISOString()}; d.author=author; d.comments.push(entry); cmtSave(d); if(window.postFeedback) window.postFeedback({type:'wp_review_comment', ...entry}); document.getElementById('cmt-input').value=''; renderComments(); refreshCommentBadges(); track('comment_added'); }
|
||
function deleteComment(id){ const d=cmtLoad(); d.comments=d.comments.filter(c=>c.id!==id); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
||
function renderComments(){ const d=cmtLoad(); const list=document.getElementById('cmt-list'); if(!list) return; if(!d.comments.length){ list.innerHTML='<div class="cmt-empty">No comments yet.</div>'; return; } const sorted=[...d.comments].sort((a,b)=>(b.ts||'').localeCompare(a.ts||'')); list.innerHTML=sorted.map(c=>{ const mine=c.clientId===d.clientId; const when=c.ts?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 exportComments(){ const d=cmtLoad(); const payload={tool:'Work Package (IWP)', exportedBy:d.author||'', exportedAt:new Date().toISOString(), clientId:d.clientId, comments:d.comments}; const blob=new Blob([JSON.stringify(payload,null,2)],{type:'application/json'}); const safe=(d.author||'review').replace(/[^a-z0-9]+/gi,'-').toLowerCase(); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=`wp-iwp-comments-${safe}-${new Date().toISOString().slice(0,10)}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); track('comments_exported'); }
|
||
function importComments(ev){ const f=ev.target.files&&ev.target.files[0]; if(!f) return; const r=new FileReader(); r.onload=()=>{ try{ const inc=JSON.parse(r.result); const incoming=Array.isArray(inc)?inc:(inc.comments||[]); if(!incoming.length){ alert('No comments found.'); return; } const d=cmtLoad(); const seen=new Set(d.comments.map(c=>c.id)); let added=0; incoming.forEach(c=>{ if(c&&c.id&&!seen.has(c.id)){ d.comments.push(c); seen.add(c.id); added++; }}); cmtSave(d); renderComments(); refreshCommentBadges(); alert(`Imported ${added} comment${added===1?'':'s'}.`); }catch(e){ alert('Could not read that file.'); } ev.target.value=''; }; r.readAsText(f); }
|
||
function clearMyComments(){ const d=cmtLoad(); const mine=d.comments.filter(c=>c.clientId===d.clientId).length; if(!mine){ alert('No comments to clear.'); return; } if(!confirm(`Delete your ${mine} comment(s)?`)) return; d.comments=d.comments.filter(c=>c.clientId!==d.clientId); cmtSave(d); renderComments(); refreshCommentBadges(); }
|
||
function cmtInit(){ const d=cmtLoad(); cmtSave(d); const a=document.getElementById('cmt-author'); if(a)a.value=d.author||''; cmtUpdateCurStep(); renderComments(); refreshCommentBadges(); }
|
||
|
||
// ── STATUS PILLS ─────────────────────────────────────────────────────────────
|
||
document.querySelectorAll('#status-group .radio-pill').forEach(p=>p.addEventListener('click',e=>{
|
||
if(e.target.tagName==='INPUT') return;
|
||
const v=p.dataset.val;
|
||
prevStatus = getRadio('status') || 'Draft'; // capture before switching (for revert)
|
||
document.querySelectorAll('#status-group .radio-pill').forEach(x=>x.classList.remove('selected'));
|
||
p.classList.add('selected'); p.querySelector('input').checked=true;
|
||
onStatusChange(v);
|
||
}));
|
||
|
||
|
||
// ── AUTOSAVE / UNSAVED-WORK GUARD / RECOVERY (S2) ────────────────────────────
|
||
// A draft is per project AND per package, so two projects cannot overwrite each
|
||
// other's recovery and a new package does not inherit the last one's draft.
|
||
function wpDraftId(){ return 'wp-form::' + (activeProjectId || 'none') + '::' + (editingId || 'new'); }
|
||
|
||
// Volatile fields move on their own (timestamps, the id assigned at collect time)
|
||
// and would make an untouched form look edited, which is exactly the false-positive
|
||
// that makes an unsaved-work dialog worthless.
|
||
function wpFormFingerprint(pkg){
|
||
if(!pkg) return '';
|
||
const copy = Object.assign({}, pkg);
|
||
['id','updatedAt','createdAt','issuedAt'].forEach(k=>delete copy[k]);
|
||
try { return JSON.stringify(copy); } catch(e){ return ''; }
|
||
}
|
||
|
||
// The baseline is taken when the form is populated and again when it is saved, so
|
||
// "dirty" means "changed since then". Comparing the form against savedPackages
|
||
// instead does not work: those records come back from the server through
|
||
// serverToPkg() in a leaner shape, so a freshly loaded, untouched form differed
|
||
// from its own record and every exit would have prompted - which is precisely the
|
||
// dialog-that-gets-clicked-through the task forbids.
|
||
let _wpFormBaseline = null;
|
||
function wpMarkFormClean(){
|
||
try { _wpFormBaseline = wpFormFingerprint(collectPackage()); } catch(e){ _wpFormBaseline = null; }
|
||
}
|
||
|
||
function wpFormIsDirty(){
|
||
// Only the form can be dirty. On the dashboard or the printed view nothing is
|
||
// being edited, so there is nothing to warn about.
|
||
if(currentView !== 'Work Package Form') return false;
|
||
if(_wpFormBaseline === null) return false;
|
||
const el = document.getElementById('wp_subject');
|
||
if(!el) return false;
|
||
let live;
|
||
try { live = collectPackage(); } catch(e){ return false; }
|
||
return wpFormFingerprint(live) !== _wpFormBaseline;
|
||
}
|
||
|
||
function initAutosave(){
|
||
if(typeof WPAutosave === 'undefined') return;
|
||
WPAutosave.register({
|
||
id: 'wp-form',
|
||
scope: document,
|
||
draftId: wpDraftId,
|
||
collect: collectPackage,
|
||
isDirty: wpFormIsDirty,
|
||
});
|
||
// B5: the indicator lives in the sticky save bar, beside the button it is telling
|
||
// the truth about. The bar is rendered by the app, so mount when it exists.
|
||
const mountHost = document.querySelector('#sticky-save .sticky-status') || document.querySelector('#sticky-save');
|
||
if(mountHost) WPAutosave.mountIndicator(mountHost);
|
||
offerDraftRecovery();
|
||
}
|
||
|
||
// Recovery. Offered only when the draft is genuinely ahead of the record - being
|
||
// asked to recover work that is already saved is its own kind of alarming.
|
||
function offerDraftRecovery(){
|
||
if(typeof WPAutosave === 'undefined') return;
|
||
const id = wpDraftId();
|
||
const d = WPAutosave.peek(id);
|
||
if(!d || !d.data) return;
|
||
const saved = editingId ? savedPackages.find(p=>p.id===editingId) : null;
|
||
if(saved && wpFormFingerprint(d.data) === wpFormFingerprint(saved)){ WPAutosave.discard(id); return; }
|
||
if(!d.data.subject && !d.data.number && !d.data.type){ WPAutosave.discard(id); return; }
|
||
showDraftRecoveryBar(id, d);
|
||
}
|
||
|
||
function showDraftRecoveryBar(id, d){
|
||
const host = document.querySelector('.main');
|
||
if(!host) return;
|
||
const old = document.getElementById('draft-recovery'); if(old) old.remove();
|
||
const when = (function(){ try { return new Date(d.at).toLocaleString(); } catch(e){ return d.at; } })();
|
||
const bar = document.createElement('div');
|
||
bar.id = 'draft-recovery';
|
||
bar.className = 'dash-panel';
|
||
bar.setAttribute('role','status');
|
||
bar.innerHTML = `<div class="dash-panel-title">Unsaved work from ${esc(when)}</div>
|
||
<div class="field-hint">This browser was holding changes to <strong>${esc(d.data.number || d.data.subject || 'a work package')}</strong> that were never saved. Nothing has been sent to the project.</div>
|
||
<div class="material-actions">
|
||
<button class="btn btn-primary" id="draft-restore">Restore them</button>
|
||
<button class="btn btn-ghost" id="draft-discard">Discard</button>
|
||
</div>`;
|
||
host.insertBefore(bar, host.firstChild);
|
||
document.getElementById('draft-restore').onclick = ()=>{
|
||
loadPackageIntoForm(Object.assign({}, d.data, {id: (editingId || d.data.id)}));
|
||
bar.remove(); toast('Unsaved work restored — Save draft to keep it.');
|
||
};
|
||
document.getElementById('draft-discard').onclick = ()=>{ WPAutosave.discard(id); bar.remove(); };
|
||
}
|
||
|
||
// ── BOOT ─────────────────────────────────────────────────────────────────────
|
||
// Resolve the active project BEFORE loading the store so namespaced keys resolve.
|
||
(function seedProject(){
|
||
const params = new URLSearchParams(location.search);
|
||
activeProjectId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()!==activeProjectId){
|
||
const cached = ProjectData.getActive && ProjectData.getActive();
|
||
ProjectData.setActive(cached && cached.id===activeProjectId ? cached : { id: activeProjectId });
|
||
}
|
||
})();
|
||
function bootSOP(){
|
||
// Prefer the SOP the wizard just completed (persisted to localStorage, already
|
||
// hydrated from the server for this project).
|
||
//
|
||
// B7/T7.1: this used to also set `body.embedded` from ?embedded=1, which hid
|
||
// this page's own header, its two sample controls and its analytics button
|
||
// because they duplicated the shell's. There is no shell. The controls live in
|
||
// the toolbar and are visible (D1); the parameter is not read any more, and an
|
||
// old link that still carries it is simply ignored rather than half-obeyed.
|
||
try {
|
||
const raw = localStorage.getItem(wpKey('wp_suite_sop'));
|
||
if(raw){
|
||
const d = JSON.parse(raw);
|
||
if(d && d.woTypes){ SOP = d; applySOP(); newPackage(); return; }
|
||
}
|
||
} catch(e){}
|
||
// No SOP found. Only show the Micron SAMPLE for a standalone preview (no project
|
||
// context). For a real project, never substitute sample data — show the empty
|
||
// state so it's clear the project's SOP must be completed first.
|
||
if(activeProjectId){ SOP=null; renderCtxBar(); newPackage(); }
|
||
else { loadSampleSOP(); }
|
||
}
|
||
// Populate the Owner picker with this project's members (+ admins). The list is
|
||
// only used to pick an assignee; the server re-validates on save.
|
||
//
|
||
// The SOP's project team is listed first (they're the people this project has
|
||
// actually named), with everyone else on the project after them — so the common
|
||
// pick is at the top without hiding anyone who could legitimately own a package.
|
||
let projectMembers = [];
|
||
function sopTeamIds(){
|
||
const p = (SOP && SOP.project) || {};
|
||
const ids = [p.pmId, p.apmId, p.cmId, p.qmId];
|
||
(p.teamMembers || []).forEach(m => ids.push(m && m.userId));
|
||
return ids.filter(Boolean);
|
||
}
|
||
async function loadMembers(){
|
||
const sel=document.getElementById('wp_assignee');
|
||
if(!sel || !activeProjectId) return;
|
||
try {
|
||
const r=await fetch('/api/projects/'+encodeURIComponent(activeProjectId)+'/members',{credentials:'same-origin'});
|
||
if(!r.ok) return;
|
||
projectMembers=await r.json();
|
||
const cur=sel.value;
|
||
const team=sopTeamIds();
|
||
const onTeam=projectMembers.filter(u=>team.includes(u.id));
|
||
const others=projectMembers.filter(u=>!team.includes(u.id));
|
||
const opt=u=>`<option value="${esc(u.id)}">${esc(u.full_name||u.username)}${u.project_role?' — '+esc(u.project_role):''}</option>`;
|
||
sel.innerHTML='<option value="">— Unassigned —</option>'+
|
||
(onTeam.length?`<optgroup label="Project team (from SOP)">${onTeam.map(opt).join('')}</optgroup>`:'')+
|
||
(others.length?`<optgroup label="${onTeam.length?'Others on this project':'On this project'}">${others.map(opt).join('')}</optgroup>`:'');
|
||
if(cur) sel.value=cur;
|
||
else defaultOwnerToMe();
|
||
// The people pickers list the same accounts, so (re)render them now that the
|
||
// member list has arrived.
|
||
if(editingId){ const p=savedPackages.find(x=>x.id===editingId); if(p) loadPeopleFromPkg(p); }
|
||
else resetPeopleForNewPackage();
|
||
} catch(e){}
|
||
}
|
||
// A new package defaults to the person creating it — they're accountable until
|
||
// they hand it over. Only applies to an unsaved, unassigned package, and only if
|
||
// they're actually assignable on this project.
|
||
function defaultOwnerToMe(){
|
||
const sel=document.getElementById('wp_assignee');
|
||
if(!sel || sel.value || editingId) return;
|
||
const me=(window.WP_USER||{}).id;
|
||
if(me && Array.from(sel.options).some(o=>o.value===me)) sel.value=me;
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function(){
|
||
LOC_LEVELS.forEach(level => {
|
||
const el = document.getElementById(LOC_FIELD[level]);
|
||
if(el) el.addEventListener('change', () => onLocationChange(level));
|
||
});
|
||
});
|
||
|
||
function bootData(){
|
||
loadStore(); // reads the localStorage cache (hydrated from the server below)
|
||
bootSOP();
|
||
setRadio('status','Draft');
|
||
loadMembers();
|
||
loadLocations(); // CR-004: the option lists come from the server
|
||
wpFilesRefreshUsage(); // CR-007: the storage meter is live before the first upload
|
||
initWpNavDrawer();
|
||
renderSavedList();
|
||
positionSectionNav();
|
||
cmtInit();
|
||
// Deep-link: a specific package (?wp=<id>, from the global search), or the dashboard.
|
||
const p=new URLSearchParams(location.search);
|
||
const wantWp=p.get('wp');
|
||
if(wantWp){
|
||
const ix=savedPackages.findIndex(x=>x.id===wantWp);
|
||
if(ix>=0) wpNavOpen(ix);
|
||
else toast('That work package is not on this project (it may have been deleted).');
|
||
}
|
||
// A pipeline cell links straight to a filtered board, so the filter has to be
|
||
// applied BEFORE the first render — applying it after would paint the whole
|
||
// board and then throw it away, which on a big project is visible.
|
||
if(p.get('view')==='dashboard' || location.hash==='#dashboard'){
|
||
dashApplyFlag(p.get('flag') || '');
|
||
showDashboard({fromUrl:true});
|
||
}
|
||
|
||
// Back / Forward. The URL is the state, so restoring is "read it and show that",
|
||
// not a bespoke undo stack. Guarded against re-pushing while we restore, or every
|
||
// Back would immediately add a new entry and the button would appear to do nothing.
|
||
if(typeof WPUrl !== 'undefined'){
|
||
WPUrl.onChange(function(state, viaPop){
|
||
if(!viaPop) return;
|
||
if(state.view === 'dashboard'){
|
||
// The flag first: showDashboardFromUrl() renders, so setting it after
|
||
// would paint the unfiltered board and then replace it. dashApplyFlag
|
||
// re-renders itself only when the board is already open.
|
||
dashApplyFlag(state.flag || '');
|
||
if(currentView !== 'Dashboard') showDashboardFromUrl();
|
||
syncToolTabs();
|
||
return;
|
||
}
|
||
const want = state.wp || '';
|
||
if(want){
|
||
const ix = savedPackages.findIndex(x => x.id === want);
|
||
if(ix >= 0){ hideDashboard(); editingId=savedPackages[ix].id;
|
||
loadPackageIntoForm(savedPackages[ix]); renderWpNav(); }
|
||
} else if(currentView === 'Dashboard'){
|
||
hideDashboard(); setFormChrome(true); currentView='Work Package Form';
|
||
document.querySelectorAll('.main > .card, .main > .nav-row').forEach(e=>e.style.display='');
|
||
syncToolTabs();
|
||
}
|
||
// F6/D3: which section you are in is state, so Back moves between sections
|
||
// as well as between packages. fromUrl, because the URL already says this -
|
||
// recording it again would make the first Back appear to do nothing.
|
||
if(state.section && currentView !== 'Dashboard'){
|
||
gotoSection(state.section, {fromUrl:true});
|
||
}
|
||
});
|
||
}
|
||
initAutosave();
|
||
wpMarkFormClean();
|
||
syncToolTabs();
|
||
track('app_open');
|
||
// Kept after T7.1, with a different reader. It existed because the frame's
|
||
// `load` event fired long before pullProject() resolved, so the shell could not
|
||
// act on a specific package until this said the data was in hand. There is no
|
||
// shell now - but the probes wait on exactly this signal for exactly the same
|
||
// reason, and a page that says when its data has landed is worth having.
|
||
window.wpCreatorReady = true;
|
||
try { document.dispatchEvent(new CustomEvent('wp-creator-ready')); } catch(e){}
|
||
}
|
||
window.addEventListener('hashchange',()=>{ if(location.hash==='#dashboard') showDashboard(); });
|
||
// Pull this project's shared SOP + Work Packages from the server first, then boot
|
||
// off the refreshed cache. Falls back to whatever is cached locally if offline.
|
||
if(activeProjectId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||
ProjectData.pullProject(activeProjectId).then(bootData).catch(bootData);
|
||
} else {
|
||
bootData();
|
||
}
|