Files
Project-SDE-WP-Suite/html/work-package-suite-app.js
n.siegfried a18ae487f6 Fix SOP→home flow, stop sample-data fallback for real projects, add custom WP types
- SOP Complete now returns to the project home page after the confirmation
  popup (instead of staying on the SOP tab), and stamps projectId onto the SOP.
- WP creator no longer substitutes the Micron SAMPLE_SOP when a project is
  active but its SOP isn't found — it shows a "complete the SOP first" empty
  state instead. Sample is only used for a standalone (no-project) preview.
  This was the source of "loaded with sample data I didn't select."
- SOP WP Types step gains "+ Add Custom Type" (editable name + remove);
  blank-named custom types are dropped from the generated SOP. Custom types
  round-trip via the saved state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:29:14 -07:00

937 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// ── GLOBAL STATE ──────────────────────────────────────────────────────────────
let currentTool = 'sop';
let currentStep = 1;
let sopComplete = false;
let allComments = [];
// Per-project storage key: 'wp_suite_sop' → 'wp_suite_sop__<projId>' when a
// project is active. Keeps each project's SOP separate in the browser.
function SK(base){ try { return (typeof ProjectData !== 'undefined' && ProjectData.key) ? ProjectData.key(base) : base; } catch(e){ return base; } }
// WP size presets — the dropdown label maps to a default split-threshold (max
// labor hours). The label is exported as governance.woSize (human-readable
// guidance); the number drives the Creator's "consider splitting" warning.
const WP_SIZE_PRESETS = {
'Small — 12 days (≈824 hrs)': 24,
'Standard — 35 days (≈4080 hrs)': 80,
'Large — 12 weeks (≈80160 hrs)': 160
};
function onSizePresetChange(){
const label = document.getElementById('gov_wosize').value;
const max = WP_SIZE_PRESETS[label];
if(max != null){ document.getElementById('gov_size_hours_max').value = max; }
// 'Custom…' / '' leave the threshold for manual entry.
}
let state = {
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('wp');
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 ──────────────────────────────────────────────────────────
function loadSampleData(){
// 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 — 35 days (≈4080 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;
// Update nav tabs
document.querySelectorAll('.nav-tab').forEach(t=>t.classList.remove('active'));
document.querySelector(`[data-tab="${tool}"]`).classList.add('active');
// Update content
document.querySelectorAll('.tool').forEach(t=>t.classList.remove('active'));
document.getElementById(`tool-${tool}`).classList.add('active');
// Reset step counter
if(tool === 'sop'){
document.getElementById('total-steps').textContent = '10';
}else{
document.getElementById('total-steps').textContent = '—';
}
if(tool === 'wp') renderWPTab();
updateStepUI();
updateProjectDisplay();
}
// Show the gate or the embedded Work Package Creator depending on SOP status.
function renderWPTab(){
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 wantDash = sp.get('view') === 'dashboard';
const projId = sp.get('project') || (activeProject && activeProject.id) || '';
frame.src = 'wp-creation-index.html?embedded=1' + (wantDash ? '&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,'&quot;')}" onchange="state.wpTypes[${i}].name=this.value" style="flex:1; padding:0.5rem; border:1px solid var(--border); border-radius:4px; font-weight:600;">
<button onclick="removeWPType(${i})" title="Remove custom type" style="background:var(--danger); color:#fff; border:none; border-radius:4px; width:28px; height:28px; cursor:pointer; font-weight:600; flex:none;">✕</button>
</div>`
: `<div style="font-weight:600;">${t.name}</div>`;
row.innerHTML = `
${nameCell}
<div style="text-align:center;"><input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})" style="width:18px; height:18px; cursor:pointer;"></div>
<input type="text" placeholder="Special rules…" value="${(t.notes||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].notes=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<input type="text" placeholder="PM / CM / QC…" value="${(t.approval||'').replace(/"/g,'&quot;')}" onchange="state.wpTypes[${i}].approval=this.value" style="padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
`;
container.appendChild(row);
});
const addRow = document.createElement('div');
addRow.style.cssText = 'margin-top:0.85rem;';
addRow.innerHTML = `<button onclick="addCustomWPType()" style="background:var(--primary,#0f62fe); color:#fff; border:none; padding:0.55rem 1rem; border-radius:4px; font-weight:600; cursor:pointer; font-size:13px;">+ Add Custom Type</button>`;
container.appendChild(addRow);
}
function toggleWPType(i){
state.wpTypes[i].enabled = !state.wpTypes[i].enabled;
renderWPTypes();
}
function addCustomWPType(){
state.wpTypes.push({name:'', enabled:true, notes:'', approval:'', custom:true});
renderWPTypes();
// Focus the new custom row's name input.
const rows = document.querySelectorAll('#wp-types-table .wp-type-row');
const last = rows[rows.length-1];
const nameInput = last && last.querySelector('input[type="text"]');
if(nameInput) nameInput.focus();
}
function removeWPType(i){
state.wpTypes.splice(i,1);
renderWPTypes();
}
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,'&quot;')}" 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,'&quot;')}" 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();
}
function renderStandardConstraints(){
const container = document.getElementById('standard-constraints');
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}" 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('');
state.constraints = STANDARD_10_CONSTRAINTS.map(c=>({...c}));
}
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(!state.constraints.find(c=>c.name===name)){
state.constraints.push({name,description:''});
}
closeConstraintModal();
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,'&quot;')}" oninput="state.sequence[${i}].label=this.value">
<button class="seq-del" title="Remove" onclick="removeSeqStep(${i})">✕</button>`;
row.addEventListener('dragstart',e=>{ seqDragIndex=i; row.classList.add('dragging'); e.dataTransfer.effectAllowed='move'; });
row.addEventListener('dragend',()=>{ row.classList.remove('dragging'); document.querySelectorAll('.seq-step').forEach(s=>s.classList.remove('drag-over')); });
row.addEventListener('dragover',e=>{ e.preventDefault(); row.classList.add('drag-over'); e.dataTransfer.dropEffect='move'; });
row.addEventListener('dragleave',()=>row.classList.remove('drag-over'));
row.addEventListener('drop',e=>{
e.preventDefault(); row.classList.remove('drag-over');
const from=seqDragIndex, to=i;
if(from===null||from===to) return;
const moved=state.sequence.splice(from,1)[0];
state.sequence.splice(to,0,moved);
seqDragIndex=null; renderSequenceSteps();
});
container.appendChild(row);
if(i<state.sequence.length-1){ const a=document.createElement('div'); a.className='seq-arrow'; a.textContent='↓'; container.appendChild(a); }
});
}
function addSequenceStep(){
const inp = document.getElementById('seq-add-input');
const v = (inp && inp.value || '').trim();
state.sequence.push({label: v || 'New Step', kind:'step'});
if(inp) inp.value = '';
renderSequenceSteps();
}
function addSequenceGate(){
state.sequence.push({label:'QC Hold', kind:'gate'});
renderSequenceSteps();
}
function removeSeqStep(i){
state.sequence.splice(i,1);
renderSequenceSteps();
}
const DEFAULT_SOURCES = [
{label:'Design Drawings', ph:'e.g. Procore, Bluebeam'},
{label:'Spool Drawings', ph:'e.g. SharePoint, BIM'},
{label:'Installation Detail Drawings', ph:'e.g. Prime details + Micron MVDs'},
{label:'IO List', ph:'e.g. controls.dev, Excel'},
{label:'Cable Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Conduit Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Tray Schedule', ph:'e.g. Excel on SharePoint'},
{label:'Datasheets', ph:'e.g. Procore'},
{label:'Specifications', ph:'e.g. client portal'},
{label:'Asset Register', ph:'e.g. CxAlloy, Excel'},
{label:'RFIs', ph:'e.g. Procore RFI log'},
{label:'Safety Documentation', ph:'e.g. site safety binder'}
];
// Escape a value for safe use inside a double-quoted HTML attribute.
// SharePoint "Copy Link" URLs contain & (and labels/notes may contain & " < >),
// so attribute values must be escaped or a re-render corrupts the field.
function escAttr(v){ return String(v==null?'':v).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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}));
container.innerHTML = state.sources.map((s,i)=>`
<div style="display:grid; grid-template-columns:150px 150px 250px 150px 30px; gap:1rem; align-items:center; padding:1rem; background:var(--bg); border-radius:6px; margin-bottom:0.5rem; border:1px solid var(--border);">
<input type="text" value="${escAttr(s.label)}" placeholder="Label" onchange="state.sources[${i}].label=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<input type="text" value="${escAttr(s.system)}" placeholder="${escAttr(s.ph||'System of record')}" onchange="state.sources[${i}].system=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<input type="text" value="${escAttr(s.link)}" placeholder="Paste SharePoint 'Copy Link' URL" onchange="state.sources[${i}].link=this.value" style="padding:0.5rem; font-size:12px; border:1px solid var(--border); border-radius:4px;">
<input type="text" value="${escAttr(s.notes)}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value" style="padding:0.5rem; font-size:12px; 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="state.sources.splice(${i},1); renderSources()">✕</button>
</div>
`).join('');
}
function addSource(){
state.sources.push({label:'',system:'',notes:'',link:''});
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 = '';
document.getElementById('commenter-name').value = '';
loadStepComments();
alert('✓ Comment submitted!');
}
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,'&lt;').replace(/>/g,'&gt;')}</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');
}