Layout — the reported "skinny scrolling windows" - .content-area capped the whole suite at 1000px, so on a 1920 screen the embedded Work Package Creator ran in a ~930px column with its own scrollbar inside the page's. The wizard now caps at 1700px and the Creator/Dashboard tab goes full-bleed: the iframe fills the window below the app chrome and owns the only scrollbar. Needed `flex: none` on the content area — as a `flex: 1` item its flex-basis overrode `height`, leaving the used height indefinite so the child's `height: 100%` collapsed the iframe to its 150px default. - The SOP wizard's fields were one per row; they now flow into ~340px columns. Navigator — now an auto-hiding drawer - It was a fixed 262px column that stole width from the form AND was hidden below 1100px, so embedded (the normal path) it never appeared at all — that's the "broken side menu". It's now an overlay drawer behind a slim always-visible edge handle: hover or tap to open, move away / Escape / pick a package to close, or pin it to keep it open (pinned shifts the form and the page chrome across, and is remembered). A gutter keeps the handle off the section-nav chips. Bugs found while checking the site over - collectStepData() still read the SOP team fields as text inputs, but wave 1 made them account pickers — so it wrote a user ID into state.team.pm where the display NAME belongs, and the SOP would print `user_ab12…` as the PM. Now synced properly from the pickers. - loadSampleData() set .value on those selects with fictional names; setting an unmatched value on a <select> silently does nothing, so the sample lost its team. It now stores them as names without an account, which the picker shows as "(no account)". - My earlier CSS block replacement had deleted the SOP-chip, people-picker and critical-tag styles. Restored. Same picker everywhere the SOP names someone - Sign-off roles (step 3, required and optional) are account pickers now, storing userId alongside the name, so a signature belongs to an account that can be notified. Titles stay free text. Per-project permissions (asked for: "change project permissions for individual users") - project_members.role overrides the account's role on that project, so a PM on one job can be a Project User on another. Empty = inherit; app admin is admin everywhere. effective_role() feeds require_project_admin, so WP delete, completed- SOP edits and project delete are all judged per project. - Project access is now its own column in the admin console (it was buried among the action buttons, which is why it couldn't be found), showing the project count per account; the dialog sets access plus the role on each project. - The members endpoint reports each person's effective role on that project. Verified: 157 API checks across five suites on clean databases (44 permissions + 22 password reset + 34 search/localization + 39 gates/notifications + 18 new per-project permission checks), 16 drawer-behaviour + 4 pinned-mode UI checks driven in headless Chrome, and probes confirming the team/sign-off pickers populate and no longer corrupt state.team on step navigation. Screenshots reviewed at 1920x1080. Service-worker cache bumped to v3 so browsers pick up the new shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1437 lines
69 KiB
JavaScript
1437 lines
69 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:''},
|
||
teamMembers: [],
|
||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||
wpTypes: [],
|
||
governance: {woformat:'', wosize:'', issuance:[], disciplines:['Mechanical','Electrical','Tech'], discMode:'choice', instanceSuffix:'letter', sizeHoursMax:''},
|
||
quality: {qcreq:'', photo:'', hold:''},
|
||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy', trackingUrl:'', commissioningUrl:''},
|
||
sequence: [],
|
||
constraints: [],
|
||
sources: []
|
||
};
|
||
|
||
let sop = null; // Generated SOP for WP tool
|
||
|
||
// ── LIBRARIES ─────────────────────────────────────────────────────────────────
|
||
const DEFAULT_WP_TYPES = [
|
||
{name:'Rough-In', enabled:true},
|
||
{name:'Mechanical Install', enabled:true},
|
||
{name:'Panel Install', enabled:true},
|
||
{name:'Conduit Install', enabled:true},
|
||
{name:'Tray Install', enabled:true},
|
||
{name:'Mechanical Tubing', enabled:false},
|
||
{name:'Instrument Install', enabled:true},
|
||
{name:'Wire Pull', enabled:true},
|
||
{name:'Terminations', enabled:true},
|
||
{name:'Prefab/Kitting', enabled:false},
|
||
{name:'Network Cabling', enabled:false},
|
||
{name:'Fiber', enabled:false},
|
||
{name:'Calibrations', enabled:false}
|
||
];
|
||
|
||
const STANDARD_10_CONSTRAINTS = [
|
||
{name:'Safety & Permitting', description:'Permits, safety reviews, environmental clearances'},
|
||
{name:'Quality Control / Inspection', description:'QC approval, inspection readiness'},
|
||
{name:'IFC Drawings & Specs', description:'Issued-for-Construction drawings and specifications'},
|
||
{name:'Schedule', description:'Scheduled sequence timing confirmed'},
|
||
{name:'Materials (on site, bagged & tagged)', description:'All required materials on site and staged'},
|
||
{name:'Prefabrication', description:'Prefabricated assemblies complete'},
|
||
{name:'Work Access & Laydown', description:'Work area accessible and staged'},
|
||
{name:'Craft Availability', description:'Required craft trades available'},
|
||
{name:'Construction Equipment & Tools', description:'Special equipment and tools on site'},
|
||
{name:'Scaffolding / Access Equipment', description:'Temporary access structures in place'}
|
||
];
|
||
|
||
const CONSTRAINT_LIBRARY = [
|
||
'Utility Clearances',
|
||
'Third-Party Approvals',
|
||
'Engineering Change Notices',
|
||
'Commissioning Sign-Off',
|
||
'As-Built Documentation',
|
||
'Labeling & Identification',
|
||
'Testing & Certification',
|
||
'Rework Completion',
|
||
'Coordination with Other Trades',
|
||
'Environmental Controls',
|
||
'Critical Path Gate',
|
||
'Client Walkthrough Approval'
|
||
];
|
||
|
||
const OPTIONAL_ROLES = [
|
||
'Assistant Project Manager',
|
||
'General Foreman',
|
||
'Construction Manager',
|
||
'HSE Professional',
|
||
'Quality Representative',
|
||
'Planner',
|
||
'Safety Manager',
|
||
'Project Controls Manager',
|
||
// BIM / VDC roles (used by the BIM template; also selectable on any SOP)
|
||
'BIM Coordinator',
|
||
'BIM Modeler / Detailer',
|
||
'VDC Manager',
|
||
'Construction Lead (CRS)',
|
||
'Field Lead',
|
||
'General Contractor'
|
||
];
|
||
|
||
const LABOR_COST_CODES = [
|
||
'1000|Project Management',
|
||
'2000|Design and Development',
|
||
'2100|Design',
|
||
'2110|Control System Design',
|
||
'2120|Instrument Design',
|
||
'2130|Electrical Design',
|
||
'2140|Panel Design',
|
||
'2141|Panel Design Rework',
|
||
'2150|BIM',
|
||
'2151|BIM Rework',
|
||
'2160|Documentation',
|
||
'2200|Development',
|
||
'2210|PLC Programming',
|
||
'2220|OIT Programming',
|
||
'2230|SCADA Programming',
|
||
'2240|Simulation Development',
|
||
'2300|Customer Training',
|
||
'3000|Operational Technology',
|
||
'3100|OT Design',
|
||
'3200|Rack Assembly',
|
||
'3300|Network Configuration',
|
||
'3400|Computer Configuration',
|
||
'4000|Construction',
|
||
'4010|Instruments Install',
|
||
'4020|Network & Computers Install',
|
||
'4040|PLC Install',
|
||
'4050|Panel Install',
|
||
'4060|Electrical Install',
|
||
'4070|Mechanical Install',
|
||
'4080|Security Install',
|
||
'4090|Radio Install',
|
||
'4100|Commissioning',
|
||
'6000|Production',
|
||
'7000|Quality',
|
||
'7100|Panel Quality Control',
|
||
'7200|Factory Acceptance Testing',
|
||
'7300|Site Acceptance Testing',
|
||
'8000|Safety',
|
||
'9000|Administration'
|
||
];
|
||
|
||
// ── BIM / VDC TEMPLATE ──────────────────────────────────────────────────────────
|
||
// The BIM/VDC department also produces work packages under Advanced Work Packaging —
|
||
// model & engineering deliverables (EWPs) that feed the field's install packages.
|
||
// These defaults are drawn from the EN06 SOP + work instructions and are added by
|
||
// enableBIM() when the "Include BIM / VDC work packages" box is ticked on Step 4
|
||
// (each is flagged bim so the Creator can offer them under the EWP kind). Everything
|
||
// remains editable afterward.
|
||
const BIM_WP_TYPES = [
|
||
'Model Area Package', 'Conduit Routing Package', 'Coordination / Clash Package',
|
||
'First-of-Kind (FOK) Package', '2D Installation Sheet Set', 'Field Detailing / Markup Package',
|
||
'Laser Scan Package', 'As-Built Model / Drawings'
|
||
];
|
||
// BIM "disciplines" are the install phases work is broken into (EN06-SOP §4.1.2.5).
|
||
const BIM_PHASES = [
|
||
'Cable Tray & Hangers', 'Conduits & Hangers', 'Wall & Slab Penetrations',
|
||
'Panels & Instrument Racks', 'In-wall Instruments & Stud-ups'
|
||
];
|
||
const BIM_TEMPLATE_ROLES = [
|
||
'BIM Coordinator', 'BIM Modeler / Detailer', 'VDC Manager',
|
||
'Construction Lead (CRS)', 'Field Lead', 'General Contractor'
|
||
];
|
||
// Release-gate constraints for a BIM/model package (the BIM equivalent of the field's
|
||
// AWP constraints). Citations point back to the EN06 documents.
|
||
const BIM_CONSTRAINTS = [
|
||
{name:'Required Docs Received (IO list, P&IDs, drawings, models, specs)', description:'Project-start inputs available — EN06-SOP §3.1'},
|
||
{name:'Conduit Schedule & Schematic Redlines Received', description:'Hard gate: no conduit modeled without these — EN06-SOP §3'},
|
||
{name:'LOD Defined & Agreed', description:'Level of detail set at kick-off — EN06-G-01'},
|
||
{name:'Field Coordination / Laser Scan Complete', description:'Field walk or scan done — EN06-WI-01 / WI-03'},
|
||
{name:'Clash-Free / Coordinated with GC & Trades', description:'Coordination complete — EN06-SOP §8.1'},
|
||
{name:'Constructability Review (CRS) Signed', description:'Internal construction-lead sign-off before GC — EN06-SOP §8.4'},
|
||
{name:'GC / Trade Sign-Off', description:'GC review and approval — EN06-SOP §8.2'},
|
||
{name:'Issued-For-Fabrication (IFF) Granted', description:'Model approved for field use — EN06-SOP §9.4'}
|
||
];
|
||
const BIM_SEQUENCE = [
|
||
'Kick-off (LOD, schedule, cost code)', 'Project start — gather required docs',
|
||
'Field coordination / laser scan', 'Model racks, instruments & panels',
|
||
'Model conduit (after schedule + redlines)', 'BIM coordination / clash with GC & trades',
|
||
'Constructability review (CRS)', 'GC submission & sign-off (IFF)',
|
||
// BIM deliverable that hands off to the field — only present when BIM is enabled.
|
||
'2D installation sheets / Spool Drawings'
|
||
];
|
||
const BIM_SOURCES = [
|
||
{label:'IO List (Point Matrix DB)', ph:'controls.dev / SharePoint'},
|
||
{label:'P&IDs', ph:'Procore / SharePoint'},
|
||
{label:'Contract / Design Drawings', ph:'Procore / Bluebeam'},
|
||
{label:'Navisworks / Revit Models', ph:'BIM360 / SharePoint'},
|
||
{label:'Specs & Submittals', ph:'client portal'},
|
||
{label:'Conduit Schedule', ph:'Excel on SharePoint'},
|
||
{label:'Bluebeam Project', ph:'Bluebeam Studio'},
|
||
{label:'Pre-Construction Tracker', ph:'SharePoint'},
|
||
{label:'Constructability Review Sheet (CRS)', ph:'SharePoint'}
|
||
];
|
||
|
||
// Toggle BIM/VDC capability on the project. ON augments the SOP with BIM package
|
||
// types + release gates (flagged bim) plus BIM roles/sources/sequence steps, so the
|
||
// project produces both install (IWP) and BIM (EWP) packages. OFF strips the
|
||
// bim-flagged items. Everything stays editable.
|
||
// ── BIM/VDC app switch (admin console → Features) ─────────────────────────────
|
||
// BIM is off until it's ready for the field. With the flag off we hide the
|
||
// per-project toggle so no new project can be put on the BIM path — but we never
|
||
// strip a SOP that already has it on, because that would silently delete its BIM
|
||
// types, gates and sequence steps. Such a SOP just stops offering BIM until the
|
||
// flag comes back.
|
||
function applyBimFlag(){
|
||
const wrap = document.getElementById('bim-toggle-wrap');
|
||
const note = document.getElementById('bim-disabled-note');
|
||
const enabled = (typeof wpBimEnabled === 'function') ? wpBimEnabled() : false;
|
||
if(wrap) wrap.style.display = enabled ? '' : 'none';
|
||
if(note){
|
||
const stale = !enabled && !!state.bimEnabled;
|
||
note.style.display = enabled ? 'none' : '';
|
||
note.innerHTML = stale
|
||
? '<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 from the home page.
|
||
const tab = params.get('tab');
|
||
if(params.get('view') === 'dashboard') switchTool('dashboard');
|
||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||
}
|
||
if(projId && typeof ProjectData!=='undefined' && ProjectData.pullProject){
|
||
ProjectData.pullProject(projId).then(afterPull).catch(afterPull);
|
||
} else {
|
||
afterPull();
|
||
}
|
||
|
||
track('app_open');
|
||
|
||
// Feedback author auto-populates from the signed-in user (auth-guard sets
|
||
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
|
||
setCommenterName();
|
||
document.addEventListener('wp-auth-ready', setCommenterName);
|
||
|
||
let _fieldTimer;
|
||
document.addEventListener('input', e=>{
|
||
const t = e.target;
|
||
if(t && t.id && /^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName)){
|
||
clearTimeout(_fieldTimer);
|
||
_fieldTimer = setTimeout(()=> track('field_edit', {field:t.id}), 600);
|
||
}
|
||
});
|
||
});
|
||
window.addEventListener('beforeunload', trackStepDwell);
|
||
|
||
function initializeWPTypes(){
|
||
state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,notes:'',approval:''}));
|
||
renderWPTypes();
|
||
}
|
||
|
||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
||
// Context-aware: on the SOP tab it loads the sample SOP; on the WP / Dashboard
|
||
// tab it loads the example Work Package inside the embedded creator.
|
||
function loadSampleData(){
|
||
if(currentTool && currentTool !== 'sop'){
|
||
const f = document.getElementById('wp-frame');
|
||
if(f && f.contentWindow && typeof f.contentWindow.loadExample === 'function'){ f.contentWindow.loadExample(); }
|
||
else { alert('Open the Work Package Creation tab first, then load the sample.'); }
|
||
return;
|
||
}
|
||
// Populate Step 1
|
||
document.getElementById('proj_name').value = 'MICRON_PH1_CUP_HPM_FMCS INSTALL';
|
||
document.getElementById('proj_number').value = '26-67-008';
|
||
document.getElementById('proj_client').value = 'Micron Technology, Inc.';
|
||
document.getElementById('proj_division').value = 'Semiconductor';
|
||
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
|
||
|
||
// Populate Step 2. 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:''};
|
||
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
|
||
|
||
// The Micron FMCS sample includes BIM/VDC — enable it so the sequence shows the
|
||
// full BIM → construction flow (BIM steps first) and the Creator offers IWP/EWP.
|
||
state.bimEnabled = true;
|
||
const beEl = document.getElementById('bim_enabled'); if(beEl) beEl.checked = true;
|
||
enableBIM();
|
||
|
||
// Collect all data
|
||
collectStepData();
|
||
track('sample_loaded');
|
||
|
||
alert('✓ Sample data loaded!\n\nNavigate through the SOP steps to see example values. You can edit or replace any field.');
|
||
|
||
// Switch to step 1
|
||
currentStep = 1;
|
||
updateStepUI();
|
||
updateProjectDisplay();
|
||
}
|
||
|
||
// ── RESTORE A COMPLETED SOP (for "Review" + WP tab across reloads) ─────────────
|
||
function restoreSavedSOP(){
|
||
let savedState = null, savedSop = null, complete = false;
|
||
try {
|
||
complete = localStorage.getItem(SK('wp_suite_sop_complete')) === '1';
|
||
savedState = JSON.parse(localStorage.getItem(SK('wp_suite_state')) || 'null');
|
||
savedSop = JSON.parse(localStorage.getItem(SK('wp_suite_sop')) || 'null');
|
||
} catch(e){}
|
||
if(!complete || !savedState) return;
|
||
|
||
state = savedState;
|
||
sop = savedSop;
|
||
sopComplete = true;
|
||
// A SOP saved before the team was account-backed has no teamIds; default them
|
||
// so the pickers render (the stored names show as "(no account)" until linked).
|
||
if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''};
|
||
|
||
// Re-render dynamic lists from restored state.
|
||
renderWPTypes();
|
||
renderTeamMembers();
|
||
renderOptionalRoles();
|
||
renderStandardConstraints();
|
||
renderSequenceSteps();
|
||
renderSources();
|
||
repopulateForm();
|
||
|
||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||
}
|
||
|
||
// Push restored state values back into the static form inputs.
|
||
function repopulateForm(){
|
||
const set = (id,val)=>{ const el=document.getElementById(id); if(el!=null && val!=null) el.value = val; };
|
||
set('proj_name', state.project.name);
|
||
set('proj_number', state.project.number);
|
||
set('proj_client', state.project.client);
|
||
set('proj_division', state.project.division);
|
||
set('proj_site', state.project.site);
|
||
// The 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 ────────────────────────────────────────────────────────────
|
||
function switchTool(tool){
|
||
currentTool = tool;
|
||
// 'dashboard' is a pseudo-tab: it reuses the WP tool's content (the embedded
|
||
// creator) but opens it straight to the dashboard view.
|
||
const isDash = (tool === 'dashboard');
|
||
const contentTool = isDash ? 'wp' : tool;
|
||
|
||
// Update nav tabs
|
||
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
|
||
const tabBtn = document.querySelector(`[data-tab="${tool}"]`);
|
||
if(tabBtn) tabBtn.classList.add('active');
|
||
|
||
// Update content
|
||
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
|
||
document.getElementById(`tool-${contentTool}`).classList.add('active');
|
||
|
||
// Reset step counter
|
||
document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—';
|
||
|
||
if(contentTool === 'wp') renderWPTab(isDash);
|
||
applyEmbedLayout(contentTool === 'wp');
|
||
|
||
updateStepUI();
|
||
updateProjectDisplay();
|
||
}
|
||
|
||
// The embedded creator/dashboard fills the window below the app chrome, so there
|
||
// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling
|
||
// page — and the creator's sticky bars have a real viewport to stick to.
|
||
function applyEmbedLayout(on){
|
||
const area = document.querySelector('.content-area');
|
||
const frame = document.getElementById('wp-frame');
|
||
if(area) area.classList.toggle('embed-full', !!on);
|
||
if(frame) frame.classList.toggle('fill', !!on);
|
||
document.body.classList.toggle('embed-full', !!on);
|
||
if(on) measureChrome();
|
||
}
|
||
|
||
// Height of the app bar + tab strip, so the iframe can be exactly the rest.
|
||
function measureChrome(){
|
||
const hdr = document.querySelector('.header');
|
||
const nav = document.querySelector('.main-nav');
|
||
const h = (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48);
|
||
document.documentElement.style.setProperty('--wp-chrome-h', h + 'px');
|
||
}
|
||
window.addEventListener('resize', () => { if(document.body.classList.contains('embed-full')) measureChrome(); }, {passive:true});
|
||
|
||
// Show the gate or the embedded Work Package Creator depending on SOP status.
|
||
// wantDash=true opens the creator straight to the dashboard view.
|
||
function renderWPTab(wantDash){
|
||
const gate = document.getElementById('wp-gate');
|
||
const frame = document.getElementById('wp-frame');
|
||
if(!gate || !frame) return;
|
||
if(sopComplete){
|
||
gate.style.display = 'none';
|
||
frame.style.display = 'block';
|
||
// Reload each time so the creator picks up the latest SOP from localStorage.
|
||
const sp = new URLSearchParams(window.location.search);
|
||
const dash = wantDash || sp.get('view') === 'dashboard';
|
||
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
|
||
frame.src = 'wp-creation-index.html?embedded=1' + (dash ? '&view=dashboard' : '')
|
||
+ (projId ? '&project=' + encodeURIComponent(projId) : '') + '&t=' + Date.now();
|
||
}else{
|
||
gate.style.display = 'block';
|
||
frame.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Called from completeSOP / restoreSavedSOP once an SOP is available.
|
||
function onSOPReady(){
|
||
if(currentTool === 'wp') renderWPTab();
|
||
}
|
||
|
||
// Active project comes from the home page (?project=<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); };
|
||
});
|
||
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';
|
||
}
|
||
}
|
||
|
||
// 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('${c.name.replace(/'/g,"\\'")}')" 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)){ alert('That constraint is already in the list.'); return; }
|
||
state.constraints.push({name, description:''});
|
||
if(inp) inp.value='';
|
||
renderStandardConstraints();
|
||
}
|
||
|
||
// Default construction flow (used when BIM is off; the BIM steps are prepended when
|
||
// the project includes BIM/VDC). Includes QC-hold gates. Items may be strings or
|
||
// {label, kind} objects.
|
||
const DEFAULT_SEQUENCE = [
|
||
{label:'Conduit Install', kind:'step'},
|
||
{label:'Tray Install', kind:'step'},
|
||
{label:'QC Hold', kind:'gate'},
|
||
{label:'Wire Pull', kind:'step'},
|
||
{label:'Device Install', kind:'step'},
|
||
{label:'Termination', kind:'step'},
|
||
{label:'QC Hold', kind:'gate'},
|
||
{label:'Commissioning', kind:'step'},
|
||
{label:'As-built (scan / redlines)', kind:'step'}
|
||
];
|
||
|
||
let seqDragIndex = null;
|
||
function renderSequenceSteps(){
|
||
const container = document.getElementById('sequence-list');
|
||
if(!container) return;
|
||
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=> typeof s==='string' ? {label:s,kind:'step'} : {label:s.label, kind:s.kind||'step'});
|
||
container.innerHTML = '';
|
||
let stepNo = 0;
|
||
state.sequence.forEach((item,i)=>{
|
||
const isGate = item.kind==='gate';
|
||
if(!isGate) stepNo++;
|
||
const row = document.createElement('div');
|
||
row.className = 'seq-step' + (isGate?' gate':'');
|
||
row.draggable = true;
|
||
row.dataset.idx = i;
|
||
const badge = isGate ? `<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,'"'); }
|
||
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 NAVIGATION ────────────────────────────────────────────────────────────
|
||
function goToStep(n){
|
||
if(!validateStep(currentStep)) return;
|
||
currentStep = n;
|
||
updateStepUI();
|
||
}
|
||
|
||
function nextStep(){
|
||
if(!validateStep(currentStep)) return;
|
||
if(currentStep < 10){
|
||
currentStep++;
|
||
updateStepUI();
|
||
}
|
||
}
|
||
|
||
function previousStep(){
|
||
if(currentStep > 1){
|
||
currentStep--;
|
||
updateStepUI();
|
||
}
|
||
}
|
||
|
||
function updateStepUI(){
|
||
trackStepDwell();
|
||
track('step_view', {step: currentStep});
|
||
// SOP steps
|
||
document.querySelectorAll('[id^="sop-step-"]').forEach(s=>s.style.display='none');
|
||
document.getElementById(`sop-step-${currentStep}`)?.style.display && (document.getElementById(`sop-step-${currentStep}`).style.display='block');
|
||
|
||
// Step indicators
|
||
document.querySelectorAll('.step-item').forEach(s=>s.classList.remove('active'));
|
||
document.querySelector(`[data-step="${currentStep}"]`)?.classList.add('active');
|
||
|
||
// Update counter
|
||
document.getElementById('current-step').textContent = currentStep;
|
||
|
||
// Update buttons
|
||
document.getElementById('sop-prev-btn').disabled = currentStep === 1;
|
||
document.getElementById('sop-next-btn').style.display = currentStep < 10 ? 'block' : 'none';
|
||
document.getElementById('sop-complete-btn').style.display = currentStep === 10 ? 'block' : 'none';
|
||
|
||
// Collect form data
|
||
collectStepData();
|
||
}
|
||
|
||
function collectStepData(){
|
||
switch(currentStep){
|
||
case 1:
|
||
state.project.name = document.getElementById('proj_name').value;
|
||
state.project.number = document.getElementById('proj_number').value;
|
||
state.project.client = document.getElementById('proj_client').value;
|
||
state.project.division = document.getElementById('proj_division').value;
|
||
state.project.site = document.getElementById('proj_site').value;
|
||
break;
|
||
case 2:
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
function validateStep(n){
|
||
collectStepData();
|
||
const proj = state.project;
|
||
|
||
if(n===1 && (!proj.name || !proj.number || !proj.client || !proj.division || !proj.site)){
|
||
alert('Please complete all required fields: Project Name, Number, Client, Division, and Site Location.');
|
||
return false;
|
||
}
|
||
if(n===5 && !state.governance.woformat){
|
||
alert('Please enter a Work Package Number Format.');
|
||
return false;
|
||
}
|
||
if(n===6 && !state.quality.qcreq){
|
||
alert('Please select a QC requirement.');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ── SOP COMPLETION ────────────────────────────────────────────────────────────
|
||
// Re-saving a SOP that is already complete changes the project's baseline, which
|
||
// the server restricts to a Project Admin. Check before doing the work so the
|
||
// answer is a clear message rather than a 403 from the sync outbox.
|
||
function canEditCompletedSOP(){
|
||
return (typeof wpCanEditCompletedSOP === 'function') ? wpCanEditCompletedSOP() : true;
|
||
}
|
||
|
||
function completeSOP(){
|
||
if(sopComplete && !canEditCompletedSOP()){
|
||
alert('This project\'s SOP is already complete, and changing it needs the Project Admin role.\n\n' +
|
||
'Ask a project admin to make the change — the SOP is the baseline every work package inherits.');
|
||
return;
|
||
}
|
||
if(!validateStep(10)) return;
|
||
collectStepData();
|
||
|
||
sop = {
|
||
meta: {tool:'Work Package Configuration', sample:false},
|
||
bimEnabled: !!state.bimEnabled, // project also produces BIM (EWP) packages → Creator offers per-package IWP/EWP kind
|
||
project: {
|
||
name: state.project.name,
|
||
number: state.project.number,
|
||
client: state.project.client,
|
||
division: state.project.division,
|
||
pm: state.team.pm,
|
||
apm: state.team.apm,
|
||
cm: state.team.cm,
|
||
qm: state.team.qm,
|
||
// User-account ids for the same four people. These are what the Creator
|
||
// uses to offer an owner and what notification routing needs — a display
|
||
// name alone can't be assigned work or emailed.
|
||
pmId: state.teamIds.pm || '',
|
||
apmId: state.teamIds.apm || '',
|
||
cmId: state.teamIds.cm || '',
|
||
qmId: state.teamIds.qm || '',
|
||
site: state.project.site,
|
||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
|
||
},
|
||
roles: state.signoffRoles.filter(r=>r.role).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),
|
||
quality: {
|
||
qcReq: state.quality.qcreq,
|
||
photo: state.quality.photo,
|
||
holdPoints: state.quality.hold,
|
||
cxTool: state.platforms.commissioning
|
||
},
|
||
sequence: state.sequence.filter(s=>s.label).map(s=>({
|
||
label: s.label,
|
||
kind: s.kind || 'step'
|
||
})),
|
||
costCodes: LABOR_COST_CODES,
|
||
constraints: state.constraints.map(c=>({
|
||
name: c.name, description: c.description || '', bim: !!c.bim,
|
||
// Critical → reopening after release is emailed, not just flagged on the package.
|
||
critical: !!c.critical
|
||
}))
|
||
};
|
||
|
||
sopComplete = true;
|
||
// Stamp the active project onto the SOP so it's unambiguously tied to it.
|
||
try { if(typeof ProjectData!=='undefined' && ProjectData.getActiveId()) sop.projectId = ProjectData.getActiveId(); } catch(e){}
|
||
updateProjectDisplay();
|
||
|
||
// Persist for the home page (green / "Review") and for the WP Creator tab,
|
||
// namespaced to the active project so each project keeps its own SOP.
|
||
try {
|
||
localStorage.setItem(SK('wp_suite_sop'), JSON.stringify(sop));
|
||
localStorage.setItem(SK('wp_suite_state'), JSON.stringify(state));
|
||
localStorage.setItem(SK('wp_suite_sop_complete'), '1');
|
||
} catch(e){}
|
||
|
||
// Share the SOP to the server so every user of this project gets it.
|
||
try {
|
||
const pid = (typeof ProjectData!=='undefined' && ProjectData.getActiveId && ProjectData.getActiveId()) || sop.projectId || '';
|
||
if(pid && ProjectData.pushSOP) ProjectData.pushSOP(pid, sop, state);
|
||
} catch(e){}
|
||
|
||
track('sop_generated', {woTypes: sop.woTypes.length, constraints: sop.constraints.length});
|
||
|
||
// Hand the SOP to the embedded Work Package Creator and unlock its tab (in case
|
||
// the user stays), then return to the project home page per the requested flow.
|
||
if(typeof onSOPReady === 'function') onSOPReady(sop);
|
||
|
||
alert('✓ SOP Configuration Complete!\n\nReturning to the project home page.');
|
||
window.location.href = 'index.html';
|
||
}
|
||
|
||
// ── COMMENTS ──────────────────────────────────────────────────────────────────
|
||
function toggleComments(){
|
||
const panel = document.getElementById('comments-panel');
|
||
panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
|
||
if(panel.style.display === 'block') loadStepComments();
|
||
}
|
||
|
||
function currentUserName(){
|
||
const u = window.WP_USER;
|
||
return (u && (u.full_name || u.username)) || '';
|
||
}
|
||
function setCommenterName(){
|
||
const el = document.getElementById('commenter-name');
|
||
if(el) el.value = currentUserName();
|
||
}
|
||
|
||
function submitComment(){
|
||
const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
|
||
const text = document.getElementById('comment-text').value.trim();
|
||
|
||
if(!text){ alert('Please enter a comment.'); return; }
|
||
|
||
const comment = {
|
||
step: currentStep,
|
||
name: name,
|
||
text: text,
|
||
timestamp: new Date().toLocaleString()
|
||
};
|
||
|
||
allComments.push(comment);
|
||
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
|
||
if(window.postFeedback) window.postFeedback({type:'sop_step_comment', ...comment});
|
||
|
||
document.getElementById('comment-text').value = '';
|
||
loadStepComments();
|
||
}
|
||
|
||
function exportComments(){
|
||
const saved = localStorage.getItem('wp_suite_comments');
|
||
const data = saved ? JSON.parse(saved) : [];
|
||
if(!data.length){ alert('No comments to export yet.'); return; }
|
||
const payload = {app:'Work Package Suite', source:'sop', exportedAt:new Date().toISOString(), comments:data};
|
||
const blob = new Blob([JSON.stringify(payload,null,2)], {type:'application/json'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'wp-suite-comments-sop-' + new Date().toISOString().slice(0,10) + '.json';
|
||
a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
|
||
function importComments(ev){
|
||
const f = ev.target.files && ev.target.files[0];
|
||
if(!f) return;
|
||
const r = new FileReader();
|
||
r.onload = ()=>{
|
||
try{
|
||
const inc = JSON.parse(r.result);
|
||
const incoming = Array.isArray(inc) ? inc : (inc.comments || []);
|
||
if(!incoming.length){ alert('No comments found in that file.'); return; }
|
||
const saved = localStorage.getItem('wp_suite_comments');
|
||
allComments = saved ? JSON.parse(saved) : [];
|
||
const seen = new Set(allComments.map(c=>c.step+'|'+c.timestamp+'|'+c.text));
|
||
let added = 0;
|
||
incoming.forEach(c=>{ const k=c.step+'|'+c.timestamp+'|'+c.text; if(c.text && !seen.has(k)){ allComments.push(c); seen.add(k); added++; }});
|
||
localStorage.setItem('wp_suite_comments', JSON.stringify(allComments));
|
||
loadStepComments();
|
||
alert('Imported ' + added + ' comment' + (added===1?'':'s') + '.');
|
||
}catch(e){ alert('Could not read that file.'); }
|
||
ev.target.value = '';
|
||
};
|
||
r.readAsText(f);
|
||
}
|
||
|
||
function loadStepComments(){
|
||
const saved = localStorage.getItem('wp_suite_comments');
|
||
if(saved) allComments = JSON.parse(saved);
|
||
|
||
const stepComments = allComments.filter(c=>c.step===currentStep);
|
||
const list = document.getElementById('comments-list');
|
||
|
||
if(stepComments.length === 0){
|
||
list.innerHTML = '<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.
|
||
const ANALYTICS_KEY = 'wp_suite_analytics_v1';
|
||
let _stepEnter = Date.now();
|
||
const _session = 's_' + Date.now().toString(36) + Math.random().toString(36).slice(2,6);
|
||
|
||
function analyticsLoad(){
|
||
try { return JSON.parse(localStorage.getItem(ANALYTICS_KEY)) || {events:[]}; }
|
||
catch(e){ return {events:[]}; }
|
||
}
|
||
function analyticsSave(data){
|
||
try { localStorage.setItem(ANALYTICS_KEY, JSON.stringify(data)); }
|
||
catch(e){ /* storage unavailable — degrade silently */ }
|
||
}
|
||
function track(event, detail){
|
||
try {
|
||
const data = analyticsLoad();
|
||
data.events.push({ ts: new Date().toISOString(), session: _session, event, detail: detail||null });
|
||
if(data.events.length > 5000) data.events = data.events.slice(-5000);
|
||
analyticsSave(data);
|
||
} catch(e){}
|
||
}
|
||
function trackStepDwell(){
|
||
const ms = Date.now() - _stepEnter;
|
||
if(ms > 400 && ms < 1000*60*60) track('step_dwell', {step: currentStep, ms});
|
||
_stepEnter = Date.now();
|
||
}
|
||
function analyticsSummary(){
|
||
const data = analyticsLoad();
|
||
const byEvent = {}, byStep = {}, dwell = {}, sessions = new Set();
|
||
data.events.forEach(e=>{
|
||
byEvent[e.event] = (byEvent[e.event]||0)+1;
|
||
sessions.add(e.session);
|
||
if(e.event==='step_view' && e.detail) byStep[e.detail.step]=(byStep[e.detail.step]||0)+1;
|
||
if(e.event==='step_dwell' && e.detail){ dwell[e.detail.step]=(dwell[e.detail.step]||0)+e.detail.ms; }
|
||
});
|
||
return {total:data.events.length, sessions:sessions.size, byEvent, byStep, dwell, first:data.events[0]?.ts, last:data.events[data.events.length-1]?.ts};
|
||
}
|
||
function showAnalytics(){
|
||
const s = analyticsSummary();
|
||
const fmtMin = ms => (ms/60000).toFixed(1)+' min';
|
||
let txt = `USAGE LOGS\n\nSessions: ${s.sessions} Events: ${s.total}\nRange: ${s.first?new Date(s.first).toLocaleString():'—'} → ${s.last?new Date(s.last).toLocaleString():'—'}\n\nStep views:\n`;
|
||
for(let i=1;i<=10;i++) txt += ` Step ${i}: ${s.byStep[i]||0} views` + (s.dwell[i]?`, ${fmtMin(s.dwell[i])} total`:'') + `\n`;
|
||
txt += `\nActions:\n`;
|
||
Object.keys(s.byEvent).filter(k=>!['step_view','step_dwell','field_edit'].includes(k)).forEach(k=> txt += ` ${k}: ${s.byEvent[k]}\n`);
|
||
txt += ` field edits: ${s.byEvent['field_edit']||0}\n`;
|
||
txt += `\nDownload full event log as JSON?`;
|
||
if(confirm(txt)) downloadAnalytics();
|
||
}
|
||
function downloadAnalytics(){
|
||
const data = analyticsLoad();
|
||
const blob = new Blob([JSON.stringify(data,null,2)], {type:'application/json'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
track('analytics_exported');
|
||
}
|