Files
Project-SDE-WP-Suite/html/work-package-suite-app.js
n.siegfried b44afa7672 T9.4 - S7: one sample-data affordance, confirmed, and fenced off the project
Four affordances under three names became ONE: "Load sample data", on the
creator's toolbar, at the far end of two separators from the live actions
(New / Duplicate), pushed right with its own gap. It confirms through the
T7.9 dialog, naming exactly what it does - and what it does not: "This page
only: nothing is written to the project unless you then save." The probe
verifies the fence the way the done-when demands - against a REAL project,
reading the server's SOP and work-package list before and after and asserting
byte-identical.

Gone: the wizard's header "Load sample" (the dangerous one: it filled the
state completeSOP() pushes to the LIVE project, one click, no confirm, no
undo - reconciled with D1 exactly as the task records: the creator's control
is the survivor, the wizard copy goes), the creator's split Sample SOP /
Load example pair (now internals behind the one entry point), and the
empty-state context bar's third button (its text now points at the toolbar
control). The location/material "Load sample values" buttons stay: they fill
a PASTE BOX that acts only through an explicit, dry-runnable import - a
different thing, stated in the code.

Probes re-pointed with reasons in place: frame_check's D1 toolbar list names
the consolidated control; validation_check's sample-driven toast checks
became the-affordance-is-gone checks (and its stale showAnalytics drive,
orphaned by T7.10, became a the-duplicate-stays-gone check).

Verification (each probe run alone): NEW tests/sample_check.py 10/10.
Regressions: validation_check 77/77, frame_check 38/38, kitting_check 26/26,
export_check 20/20, sections_check 95/95.

Items: S7 (D1 reconciliation honored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 13:05:56 -07:00

2225 lines
108 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── GLOBAL STATE ──────────────────────────────────────────────────────────────
let currentTool = 'sop';
let currentStep = 1;
let sopComplete = false;
let allComments = [];
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' 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 — 12 days (≈824 hrs)': 24,
'Standard — 35 days (≈4080 hrs)': 80,
'Large — 12 weeks (≈80160 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
? '<strong>BIM / VDC is switched off for the whole suite.</strong> 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.'
: '<strong>BIM / VDC packages aren\'t available yet.</strong> 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=<id> | ?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=<id> 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 <select>; 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 ────────────────────────────────────────────
// Only one of the three tabs is a tool on this page now. `wp` and `dashboard`
// are the creator, which is a document of its own since T7.1, so switching to
// them is navigation - handled by the links themselves, or by openCreator() when
// something in code asks for them.
function switchTool(tool, opts){
if(tool === 'wp' || tool === 'dashboard'){
return openCreator(tool === 'dashboard' ? 'dashboard' : '', opts);
}
currentTool = 'sop';
// 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)){
// `flag` was the dashboard's filter (T5.3) and `view` the board. Both belong
// to the creator's URL now, so leaving the SOP tab clears them here rather
// than carrying a filter nobody asked for into a page that no longer reads it.
WPUrl.push({ tab: 'sop', view: '', flag: '' });
}
document.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active'); t.removeAttribute('aria-current');
});
const tabBtn = document.querySelector('[data-tab="sop"]');
if(tabBtn){ tabBtn.classList.add('active'); tabBtn.setAttribute('aria-current', 'page'); }
document.querySelectorAll('.tool').forEach(t => t.classList.remove('active'));
const pane = document.getElementById('tool-sop');
if(pane) pane.classList.add('active');
updateStepUI();
updateProjectDisplay();
}
// ── WHERE THE CREATOR LIVES (B7 / T7.1) ──────────────────────────────────────
// This block used to be 90 lines of iframe arithmetic: measure the chrome, size
// the frame to what is left, re-measure on resize, watch the bar with a
// ResizeObserver because it grows after the frame is already sized, and pin the
// result with inline styles so a stale cached stylesheet could not collapse the
// frame to the 300x150 default box. All of it existed to make one document look
// like part of another.
//
// The creator is its own document now. Its size is the window.
//
// What replaces the four cross-frame calls is the URL, and it was already
// built: `?project=`, `?view=` and `?wp=` are read by the creator during its own
// boot (T4.2 / S3). That is why those calls could be deleted rather than
// migrated - they existed only because two documents could not share a variable.
// The creator's address, carrying this page's project.
function creatorUrl(extra){
const sp = new URLSearchParams();
const projId = new URLSearchParams(window.location.search).get('project')
|| (activeProject && activeProject.id) || '';
if(projId) sp.set('project', projId);
Object.keys(extra || {}).forEach(k => { if(extra[k]) sp.set(k, extra[k]); });
const q = sp.toString();
return 'wp-creation-index.html' + (q ? '?' + q : '');
}
// Write the real address onto the two tab links. They ship with a `data-nav-href`
// rather than an `href` so a click before the project is known cannot navigate
// somewhere wrong; once this runs they are ordinary links, which is what makes
// middle-click, ctrl-click and "copy link address" work on them.
function stampCreatorLinks(){
document.querySelectorAll('a.nav-tab[data-nav-href]').forEach(a => {
const base = a.getAttribute('data-nav-href') || '';
const q = base.indexOf('?');
const extra = {};
if(q >= 0) new URLSearchParams(base.slice(q + 1)).forEach((v, k) => { extra[k] = v; });
a.setAttribute('href', creatorUrl(extra));
a.classList.toggle('gated', !sopComplete);
if(sopComplete) a.removeAttribute('aria-disabled');
else a.setAttribute('aria-disabled', 'true');
});
}
// The gate: with no SOP there is nothing for the creator to build from. Show the
// panel on this page rather than sending someone to a creator that would render
// its own empty state and explain nothing. The tab stays a real control and
// keeps focus - it says why instead of disappearing.
function showCreatorGate(){
currentTool = 'wp';
document.querySelectorAll('.nav-tab').forEach(t => {
t.classList.remove('active'); t.removeAttribute('aria-current');
});
const tabBtn = document.querySelector('[data-tab="wp"]');
if(tabBtn){ tabBtn.classList.add('active'); tabBtn.setAttribute('aria-current', 'page'); }
document.querySelectorAll('.tool').forEach(t => t.classList.remove('active'));
const pane = document.getElementById('tool-wp');
if(pane) pane.classList.add('active');
const gate = document.getElementById('wp-gate');
if(gate) gate.style.display = 'block';
updateStepUI();
updateProjectDisplay();
}
// Called by the two tab links. Returns true to let the browser navigate.
function gateCreatorLink(ev){
if(sopComplete) return true;
if(ev && ev.preventDefault) ev.preventDefault();
showCreatorGate();
return false;
}
// Open the creator, or the gate. `opts.replace` is for a deep link being
// forwarded: the redirect is not a place anyone chose to be, so Back must not
// land back on it.
function openCreator(view, opts){
if(!sopComplete){ showCreatorGate(); return false; }
const url = creatorUrl({ view: view || '', wp: (opts && opts.wp) || '' });
if(opts && opts.replace) window.location.replace(url);
else window.location.assign(url);
return true;
}
// Called from completeSOP / restoreSavedSOP once an SOP is available. There is
// no frame to re-render since T7.1; what changes is that the two creator tabs
// stop being gated, so their links have to start working without a reload.
function onSOPReady(){
stampCreatorLinks();
if(currentTool === 'wp' && sopComplete) openCreator('', {replace:true});
}
// Active project comes from the home page (?project=<id> + 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 = `<div class="wp-types-header">
<div>Work Order Type</div>
<div style="text-align:center;">Enabled</div>
<div>Spec Section</div>
<div>Special Rules / Notes</div>
<div>WO Complete Approval</div>
</div>`;
state.wpTypes.forEach((t,i)=>{
const row = document.createElement('div');
row.className = 'wp-type-row';
const nameCell = t.custom
? `<div style="display:flex; gap:6px; align-items:center;">
<input type="text" placeholder="Custom type name" value="${(t.name||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
</div>`
: `<div style="font-weight:600;">${t.name}</div>`;
row.innerHTML = `
${nameCell}
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
<input type="text" placeholder="e.g. 26_05_33_00" title="Specification section for this WP type. The Creator fills it in automatically on every package of this type, so nobody types it per package." value="${(t.specSection||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].specSection=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-family:var(--mono,monospace); font-size:12.5px;">
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`;
container.appendChild(row);
});
const addRow = document.createElement('div');
addRow.style.cssText = 'margin-top:0.85rem;';
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add custom type</button>`;
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 <select> per leadership slot. A name already on the SOP that no longer
// matches an account is kept as a selected option (tagged) rather than silently
// dropped — an old SOP shouldn't lose its PM because they left the project.
function renderTeamPickers(){
const warn = document.getElementById('team-accounts-warn');
const orphans = [];
['pm','apm','cm','qm'].forEach(key => {
const sel = document.getElementById('proj_' + key);
if(!sel) return;
const curId = state.teamIds[key] || '';
const curName = state.team[key] || '';
let html = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
// A stored name with no matching account (typed on an older SOP, or the
// person has since been removed from the project).
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
orphans.push(curName);
}
sel.innerHTML = html;
sel.onchange = function(){ setTeamLead(key, this.value); };
});
renderQaGroupPicker();
if(!warn) return;
if(projectUsersLoaded && !projectUsers.length){
warn.style.display = '';
warn.innerHTML = 'No user accounts are assigned to this project yet, so there is nobody to pick. ' +
'Assign people to the project in the <a href="admin.html" target="_blank" rel="noopener">Admin Console</a> ' +
'(User administration → Projects), then reopen this step.';
} else if(orphans.length){
warn.style.display = '';
warn.textContent = 'Named on this SOP but not a user account on the project: ' + orphans.join(', ') +
'. They cannot be assigned work packages or emailed until they are added as a user and assigned to this project.';
} else {
warn.style.display = 'none';
}
}
// D2: the QA group - a multi-pick of project members, chosen once here and read
// by the server when a package reaches Ready for QA. Stored as account ids;
// display names are derived at save so a rename never breaks the routing.
function renderQaGroupPicker(){
const sel = document.getElementById('proj_qagroup');
if(!sel) return;
const cur = new Set(state.qaGroupIds || []);
sel.innerHTML = projectUsers.map(u =>
`<option value="${escAttr(u.id)}"${cur.has(u.id)?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
sel.onchange = function(){
state.qaGroupIds = [...this.selectedOptions].map(o => o.value).filter(Boolean);
};
}
// One <select> 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 = '<option value="">— not assigned —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===curId?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
if(curName && !userById(curId)){
html += `<option value="__orphan__" selected>${escAttr(curName)} (no account)</option>`;
}
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) => '<option value="">— pick a person —</option>' +
projectUsers.map(u => `<option value="${escAttr(u.id)}"${u.id===cur?' selected':''}>${escAttr(userLabel(u))}</option>`).join('');
container.innerHTML = state.teamMembers.map((m,i)=>`
<div style="display:grid; grid-template-columns:1fr 1fr 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="setExtraTeamMember(${i}, this.value)" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">${opts(m.userId||'')}</select>
<input type="text" placeholder="Role / title on this project" value="${escAttr(m.role)}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="removeTeamMember(${i})">✕</button>
</div>
${(m.name && !m.userId) ? `<div style="font-size:12px; margin:-0.25rem 0 0.75rem 0.25rem; color:var(--warning);">“${escAttr(m.name)}” was typed on an earlier version of this SOP and has no user account — pick the person to link them.</div>` : ''}
`).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)=>`
<div style="display:grid; grid-template-columns:1fr 200px 30px; gap:1rem; align-items:center; padding:0.75rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<select onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].role=this.value">
${OPTIONAL_ROLES.map(o=>`<option ${r.role===o?'selected':''}>${o}</option>`).join('')}
</select>
<select onchange="setOptionalRolePerson(${state.signoffRoles.indexOf(r)}, this.value)">${userSelectOptions(r.userId||'', r.name||'')}</select>
<button class="row-del" style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer;" onclick="removeRole(${state.signoffRoles.indexOf(r)})">✕</button>
</div>
`).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=>`
<div style="display:flex; align-items:start; gap:0.75rem; padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<input type="checkbox" id="const_${c.name}" ${active(c.name)?'checked':''} onchange="toggleConstraint('${c.name}')" style="width:18px; height:18px; cursor:pointer; margin-top:0.2rem;">
<div style="flex:1;">
<label for="const_${c.name}" style="margin:0; font-weight:600; display:block; cursor:pointer;">${c.name}</label>
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
</div>
${criticalToggle(c.name, active(c.name), critical(c.name))}
</div>
`).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 `<label for="${id}" title="${escAttr(tip)}" style="display:flex; align-items:center; gap:0.4rem; white-space:nowrap; font-size:12px; font-weight:600; cursor:${enabled?'pointer':'not-allowed'}; opacity:${enabled?1:0.45}; color:${isCritical?'var(--danger)':'var(--text-light)'};">
<input type="checkbox" id="${id}" ${isCritical?'checked':''} ${enabled?'':'disabled'} onchange="toggleCriticalConstraint(this.dataset.name, this.checked)" data-name="${escAttr(name)}" style="width:16px; height:16px; cursor:inherit;">
${isCritical ? '⚠ Critical' : 'Critical?'}
</label>`;
}
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=>`
<div style="display:flex; align-items:center; justify-content:space-between; gap:0.75rem; padding:0.6rem 0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem;">
<strong>${escAttr(c.name)}</strong>
<span style="display:flex; align-items:center; gap:0.75rem;">
${criticalToggle(c.name, true, !!c.critical)}
<button onclick="removeCustomConstraint('${escHandlerArg(c.name)}')" title="Remove" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600;">✕</button>
</span>
</div>`).join('') : `<div style="font-size:12px; color:var(--text-dim);">No custom constraints added yet.</div>`;
}
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=>`
<div class="constraint-option" style="padding:0.75rem; background:var(--bg); border:1px solid var(--border); border-radius:6px; margin-bottom:0.5rem; cursor:pointer; transition:all 0.2s;" onmouseover="this.style.borderColor='var(--primary)'; this.style.background='var(--primary-light)'" onmouseout="this.style.borderColor='var(--border)'; this.style.background='var(--bg)'" onclick="addCustomConstraint('${c}')">
<strong>${c}</strong>
<div style="font-size:12px; color:var(--text-light); margin-top:0.25rem;">Click to add to this project</div>
</div>
`).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 ? `<span class="seq-gate-badge" title="QC / hold gate">◆ HOLD</span>`
: `<span class="seq-num">${stepNo}</span>`;
row.innerHTML = `
<span class="seq-handle" title="Drag to reorder">⠿</span>
${badge}
<input type="text" class="seq-label" value="${(item.label||'').replace(/"/g,'&quot;')}" oninput="state.sequence[${i}].label=this.value">
<button class="seq-del" title="Remove" onclick="removeSeqStep(${i})">✕</button>`;
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<state.sequence.length-1){ const a=document.createElement('div'); a.className='seq-arrow'; a.textContent='↓'; container.appendChild(a); }
});
}
function addSequenceStep(){
const inp = document.getElementById('seq-add-input');
const v = (inp && inp.value || '').trim();
state.sequence.push({label: v || 'New Step', kind:'step'});
if(inp) inp.value = '';
renderSequenceSteps();
}
function addSequenceGate(){
state.sequence.push({label:'QC Hold', kind:'gate'});
renderSequenceSteps();
}
function removeSeqStep(i){
state.sequence.splice(i,1);
renderSequenceSteps();
}
const DEFAULT_SOURCES = [
{label:'Design Drawings', ph:'e.g. Procore, Bluebeam'},
{label:'Spool Drawings', ph:'e.g. SharePoint, BIM'},
{label:'Installation Detail Drawings', ph:'e.g. Prime details + Micron MVDs'},
{label:'IO List', ph:'e.g. controls.dev, Excel'},
{label:'Cable Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Conduit Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Tray Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Datasheets', ph:'e.g. Procore'},
{label:'Specifications', ph:'e.g. client portal'},
{label:'Asset Register', ph:'e.g. CxAlloy, Excel'},
{label:'RFIs', ph:'e.g. Procore RFI log'},
{label:'Safety Documentation', ph:'e.g. site safety binder'}
];
// Escape a value for safe use inside a double-quoted HTML attribute.
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
// so attribute values must be escaped or a re-render corrupts the field.
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// 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 = `<div style="${grid} padding:0 1rem 0.4rem; font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.03em; color:var(--text-dim);">
<div>Data Type</div><div>Location / Platform</div><div>URL</div><div>Notes</div><div></div>
</div>`;
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
? `<div style="font-weight:600; font-size:13px;">${escAttr(s.label)}</div>`
: `<input type="text" value="${escAttr(s.label)}" placeholder="Custom data type" onchange="state.sources[${i}].label=this.value" style="${inStyle} font-weight:600;">`;
return `<div style="${grid} padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
${dataType}
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'Procore / Bluebeam / SharePoint…')}" onchange="state.sources[${i}].system=this.value" style="${inStyle}">
<input type="text" value="${escAttr(s.link)}" placeholder="Paste the 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="${inStyle}">
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="${inStyle}">
<button style="background:var(--danger); color:white; padding:0.25rem; width:30px; height:30px; border:none; border-radius:4px; cursor:pointer; font-weight:600;" onclick="state.sources.splice(${i},1); renderSources()" title="Remove">✕</button>
</div>`;
}).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 => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.path)}</code> — ${locEsc(r.reason)}</li>`,
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 <code>${locEsc(body.path)}</code>.`,
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 = '<p class="field-hint">Nothing configured yet. Paste or upload a list above, '
+ 'or add values one at a time.</p>';
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 `<div class="loc-row loc-depth-${depth}${n.active ? '' : ' is-off'}" data-id="${locEsc(n.id)}">
<span class="loc-level">${locEsc(n.level)}</span>
<input class="loc-name" type="text" value="${locEsc(n.name)}"
aria-label="Name for ${locEsc(n.path)}" data-id="${locEsc(n.id)}">
<code class="loc-code" title="The stable code cost rolls up by. Renaming does not change it.">${locEsc(n.path)}</code>
<label class="loc-toggle"><input type="checkbox" class="loc-active" data-id="${locEsc(n.id)}"
${n.active ? 'checked' : ''}><span>In use</span></label>
</div>`;
}).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 = ['<option value="">(nothing — add a building)</option>'].concat(
_locNodes.filter(n => n.active && n.level !== 'sector')
.map(n => `<option value="${locEsc(n.id)}">${locEsc(n.path)} — adds a ${n.level === 'building' ? 'floor' : 'sector'}</option>`)
);
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
? `<code>${locEsc(b.path)}</code> is back in use.`
: `<code>${locEsc(b.path)}</code> 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 <code>${locEsc(b.path)}</code> 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 => `
<label class="field-toggle${fields[f.id] ? '' : ' is-off'}" for="fld-${escAttr(f.id)}">
<input type="checkbox" id="fld-${escAttr(f.id)}" data-field="${escAttr(f.id)}"
${fields[f.id] ? 'checked' : ''}>
<span class="section-toggle-body">
<span class="section-toggle-name">${escAttr(f.label)}</span>
<span class="section-toggle-note">${escAttr(f.note)}</span>
</span>
<span class="section-toggle-state">${fields[f.id] ? 'In use' : 'Not used'}</span>
</label>`).join('');
host.innerHTML = WPSections.LIST.map(sec => `
<label class="section-toggle${on[sec.id] ? '' : ' is-off'}" for="sec-${escAttr(sec.id)}">
<input type="checkbox" id="sec-${escAttr(sec.id)}" data-section="${escAttr(sec.id)}"
${on[sec.id] ? 'checked' : ''}>
<span class="section-toggle-body">
<span class="section-toggle-name">${escAttr(sec.label)}</span>
<span class="section-toggle-note">${escAttr(sec.note)}</span>
</span>
<span class="section-toggle-state">${on[sec.id] ? 'In use' : 'Not used'}</span>
</label>` + 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.
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 < 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 <button>. Arrow keys, Home and End do not, and
// they are what makes a ten-item rail navigable rather than ten tab stops.
list.addEventListener('keydown', function(e){
const btn = e.target.closest('.step-btn');
if(!btn) return;
const btns = Array.from(list.querySelectorAll('.step-btn'));
const i = btns.indexOf(btn);
let to = -1;
if(e.key === 'ArrowDown' || e.key === 'ArrowRight') to = (i + 1) % btns.length;
else if(e.key === 'ArrowUp' || e.key === 'ArrowLeft') to = (i - 1 + btns.length) % btns.length;
else if(e.key === 'Home') to = 0;
else if(e.key === 'End') to = btns.length - 1;
if(to < 0) return;
e.preventDefault(); // stop the page scrolling out from under the rail
btns[to].focus();
});
});
function updateStepUI(){
trackStepDwell();
track('step_view', {step: currentStep});
// SOP steps
document.querySelectorAll('[id^="sop-step-"]').forEach(s=>s.style.display='none');
document.getElementById(`sop-step-${currentStep}`)?.style.display && (document.getElementById(`sop-step-${currentStep}`).style.display='block');
renderStepRail();
// The location list lives on the server, so it is fetched when the step is
// actually opened rather than on every page load. locLoad() is idempotent and
// no-ops once it has the list.
if(currentStep === 11 && typeof locLoad === 'function'){ locLoad(); if(typeof matList !== 'undefined') matList.load(); }
if(currentStep === 12 && typeof renderSectionToggles === 'function') renderSectionToggles();
// Update buttons
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
document.getElementById('sop-next-btn').style.display = currentStep < LAST_STEP ? 'block' : 'none';
document.getElementById('sop-complete-btn').style.display = currentStep === LAST_STEP ? 'block' : 'none';
// Collect form data
collectStepData();
}
function collectStepData(){
switch(currentStep){
case 1:
state.project.name = document.getElementById('proj_name').value;
state.project.number = document.getElementById('proj_number').value;
state.project.client = document.getElementById('proj_client').value;
state.project.division = document.getElementById('proj_division').value;
state.project.site = document.getElementById('proj_site').value;
break;
case 2:
// These are user-account pickers now, so their .value is an ID, not a name.
// Reading them straight into state.team would put an id where the display
// name belongs (and it would then print on the SOP as `user_ab12…`).
syncTeamFromPickers();
break;
case 3:
// The two required roles now have editable titles (default Superintendent/Foreman).
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
// Titles are still free text; the NAMES are account pickers whose .value is
// an id, so they're maintained by their own onchange (see
// renderSignoffRolePickers) rather than read as text here.
state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
break;
case 5:
state.governance.woformat = document.getElementById('gov_woformat').value;
state.governance.wosize = document.getElementById('gov_wosize').value;
const sel = document.getElementById('gov_issuance');
state.governance.issuance = Array.from(sel.selectedOptions).map(o=>o.value);
state.governance.disciplines = (document.getElementById('gov_disciplines').value||'')
.split(',').map(d=>d.trim()).filter(Boolean);
state.governance.discMode = document.getElementById('gov_discmode').value;
state.governance.sizeHoursMax = document.getElementById('gov_size_hours_max').value;
break;
case 6:
state.quality.qcreq = document.getElementById('qual_qcreq').value;
state.quality.photo = document.getElementById('qual_photo').value;
state.quality.hold = document.getElementById('qual_hold').value;
break;
case 7:
state.platforms.tracking = document.getElementById('plat_tracking').value;
state.platforms.commissioning = document.getElementById('plat_commissioning').value;
state.platforms.trackingUrl = (document.getElementById('plat_tracking_url').value || '').trim();
state.platforms.commissioningUrl = (document.getElementById('plat_commissioning_url').value || '').trim();
break;
}
}
// ── INLINE VALIDATION (S1 wizard half / T5.8) ────────────────────────────────
// Was one native dialog per step, naming no field. Now: an error at each field
// that needs one, associated with it through aria-describedby, announced through
// its own live region, and the first one focused and scrolled to.
//
// The error elements are BUILT from STEP_GATES rather than written into the
// markup twelve times. That is not laziness — it is what makes the table the
// single source: adding a required field is one row here, and its error element,
// its aria-describedby and its announcement all follow. A markup-side error box
// that somebody forgets to add is an error nobody ever sees.
function errBoxId(fieldId){ return fieldId + '_err'; }
function ensureErrorBoxes(){
Object.keys(STEP_GATES).forEach(n => {
STEP_GATES[n].fields.forEach(([id]) => {
const el = document.getElementById(id);
if(!el || document.getElementById(errBoxId(id))) return;
const box = document.createElement('div');
box.className = 'field-error';
box.id = errBoxId(id);
// role="alert" so a validation failure interrupts. T4.5's rule: an error is
// the case where waiting for a pause is too late, because the person is
// already trying to leave the step.
box.setAttribute('role', 'alert');
(el.parentNode || document.body).insertBefore(box, el.nextSibling);
// Appended rather than assigned: several of these fields already point at a
// hint, and clobbering it would trade one message for another.
const prior = (el.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
if(prior.indexOf(box.id) < 0) prior.push(box.id);
el.setAttribute('aria-describedby', prior.join(' '));
});
});
}
function setFieldError(fieldId, message){
const el = document.getElementById(fieldId);
const box = document.getElementById(errBoxId(fieldId));
if(box) box.textContent = message || '';
if(el){
if(message) el.setAttribute('aria-invalid', 'true');
else el.removeAttribute('aria-invalid');
}
}
// For a field that is not part of a step gate but still needs to say something
// at itself — the feedback textarea. Its error box is in the markup because it is
// one field rather than a table of them.
function setSimpleFieldError(fieldId, message){
const el = document.getElementById(fieldId);
const box = document.getElementById(fieldId + '_err');
if(box) box.textContent = message || '';
if(el){
if(message) el.setAttribute('aria-invalid', 'true');
else el.removeAttribute('aria-invalid');
}
}
function clearStepErrors(n){
stepGateFields(n).forEach(id => setFieldError(id, ''));
}
/* Validate one step, marking every field that needs marking and focusing the
first. Nothing here paints a step you have not reached: the rail asks
stepGateMet(), which reads the same fields and marks nothing. Painting nine
steps red on arrival is not validation, it is noise. */
function validateStep(n){
collectStepData();
const gate = STEP_GATES[n];
if(!gate) return true;
let first = null;
gate.fields.forEach(([id, msg]) => {
const bad = !fieldFilled(id);
if(bad && !first) first = id;
setFieldError(id, bad ? msg : '');
});
if(!first) return true;
const el = document.getElementById(first);
if(el){
try { el.scrollIntoView({block: 'center', behavior: 'smooth'}); } catch(e) { }
el.focus();
}
return false;
}
// ── PROJECT MATERIAL LIST (D6 / T8.6) ─────────────────────────────────────────
// The CR-005 call, made again for materials: build the upload path now rather
// than wait for the master workbook. Same component as the location list -
// paste or file, dry-run check, rejected rows with source line numbers, and an
// editable list whose entries deactivate rather than delete. The field set is
// deliberately small: description, unit, an optional code. No inventory level,
// no price, no warehouse id - that is the deferred catalog, and it stays deferred.
const MATERIAL_SAMPLE = [
'Sample 3/4in EMT,FT,SAMPLE-EMT-075',
'Sample 1in EMT,FT,SAMPLE-EMT-100',
'Sample strut channel,FT,SAMPLE-STRUT',
'Sample junction box 4x4,EA',
].join('\n');
function matApi(suffix){
return '/api/projects/' + encodeURIComponent(locProjectId()) + '/materials' + (suffix || '');
}
const matList = WPListImport({
prefix: 'mat', api: matApi, projectId: locProjectId, sample: MATERIAL_SAMPLE,
loadKey: 'items', esc: locEsc,
render: matRender,
rejectedRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
duplicateRow: r => `<li><span class="loc-line">row ${r.line}</span> <code>${locEsc(r.text)}</code> — ${locEsc(r.reason)}</li>`,
addPayload: function(){
const name = ((document.getElementById('mat-add-name') || {}).value || '').trim();
if(!name) return {error: 'Enter a description for the material you are adding.'};
const unit = ((document.getElementById('mat-add-unit') || {}).value || '').trim();
return {payload: {description: name, unit: unit, code: ''}};
},
addedMessage: body => `Added <code>${locEsc(body.description)}</code>.`,
noProjectMessage: 'Open this wizard from a project to configure its material list. '
+ 'The list is stored against the project on the server, not in this browser.',
});
function matRender(){
const list = document.getElementById('mat-list');
const count = document.getElementById('mat-count');
if(!list) return;
const rows = matList.state.rows;
const active = rows.filter(r => r.active);
if(count){
count.textContent = rows.length
? `${active.length} material${active.length===1?'':'s'} in use`
+ (rows.length > active.length ? `, ${rows.length - active.length} deactivated` : '')
: '';
}
if(!rows.length){
list.innerHTML = '<p class="field-hint">No material list yet. Requests fall back to free '
+ 'text until one is loaded - a project with no list can still raise a request.</p>';
return;
}
list.innerHTML = rows.map(r => `<div class="loc-row${r.active ? '' : ' is-off'}" data-id="${locEsc(r.id)}">
<span class="loc-level">${locEsc(r.unit || '—')}</span>
<input class="mat-desc" type="text" value="${locEsc(r.description)}"
aria-label="Description for ${locEsc(r.code || r.description)}" data-id="${locEsc(r.id)}">
${r.code ? `<code class="loc-code">${locEsc(r.code)}</code>` : ''}
<label class="loc-toggle"><input type="checkbox" class="mat-active" data-id="${locEsc(r.id)}"
${r.active ? 'checked' : ''}><span>In use</span></label>
</div>`).join('');
}
document.addEventListener('DOMContentLoaded', function(){
const list = document.getElementById('mat-list');
if(!list) return;
list.addEventListener('change', function(e){
const t = e.target;
if(!t) return;
if(t.classList.contains('mat-active')){
matList.patch(t.dataset.id, {active: t.checked},
b => b.active
? `<code>${locEsc(b.description)}</code> is back in use.`
: `<code>${locEsc(b.description)}</code> is no longer offered on new requests. `
+ 'Requests already referencing it are unchanged.');
return;
}
if(t.classList.contains('mat-desc')){
const row = matList.state.rows.find(n => n.id === t.dataset.id);
const next = (t.value || '').trim();
if(!row || next === row.description){ if(row) t.value = row.description; return; }
if(!next){ t.value = row.description; return; }
matList.patch(t.dataset.id, {description: next},
b => `Renamed to “${locEsc(b.description)}”.`);
}
});
});
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
// Re-saving a SOP that is already complete changes the project's baseline, which
// the server restricts to a Project Admin. Check before doing the work so the
// answer is a clear message rather than a 403 from the sync outbox.
function canEditCompletedSOP(){
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
}
function completeSOP(){
if(sopComplete && !canEditCompletedSOP()){
wizardToast('This project\'s SOP is already complete, and changing it needs the Project Admin '
+ 'role. Ask a project admin — the SOP is the baseline every work package inherits.',
{role: 'alert'});
return;
}
if(!validateStep(LAST_STEP)) return;
collectStepData();
sop = {
meta: {tool:'Work Package Configuration', sample:false},
bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind
project: {
name: state.project.name,
number: state.project.number,
client: state.project.client,
division: state.project.division,
pm: state.team.pm,
apm: state.team.apm,
cm: state.team.cm,
qm: state.team.qm,
// User-account ids for the same four people. These are what the Creator
// uses to offer an owner and what notification routing needs — a display
// name alone can't be assigned work or emailed.
pmId: state.teamIds.pm || '',
apmId: state.teamIds.apm || '',
cmId: state.teamIds.cm || '',
qmId: state.teamIds.qm || '',
// D2: the QA group - ids are the routing, names are for display.
qaGroupIds: (state.qaGroupIds || []).slice(),
qaGroup: (state.qaGroupIds || []).map(id => { const u = userById(id); return u ? (u.full_name || u.username) : ''; }).filter(Boolean),
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
},
roles: state.signoffRoles.filter(r=>r.role).map(r=>({
role: r.role, name: r.name || '',
userId: r.userId || '' // who signs — an account, so it can be notified
})),
governance: {
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
woSize: state.governance.wosize,
woFormat: state.governance.woformat,
disciplines: (state.governance.disciplines && state.governance.disciplines.length)
? state.governance.disciplines : ['Mechanical','Electrical','Tech'],
discMode: state.governance.discMode || 'choice',
instanceSuffix: state.governance.instanceSuffix || 'letter',
sizeHoursMax: state.governance.sizeHoursMax || ''
},
woTypes: state.wpTypes.filter(t=>t.enabled && (t.name||'').trim()).map(t=>({
name: t.name.trim(),
enabled: true,
notes: t.notes || '',
approval: t.approval || '',
// Spec section for this type — the Creator fills the WP's Specification
// Section from it, so it's authored once here instead of per package.
specSection: t.specSection || '',
bim: !!t.bim
})),
sources: state.sources.filter(s=>s.label),
field: {trackPlatform: state.platforms.tracking, trackPlatformUrl: state.platforms.trackingUrl || ''},
commissioning: {tool: state.platforms.commissioning, toolUrl: state.platforms.commissioningUrl || ''},
// Project homepage links in the tracking / commissioning systems. The Creator
// copies these onto every Work Package created for this project.
projectLinks: [
state.platforms.trackingUrl ? {label:'Tracking — '+state.platforms.tracking, system:state.platforms.tracking, url:state.platforms.trackingUrl} : null,
state.platforms.commissioningUrl ? {label:'Commissioning — '+state.platforms.commissioning, system:state.platforms.commissioning, url:state.platforms.commissioningUrl} : null
].filter(Boolean),
// CR-006. Rides on the SOP because the creator already reads the SOP: this is
// what makes the toggles reach a work package without a second channel.
sections: (typeof WPSections !== 'undefined') ? WPSections.normalize(state.sections) : {},
fields: (typeof WPSections !== 'undefined') ? WPSections.normalizeFields(state.fields) : {},
quality: {
qcReq: state.quality.qcreq,
photo: state.quality.photo,
holdPoints: state.quality.hold,
cxTool: state.platforms.commissioning
},
sequence: state.sequence.filter(s=>s.label).map(s=>({
label: s.label,
kind: s.kind || 'step'
})),
costCodes: LABOR_COST_CODES,
constraints: state.constraints.map(c=>({
name: c.name, description: c.description || '', bim: !!c.bim,
// Critical → reopening after release is emailed, not just flagged on the package.
critical: !!c.critical
}))
};
sopComplete = true;
// Stamp the active project onto the SOP so it's unambiguously tied to it.
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
updateProjectDisplay();
// Persist for the home page (green / "Review") and for the WP Creator tab,
// namespaced to the active project so each project keeps its own SOP.
try {
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
if(typeof WPAutosave !== 'undefined'){ sopMarkSaved(); WPAutosave.settled(sopDraftId()); }
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
} catch(e){}
// Share the SOP to the server so every user of this project gets it.
try {
const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || '';
if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state);
} catch(e){}
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
// Unlock the creator tabs (in case the user stays on this page), then return to
// the project home page per the requested flow. The SOP itself is not handed
// anywhere: it has just been written to storage and to the server, and the
// creator reads it at its own boot - which is the only path left since T7.1.
if(typeof onSOPReady === 'function') onSOPReady(sop);
wizardToast('SOP configuration complete. Returning to the project home page.');
window.location.href = 'index.html';
}
// ── COMMENTS ──────────────────────────────────────────────────────────────────
function toggleComments(){
const panel = document.getElementById('comments-panel');
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
if(panel.style.display === 'block') loadStepComments();
}
function currentUserName(){
const u = window.WP_USER;
return (u && (u.full_name || u.username)) || '';
}
function setCommenterName(){
const el = document.getElementById('commenter-name');
if(el) el.value = currentUserName();
}
function submitComment(){
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
const text = document.getElementById('comment-text').value.trim();
if(!text){
// At the field, like every other validation message on this page now.
setSimpleFieldError('comment-text', 'Enter some feedback before submitting.');
document.getElementById('comment-text')?.focus();
return;
}
setSimpleFieldError('comment-text', '');
const comment = {
step: currentStep,
name: name,
text: text,
timestamp: new Date().toLocaleString()
};
allComments.push(comment);
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
document.getElementById('comment-text').value = '';
loadStepComments();
}
function exportComments(){
const saved = localStorage.getItem('wp_suite_comments');
const data = saved ? JSON.parse(saved) : [];
if(!data.length){ wizardToast('There is no feedback to export yet.', {role: 'alert'}); return; }
const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data};
const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-comments-sop-' + new Date().toISOString().slice(0,10) + '.json';
a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
function importComments(ev){
const f = ev.target.files && ev.target.files[0];
if(!f) return;
const r = new FileReader();
r.onload = ()=>{
try{
const inc = JSON.parse(r.result);
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
if(!incoming.length){ wizardToast('No feedback found in that file.', {role: 'alert'}); return; }
const saved = localStorage.getItem('wp_suite_comments');
allComments = saved ? JSON.parse(saved) : [];
const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text));
let added = 0;
incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }});
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
loadStepComments();
wizardToast('Imported ' + added + ' feedback item' + (added===1?'':'s') + '.');
}catch(e){ wizardToast('Could not read that file.', {role: 'alert'}); }
ev.target.value = '';
};
r.readAsText(f);
}
function loadStepComments(){
const saved = localStorage.getItem('wp_suite_comments');
if(saved) allComments = JSON.parse(saved);
const stepComments = allComments.filter(c=>c.step===currentStep);
const list = document.getElementById('comments-list');
if(stepComments.length === 0){
list.innerHTML = '<div style="font-size:12px; color:var(--text-dim); font-style:italic;">No comments yet on this step.</div>';
}else{
list.innerHTML = stepComments.map(c=>`
<div style="padding:0.5rem; background:white; border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem;">
<div style="font-size:11px; color:var(--text-dim); margin-bottom:0.25rem;"><strong>${escAttr(c.name)}</strong> • ${escAttr(c.timestamp)}</div>
<div style="font-size:12px; color:var(--text);">${escAttr(c.text)}</div>
</div>
`).join('');
}
}
// ── USAGE ANALYTICS ─────────────────────────────────────────────────────────
// Lightweight usage analytics stored in localStorage so the tool owner can review
// engagement over time. No field VALUES are stored (field-edit events record only
// the field id), keeping captured data non-sensitive.
// D5 / T7.10: the analytics implementation lives in wp-usage.js and the report
// on the admin console. The wizard's own copy of showAnalytics() never had a
// caller here - the button lived on the creator - and once B7 dissolved the
// frame the duplicate sat in the same document as five colliding globals.
// This page only records; dwell tracking keeps its page-local state below.
let _stepEnter = Date.now();
function track(event, detail){
WPUsage.track(WPUsage.KEYS.wizard, event, detail);
}
function trackStepDwell(){
const ms = Date.now() - _stepEnter;
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
_stepEnter = Date.now();
}