// ── 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 | ?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. const tab = params.get('tab'); _deepLinkWp = params.get('wp') || ''; const wantDashboard = params.get('view') === 'dashboard'; if(wantDashboard) switchTool('dashboard', {fromUrl:true}); else if(tab === 'wp' || tab === 'sop') switchTool(tab, {fromUrl:true}); else if(_deepLinkWp) switchTool('wp', {fromUrl:true}); const bootStep = parseInt(params.get('step'), 10); if(bootStep >= 1 && bootStep <= 10 && 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 ────────────────────────────────────────────────────────── // 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. The leadership slots are account pickers now, so the sample's // fictional names can't be "selected" — setting .value on a ; if a saved value isn't one of the presets // (e.g. legacy free text), add it as an option so the round-trip preserves it. const wsEl = document.getElementById('gov_wosize'); if(wsEl && state.governance.wosize && !Array.from(wsEl.options).some(o=>o.value===state.governance.wosize)){ wsEl.add(new Option(state.governance.wosize, state.governance.wosize)); } set('gov_wosize', state.governance.wosize); set('gov_disciplines', (state.governance.disciplines||[]).join(', ')); set('gov_discmode', state.governance.discMode); set('gov_size_hours_max', state.governance.sizeHoursMax); set('qual_qcreq', state.quality.qcreq); set('qual_photo', state.quality.photo); set('qual_hold', state.quality.hold); set('plat_tracking', state.platforms.tracking); set('plat_commissioning', state.platforms.commissioning); set('plat_tracking_url', state.platforms.trackingUrl); set('plat_commissioning_url', state.platforms.commissioningUrl); const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = !!state.bimEnabled; } // ── TOOL SWITCHING ──────────────────────────────────────────────────────────── function switchTool(tool, opts){ currentTool = tool; // S3: which tool is open is addressable state. `fromUrl` is set when we are // restoring because the user pressed Back - recording that as a new entry would // make Back appear to do nothing. if(typeof WPUrl !== 'undefined' && !(opts && opts.fromUrl)){ WPUrl.push(tool === 'dashboard' ? { tab: 'wp', view: 'dashboard' } : { tab: tool, view: '' }); } // 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded // creator) but opens it straight to the dashboard view. const isDash = (tool === 'dashboard'); const contentTool = isDash ? 'wp' : tool; // Update nav tabs document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active')); const tabBtn = document.querySelector(`[data-tab="${tool}"]`); if(tabBtn) tabBtn.classList.add('active'); // Update content document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active')); document.getElementById(`tool-${contentTool}`).classList.add('active'); if(contentTool === 'wp') renderWPTab(isDash); // Only go full-bleed when the creator is actually showing. With the SOP // incomplete this tab shows a short 'complete the SOP first' gate; making the // page unscrollable around it can clip its button off the bottom. applyEmbedLayout(contentTool === 'wp' && sopComplete); updateStepUI(); updateProjectDisplay(); } // The embedded creator/dashboard fills the window below the app chrome, so there // is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling // page — and the creator's sticky bars have a real viewport to stick to. // A work package the global search asked us to open, handed to the creator on its // next load and then cleared. let _deepLinkWp = ''; function applyEmbedLayout(on){ // Below this, "fill the window" leaves nothing usable: the iframe would be shorter // than the creator's own sticky bars while the page itself can't scroll. Fall back // to normal page flow and let the page scroll instead. const MIN_FILL_H = 460; const fits = (window.innerHeight - chromeHeight()) >= MIN_FILL_H; const full = !!on && fits; const area = document.querySelector('.content-area'); const frame = document.getElementById('wp-frame'); if(area) area.classList.toggle('embed-full', full); if(frame) frame.classList.toggle('fill', full); document.body.classList.toggle('embed-full', full); sizeWPFrame(full); } // Size the frame with INLINE styles, not only CSS classes. Inline wins over any // stylesheet — including a stale cached one — so the creator can't collapse to the // 300x150 default iframe box if the CSS and the HTML are ever out of step. function sizeWPFrame(on){ const frame = document.getElementById('wp-frame'); if(!frame) return; frame.style.width = '100%'; frame.style.border = '0'; if(on){ const h = viewportMinusChrome(); document.documentElement.style.setProperty('--wp-chrome-h', (window.innerHeight - h) + 'px'); frame.style.height = h + 'px'; frame.style.minHeight = '0'; } else { frame.style.height = ''; frame.style.minHeight = 'calc(100vh - 200px)'; } } function chromeHeight(){ const hdr = document.querySelector('.header'); const nav = document.querySelector('.main-nav'); return (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48); } // Whatever is left of the window below the app bar + tab strip. No floor: a floor // taller than the remaining space pushes the frame (and the creator's fixed save bar) // off a window that has scrolling disabled. function viewportMinusChrome(){ return Math.max(0, window.innerHeight - chromeHeight()); } // Re-measure on resize, and re-decide whether full-bleed still fits. window.addEventListener('resize', () => { if(currentTool === 'wp' || currentTool === 'dashboard') applyEmbedLayout(true); }, {passive:true}); // The app bar grows when wp-chrome.js injects the project switcher and search, which // happens after the frame has already been sized. Watch the chrome instead of relying // on someone resizing the window. try { const _ro = new ResizeObserver(() => { if(document.body.classList.contains('embed-full')) sizeWPFrame(true); }); ['.header', '.main-nav'].forEach(sel => { const el = document.querySelector(sel); if(el) _ro.observe(el); }); } catch(e) { /* no ResizeObserver: the resize handler above still covers it */ } // Show the gate or the embedded Work Package Creator depending on SOP status. // wantDash=true opens the creator straight to the dashboard view. function renderWPTab(wantDash){ const gate = document.getElementById('wp-gate'); const frame = document.getElementById('wp-frame'); if(!gate || !frame) return; if(sopComplete){ gate.style.display = 'none'; frame.style.display = 'block'; const sp = new URLSearchParams(window.location.search); const projId = sp.get('project') || (activeProject && activeProject.id) || ''; const wantWp = _deepLinkWp; _deepLinkWp = ''; const dash = wantDash; // The frame's identity is the PROJECT only. The view (form vs dashboard) and // which package to open are applied by calling into the loaded document, so // switching tabs never reloads it — reloading discarded unsaved form edits, made // the creator unreachable offline, and stored a fresh copy in the SW cache each // time. It's same-origin, so a direct call is fine. const src = 'wp-creation-index.html?embedded=1' + (projId ? '&project=' + encodeURIComponent(projId) : ''); const applyNow = () => { try { const cw = frame.contentWindow; if(!cw) return; if(wantWp && typeof cw.openWpById === 'function' && !cw.openWpById(wantWp)){ if(typeof cw.toast === 'function') cw.toast('That work package is not on this project.'); } if(dash && typeof cw.showDashboard === 'function') cw.showDashboard(); else if(!dash && typeof cw.showForm === 'function') cw.showForm(); } catch(e){ /* cross-document timing; nothing useful to do */ } }; // Wait for the creator's data, not just its document. `load` fires before // pullProject() resolves, so opening a specific package straight after load // silently found nothing. const whenReady = () => { try { const cw = frame.contentWindow; if(cw && cw.wpCreatorReady) { applyNow(); return; } if(cw && cw.document) { cw.document.addEventListener('wp-creator-ready', applyNow, {once:true}); return; } } catch(e){} applyNow(); }; if(frame.getAttribute('data-src') === src && frame.contentWindow){ whenReady(); } else { frame.setAttribute('data-src', src); frame.addEventListener('load', whenReady, {once:true}); frame.src = src; } }else{ gate.style.display = 'block'; frame.style.display = 'none'; } } // Called from completeSOP / restoreSavedSOP once an SOP is available. function onSOPReady(){ if(currentTool === 'wp') renderWPTab(); } // Active project comes from the home page (?project= + ProjectData.getActive()). // When the SOP's project fields are still empty, prefill them from the project // record so the SOP is authored against the chosen project. let activeProject = null; function applyProjectContext(projectId){ try { if(typeof ProjectData !== 'undefined'){ if(projectId && ProjectData.getActiveId() !== projectId){ // Deep-linked to a project that isn't the cached active one. Seed the id // immediately so namespaced storage keys resolve, then fetch the full record. const cached = ProjectData.getActive(); ProjectData.setActive(cached && cached.id === projectId ? cached : { id: projectId }); ProjectData.get(projectId).then(p => { if(p){ activeProject = p; ProjectData.setActive(p); prefillProjectFields(); updateProjectDisplay(); } }); } activeProject = ProjectData.getActive(); } } catch(e){} prefillProjectFields(); } function prefillProjectFields(){ if(!activeProject) return; const set = (id,v)=>{ const el=document.getElementById(id); if(el && !el.value && v) el.value = v; }; set('proj_name', activeProject.name); set('proj_number', activeProject.number); set('proj_client', activeProject.client); set('proj_division', activeProject.division); set('proj_site', activeProject.site); if(typeof state !== 'undefined' && state.project){ state.project.name = state.project.name || activeProject.name || ''; state.project.number = state.project.number || activeProject.number || ''; state.project.client = state.project.client || activeProject.client || ''; state.project.division = state.project.division || activeProject.division || ''; state.project.site = state.project.site || activeProject.site || ''; } } function updateProjectDisplay(){ const projName = document.getElementById('proj_name')?.value || (activeProject && activeProject.name) || 'Project'; const display = document.getElementById('project-display'); if(display) display.textContent = sopComplete ? `✓ ${projName} (SOP Ready)` : projName; } // ── RENDERING (SOP) ──────────────────────────────────────────────────────────── function renderWPTypes(){ const container = document.getElementById('wp-types-table'); container.innerHTML = `
Work Order Type
Enabled
Spec Section
Special Rules / Notes
WO Complete Approval
`; state.wpTypes.forEach((t,i)=>{ const row = document.createElement('div'); row.className = 'wp-type-row'; const nameCell = t.custom ? `
` : `
${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'); 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,'"'); } // 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 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. const STEP_GATES = { 1: {fields: ['proj_name', 'proj_number', 'proj_client', 'proj_division', 'proj_site'], msg: 'Please complete all required fields: Project Name, Number, Client, Division, and Site Location.'}, 5: {fields: ['gov_woformat'], msg: 'Please enter a Work Package Number Format.'}, 6: {fields: ['qual_qcreq'], msg: 'Please select a QC requirement.'} }; 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'}; // 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 stepGateMet(n){ const gate = STEP_GATES[n]; if(!gate) return true; return gate.fields.every(id => { const el = document.getElementById(id); return !!(el && String(el.value || '').trim()); }); } // 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() ends in alert() when a required // field is empty, which on a freshly-loaded deep link is ALWAYS - so a shared // link to step 3 opened a modal dialog 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; const wantTool = state.view === 'dashboard' ? 'dashboard' : (state.tab || 'sop'); if(wantTool !== currentTool) switchTool(wantTool, {fromUrl:true}); const step = parseInt(state.step, 10); if(currentTool === 'sop' && step >= 1 && step !== currentStep) goToStep(step, {fromUrl:true}); }); } function nextStep(){ if(!validateStep(currentStep)) return; if(currentStep < 10){ 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 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 = gate.fields .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(STEP_GATES[n].fields), [])); function railWatchField(e){ const t = e.target; if(!t || !t.id || !_GATE_FIELD_IDS.has(t.id)) return; // The refusal message is about a state that no longer holds once the field it // named has been filled. if(stepGateMet(currentStep)) railMessage(''); renderStepRail(); } document.addEventListener('DOMContentLoaded', function(){ const list = document.getElementById('step-rail-list'); const toggle = document.getElementById('step-rail-toggle'); 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