588 lines
22 KiB
JavaScript
588 lines
22 KiB
JavaScript
// ── STATE ────────────────────────────────────────────────────────────────────
|
||
let currentStep = 1;
|
||
let allComments = []; // Track all comments for export
|
||
let state = {
|
||
project: {name:'', number:'', client:'', division:'', site:''},
|
||
team: {pm:'', cm:'', qm:''},
|
||
signoffRoles: [{role:'Superintendent',name:''},{role:'Foreman',name:''}],
|
||
wpTypes: [],
|
||
governance: {woformat:'', wosize:'', issuance:[]},
|
||
quality: {qcreq:'', photo:'', hold:''},
|
||
platforms: {tracking:'CxAlloy', commissioning:'CxAlloy'},
|
||
sequence: [],
|
||
constraints: [],
|
||
sources: []
|
||
};
|
||
|
||
// ── LIBRARIES ────────────────────────────────────────────────────────────────
|
||
|
||
const DISCIPLINES = [
|
||
'Conduit Installation',
|
||
'Cable Tray Installation',
|
||
'Instrument Raceway Installation',
|
||
'Wire / Cable Pull',
|
||
'Electrical/Instrument Cable Installation',
|
||
'Cable Terminations',
|
||
'Electrical Junction Box Installation',
|
||
'Instrument Installation',
|
||
'Control Panel Installation',
|
||
'Heat Tracing Installation'
|
||
];
|
||
|
||
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 TYPE_DISCIPLINE_MAP = {
|
||
'Rough-In':'Conduit Installation',
|
||
'Mechanical Install':'Heat Tracing Installation',
|
||
'Panel Install':'Control Panel Installation',
|
||
'Conduit Install':'Conduit Installation',
|
||
'Tray Install':'Cable Tray Installation',
|
||
'Mechanical Tubing':'Heat Tracing Installation',
|
||
'Instrument Install':'Instrument Installation',
|
||
'Wire Pull':'Wire / Cable Pull',
|
||
'Terminations':'Cable Terminations',
|
||
'Prefab/Kitting':'Electrical Junction Box Installation',
|
||
'Network Cabling':'Cable Tray Installation',
|
||
'Fiber':'Wire / Cable Pull',
|
||
'Calibrations':'Instrument Installation'
|
||
};
|
||
|
||
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 = [
|
||
'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();
|
||
renderOptionalRoles();
|
||
renderStandardConstraints();
|
||
renderSequenceSteps();
|
||
renderSources();
|
||
updateStepUI();
|
||
});
|
||
|
||
function initializeWPTypes(){
|
||
state.wpTypes = JSON.parse(JSON.stringify(DEFAULT_WP_TYPES)).map(t=>({...t,discipline:TYPE_DISCIPLINE_MAP[t.name]||''}));
|
||
renderWPTypes();
|
||
}
|
||
|
||
// ── LOAD SAMPLE DATA ──────────────────────────────────────────────────────────
|
||
function loadSampleData(){
|
||
// Populate Step 1
|
||
document.getElementById('proj_name').value = 'Micron — INC Construction Work Packages';
|
||
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 = 'Nick Siegfried';
|
||
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]-[Discipline]-[TYPE]';
|
||
document.getElementById('gov_wosize').value = '3–5 days / 40–80 hours';
|
||
|
||
// 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();
|
||
|
||
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();
|
||
}
|
||
|
||
// ── RENDERING ─────────────────────────────────────────────────────────────────
|
||
function renderWPTypes(){
|
||
const container = document.getElementById('wp-types-table');
|
||
container.innerHTML = `<div style="padding:0.5rem; font-size:12px; font-weight:600; display:grid; grid-template-columns:30px 200px 200px; gap:1rem; margin-bottom:1rem; border-bottom:1px solid var(--border);">
|
||
<div></div><div>Type</div><div>Discipline</div>
|
||
</div>`;
|
||
state.wpTypes.forEach((t,i)=>{
|
||
const row = document.createElement('div');
|
||
row.className = 'wp-type-row';
|
||
row.innerHTML = `
|
||
<input type="checkbox" ${t.enabled?'checked':''} onchange="toggleWPType(${i})">
|
||
<div>${t.name}</div>
|
||
<select onchange="state.wpTypes[${i}].discipline=this.value">
|
||
${DISCIPLINES.map(d=>`<option ${t.discipline===d?'selected':''}>${d}</option>`).join('')}
|
||
</select>
|
||
`;
|
||
container.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function toggleWPType(i){
|
||
state.wpTypes[i].enabled = !state.wpTypes[i].enabled;
|
||
renderWPTypes();
|
||
}
|
||
|
||
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" 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 class="constraint-check">
|
||
<input type="checkbox" id="const_${c.name}" checked onchange="toggleConstraint('${c.name}')">
|
||
<div style="flex:1;">
|
||
<label for="const_${c.name}" style="margin:0; font-weight:600;">${c.name}</label>
|
||
<div style="font-size:12px; color:var(--text-dim); margin-top:0.25rem;">${c.description}</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
// Initialize constraints with all standard 10
|
||
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" 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();
|
||
}
|
||
|
||
function renderSequenceSteps(){
|
||
const container = document.getElementById('sequence-list');
|
||
if(!state.sequence.length) state.sequence = [{label:'Layout',kind:'step'},{label:'Installation',kind:'step'},{label:'Testing',kind:'step'},{label:'Commissioning',kind:'step'}];
|
||
container.innerHTML = state.sequence.map((s,i)=>`
|
||
<div class="sequence-row">
|
||
<input type="text" value="${s.label}" placeholder="Step name" onchange="state.sequence[${i}].label=this.value">
|
||
<select onchange="state.sequence[${i}].kind=this.value">
|
||
<option ${s.kind==='step'?'selected':''}>step</option>
|
||
<option ${s.kind==='gate'?'selected':''}>gate</option>
|
||
</select>
|
||
<button class="row-del" onclick="state.sequence.splice(${i},1); renderSequenceSteps()">✕</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
function addSequenceStep(){
|
||
state.sequence.push({label:'',kind:'step'});
|
||
renderSequenceSteps();
|
||
}
|
||
|
||
function renderSources(){
|
||
const container = document.getElementById('sources-list');
|
||
if(!state.sources.length) state.sources = [
|
||
{label:'Design Drawings',system:'Procore',notes:'',link:''},
|
||
{label:'Specifications',system:'Procore',notes:'',link:''},
|
||
{label:'IO List',system:'controls.dev',notes:'',link:''},
|
||
{label:'Cable Schedule',system:'SharePoint',notes:'',link:''}
|
||
];
|
||
container.innerHTML = state.sources.map((s,i)=>`
|
||
<div class="source-row">
|
||
<input type="text" value="${s.label}" placeholder="Label" onchange="state.sources[${i}].label=this.value">
|
||
<input type="text" value="${s.system}" placeholder="System" onchange="state.sources[${i}].system=this.value">
|
||
<input type="text" value="${s.link}" placeholder="URL" onchange="state.sources[${i}].link=this.value">
|
||
<input type="text" value="${s.notes}" placeholder="Notes" onchange="state.sources[${i}].notes=this.value">
|
||
<button class="row-del" 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();
|
||
}else{
|
||
exportSOP();
|
||
}
|
||
}
|
||
|
||
function previousStep(){
|
||
if(currentStep > 1){
|
||
currentStep--;
|
||
updateStepUI();
|
||
}
|
||
}
|
||
|
||
function updateStepUI(){
|
||
// Hide all steps
|
||
document.querySelectorAll('.step').forEach(s=>s.style.display='none');
|
||
document.getElementById(`step-${currentStep}`).style.display='block';
|
||
|
||
// Update 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('prev-btn').disabled = currentStep === 1;
|
||
document.getElementById('next-btn').style.display = currentStep < 10 ? 'block' : 'none';
|
||
document.getElementById('export-btn').style.display = currentStep === 10 ? 'block' : 'none';
|
||
|
||
// Collect form data on display
|
||
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.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);
|
||
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;
|
||
const team = state.team;
|
||
|
||
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;
|
||
}
|
||
|
||
// ── EXPORT ────────────────────────────────────────────────────────────────────
|
||
function exportSOP(){
|
||
collectStepData();
|
||
|
||
const 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,
|
||
cm: state.team.cm,
|
||
qm: state.team.qm,
|
||
site: state.project.site
|
||
},
|
||
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
|
||
},
|
||
woTypes: state.wpTypes.filter(t=>t.enabled).map(t=>({
|
||
name: t.name,
|
||
enabled: true,
|
||
discipline: t.discipline || ''
|
||
})),
|
||
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 || ''}))
|
||
};
|
||
|
||
// Validate SOP
|
||
if(!sop.project.name || !sop.project.number || !sop.governance.woFormat){
|
||
alert('Cannot export: Missing critical fields (Project Name, Number, or WP Format).');
|
||
return;
|
||
}
|
||
|
||
// Download as JSON
|
||
const json = JSON.stringify(sop, null, 2);
|
||
const blob = new Blob([json], {type: 'application/json'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `wp-sop-${sop.project.name.replace(/\s+/g,'-').toLowerCase()}.json`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
|
||
alert(`✓ SOP exported: ${a.download}`);
|
||
|
||
// Also offer to export comments
|
||
if(allComments.length > 0){
|
||
setTimeout(()=>{
|
||
if(confirm('Export feedback comments as well? (CSV file)')){
|
||
exportComments();
|
||
}
|
||
}, 500);
|
||
}
|
||
}
|
||
|
||
// ── 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('sop_config_comments', JSON.stringify(allComments));
|
||
|
||
document.getElementById('comment-text').value = '';
|
||
document.getElementById('commenter-name').value = '';
|
||
loadStepComments();
|
||
alert('✓ Comment submitted! Thank you for your feedback.');
|
||
}
|
||
|
||
function loadStepComments(){
|
||
const saved = localStorage.getItem('sop_config_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('');
|
||
}
|
||
}
|
||
|
||
function exportComments(){
|
||
const saved = localStorage.getItem('sop_config_comments');
|
||
if(!saved || JSON.parse(saved).length === 0){
|
||
alert('No comments to export.');
|
||
return;
|
||
}
|
||
|
||
allComments = JSON.parse(saved);
|
||
|
||
// Build CSV
|
||
let csv = 'Step,Name,Timestamp,Feedback\n';
|
||
allComments.forEach(c=>{
|
||
const text = `"${c.text.replace(/"/g,'""')}"`;
|
||
csv += `${c.step},"${c.name}","${c.timestamp}",${text}\n`;
|
||
});
|
||
|
||
const blob = new Blob([csv], {type: 'text/csv'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `sop-config-feedback-${new Date().toISOString().split('T')[0]}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
|
||
alert(`✓ ${allComments.length} comments exported to CSV.`);
|
||
}
|