// ── 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:''}, qaGroupIds: [], // D2: who is emailed when a package reaches Ready for QA teamMembers: [], signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}], wpTypes: [], governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''}, // CR-006: which work package sections this project uses. Present from the start // rather than filled in lazily, because sopIsDirty() fingerprints this object — // adding a key on first read would make merely LOOKING at step 12 count as an // unsaved change and fire T4.3's guard on the way out. sections: (typeof WPSections !== 'undefined') ? WPSections.defaults() : {}, // CR-002: the two Acumatica fields, off-switchable inside General Information. fields: (typeof WPSections !== 'undefined') ? WPSections.fieldDefaults() : {}, 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 | ?wp= | ?step=N. // // These used to be consumed ONCE, because a leftover ?view=dashboard made the // Work Package Creation tab keep reopening the dashboard for the rest of the // session. Since T4.2 the URL is not a one-shot instruction — it TRACKS the // current state and switchTool() clears `view` when you leave the dashboard — // so the param can be honoured every time and a refresh lands where you were. // // fromUrl on every call: the URL already says this, so restoring it must not // add a history entry. Without that, the first Back after loading a deep link // would just return you to the same view. // // T7.1: ?tab=wp, ?view=dashboard and ?wp= now describe a different // document. They are still honoured rather than ignored - they are in // bookmarks, in wp-sidenav's link map, and they are the shape the CR-011 and // CR-014 emails were specified against. Forward them, with replace() so the // address that was only ever a redirect is not a place Back can return to. const tab = params.get('tab'); const deepWp = params.get('wp') || ''; const wantDashboard = params.get('view') === 'dashboard'; if(wantDashboard || tab === 'wp' || deepWp){ if(openCreator(wantDashboard ? 'dashboard' : '', {wp: deepWp, replace: true})) return; } else if(tab === 'sop') switchTool(tab, {fromUrl:true}); stampCreatorLinks(); const bootStep = parseInt(params.get('step'), 10); if(bootStep >= 1 && bootStep <= LAST_STEP && currentTool === 'sop') goToStep(bootStep, {fromUrl:true}); } 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); } }); }); // Analytics dwell. S2 adds a SECOND beforeunload listener in wp-autosave.js for the // unsaved-work guard; both fire, and this one does not preventDefault, so the two do // not interact. The task says to add the guard "alongside the analytics listener // rather than replacing it" - this is the listener it means. window.addEventListener('beforeunload', trackStepDwell); // ── AUTOSAVE (S2) ──────────────────────────────────────────────────────────── // The wizard already wrote its state to localStorage on save; what it lacked was // writing it WITHOUT being asked, and telling anyone when that failed. function sopDraftId(){ const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || 'none'; return 'sop-wizard::' + pid; } function sopIsDirty(){ if(currentTool !== 'sop') return false; try { collectStepData(); return JSON.stringify(state) !== _sopSavedFingerprint; } catch(e){ return false; } } let _sopSavedFingerprint = ''; function sopMarkSaved(){ try { _sopSavedFingerprint = JSON.stringify(state); } catch(e){} } document.addEventListener('DOMContentLoaded', function(){ if(typeof WPAutosave === 'undefined') return; sopMarkSaved(); WPAutosave.register({ id: 'sop-wizard', scope: document, draftId: sopDraftId, collect: function(){ collectStepData(); return state; }, isDirty: sopIsDirty, }); // B5: in the step navigation, where Save/Next already are. const host = document.querySelector('.step-navigation'); if(host) WPAutosave.mountIndicator(host, {id:'wp-draft-status-sop'}); }); function initializeWPTypes(){ state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''})); renderWPTypes(); } // ── LOAD SAMPLE DATA ────────────────────────────────────────────────────────── // Removed at T9.4 (S7). This was the affordance one click from live project // data: it filled the wizard state that completeSOP() pushes to the PROJECT, // with no confirm and no undo. The suite's one sample control lives on the // creator's toolbar, where it fills that page only. The location and material // list steps keep their "Load sample values" buttons - those fill a PASTE BOX // that acts only through an explicit, dry-runnable Import, which is a // different thing from loading sample data into a live configuration. 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 CR-006 mentions no sections, and "says nothing" has to // mean every section is on — the alternative would switch them all off for // every project in the estate. Normalised HERE, before the dirty fingerprint // is taken, so filling the gap does not read as an edit. if(typeof WPSections !== 'undefined'){ state.sections = WPSections.normalize(state.sections); state.fields = WPSections.normalizeFields(state.fields); } // 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:''}; if(!Array.isArray(state.qaGroupIds)) state.qaGroupIds = []; // D2, pre-T7.6 SOPs // Re-render dynamic lists from restored state. renderWPTypes(); renderTeamMembers(); renderOptionalRoles(); renderStandardConstraints(); renderSequenceSteps(); renderSources(); repopulateForm(); // A completed SOP has been all the way through: the rail marks every step done // rather than showing ten unstarted steps over a finished configuration. for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i); 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 leadership slots and sign-off names are account pickers, not text inputs — // these build their options and mark the current selection. renderTeamPickers(); renderSignoffRolePickers(); if(state.signoffRoles[0]) set('role_super_title', state.signoffRoles[0].role); if(state.signoffRoles[1]) set('role_foreman_title', state.signoffRoles[1].role); 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(); renderSignoffRolePickers(); } // One of project people, reused everywhere the SOP names someone. Keeps a // name that has no matching account as a selected "(no account)" option so older // SOPs — and the sample's fictional names — are never silently dropped. function userSelectOptions(curId, curName){ let html = '' + projectUsers.map(u => ``).join(''); if(curName && !userById(curId)){ html += ``; } return html; } // Sign-off roles (step 3) name the people who must sign a package, so they use the // same picker as the leadership slots — a signature belongs to an account. function renderSignoffRolePickers(){ [['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => { const sel = document.getElementById(id); const r = state.signoffRoles[ix]; if(!sel || !r) return; sel.innerHTML = userSelectOptions(r.userId || '', r.name || ''); sel.onchange = function(){ if(this.value === '__orphan__') return; const u = userById(this.value); r.userId = u ? u.id : ''; r.name = u ? (u.full_name || u.username) : ''; renderSignoffRolePickers(); }; }); renderOptionalRoles(); } // Read the four leadership pickers back into state. `state.team[key]` always holds // a display NAME and `state.teamIds[key]` the account id; a name kept from an older // SOP whose person has no account (the "(no account)" option) is left alone. function syncTeamFromPickers(){ if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''}; ['pm','apm','cm','qm'].forEach(key => { const sel = document.getElementById('proj_' + key); if(!sel) return; if(sel.value === '__orphan__') return; // legacy typed name — keep it const u = userById(sel.value); state.teamIds[key] = u ? u.id : ''; if(u) state.team[key] = u.full_name || u.username; else if(sel.value === '') state.team[key] = ''; // explicitly unassigned }); } function setOptionalRolePerson(ix, value){ const r = state.signoffRoles[ix]; if(!r || value === '__orphan__') return; const u = userById(value); r.userId = u ? u.id : ''; r.name = u ? (u.full_name || u.username) : ''; renderOptionalRoles(); } function setTeamLead(key, userId){ if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing const u = userById(userId); state.teamIds[key] = u ? u.id : ''; state.team[key] = u ? (u.full_name || u.username) : ''; renderTeamPickers(); } function renderTeamMembers(){ const container = document.getElementById('team-members-list'); if(!container) return; const opts = (cur) => '' + projectUsers.map(u => ``).join(''); container.innerHTML = state.teamMembers.map((m,i)=>`
${(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'); // C1/T9.5: a library entry is an ACTION, so it is a button - keyboard and // touch come free, and the hover styling moved to CSS where it belongs. lib.innerHTML = CONSTRAINT_LIBRARY.map(c=>` `).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)){ wizardToast('“' + name + '” is already in the list.', {role: 'alert'}); 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,'"'); } // A value bound into an inline handler — onclick="fn('…')" — needs BOTH escapes, in // this order: backslash, then quote (for the JS string literal), then escAttr (for // the attribute carrying it). Quote-escaping alone breaks on a value containing a // backslash — the backslash escapes the backslash, the quote closes the literal, and // the rest runs as code. Constraint names travel with the SOP to everyone on the // project, so they are not this browser's own input. function escHandlerArg(v){ return escAttr(String(v==null?'':v).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 11: LOCATION LIST (CR-005) ─────────────────────────────────────────── // Server-backed throughout. CLAUDE.md: "If a task touches one of these and you // find yourself writing to localStorage, you are building the wrong thing." // CR-018 rolls cost up by these values, and a taxonomy each browser keeps its own // copy of cannot be rolled up by anything. // // The list is fetched with include_inactive=true here, deliberately: this is the // screen where you MANAGE the list, so a value somebody deactivated has to be // visible in order to be brought back. The work package form asks for the default // (active only), which is what makes deactivating hide it from new packages. // Obviously fake, and labelled as such in the values themselves. IMPLEMENTATION.md // section 8: the B100 floor and area list has not been supplied, and hardcoding a // guess would put invented Micron floor names into the repository. "Sample" is in // every string so nobody can mistake one for real data. const LOCATION_SAMPLE = [ 'Sample Building One,Sample Level 1,Sample Sector A', 'Sample Building One,Sample Level 1,Sample Sector B', 'Sample Building One,Sample Level 2,Sample Sector A', 'Sample Building Two,Sample Level 1,Sample Sector A', ].join('\n'); let _locNodes = []; function locProjectId(){ try { const fromUrl = new URLSearchParams(window.location.search).get('project'); if(fromUrl) return fromUrl; if(typeof ProjectData !== 'undefined' && ProjectData.getActiveId) return ProjectData.getActiveId() || ''; } catch(e){} return ''; } function locApi(suffix){ return '/api/projects/' + encodeURIComponent(locProjectId()) + '/locations' + (suffix || ''); } function locEsc(v){ return escAttr(v); } // D6 / T8.6: the paste-or-file import machinery moved to wp-list-import.js - // ONE component; the location list and the material list are both instances of // it. These names survive as thin delegates because the row handlers, the step // entry and the probes all call them. const locList = WPListImport({ prefix: 'loc', api: locApi, projectId: locProjectId, sample: LOCATION_SAMPLE, loadKey: 'nodes', esc: locEsc, render: function(){ _locNodes = locList.state.rows; locRender(); }, rejectedRow: r => `
  • row ${r.line} ${locEsc(r.text)} — ${locEsc(r.reason)}
  • `, duplicateRow: r => `
  • row ${r.line} ${locEsc(r.path)} — ${locEsc(r.reason)}
  • `, addPayload: function(){ const name = ((document.getElementById('loc-add-name') || {}).value || '').trim(); if(!name) return {error: 'Enter a name for the value you are adding.'}; const parentId = (document.getElementById('loc-add-parent') || {}).value || ''; const parent = _locNodes.find(n => n.id === parentId); const level = !parent ? 'building' : (parent.level === 'building' ? 'floor' : 'sector'); return {payload: {level: level, parent_id: parentId || null, name: name}}; }, addedMessage: body => `Added ${locEsc(body.path)}.`, noProjectMessage: 'Open this wizard from a project to configure its location list. ' + 'The list is stored against the project on the server, not in this browser.', }); function locSay(html, isProblem){ locList.say(html, isProblem); } function locSetAddError(msg){ locList.setAddError(msg); } function locLoad(force){ return locList.load(force); } function locRender(){ const list = document.getElementById('loc-list'); const count = document.getElementById('loc-count'); if(!list) return; const active = _locNodes.filter(n => n.active); if(count){ count.textContent = _locNodes.length ? `${active.length} value${active.length===1?'':'s'} in use` + (_locNodes.length > active.length ? `, ${_locNodes.length - active.length} deactivated` : '') : ''; } if(!_locNodes.length){ list.innerHTML = '

    Nothing configured yet. Paste or upload a list above, ' + 'or add values one at a time.

    '; locRenderParentPicker(); return; } // Ordered by path from the server, so a plain walk is already the tree order. list.innerHTML = _locNodes.map(n => { const depth = (n.path.match(/\//g) || []).length; return `
    ${locEsc(n.level)} ${locEsc(n.path)}
    `; }).join(''); locRenderParentPicker(); } // What a hand-added value can hang off. A building has nothing above it, so the // empty option is a real choice rather than a prompt. function locRenderParentPicker(){ const sel = document.getElementById('loc-add-parent'); if(!sel) return; const keep = sel.value; const opts = [''].concat( _locNodes.filter(n => n.active && n.level !== 'sector') .map(n => ``) ); sel.innerHTML = opts.join(''); if(keep && Array.from(sel.options).some(o => o.value === keep)) sel.value = keep; } function locImport(dryRun){ locList.importText(dryRun); } function locAdd(){ locList.add(); } function locPatch(id, patch, describe){ return locList.patch(id, patch, describe); } document.addEventListener('DOMContentLoaded', function(){ const btn = id => document.getElementById(id); locList.wire(); matList.wire(); const addName = btn('loc-add-name'); if(addName) addName.addEventListener('keydown', e => { if(e.key === 'Enter'){ e.preventDefault(); locAdd(); } }); const list = document.getElementById('loc-list'); if(list){ // Rename on blur, not on every keystroke: a rename is a write, and one per // character would be a hundred writes for a hundred-character name. list.addEventListener('change', function(e){ const t = e.target; if(!t) return; if(t.classList.contains('loc-active')){ const node = _locNodes.find(n => n.id === t.dataset.id); locPatch(t.dataset.id, {active: t.checked}, b => b.active ? `${locEsc(b.path)} is back in use.` : `${locEsc(b.path)} is no longer offered on new work packages. ` + 'Existing packages that reference it are unchanged.'); if(node) node.active = t.checked; return; } if(t.classList.contains('loc-name')){ const node = _locNodes.find(n => n.id === t.dataset.id); const next = (t.value || '').trim(); if(!node || next === node.name){ if(node) t.value = node.name; return; } if(!next){ t.value = node.name; return; } locPatch(t.dataset.id, {name: next}, b => `Renamed to “${locEsc(b.name)}”. Its code ${locEsc(b.path)} is unchanged, ` + 'so work packages already using it still point at the same value.'); } }); } }); // ── ANNOUNCEMENTS (S1 / T5.8) ──────────────────────────────────────────────── // Everything the wizard used to say in a native dialog says it here. // // A dialog is not merely ugly. It blocks the page until dismissed, it cannot be // styled or placed, a screen reader can only present it as a modal interruption, // and it is one OK button whatever it is telling you — so "sample data loaded" // and "you cannot do that" arrive identically. This is the same split T4.5 // established everywhere else: an error interrupts, a confirmation does not. let _toastTimer = null; function wizardToast(message, opts){ opts = opts || {}; const el = document.getElementById('wp-toast'); if(!el) return; clearTimeout(_toastTimer); el.hidden = false; el.setAttribute('role', opts.role === 'alert' ? 'alert' : 'status'); el.className = 'wp-toast' + (opts.role === 'alert' ? ' is-alert' : ''); el.textContent = ''; const text = document.createElement('span'); text.className = 'wp-toast-text'; text.textContent = message; el.appendChild(text); if(opts.action && opts.action.label){ const b = document.createElement('button'); b.type = 'button'; b.className = 'wp-toast-action'; b.textContent = opts.action.label; b.addEventListener('click', function(){ hideWizardToast(); opts.action.fn(); }); el.appendChild(b); } const close = document.createElement('button'); close.type = 'button'; close.className = 'wp-toast-close'; close.setAttribute('aria-label', 'Dismiss this message'); close.textContent = '\u2715'; close.addEventListener('click', hideWizardToast); el.appendChild(close); // An error stays until dismissed. A confirmation does not need to be read // twice, and one that lingers becomes furniture. if(opts.role !== 'alert' && !opts.action){ _toastTimer = setTimeout(hideWizardToast, 6000); } } function hideWizardToast(){ clearTimeout(_toastTimer); const el = document.getElementById('wp-toast'); if(el){ el.hidden = true; el.textContent = ''; } } // ── STEP 12: WORK PACKAGE SECTIONS (CR-006) ─────────────────────────────────── // Which sections of a work package this project uses. The list itself lives in // wp-sections.js, shared with the creator so the two cannot disagree about what // "Assets is off" means. // // Toggling a section OFF never deletes anything — it stops the section rendering, // in the form, in the package and in the export. Whatever was captured stays on // the package and comes back intact. That is the difference between this and // removing a field, and it is the reason CR-002 and CR-016 are expressed here. function sopSections(){ if(typeof WPSections === 'undefined') return {}; state.sections = WPSections.normalize(state.sections); return state.sections; } function sopFields(){ if(typeof WPSections === 'undefined') return {}; state.fields = WPSections.normalizeFields(state.fields); return state.fields; } function renderSectionToggles(){ const host = document.getElementById('section-toggles'); if(!host || typeof WPSections === 'undefined') return; const on = sopSections(); const fields = sopFields(); // A field row nested under the section that contains it, so "Acumatica task" // is visibly a part of General Information rather than an eleventh section. const fieldRows = secId => WPSections.fieldsFor(secId).map(f => ` `).join(''); host.innerHTML = WPSections.LIST.map(sec => ` ` + fieldRows(sec.id)).join(''); renderSectionSummary(); } function renderSectionSummary(){ const el = document.getElementById('section-summary'); if(!el || typeof WPSections === 'undefined') return; const off = WPSections.offList(sopSections()).concat(WPSections.offFieldList(sopFields())); el.textContent = off.length ? `${off.length} turned off: ${off.join(', ')}. ` + 'Their data is retained and returns if they are turned back on.' : 'Every section is in use. Turn off anything this project does not need.'; } document.addEventListener('DOMContentLoaded', function(){ const host = document.getElementById('section-toggles'); if(!host) return; host.addEventListener('change', function(e){ const t = e.target; if(!t || !t.dataset) return; const isField = !!t.dataset.field; if(!isField && !t.dataset.section) return; if(isField) sopFields()[t.dataset.field] = !!t.checked; else sopSections()[t.dataset.section] = !!t.checked; const wrap = t.closest(isField ? '.field-toggle' : '.section-toggle'); if(wrap){ wrap.classList.toggle('is-off', !t.checked); const st = wrap.querySelector('.section-toggle-state'); if(st) st.textContent = t.checked ? 'In use' : 'Not used'; } renderSectionSummary(); // No hand-off: the creator reads SOP.sections at its own boot (T5.5), and // since T7.1 there is no already-open frame to be out of date. }); }); // X4, resolved. Section toggles reached the creator by two paths while it was an // iframe child: the SOP it reads at its own boot, and a live hand-off across the // boundary for a frame that was already open. // // T7.1 deleted the second one, because there is no longer a second document to // hand anything to. What is left is the path T5.5 built and proved: applySOP() // reads SOP.sections during the creator's boot, which covers a reload, a fresh // tab and a deep link equally. The creator now always boots after the toggle was // saved, so "already open and out of date" is not a state that exists. // ── STEP GATES (A4 / S9) ────────────────────────────────────────────────────── // validateStep() guarded steps 1, 5 and 6 with three hand-written conditions and // three hand-written messages. The rail needs the same answer for every step, not // just the one you are standing on, so the list is data now: one source that both // the guard and the rail read. A rail that offers a step the guard then refuses is // worse than no rail. // // The predicate reads the DOM, not `state`. collectStepData() only ever collects // the CURRENT step, so `state.governance.woformat` is stale for any step you have // not visited — and the rail has to judge all ten. Every step's markup is in the // document at all times (steps are shown and hidden with display), so the fields // are always readable. // // T5.8 widens this to every step whose markup marks a field required, and replaces // the native dialog with inline errors. The shape is chosen so that is an edit to this // table rather than to the four functions below it. // T5.8 widened this from three steps to every step whose markup marks a field // required — which was the S1 defect: the markup marked fields throughout and // validateStep() guarded 1, 5 and 6. Steps 3 and 7 marked required fields with an // asterisk that meant nothing. // // The message is per FIELD now, not per step. "Subject and WP Type are required" // named no field, highlighted nothing and scrolled nowhere; each of these names // the one field it is about, renders at that field, and says what to do. const STEP_GATES = { 1: {fields: [ ['proj_name', 'Enter the project name.'], ['proj_number', 'Enter the project number.'], ['proj_client', 'Enter the client name.'], ['proj_division', 'Enter the division or sector.'], ['proj_site', 'Enter the site location.'], ]}, 3: {fields: [ ['role_super_title', 'Name the first required sign-off role — rename it if “Superintendent” is not what this project calls it.'], ['role_foreman_title', 'Name the second required sign-off role — rename it if “Foreman” is not what this project calls it.'], ]}, 5: {fields: [ ['gov_woformat', 'Enter a work package number format, e.g. WP##-[Sector]-[TYPE].'], ['gov_discmode', 'Choose how work packages use disciplines.'], ]}, 6: {fields: [ ['qual_qcreq', 'Choose whether QC is required on this project.'], ]}, 7: {fields: [ ['plat_tracking', 'Choose the construction tracking platform.'], ['plat_commissioning', 'Choose the commissioning tool.'], ]}, }; // Just the ids, for the places that only care which fields a step gates on. function stepGateFields(n){ const gate = STEP_GATES[n]; return gate ? gate.fields.map(f => f[0]) : []; } const STEP_LABELS = {1:'Project', 2:'Team', 3:'Sign-offs', 4:'WP types', 5:'Governance', 6:'Quality', 7:'Platforms', 8:'Sequence', 9:'Constraints', 10:'Sources', 11:'Locations', 12:'Sections'}; const LAST_STEP = 12; // Steps you have actually been on. A stepper's tick means "done", and a step you // have never opened is not done however its defaults happen to read — step 6's QC // dropdown, for instance, is never empty. // // Deliberately NOT part of `state`: state is fingerprinted by sopIsDirty(), so // recording navigation in it would make merely looking at a step count as an // unsaved change and fire T4.3's unsaved-work guard on the way out. const _visitedSteps = new Set([1]); function fieldFilled(id){ const el = document.getElementById(id); return !!(el && String(el.value || '').trim()); } function stepGateMet(n){ return stepGateFields(n).every(fieldFilled); } // What the rail may offer, stated as the guard already behaved rather than as // something stricter. goToStep()/nextStep() have one rule: you may leave the step // you are on once ITS required fields are filled. So the unreachable set is // "everything ahead of here, while here is incomplete" — nothing more elaborate. // // The temptation is to lock every step after the first unmet gate anywhere in the // wizard. That is a rule the guard does not enforce: with step 1 blank you can // still jump 3 -> 5, because validateStep() only ever looks at the step you are // standing on. A rail that showed a padlock the Next button then walked straight // past would be the drift this table exists to prevent. function stepReachable(n){ if(n <= currentStep) return true; // going back is never gated (previousStep never was) return stepGateMet(currentStep); } // ── STEP NAVIGATION ──────────────────────────────────────────────────────────── function goToStep(n, opts){ const fromUrl = !!(opts && opts.fromUrl); // Restoring a step from the URL is not a forward navigation, so it must not run // the forward-navigation guard. validateStep marks every empty required field // and focuses the first, which on a freshly-loaded deep link would mean landing // on a step already painted red before you had typed anything. (Before T5.8 it // was worse: a native dialog opened before the page had finished booting.) // // Neither is going BACKWARDS. previousStep() has never validated, so a rail that // did would trap you on an incomplete step with no way out but the Back button — // and the rail's whole point is that you can move around. if(!fromUrl && n > currentStep && !validateStep(currentStep)) return; railMessage(''); _visitedSteps.add(n); currentStep = n; if(typeof WPAutosave !== 'undefined') WPAutosave.flush('step'); // S3: the step you are on survives a refresh and a shared link. if(typeof WPUrl !== 'undefined' && !fromUrl) WPUrl.push({ step: n > 1 ? n : '' }); updateStepUI(); } // S3: Back / Forward across tools and steps. The URL is the state, so restoring is // "read it and show that" rather than a bespoke undo stack. if(typeof WPUrl !== 'undefined'){ WPUrl.onChange(function(state, viaPop){ if(!viaPop) return; // T7.1: this page only has the SOP tool. A popstate carrying ?tab=wp or // ?view=dashboard would once have swapped the frame; acting on it now would // NAVIGATE, so Back would bounce forward again and the button would appear // broken. Those addresses belong to the creator's own history stack, and the // boot redirect uses replace() precisely so no such entry is left here. // BL-016 (fixed at T9.9): Back from ?step=6 to a URL with NO step used to // parse NaN and do nothing - the wizard stayed on 6 while the address bar // said otherwise. A step-less wizard URL IS step 1. const step = parseInt(state.step, 10) || 1; if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true}); }); } function nextStep(){ if(!validateStep(currentStep)) return; if(currentStep < LAST_STEP){ railMessage(''); currentStep++; _visitedSteps.add(currentStep); updateStepUI(); } } function previousStep(){ if(currentStep > 1){ railMessage(''); currentStep--; _visitedSteps.add(currentStep); updateStepUI(); } } // ── THE STEP RAIL (A4 / S9) ─────────────────────────────────────────────────── // One state per step, each told apart by a word and a marker shape as well as a // colour (C1). The rail is static markup; this only re-labels it. const _STEP_STATE_TEXT = {current: 'Current step', complete: 'Complete', locked: 'Locked', todo: ''}; function railMessage(text){ const el = document.getElementById('step-rail-msg'); if(!el) return; // Re-setting identical text does not re-announce, and clearing then setting in // the same tick is a no-op to most screen readers. Only touch it on a change. if(el.textContent === text) return; el.textContent = text; } function stepRailState(n){ if(n === currentStep) return 'current'; if(!stepReachable(n)) return 'locked'; // Visited AND valid. A step you have never opened is not "complete" however its // defaults happen to read — step 6's QC dropdown, for one, is never empty. if(_visitedSteps.has(n) && stepGateMet(n)) return 'complete'; return 'todo'; } function renderStepRail(){ const list = document.getElementById('step-rail-list'); if(!list) return; list.querySelectorAll('.step-btn').forEach(btn => { const n = parseInt(btn.dataset.step, 10); const st = stepRailState(n); btn.classList.toggle('is-current', st === 'current'); btn.classList.toggle('is-complete', st === 'complete'); btn.classList.toggle('is-locked', st === 'locked'); if(st === 'current') btn.setAttribute('aria-current', 'step'); else btn.removeAttribute('aria-current'); // aria-disabled, not disabled: the button stays focusable so a keyboard user // can reach it and be told what is in the way. `disabled` would remove it from // the tab order and from most screen readers' element lists entirely. if(st === 'locked'){ btn.setAttribute('aria-disabled', 'true'); btn.title = `Finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`; } else { btn.removeAttribute('aria-disabled'); btn.removeAttribute('title'); } const marker = btn.querySelector('.step-btn-marker'); if(marker) marker.textContent = st === 'complete' ? '✓' : String(n); const stateEl = btn.querySelector('.step-btn-state'); if(stateEl) stateEl.textContent = _STEP_STATE_TEXT[st]; }); const pos = document.getElementById('step-rail-pos'); if(pos) pos.textContent = currentStep; const total = document.getElementById('step-rail-total'); if(total) total.textContent = LAST_STEP; const here = document.getElementById('step-rail-here'); if(here) here.textContent = STEP_LABELS[currentStep] || ''; } // Clicking a step you cannot reach yet says why, and puts the cursor in the field // that is in the way. Saying "no" and leaving you where you were, with no idea // which of five inputs was empty, is what the old native dialog did. // // The blocker is always the step you are standing on — that is the only thing // stepReachable() gates on — so there is nowhere to navigate to. function railBlockedClick(n){ const gate = STEP_GATES[currentStep]; railMessage(`Step ${n}, ${STEP_LABELS[n]}, is not available yet — finish step ${currentStep}, ${STEP_LABELS[currentStep]}, first.`); if(!gate) return; const missing = stepGateFields(currentStep) .map(id => document.getElementById(id)) .find(el => el && !String(el.value || '').trim()); if(missing){ try { missing.scrollIntoView({block: 'center', behavior: 'smooth'}); } catch(e) { } missing.focus(); } } function collapseRailIfNarrow(){ const rail = document.getElementById('step-rail'); const toggle = document.getElementById('step-rail-toggle'); if(!rail || !toggle) return; // Only when the disclosure is the live control. Above the breakpoint the toggle // is display:none and the list is always shown, so collapsing would set a class // nothing reads and leave aria-expanded describing a control nobody can see. if(!toggle.offsetParent) return; rail.classList.add('is-collapsed'); toggle.setAttribute('aria-expanded', 'false'); } // Every field any gate depends on, flattened once. Typing into one of these // changes what the rail is allowed to offer, and a rail that only refreshes when // you navigate is a rail that says "locked" over a form you have just filled in — // which is worse than the strip it replaced, because that one at least lied // consistently. const _GATE_FIELD_IDS = new Set( Object.keys(STEP_GATES).reduce((all, n) => all.concat(stepGateFields(n)), [])); function railWatchField(e){ const t = e.target; if(!t || !t.id || !_GATE_FIELD_IDS.has(t.id)) return; // An error message is about a state that no longer holds once the field it // named has been filled. Cleared as you type rather than on the next submit — // an error still showing over a field you have just corrected teaches people // to ignore errors. if(fieldFilled(t.id)) setFieldError(t.id, ''); if(stepGateMet(currentStep)) railMessage(''); renderStepRail(); } document.addEventListener('DOMContentLoaded', function(){ const list = document.getElementById('step-rail-list'); const toggle = document.getElementById('step-rail-toggle'); ensureErrorBoxes(); document.addEventListener('input', railWatchField); document.addEventListener('change', railWatchField); if(toggle){ toggle.addEventListener('click', function(){ const rail = document.getElementById('step-rail'); const open = rail.classList.toggle('is-collapsed') === false; toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); }); } if(!list) return; list.addEventListener('click', function(e){ const btn = e.target.closest('.step-btn'); if(!btn) return; const n = parseInt(btn.dataset.step, 10); if(btn.getAttribute('aria-disabled') === 'true'){ railBlockedClick(n); return; } goToStep(n); collapseRailIfNarrow(); }); // Enter and Space come free with