CR-013 accepted free text because the master workbook never arrived; the
Aug 18 call was the CR-005 call again - build the upload path now.
THE component, extracted: T5.4's paste-or-file machinery (file read in the
browser, ONE parser on the server; dry-run check; a report naming every
rejected row with its source line; an editable list that deactivates rather
than deletes) moved from the location-specific functions into
html/wp-list-import.js. The location list and the new material list are both
instances of it - the done-when's "against the same component, not beside it"
made literally true. The loc* names survive as thin delegates because row
handlers, step entry and the probes call them; locations_check re-pointed its
fetch-count assertion to where the fetches now live and still demands every
read and write reach the server.
The material list itself: description, unit, optional code - one new table
(Alembic a1b8c6d4e2f9, additive), GET/import/POST/PATCH routes on the CR-005
pattern, deactivate-never-delete, reactivation reuses the same row so nothing
referencing it orphans. The sample rows are obviously fake (SAMPLE-EMT-075).
NO inventory, price, stock or warehouse field anywhere - the probe walks the
model's columns by regex. The wizard hosts it on step 11 beside the location
list, optional by design: a project with no list still raises free-text
requests (T8.5 wires that).
Parser bug caught by the probe's first run: strip(',;') ate a LEADING comma,
so ',FT' - an empty description - was accepted as a material named FT.
rstrip only, now; the empty first column is rejected with its line number.
Verification (each probe run alone): NEW tests/materials_check.py 17/17.
Regression: locations_check 58/58 through the shared component.
Items: D6
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2305 lines
112 KiB
JavaScript
2305 lines
112 KiB
JavaScript
// ── 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 — 1–2 days (≈8–24 hrs)': 24,
|
||
'Standard — 3–5 days (≈40–80 hrs)': 80,
|
||
'Large — 1–2 weeks (≈80–160 hrs)': 160
|
||
};
|
||
function onSizePresetChange(){
|
||
const label = document.getElementById('gov_wosize').value;
|
||
const max = WP_SIZE_PRESETS[label];
|
||
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
|
||
// 'Custom…' / '' leave the threshold for manual entry.
|
||
}
|
||
|
||
let state = {
|
||
bimEnabled: false, // does this project also produce BIM/VDC packages? If so the Creator tags each WP Install (IWP) or BIM (EWP); if not, it's IWP-only.
|
||
project: {name:'', number:'', client:'', division:'', site:''},
|
||
// Leadership names are kept as display strings (so existing SOPs and exports
|
||
// still read the same) alongside the user-account id each one resolves to.
|
||
// The ids are what let the Creator offer these people as a WP owner and what
|
||
// notification routing uses — a typed name can't be emailed.
|
||
team: {pm:'', apm:'', cm:'', qm:''},
|
||
teamIds: {pm:'', apm:'', cm:'', qm:''},
|
||
qaGroupIds: [], // D2: who is emailed when a package reaches Ready for QA
|
||
teamMembers: [],
|
||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||
wpTypes: [],
|
||
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
|
||
// CR-006: which work package sections this project uses. Present from the start
|
||
// rather than filled in lazily, because sopIsDirty() fingerprints this object —
|
||
// adding a key on first read would make merely LOOKING at step 12 count as an
|
||
// unsaved change and fire T4.3's guard on the way out.
|
||
sections: (typeof WPSections !== 'undefined') ? WPSections.defaults() : {},
|
||
// CR-002: the two Acumatica fields, off-switchable inside General Information.
|
||
fields: (typeof WPSections !== 'undefined') ? WPSections.fieldDefaults() : {},
|
||
quality: {qcreq:'', photo:'', hold:''},
|
||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''},
|
||
sequence: [],
|
||
constraints: [],
|
||
sources: []
|
||
};
|
||
|
||
let sop = null; // Generated SOP for WP tool
|
||
|
||
// ── LIBRARIES ─────────────────────────────────────────────────────────────────
|
||
const DEFAULT_WP_TYPES = [
|
||
{name:'Rough-In', enabled:true},
|
||
{name:'Mechanical Install', enabled:true},
|
||
{name:'Panel Install', enabled:true},
|
||
{name:'Conduit Install', enabled:true},
|
||
{name:'Tray Install', enabled:true},
|
||
{name:'Mechanical Tubing', enabled:false},
|
||
{name:'Instrument Install', enabled:true},
|
||
{name:'Wire Pull', enabled:true},
|
||
{name:'Terminations', enabled:true},
|
||
{name:'Prefab/Kitting', enabled:false},
|
||
{name:'Network Cabling', enabled:false},
|
||
{name:'Fiber', enabled:false},
|
||
{name:'Calibrations', enabled:false}
|
||
];
|
||
|
||
const STANDARD_10_CONSTRAINTS = [
|
||
{name:'Safety & Permitting', description:'Permits, safety reviews, environmental clearances'},
|
||
{name:'Quality Control / Inspection', description:'QC approval, inspection readiness'},
|
||
{name:'IFC Drawings & Specs', description:'Issued-for-Construction drawings and specifications'},
|
||
{name:'Schedule', description:'Scheduled sequence timing confirmed'},
|
||
{name:'Materials (on site, bagged & tagged)', description:'All required materials on site and staged'},
|
||
{name:'Prefabrication', description:'Prefabricated assemblies complete'},
|
||
{name:'Work Access & Laydown', description:'Work area accessible and staged'},
|
||
{name:'Craft Availability', description:'Required craft trades available'},
|
||
{name:'Construction Equipment & Tools', description:'Special equipment and tools on site'},
|
||
{name:'Scaffolding / Access Equipment', description:'Temporary access structures in place'}
|
||
];
|
||
|
||
const CONSTRAINT_LIBRARY = [
|
||
'Utility Clearances',
|
||
'Third-Party Approvals',
|
||
'Engineering Change Notices',
|
||
'Commissioning Sign-Off',
|
||
'As-Built Documentation',
|
||
'Labeling & Identification',
|
||
'Testing & Certification',
|
||
'Rework Completion',
|
||
'Coordination with Other Trades',
|
||
'Environmental Controls',
|
||
'Critical Path Gate',
|
||
'Client Walkthrough Approval'
|
||
];
|
||
|
||
const OPTIONAL_ROLES = [
|
||
'Assistant Project Manager',
|
||
'General Foreman',
|
||
'Construction Manager',
|
||
'HSE Professional',
|
||
'Quality Representative',
|
||
'Planner',
|
||
'Safety Manager',
|
||
'Project Controls Manager',
|
||
// BIM / VDC roles (used by the BIM template; also selectable on any SOP)
|
||
'BIM Coordinator',
|
||
'BIM Modeler / Detailer',
|
||
'VDC Manager',
|
||
'Construction Lead (CRS)',
|
||
'Field Lead',
|
||
'General Contractor'
|
||
];
|
||
|
||
const LABOR_COST_CODES = [
|
||
'1000|Project Management',
|
||
'2000|Design and Development',
|
||
'2100|Design',
|
||
'2110|Control System Design',
|
||
'2120|Instrument Design',
|
||
'2130|Electrical Design',
|
||
'2140|Panel Design',
|
||
'2141|Panel Design Rework',
|
||
'2150|BIM',
|
||
'2151|BIM Rework',
|
||
'2160|Documentation',
|
||
'2200|Development',
|
||
'2210|PLC Programming',
|
||
'2220|OIT Programming',
|
||
'2230|SCADA Programming',
|
||
'2240|Simulation Development',
|
||
'2300|Customer Training',
|
||
'3000|Operational Technology',
|
||
'3100|OT Design',
|
||
'3200|Rack Assembly',
|
||
'3300|Network Configuration',
|
||
'3400|Computer Configuration',
|
||
'4000|Construction',
|
||
'4010|Instruments Install',
|
||
'4020|Network & Computers Install',
|
||
'4040|PLC Install',
|
||
'4050|Panel Install',
|
||
'4060|Electrical Install',
|
||
'4070|Mechanical Install',
|
||
'4080|Security Install',
|
||
'4090|Radio Install',
|
||
'4100|Commissioning',
|
||
'6000|Production',
|
||
'7000|Quality',
|
||
'7100|Panel Quality Control',
|
||
'7200|Factory Acceptance Testing',
|
||
'7300|Site Acceptance Testing',
|
||
'8000|Safety',
|
||
'9000|Administration'
|
||
];
|
||
|
||
// ── BIM / VDC TEMPLATE ──────────────────────────────────────────────────────────
|
||
// The BIM/VDC department also produces work packages under Advanced Work Packaging —
|
||
// model & engineering deliverables (EWPs) that feed the field's install packages.
|
||
// These defaults are drawn from the EN06 SOP + work instructions and are added by
|
||
// enableBIM() when the "Include BIM / VDC work packages" box is ticked on Step 4
|
||
// (each is flagged bim so the Creator can offer them under the EWP kind). Everything
|
||
// remains editable afterward.
|
||
const BIM_WP_TYPES = [
|
||
'Model Area Package', 'Conduit Routing Package', 'Coordination / Clash Package',
|
||
'First-of-Kind (FOK) Package', '2D Installation Sheet Set', 'Field Detailing / Markup Package',
|
||
'Laser Scan Package', 'As-Built Model / Drawings'
|
||
];
|
||
// BIM "disciplines" are the install phases work is broken into (EN06-SOP §4.1.2.5).
|
||
const BIM_PHASES = [
|
||
'Cable Tray & Hangers', 'Conduits & Hangers', 'Wall & Slab Penetrations',
|
||
'Panels & Instrument Racks', 'In-wall Instruments & Stud-ups'
|
||
];
|
||
const BIM_TEMPLATE_ROLES = [
|
||
'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager',
|
||
'Construction Lead (CRS)', 'Field Lead', 'General Contractor'
|
||
];
|
||
// Release-gate constraints for a BIM/model package (the BIM equivalent of the field's
|
||
// AWP constraints). Citations point back to the EN06 documents.
|
||
const BIM_CONSTRAINTS = [
|
||
{name:'Required Docs Received (IO list, P&IDs, drawings, models, specs)', description:'Project-start inputs available — EN06-SOP §3.1'},
|
||
{name:'Conduit Schedule & Schematic Redlines Received', description:'Hard gate: no conduit modeled without these — EN06-SOP §3'},
|
||
{name:'LOD Defined & Agreed', description:'Level of detail set at kick-off — EN06-G-01'},
|
||
{name:'Field Coordination / Laser Scan Complete', description:'Field walk or scan done — EN06-WI-01 / WI-03'},
|
||
{name:'Clash-Free / Coordinated with GC & Trades', description:'Coordination complete — EN06-SOP §8.1'},
|
||
{name:'Constructability Review (CRS) Signed', description:'Internal construction-lead sign-off before GC — EN06-SOP §8.4'},
|
||
{name:'GC / Trade Sign-Off', description:'GC review and approval — EN06-SOP §8.2'},
|
||
{name:'Issued-For-Fabrication (IFF) Granted', description:'Model approved for field use — EN06-SOP §9.4'}
|
||
];
|
||
const BIM_SEQUENCE = [
|
||
'Kick-off (LOD, schedule, cost code)', 'Project start — gather required docs',
|
||
'Field coordination / laser scan', 'Model racks, instruments & panels',
|
||
'Model conduit (after schedule + redlines)', 'BIM coordination / clash with GC & trades',
|
||
'Constructability review (CRS)', 'GC submission & sign-off (IFF)',
|
||
// BIM deliverable that hands off to the field — only present when BIM is enabled.
|
||
'2D installation sheets / Spool Drawings'
|
||
];
|
||
const BIM_SOURCES = [
|
||
{label:'IO List (Point Matrix DB)', ph:'controls.dev / SharePoint'},
|
||
{label:'P&IDs', ph:'Procore / SharePoint'},
|
||
{label:'Contract / Design Drawings', ph:'Procore / Bluebeam'},
|
||
{label:'Navisworks / Revit Models', ph:'BIM360 / SharePoint'},
|
||
{label:'Specs & Submittals', ph:'client portal'},
|
||
{label:'Conduit Schedule', ph:'Excel on SharePoint'},
|
||
{label:'Bluebeam Project', ph:'Bluebeam Studio'},
|
||
{label:'Pre-Construction Tracker', ph:'SharePoint'},
|
||
{label:'Constructability Review Sheet (CRS)', ph:'SharePoint'}
|
||
];
|
||
|
||
// Toggle BIM/VDC capability on the project. ON augments the SOP with BIM package
|
||
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
|
||
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the
|
||
// bim-flagged items. Everything stays editable.
|
||
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
|
||
// BIM is off until it's ready for the field. With the flag off we hide the
|
||
// per-project toggle so no new project can be put on the BIM path — but we never
|
||
// strip a SOP that already has it on, because that would silently delete its BIM
|
||
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
|
||
// flag comes back.
|
||
function applyBimFlag(){
|
||
const wrap = document.getElementById('bim-toggle-wrap');
|
||
const note = document.getElementById('bim-disabled-note');
|
||
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
|
||
if(wrap) wrap.style.display = enabled ? '' : 'none';
|
||
if(note){
|
||
const stale = !enabled && !!state.bimEnabled;
|
||
note.style.display = enabled ? 'none' : '';
|
||
note.innerHTML = stale
|
||
? '<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 ──────────────────────────────────────────────────────────
|
||
// This page's sample is the sample SOP. It used to reach into the frame and call
|
||
// the creator's loadExample() when the WP tab was open; the creator is its own
|
||
// page since T7.1 and carries its own sample controls in its toolbar (D1), so
|
||
// there is nothing to reach into and nothing to duplicate here.
|
||
//
|
||
// S7 still counts more sample affordances than it should. T9.4 reduces them; this
|
||
// task only stops one of them pretending to belong to a document it is not in.
|
||
function loadSampleData(){
|
||
if(currentTool && currentTool !== 'sop'){
|
||
wizardToast('The work package sample is on the Work Package Creation page, in its toolbar.',
|
||
{role: 'status'});
|
||
return;
|
||
}
|
||
// Populate Step 1
|
||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
||
document.getElementById('proj_number').value = '26-67-008';
|
||
document.getElementById('proj_client').value = 'Micron Technology, Inc.';
|
||
document.getElementById('proj_division').value = 'Semiconductor';
|
||
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
||
|
||
// Populate Step 2. The leadership slots are account pickers now, so the sample's
|
||
// fictional names can't be "selected" — setting .value on a <select> with no
|
||
// matching option silently does nothing. Store them as names without an account,
|
||
// which is exactly how the picker shows a person who isn't a suite user yet.
|
||
state.team.pm = 'Mariano Sanchez';
|
||
state.team.apm = 'Assistant PM';
|
||
state.team.cm = 'K. Boyd';
|
||
state.team.qm = 'D. Nguyen';
|
||
state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||
state.qaGroupIds = [];
|
||
renderTeamPickers();
|
||
|
||
// Step 3 — standard required roles
|
||
if(state.signoffRoles[0]) state.signoffRoles[0].role = 'Superintendent';
|
||
if(state.signoffRoles[1]) state.signoffRoles[1].role = 'Foreman';
|
||
const stEl = document.getElementById('role_super_title'); if(stEl) stEl.value = 'Superintendent';
|
||
const ftEl = document.getElementById('role_foreman_title'); if(ftEl) ftEl.value = 'Foreman';
|
||
state.signoffRoles[0].name = 'John Smith'; state.signoffRoles[0].userId = '';
|
||
state.signoffRoles[1].name = 'Mike Jones'; state.signoffRoles[1].userId = '';
|
||
renderSignoffRolePickers();
|
||
|
||
// Populate Step 5
|
||
document.getElementById('gov_woformat').value = 'WP##-[Sector]-[TYPE]';
|
||
document.getElementById('gov_wosize').value = 'Standard — 3–5 days (≈40–80 hrs)';
|
||
document.getElementById('gov_disciplines').value = 'Mechanical, Electrical, Tech';
|
||
document.getElementById('gov_discmode').value = 'choice';
|
||
document.getElementById('gov_size_hours_max').value = '80';
|
||
|
||
// Populate Step 6
|
||
document.getElementById('qual_qcreq').value = 'Yes — Detailed inspection items';
|
||
document.getElementById('qual_photo').value = 'Key checkpoints only';
|
||
document.getElementById('qual_hold').value = 'HOLD: Prime QAQC to inspect rough-in before cover/cover-up.\nWITNESS: Client QC to observe megger test before energization.';
|
||
|
||
// Step 7 already has defaults
|
||
|
||
// CR-016 / T5.7. The sample is the Micron EUV configuration, and Micron does not
|
||
// use Assets: its content duplicates the database Clinton's team maintains, and
|
||
// that integration is deferred. Off by toggle, so the section and its model stay
|
||
// in the application for the integration to land in — and any other project can
|
||
// turn it back on from step 12 without a code change.
|
||
//
|
||
// A partial map on purpose. WPSections.normalize fills the rest in as ON, so a
|
||
// section added after today is not silently off for this SOP.
|
||
if(typeof WPSections !== 'undefined'){
|
||
state.sections = WPSections.normalize({assets: false, kitting: false}); // CR-009: Micron EUV is not kitting today
|
||
}
|
||
|
||
// The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the
|
||
// full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP.
|
||
state.bimEnabled = true;
|
||
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true;
|
||
enableBIM();
|
||
|
||
// Collect all data
|
||
collectStepData();
|
||
track('sample_loaded');
|
||
|
||
wizardToast('Sample data loaded. Every step now holds example values — edit or replace any of them.');
|
||
|
||
// Switch to step 1. Every step now holds sample content, so the rail marks them
|
||
// all as visited — otherwise a fully populated wizard shows ten unstarted steps.
|
||
for(let i = 1; i <= LAST_STEP; i++) _visitedSteps.add(i);
|
||
currentStep = 1;
|
||
updateStepUI();
|
||
updateProjectDisplay();
|
||
}
|
||
|
||
// ── RESTORE A COMPLETED SOP (for "Review" + WP tab across reloads) ─────────────
|
||
function restoreSavedSOP(){
|
||
let savedState = null, savedSop = null, complete = false;
|
||
try {
|
||
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||
} catch(e){}
|
||
if(!complete || !savedState) return;
|
||
|
||
state = savedState;
|
||
sop = savedSop;
|
||
sopComplete = true;
|
||
// A SOP saved before 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,'"')}" 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,'"')}" 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,'"')}" 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,'"')}" 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,'"')}" 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
// A value bound into an inline handler — onclick="fn('…')" — needs BOTH escapes, in
|
||
// this order: backslash, then quote (for the JS string literal), then escAttr (for
|
||
// the attribute carrying it). Quote-escaping alone breaks on a value containing a
|
||
// backslash — the backslash escapes the backslash, the quote closes the literal, and
|
||
// the rest runs as code. Constraint names travel with the SOP to everyone on the
|
||
// project, so they are not this browser's own input.
|
||
function escHandlerArg(v){ return escAttr(String(v==null?'':v).replace(/\\/g,'\\\\').replace(/'/g,"\\'")); }
|
||
function renderSources(){
|
||
const container = document.getElementById('sources-list');
|
||
if(!state.sources.length) state.sources = DEFAULT_SOURCES.map(s=>({label:s.label, system:'', notes:'', link:'', ph:s.ph, preset:true}));
|
||
const grid = "display:grid; grid-template-columns:170px 170px 1fr 160px 30px; gap:1rem; align-items:center;";
|
||
const inStyle = "padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;";
|
||
const header = `<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();
|
||
}
|
||
|