- API smoke test (server/smoketest.py): stdlib end-to-end check of health, projects, SOPs, WPs, the AWP issue gate (409 → 200), status, metrics, comments, and cascade delete. Referenced from DEPLOYMENT.md. SOP config: - Constraints: fix custom constraints never appearing — renderStandardConstraints no longer clobbers state.constraints; customs render in their own list with remove buttons; modal gains a free-text "Add" field. - Sources: add column headers (Data Type / Location-Platform / URL / Notes); preset data types are now fixed labels, "Add Source" creates an editable custom row. - Issuance strategy: add a tooltip + worked examples for each option. - Remove the "Comment submitted" acknowledgement popup (home + suite); keep the commenter name between comments. WP creator: - Clearing the last open constraint now offers to mark the package Issued and scrolls to the status control. - Form sections are collapsible (click a section heading to fold it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
993 lines
45 KiB
JavaScript
993 lines
45 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 = {
|
||
project: {name:'', number:'', client:'', division:'', site:''},
|
||
team: {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'},
|
||
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'
|
||
];
|
||
|
||
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'
|
||
];
|
||
|
||
// ── 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);
|
||
applyProjectContext(params.get('project'));
|
||
restoreSavedSOP();
|
||
updateStepUI();
|
||
updateProjectDisplay();
|
||
|
||
// 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);
|
||
|
||
track('app_open');
|
||
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
|
||
document.getElementById('proj_pm').value = 'Mariano Sanchez';
|
||
document.getElementById('proj_apm').value = 'Assistant PM';
|
||
document.getElementById('proj_cm').value = 'K. Boyd';
|
||
document.getElementById('proj_qm').value = 'D. Nguyen';
|
||
|
||
// Step 3 already has defaults
|
||
document.getElementById('role_super_name').value = 'John Smith';
|
||
document.getElementById('role_foreman_name').value = 'Mike Jones';
|
||
|
||
// 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
|
||
|
||
// 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;
|
||
|
||
// 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);
|
||
set('proj_pm', state.team.pm);
|
||
set('proj_apm', state.team.apm);
|
||
set('proj_cm', state.team.cm);
|
||
set('proj_qm', state.team.qm);
|
||
if(state.signoffRoles[0]) set('role_super_name', state.signoffRoles[0].name);
|
||
if(state.signoffRoles[1]) set('role_foreman_name', state.signoffRoles[1].name);
|
||
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);
|
||
}
|
||
|
||
// ── 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);
|
||
|
||
updateStepUI();
|
||
updateProjectDisplay();
|
||
}
|
||
|
||
// 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>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="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();
|
||
}
|
||
|
||
function renderTeamMembers(){
|
||
const container = document.getElementById('team-members-list');
|
||
if(!container) return;
|
||
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);">
|
||
<input type="text" placeholder="Role / title (e.g., Scheduler)" value="${(m.role||'').replace(/"/g,'"')}" onchange="state.teamMembers[${i}].role=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
|
||
<input type="text" placeholder="Name" value="${(m.name||'').replace(/"/g,'"')}" onchange="state.teamMembers[${i}].name=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>
|
||
`).join('');
|
||
}
|
||
|
||
function addTeamMember(){
|
||
state.teamMembers.push({role:'',name:''});
|
||
renderTeamMembers();
|
||
}
|
||
|
||
function removeTeamMember(i){
|
||
state.teamMembers.splice(i,1);
|
||
renderTeamMembers();
|
||
}
|
||
|
||
function renderOptionalRoles(){
|
||
const container = document.getElementById('optional-roles-list');
|
||
const current = state.signoffRoles.filter(r=>r.role!=='Superintendent'&&r.role!=='Foreman');
|
||
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>
|
||
<input type="text" placeholder="Name (optional)" value="${r.name||''}" onchange="state.signoffRoles[${state.signoffRoles.indexOf(r)}].name=this.value">
|
||
<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);
|
||
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>
|
||
</div>
|
||
`).join('');
|
||
renderCustomConstraints();
|
||
}
|
||
|
||
// 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>
|
||
<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>
|
||
</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 state.constraints.push(STANDARD_10_CONSTRAINTS.find(c=>c.name===name));
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
const DEFAULT_SEQUENCE = ['Layout','Conduit Install','Tray Install','Wire Pull','Device Install','Termination','QC Inspection','Commissioning'];
|
||
|
||
let seqDragIndex = null;
|
||
function renderSequenceSteps(){
|
||
const container = document.getElementById('sequence-list');
|
||
if(!container) return;
|
||
if(!state.sequence.length) state.sequence = DEFAULT_SEQUENCE.map(s=>({label: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:
|
||
state.team.pm = document.getElementById('proj_pm').value;
|
||
state.team.apm = document.getElementById('proj_apm').value;
|
||
state.team.cm = document.getElementById('proj_cm').value;
|
||
state.team.qm = document.getElementById('proj_qm').value;
|
||
break;
|
||
case 3:
|
||
state.signoffRoles[0].name = document.getElementById('role_super_name').value;
|
||
state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
|
||
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;
|
||
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 ────────────────────────────────────────────────────────────
|
||
function completeSOP(){
|
||
if(!validateStep(10)) return;
|
||
collectStepData();
|
||
|
||
sop = {
|
||
meta: {tool:'Work Package Configuration', sample:false},
|
||
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,
|
||
site: state.project.site,
|
||
teamMembers: state.teamMembers.filter(m=>(m.role||m.name))
|
||
},
|
||
roles: state.signoffRoles.filter(r=>r.role),
|
||
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 || ''
|
||
})),
|
||
sources: state.sources.filter(s=>s.label),
|
||
field: {trackPlatform: state.platforms.tracking},
|
||
commissioning: {tool: state.platforms.commissioning},
|
||
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 || ''}))
|
||
};
|
||
|
||
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){}
|
||
|
||
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 submitComment(){
|
||
const name = document.getElementById('commenter-name').value || '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>${c.name}</strong> • ${c.timestamp}</div>
|
||
<div style="font-size:12px; color:var(--text);">${c.text.replace(/</g,'<').replace(/>/g,'>')}</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');
|
||
}
|