// ── GLOBAL STATE ────────────────────────────────────────────────────────────── let currentTool = 'sop'; let currentStep = 1; let sopComplete = false; let allComments = []; // Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__' when a // project is active. Keeps each project's SOP separate in the browser. function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } } // WP size presets — the dropdown label maps to a default split-threshold (max // labor hours). The label is exported as governance.woSize (human-readable // guidance); the number drives the Creator's "consider splitting" warning. const WP_SIZE_PRESETS = { 'Small — 1–2 days (≈8–24 hrs)': 24, 'Standard — 3–5 days (≈40–80 hrs)': 80, 'Large — 1–2 weeks (≈80–160 hrs)': 160 }; function onSizePresetChange(){ const label = document.getElementById('gov_wosize').value; const max = WP_SIZE_PRESETS[label]; if(max != null){ document.getElementById('gov_size_hours_max').value = max; } // 'Custom…' / '' leave the threshold for manual entry. } let state = { bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only. project: {name:'', number:'', client:'', division:'', site:''}, // Leadership names are kept as display strings (so existing SOPs and exports // still read the same) alongside the user-account id each one resolves to. // The ids are what let the Creator offer these people as a WP owner and what // notification routing uses — a typed name can't be emailed. team: {pm:'', apm:'', cm:'', qm:''}, teamIds: {pm:'', apm:'', cm:'', qm:''}, teamMembers: [], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], wpTypes: [], governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''}, quality: {qcreq:'', photo:'', hold:''}, platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''}, sequence: [], constraints: [], sources: [] }; let sop = null; // Generated SOP for WP tool // ── LIBRARIES ───────────────────────────────────────────────────────────────── const DEFAULT_WP_TYPES = [ {name:'Rough-In', enabled:true}, {name:'Mechanical Install', enabled:true}, {name:'Panel Install', enabled:true}, {name:'Conduit Install', enabled:true}, {name:'Tray Install', enabled:true}, {name:'Mechanical Tubing', enabled:false}, {name:'Instrument Install', enabled:true}, {name:'Wire Pull', enabled:true}, {name:'Terminations', enabled:true}, {name:'Prefab/Kitting', enabled:false}, {name:'Network Cabling', enabled:false}, {name:'Fiber', enabled:false}, {name:'Calibrations', enabled:false} ]; const STANDARD_10_CONSTRAINTS = [ {name:'Safety & Permitting', description:'Permits, safety reviews, environmental clearances'}, {name:'Quality Control / Inspection', description:'QC approval, inspection readiness'}, {name:'IFC Drawings & Specs', description:'Issued-for-Construction drawings and specifications'}, {name:'Schedule', description:'Scheduled sequence timing confirmed'}, {name:'Materials (on site, bagged & tagged)', description:'All required materials on site and staged'}, {name:'Prefabrication', description:'Prefabricated assemblies complete'}, {name:'Work Access & Laydown', description:'Work area accessible and staged'}, {name:'Craft Availability', description:'Required craft trades available'}, {name:'Construction Equipment & Tools', description:'Special equipment and tools on site'}, {name:'Scaffolding / Access Equipment', description:'Temporary access structures in place'} ]; const CONSTRAINT_LIBRARY = [ 'Utility Clearances', 'Third-Party Approvals', 'Engineering Change Notices', 'Commissioning Sign-Off', 'As-Built Documentation', 'Labeling & Identification', 'Testing & Certification', 'Rework Completion', 'Coordination with Other Trades', 'Environmental Controls', 'Critical Path Gate', 'Client Walkthrough Approval' ]; const OPTIONAL_ROLES = [ 'Assistant Project Manager', 'General Foreman', 'Construction Manager', 'HSE Professional', 'Quality Representative', 'Planner', 'Safety Manager', 'Project Controls Manager', // BIM / VDC roles (used by the BIM template; also selectable on any SOP) 'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager', 'Construction Lead (CRS)', 'Field Lead', 'General Contractor' ]; const LABOR_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', '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', '6000|Production', '7000|Quality', '7100|Panel Quality Control', '7200|Factory Acceptance Testing', '7300|Site Acceptance Testing', '8000|Safety', '9000|Administration' ]; // ── BIM / VDC TEMPLATE ────────────────────────────────────────────────────────── // The BIM/VDC department also produces work packages under Advanced Work Packaging — // model & engineering deliverables (EWPs) that feed the field's install packages. // These defaults are drawn from the EN06 SOP + work instructions and are added by // enableBIM() when the "Include BIM / VDC work packages" box is ticked on Step 4 // (each is flagged bim so the Creator can offer them under the EWP kind). Everything // remains editable afterward. const BIM_WP_TYPES = [ 'Model Area Package', 'Conduit Routing Package', 'Coordination / Clash Package', 'First-of-Kind (FOK) Package', '2D Installation Sheet Set', 'Field Detailing / Markup Package', 'Laser Scan Package', 'As-Built Model / Drawings' ]; // BIM "disciplines" are the install phases work is broken into (EN06-SOP §4.1.2.5). const BIM_PHASES = [ 'Cable Tray & Hangers', 'Conduits & Hangers', 'Wall & Slab Penetrations', 'Panels & Instrument Racks', 'In-wall Instruments & Stud-ups' ]; const BIM_TEMPLATE_ROLES = [ 'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager', 'Construction Lead (CRS)', 'Field Lead', 'General Contractor' ]; // Release-gate constraints for a BIM/model package (the BIM equivalent of the field's // AWP constraints). Citations point back to the EN06 documents. const BIM_CONSTRAINTS = [ {name:'Required Docs Received (IO list, P&IDs, drawings, models, specs)', description:'Project-start inputs available — EN06-SOP §3.1'}, {name:'Conduit Schedule & Schematic Redlines Received', description:'Hard gate: no conduit modeled without these — EN06-SOP §3'}, {name:'LOD Defined & Agreed', description:'Level of detail set at kick-off — EN06-G-01'}, {name:'Field Coordination / Laser Scan Complete', description:'Field walk or scan done — EN06-WI-01 / WI-03'}, {name:'Clash-Free / Coordinated with GC & Trades', description:'Coordination complete — EN06-SOP §8.1'}, {name:'Constructability Review (CRS) Signed', description:'Internal construction-lead sign-off before GC — EN06-SOP §8.4'}, {name:'GC / Trade Sign-Off', description:'GC review and approval — EN06-SOP §8.2'}, {name:'Issued-For-Fabrication (IFF) Granted', description:'Model approved for field use — EN06-SOP §9.4'} ]; const BIM_SEQUENCE = [ 'Kick-off (LOD, schedule, cost code)', 'Project start — gather required docs', 'Field coordination / laser scan', 'Model racks, instruments & panels', 'Model conduit (after schedule + redlines)', 'BIM coordination / clash with GC & trades', 'Constructability review (CRS)', 'GC submission & sign-off (IFF)', // BIM deliverable that hands off to the field — only present when BIM is enabled. '2D installation sheets / Spool Drawings' ]; const BIM_SOURCES = [ {label:'IO List (Point Matrix DB)', ph:'controls.dev / SharePoint'}, {label:'P&IDs', ph:'Procore / SharePoint'}, {label:'Contract / Design Drawings', ph:'Procore / Bluebeam'}, {label:'Navisworks / Revit Models', ph:'BIM360 / SharePoint'}, {label:'Specs & Submittals', ph:'client portal'}, {label:'Conduit Schedule', ph:'Excel on SharePoint'}, {label:'Bluebeam Project', ph:'Bluebeam Studio'}, {label:'Pre-Construction Tracker', ph:'SharePoint'}, {label:'Constructability Review Sheet (CRS)', ph:'SharePoint'} ]; // Toggle BIM/VDC capability on the project. ON augments the SOP with BIM package // types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the // project produces both install (IWP) and BIM (EWP) packages. OFF strips the // bim-flagged items. Everything stays editable. // ── BIM/VDC app switch (admin console → Features) ───────────────────────────── // BIM is off until it's ready for the field. With the flag off we hide the // per-project toggle so no new project can be put on the BIM path — but we never // strip a SOP that already has it on, because that would silently delete its BIM // types, gates and sequence steps. Such a SOP just stops offering BIM until the // flag comes back. function applyBimFlag(){ const wrap = document.getElementById('bim-toggle-wrap'); const note = document.getElementById('bim-disabled-note'); const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false; if(wrap) wrap.style.display = enabled ? '' : 'none'; if(note){ const stale = !enabled && !!state.bimEnabled; note.style.display = enabled ? 'none' : ''; note.innerHTML = stale ? 'BIM / VDC is switched off for the whole suite. This SOP already has BIM enabled, so its ' + 'BIM package types, gates and sequence steps are kept as they are — but they aren\'t offered while the ' + 'feature is off. An administrator can turn it back on under Features in the Admin Console.' : 'BIM / VDC packages aren\'t available yet. Every project is install-only (IWP) for now. ' + 'An administrator can enable the BIM tooling under Features in the Admin Console once it\'s ready.'; } } // Re-check once the flags land (they arrive asynchronously after auth). document.addEventListener('wp-flags-ready', applyBimFlag); function setBimEnabled(on){ state.bimEnabled = !!on; if(on) enableBIM(); else disableBIM(); const cb = document.getElementById('bim_enabled'); if(cb) cb.checked = !!on; track(on ? 'bim_enabled' : 'bim_disabled'); } function enableBIM(){ // Package types (flagged bim so the Creator can offer them under "BIM (EWP)"). BIM_WP_TYPES.forEach(n => { const t = state.wpTypes.find(x => x.name === n); if(t){ t.bim = true; t.enabled = true; } else state.wpTypes.push({name:n, enabled:true, notes:'', approval:'', bim:true}); }); renderWPTypes(); // Release-gate constraints (seed standard 10 first if empty, then add BIM gates). if(!state.constraints || !state.constraints.length) state.constraints = STANDARD_10_CONSTRAINTS.map(c => ({...c})); _constraintsSeeded = true; BIM_CONSTRAINTS.forEach(c => { if(!state.constraints.some(x => x.name === c.name)) state.constraints.push({...c, bim:true}); }); renderStandardConstraints(); // BIM sign-off roles (optional), reference sources, and process steps (idempotent). BIM_TEMPLATE_ROLES.forEach(r => { if(!state.signoffRoles.some(x => x.role === r)) state.signoffRoles.push({role:r, name:'', bim:true}); }); renderOptionalRoles(); // BIM work precedes construction, so put the BIM steps at the FRONT of the sequence. const bimSteps = BIM_SEQUENCE.filter(lbl => !state.sequence.some(s => s.label === lbl)).map(lbl => ({label:lbl, kind:'step', bim:true})); state.sequence = [...bimSteps, ...state.sequence]; renderSequenceSteps(); BIM_SOURCES.forEach(s => { if(!state.sources.some(x => x.label === s.label)) state.sources.push({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true, bim:true}); }); renderSources(); } function disableBIM(){ state.wpTypes = state.wpTypes.filter(t => !t.bim); renderWPTypes(); state.constraints = (state.constraints || []).filter(c => !c.bim); renderStandardConstraints(); state.signoffRoles = state.signoffRoles.filter((r,i) => i < 2 || !r.bim); renderOptionalRoles(); state.sequence = (state.sequence || []).filter(s => !s.bim); renderSequenceSteps(); state.sources = (state.sources || []).filter(s => !s.bim); renderSources(); } // ── INITIALIZATION ──────────────────────────────────────────────────────────── window.addEventListener('DOMContentLoaded',()=>{ initializeWPTypes(); renderTeamMembers(); renderOptionalRoles(); renderStandardConstraints(); renderSequenceSteps(); renderSources(); // Resolve the active project FIRST so per-project storage keys are correct // before we restore this project's SOP. const params = new URLSearchParams(window.location.search); const projId = params.get('project') || (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || ''; applyProjectContext(params.get('project')); // Pull the project's shared SOP from the server into the local cache, THEN // restore it. Falls back to the local cache if offline. function afterPull(){ restoreSavedSOP(); updateStepUI(); updateProjectDisplay(); loadProjectUsers(); // team pickers: who's on this project applyBimFlag(); // hide the BIM section unless an admin enabled it // Deep-link: ?tab=sop | ?tab=wp | ?view=dashboard from the home page. const tab = params.get('tab'); if(params.get('view') === 'dashboard') switchTool('dashboard'); else if(tab === 'wp' || tab === 'sop') switchTool(tab); } if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){ ProjectData.pullProject(projId).then(afterPull).catch(afterPull); } else { afterPull(); } track('app_open'); // Feedback author auto-populates from the signed-in user (auth-guard sets // window.WP_USER and fires 'wp-auth-ready'); the field is read-only. setCommenterName(); document.addEventListener('wp-auth-ready', setCommenterName); let _fieldTimer; document.addEventListener('input', e=>{ const t = e.target; if(t && t.id && /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)){ clearTimeout(_fieldTimer); _fieldTimer = setTimeout(()=> track('field_edit', {field:t.id}), 600); } }); }); window.addEventListener('beforeunload', trackStepDwell); function initializeWPTypes(){ state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''})); renderWPTypes(); } // ── LOAD SAMPLE DATA ────────────────────────────────────────────────────────── // Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard // tab it loads the example Work Package inside the embedded creator. function loadSampleData(){ if(currentTool && currentTool !== 'sop'){ const f = document.getElementById('wp-frame'); if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); } else { alert('Open the Work Package Creation tab first, then load the sample.'); } return; } // Populate Step 1 document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL'; document.getElementById('proj_number').value = '26-67-008'; document.getElementById('proj_client').value = 'Micron Technology, Inc.'; document.getElementById('proj_division').value = 'Semiconductor'; document.getElementById('proj_site').value = 'Boise, ID — Fab 7'; // Populate Step 2 document.getElementById('proj_pm').value = 'Mariano Sanchez'; document.getElementById('proj_apm').value = 'Assistant PM'; document.getElementById('proj_cm').value = 'K. Boyd'; document.getElementById('proj_qm').value = 'D. Nguyen'; // Step 3 — standard required roles if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent'; if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman'; const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent'; const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman'; document.getElementById('role_super_name').value = 'John Smith'; document.getElementById('role_foreman_name').value = 'Mike Jones'; // Populate Step 5 document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]'; document.getElementById('gov_wosize').value = 'Standard — 3–5 days (≈40–80 hrs)'; document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech'; document.getElementById('gov_discmode').value = 'choice'; document.getElementById('gov_size_hours_max').value = '80'; // Populate Step 6 document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items'; document.getElementById('qual_photo').value = 'Key checkpoints only'; document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.'; // Step 7 already has defaults // The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the // full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP. state.bimEnabled = true; const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true; enableBIM(); // Collect all data collectStepData(); track('sample_loaded'); alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.'); // Switch to step 1 currentStep = 1; updateStepUI(); updateProjectDisplay(); } // ── RESTORE A COMPLETED SOP (for "Review" + WP tab across reloads) ───────────── function restoreSavedSOP(){ let savedState = null, savedSop = null, complete = false; try { complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1'; savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null'); savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null'); } catch(e){} if(!complete || !savedState) return; state = savedState; sop = savedSop; sopComplete = true; // A SOP saved before the team was account-backed has no teamIds; default them // so the pickers render (the stored names show as "(no account)" until linked). if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''}; // Re-render dynamic lists from restored state. renderWPTypes(); renderTeamMembers(); renderOptionalRoles(); renderStandardConstraints(); renderSequenceSteps(); renderSources(); repopulateForm(); if(typeof onSOPReady === 'function') onSOPReady(sop); } // Push restored state values back into the static form inputs. function repopulateForm(){ const set = (id,val)=>{ const el=document.getElementById(id); if(el!=null && val!=null) el.value = val; }; set('proj_name', state.project.name); set('proj_number', state.project.number); set('proj_client', state.project.client); set('proj_division', state.project.division); set('proj_site', state.project.site); // The four leadership slots are user-account pickers, not text inputs — // renderTeamPickers() builds their options and marks the current selection. renderTeamPickers(); if(state.signoffRoles[0]){ set('role_super_title', state.signoffRoles[0].role); set('role_super_name', state.signoffRoles[0].name); } if(state.signoffRoles[1]){ set('role_foreman_title', state.signoffRoles[1].role); set('role_foreman_name', state.signoffRoles[1].name); } set('gov_woformat', state.governance.woformat); // gov_wosize is now a ` : `
${t.name}
`; row.innerHTML = ` ${nameCell}
`; container.appendChild(row); }); const addRow = document.createElement('div'); addRow.style.cssText = 'margin-top:0.85rem;'; addRow.innerHTML = ``; container.appendChild(addRow); } function toggleWPType(i){ state.wpTypes[i].enabled = !state.wpTypes[i].enabled; renderWPTypes(); } function addCustomWPType(){ state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true}); renderWPTypes(); // Focus the new custom row's name input. const rows = document.querySelectorAll('#wp-types-table .wp-type-row'); const last = rows[rows.length-1]; const nameInput = last && last.querySelector('input[type="text"]'); if(nameInput) nameInput.focus(); } function removeWPType(i){ state.wpTypes.splice(i,1); renderWPTypes(); } // ── PROJECT TEAM (drawn from user accounts on the project) ──────────────── // The people nameable on a SOP are the project's members (plus admins), so every // name on the team resolves to an account the suite can assign work to and email. // Their `project_role` (job function, set in the Admin Console) is offered as the // default title for an additional team member. let projectUsers = []; // [{id, full_name, username, email, project_role}] let projectUsersLoaded = false; function userLabel(u){ const name = u.full_name || u.username || ''; return u.project_role ? `${name} — ${u.project_role}` : name; } function userById(id){ return projectUsers.find(u => u.id === id) || null; } async function loadProjectUsers(){ const pid = (typeof ProjectData !== 'undefined' && ProjectData.getActiveId) ? ProjectData.getActiveId() : ''; if(pid){ try { const r = await fetch('/api/projects/' + encodeURIComponent(pid) + '/members', {credentials:'same-origin'}); if(r.ok) projectUsers = await r.json(); } catch(e){ /* offline — fall back to whatever the SOP already stored */ } } projectUsersLoaded = true; renderTeamPickers(); renderTeamMembers(); } // One ${opts(m.userId||'')} ${(m.name && !m.userId) ? `
“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.
` : ''} `).join(''); } // Picking the person fills the title from their project role but leaves it // editable — the same person can wear a different hat on a given project. function setExtraTeamMember(i, userId){ const m = state.teamMembers[i]; if(!m) return; const u = userById(userId); m.userId = u ? u.id : ''; m.name = u ? (u.full_name || u.username) : ''; if(u && !m.role) m.role = u.project_role || ''; renderTeamMembers(); } function addTeamMember(){ state.teamMembers.push({role:'',name:'',userId:''}); renderTeamMembers(); } function removeTeamMember(i){ state.teamMembers.splice(i,1); renderTeamMembers(); } function renderOptionalRoles(){ const container = document.getElementById('optional-roles-list'); // The first two entries are the required (editable-title) roles; the rest are optional. const current = state.signoffRoles.slice(2); container.innerHTML = current.map((r,i)=>`
`).join(''); } function addOptionalRole(){ state.signoffRoles.push({role:'General Foreman',name:''}); renderOptionalRoles(); } function removeRole(i){ state.signoffRoles.splice(i,1); renderOptionalRoles(); } // Seed the standard 10 once; after that, render reflects state.constraints // (checkbox = whether each standard one is active) and never clobbers customs. let _constraintsSeeded = false; function renderStandardConstraints(){ const container = document.getElementById('standard-constraints'); if(!_constraintsSeeded){ if(!state.constraints || !state.constraints.length){ state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c})); } _constraintsSeeded = true; } const active = name => state.constraints.some(c=>c.name===name); const critical = name => { const c = state.constraints.find(x=>x.name===name); return !!(c && c.critical); }; container.innerHTML = STANDARD_10_CONSTRAINTS.map(c=>`
${c.description}
${criticalToggle(c.name, active(c.name), critical(c.name))}
`).join(''); renderCustomConstraints(); } // A CRITICAL constraint is one whose reopening after release is announced by // email (PM + CM + the package owner) rather than only dropping the package to // Issue (Hold). Only meaningful for a constraint that's switched on. function criticalToggle(name, enabled, isCritical){ const id = 'crit_' + name.replace(/[^A-Za-z0-9]+/g,'_'); const tip = 'Critical: if this constraint reopens after the work package has been released, ' + 'notify the PM, CM and the package owner by email.'; return ``; } function toggleCriticalConstraint(name, on){ const c = state.constraints.find(x=>x.name===name); if(!c) return; c.critical = !!on; renderStandardConstraints(); track(on ? 'constraint_marked_critical' : 'constraint_unmarked_critical', {name}); } // Render the custom (non-standard) constraints into their own list with remove buttons. function renderCustomConstraints(){ const el = document.getElementById('custom-constraints-list'); if(!el) return; const stdNames = STANDARD_10_CONSTRAINTS.map(c=>c.name); const customs = state.constraints.filter(c=>!stdNames.includes(c.name)); el.innerHTML = customs.length ? customs.map(c=>`
${escAttr(c.name)} ${criticalToggle(c.name, true, !!c.critical)}
`).join('') : `
No custom constraints added yet.
`; } function removeCustomConstraint(name){ state.constraints = state.constraints.filter(c=>c.name!==name); renderCustomConstraints(); } function toggleConstraint(name){ const idx = state.constraints.findIndex(c=>c.name===name); if(idx>=0) state.constraints.splice(idx,1); else { // Copy the library entry — pushing the shared object would let one project's // `critical` flag leak into every other project's default constraint set. const def = STANDARD_10_CONSTRAINTS.find(c=>c.name===name); if(def) state.constraints.push({...def}); } renderStandardConstraints(); // the Critical toggle enables/disables with the row } function showConstraintLibrary(){ const modal = document.getElementById('constraint-modal'); const lib = document.getElementById('constraint-library'); lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>`
${c}
Click to add to this project
`).join(''); modal.style.display = 'flex'; } function closeConstraintModal(){ document.getElementById('constraint-modal').style.display = 'none'; } function addCustomConstraint(name){ if(name && !state.constraints.find(c=>c.name===name)){ state.constraints.push({name,description:''}); } closeConstraintModal(); renderStandardConstraints(); } // Free-text custom constraint from the modal's input. function addCustomConstraintText(){ const inp = document.getElementById('custom-constraint-input'); const name = (inp && inp.value || '').trim(); if(!name){ if(inp) inp.focus(); return; } if(state.constraints.find(c=>c.name===name)){ alert('That constraint is already in the list.'); return; } state.constraints.push({name, description:''}); if(inp) inp.value=''; renderStandardConstraints(); } // Default construction flow (used when BIM is off; the BIM steps are prepended when // the project includes BIM/VDC). Includes QC-hold gates. Items may be strings or // {label, kind} objects. const DEFAULT_SEQUENCE = [ {label:'Conduit Install', kind:'step'}, {label:'Tray Install', kind:'step'}, {label:'QC Hold', kind:'gate'}, {label:'Wire Pull', kind:'step'}, {label:'Device Install', kind:'step'}, {label:'Termination', kind:'step'}, {label:'QC Hold', kind:'gate'}, {label:'Commissioning', kind:'step'}, {label:'As-built (scan / redlines)', kind:'step'} ]; let seqDragIndex = null; function renderSequenceSteps(){ const container = document.getElementById('sequence-list'); if(!container) return; if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'}); container.innerHTML = ''; let stepNo = 0; state.sequence.forEach((item,i)=>{ const isGate = item.kind==='gate'; if(!isGate) stepNo++; const row = document.createElement('div'); row.className = 'seq-step' + (isGate?' gate':''); row.draggable = true; row.dataset.idx = i; const badge = isGate ? `◆ HOLD` : `${stepNo}`; row.innerHTML = ` ${badge} `; row.addEventListener('dragstart',e=>{ seqDragIndex=i; row.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; }); row.addEventListener('dragend',()=>{ row.classList.remove('dragging'); document.querySelectorAll('.seq-step').forEach(s=>s.classList.remove('drag-over')); }); row.addEventListener('dragover',e=>{ e.preventDefault(); row.classList.add('drag-over'); e.dataTransfer.dropEffect='move'; }); row.addEventListener('dragleave',()=>row.classList.remove('drag-over')); row.addEventListener('drop',e=>{ e.preventDefault(); row.classList.remove('drag-over'); const from=seqDragIndex, to=i; if(from===null||from===to) return; const moved=state.sequence.splice(from,1)[0]; state.sequence.splice(to,0,moved); seqDragIndex=null; renderSequenceSteps(); }); container.appendChild(row); if(i), // so attribute values must be escaped or a re-render corrupts the field. function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function renderSources(){ const container = document.getElementById('sources-list'); if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true})); const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;"; const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;"; const header = `
Data Type
Location / Platform
URL
Notes
`; container.innerHTML = header + state.sources.map((s,i)=>{ // Preset data types are fixed labels; custom rows (Add Source) get an editable name. const dataType = s.preset ? `
${escAttr(s.label)}
` : ``; return `
${dataType}
`; }).join(''); } function addSource(){ // Added rows are custom — the user types their own data type here. state.sources.push({label:'', system:'', notes:'', link:'', preset:false}); renderSources(); } // ── STEP NAVIGATION ──────────────────────────────────────────────────────────── function goToStep(n){ if(!validateStep(currentStep)) return; currentStep = n; updateStepUI(); } function nextStep(){ if(!validateStep(currentStep)) return; if(currentStep < 10){ currentStep++; updateStepUI(); } } function previousStep(){ if(currentStep > 1){ currentStep--; updateStepUI(); } } function updateStepUI(){ trackStepDwell(); track('step_view', {step: currentStep}); // SOP steps document.querySelectorAll('[id^="sop-step-"]').forEach(s=>s.style.display='none'); document.getElementById(`sop-step-${currentStep}`)?.style.display && (document.getElementById(`sop-step-${currentStep}`).style.display='block'); // Step indicators document.querySelectorAll('.step-item').forEach(s=>s.classList.remove('active')); document.querySelector(`[data-step="${currentStep}"]`)?.classList.add('active'); // Update counter document.getElementById('current-step').textContent = currentStep; // Update buttons document.getElementById('sop-prev-btn').disabled = currentStep === 1; document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none'; document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none'; // Collect form data collectStepData(); } function collectStepData(){ switch(currentStep){ case 1: state.project.name = document.getElementById('proj_name').value; state.project.number = document.getElementById('proj_number').value; state.project.client = document.getElementById('proj_client').value; state.project.division = document.getElementById('proj_division').value; state.project.site = document.getElementById('proj_site').value; break; case 2: state.team.pm = document.getElementById('proj_pm').value; state.team.apm = document.getElementById('proj_apm').value; state.team.cm = document.getElementById('proj_cm').value; state.team.qm = document.getElementById('proj_qm').value; break; case 3: // The two required roles now have editable titles (default Superintendent/Foreman). state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim(); state.signoffRoles[0].name = document.getElementById('role_super_name').value; state.signoffRoles[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim(); state.signoffRoles[1].name = document.getElementById('role_foreman_name').value; break; case 5: state.governance.woformat = document.getElementById('gov_woformat').value; state.governance.wosize = document.getElementById('gov_wosize').value; const sel = document.getElementById('gov_issuance'); state.governance.issuance = Array.from(sel.selectedOptions).map(o=>o.value); state.governance.disciplines = (document.getElementById('gov_disciplines').value||'') .split(',').map(d=>d.trim()).filter(Boolean); state.governance.discMode = document.getElementById('gov_discmode').value; state.governance.sizeHoursMax = document.getElementById('gov_size_hours_max').value; break; case 6: state.quality.qcreq = document.getElementById('qual_qcreq').value; state.quality.photo = document.getElementById('qual_photo').value; state.quality.hold = document.getElementById('qual_hold').value; break; case 7: state.platforms.tracking = document.getElementById('plat_tracking').value; state.platforms.commissioning = document.getElementById('plat_commissioning').value; state.platforms.trackingUrl = (document.getElementById('plat_tracking_url').value || '').trim(); state.platforms.commissioningUrl = (document.getElementById('plat_commissioning_url').value || '').trim(); break; } } function validateStep(n){ collectStepData(); const proj = state.project; if(n===1 && (!proj.name || !proj.number || !proj.client || !proj.division || !proj.site)){ alert('Please complete all required fields: Project Name, Number, Client, Division, and Site Location.'); return false; } if(n===5 && !state.governance.woformat){ alert('Please enter a Work Package Number Format.'); return false; } if(n===6 && !state.quality.qcreq){ alert('Please select a QC requirement.'); return false; } return true; } // ── SOP COMPLETION ──────────────────────────────────────────────────────────── // Re-saving a SOP that is already complete changes the project's baseline, which // the server restricts to a Project Admin. Check before doing the work so the // answer is a clear message rather than a 403 from the sync outbox. function canEditCompletedSOP(){ return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true; } function completeSOP(){ if(sopComplete && !canEditCompletedSOP()){ alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' + 'Ask a project admin to make the change — the SOP is the baseline every work package inherits.'); return; } if(!validateStep(10)) return; collectStepData(); sop = { meta: {tool:'Work Package Configuration', sample:false}, bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind project: { name: state.project.name, number: state.project.number, client: state.project.client, division: state.project.division, pm: state.team.pm, apm: state.team.apm, cm: state.team.cm, qm: state.team.qm, // User-account ids for the same four people. These are what the Creator // uses to offer an owner and what notification routing needs — a display // name alone can't be assigned work or emailed. pmId: state.teamIds.pm || '', apmId: state.teamIds.apm || '', cmId: state.teamIds.cm || '', qmId: state.teamIds.qm || '', site: state.project.site, teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId)) }, roles: state.signoffRoles.filter(r=>r.role), governance: { issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'], woSize: state.governance.wosize, woFormat: state.governance.woformat, disciplines: (state.governance.disciplines && state.governance.disciplines.length) ? state.governance.disciplines : ['Mechanical','Electrical','Tech'], discMode: state.governance.discMode || 'choice', instanceSuffix: state.governance.instanceSuffix || 'letter', sizeHoursMax: state.governance.sizeHoursMax || '' }, woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({ name: t.name.trim(), enabled: true, notes: t.notes || '', approval: t.approval || '', // Spec section for this type — the Creator fills the WP's Specification // Section from it, so it's authored once here instead of per package. specSection: t.specSection || '', bim: !!t.bim })), sources: state.sources.filter(s=>s.label), field: {trackPlatform: state.platforms.tracking, trackPlatformUrl: state.platforms.trackingUrl || ''}, commissioning: {tool: state.platforms.commissioning, toolUrl: state.platforms.commissioningUrl || ''}, // Project homepage links in the tracking / commissioning systems. The Creator // copies these onto every Work Package created for this project. projectLinks: [ state.platforms.trackingUrl ? {label:'Tracking — '+state.platforms.tracking, system:state.platforms.tracking, url:state.platforms.trackingUrl} : null, state.platforms.commissioningUrl ? {label:'Commissioning — '+state.platforms.commissioning, system:state.platforms.commissioning, url:state.platforms.commissioningUrl} : null ].filter(Boolean), quality: { qcReq: state.quality.qcreq, photo: state.quality.photo, holdPoints: state.quality.hold, cxTool: state.platforms.commissioning }, sequence: state.sequence.filter(s=>s.label).map(s=>({ label: s.label, kind: s.kind || 'step' })), costCodes: LABOR_COST_CODES, constraints: state.constraints.map(c=>({ name: c.name, description: c.description || '', bim: !!c.bim, // Critical → reopening after release is emailed, not just flagged on the package. critical: !!c.critical })) }; sopComplete = true; // Stamp the active project onto the SOP so it's unambiguously tied to it. try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){} updateProjectDisplay(); // Persist for the home page (green / "Review") and for the WP Creator tab, // namespaced to the active project so each project keeps its own SOP. try { localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop)); localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state)); localStorage.setItem(SK('wp_suite_sop_complete'), '1'); } catch(e){} // Share the SOP to the server so every user of this project gets it. try { const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || ''; if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state); } catch(e){} track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length}); // Hand the SOP to the embedded Work Package Creator and unlock its tab (in case // the user stays), then return to the project home page per the requested flow. if(typeof onSOPReady === 'function') onSOPReady(sop); alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.'); window.location.href = 'index.html'; } // ── COMMENTS ────────────────────────────────────────────────────────────────── function toggleComments(){ const panel = document.getElementById('comments-panel'); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; if(panel.style.display === 'block') loadStepComments(); } function currentUserName(){ const u = window.WP_USER; return (u && (u.full_name || u.username)) || ''; } function setCommenterName(){ const el = document.getElementById('commenter-name'); if(el) el.value = currentUserName(); } function submitComment(){ const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous'; const text = document.getElementById('comment-text').value.trim(); if(!text){ alert('Please enter a comment.'); return; } const comment = { step: currentStep, name: name, text: text, timestamp: new Date().toLocaleString() }; allComments.push(comment); localStorage.setItem('wp_suite_comments', JSON.stringify(allComments)); if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment}); document.getElementById('comment-text').value = ''; loadStepComments(); } function exportComments(){ const saved = localStorage.getItem('wp_suite_comments'); const data = saved ? JSON.parse(saved) : []; if(!data.length){ alert('No comments to export yet.'); return; } const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data}; const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'wp-suite-comments-sop-' + new Date().toISOString().slice(0,10) + '.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000); } 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 in that file.'); return; } const saved = localStorage.getItem('wp_suite_comments'); allComments = saved ? JSON.parse(saved) : []; const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text)); let added = 0; incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }}); localStorage.setItem('wp_suite_comments', JSON.stringify(allComments)); loadStepComments(); alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.'); }catch(e){ alert('Could not read that file.'); } ev.target.value = ''; }; r.readAsText(f); } function loadStepComments(){ const saved = localStorage.getItem('wp_suite_comments'); if(saved) allComments = JSON.parse(saved); const stepComments = allComments.filter(c=>c.step===currentStep); const list = document.getElementById('comments-list'); if(stepComments.length === 0){ list.innerHTML = '
No comments yet on this step.
'; }else{ list.innerHTML = stepComments.map(c=>`
${escAttr(c.name)} • ${escAttr(c.timestamp)}
${escAttr(c.text)}
`).join(''); } } // ── USAGE ANALYTICS ───────────────────────────────────────────────────────── // Lightweight usage analytics stored in localStorage so the tool owner can review // engagement over time. No field VALUES are stored (field-edit events record only // the field id), keeping captured data non-sensitive. const ANALYTICS_KEY = 'wp_suite_analytics_v1'; let _stepEnter = Date.now(); 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(data){ try { localStorage.setItem(ANALYTICS_KEY, JSON.stringify(data)); } catch(e){ /* storage unavailable — degrade silently */ } } function track(event, detail){ try { const data = analyticsLoad(); data.events.push({ ts: new Date().toISOString(), session: _session, event, detail: detail||null }); if(data.events.length > 5000) data.events = data.events.slice(-5000); analyticsSave(data); } catch(e){} } function trackStepDwell(){ const ms = Date.now() - _stepEnter; if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms}); _stepEnter = Date.now(); } function analyticsSummary(){ const data = analyticsLoad(); const byEvent = {}, byStep = {}, dwell = {}, sessions = new Set(); data.events.forEach(e=>{ byEvent[e.event] = (byEvent[e.event]||0)+1; sessions.add(e.session); if(e.event==='step_view' && e.detail) byStep[e.detail.step]=(byStep[e.detail.step]||0)+1; if(e.event==='step_dwell' && e.detail){ dwell[e.detail.step]=(dwell[e.detail.step]||0)+e.detail.ms; } }); return {total:data.events.length, sessions:sessions.size, byEvent, byStep, dwell, first:data.events[0]?.ts, last:data.events[data.events.length-1]?.ts}; } function showAnalytics(){ const s = analyticsSummary(); const fmtMin = ms => (ms/60000).toFixed(1)+' min'; let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'} → ${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`; for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`; txt += `\nActions:\n`; Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`); txt += ` field edits: ${s.byEvent['field_edit']||0}\n`; txt += `\nDownload full event log as JSON?`; if(confirm(txt)) downloadAnalytics(); } function downloadAnalytics(){ const data = analyticsLoad(); const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json'; a.click(); URL.revokeObjectURL(a.href); track('analytics_exported'); }