// ============================================================================= // SLA Calculator - Script // All business rules loaded from config.json. Zero hard-coded logic. // ============================================================================= let CONFIG = null; let currentStep = 1; let activeStages = [false, false, false, false, false, false, false, false]; let lastCalculationData = null; // ---- Bootstrap ---- document.addEventListener('DOMContentLoaded', async () => { await loadConfig(); initDarkMode(); initStepper(); initDatePickers(); bindEvents(); }); async function loadConfig() { try { const res = await fetch('config.json'); CONFIG = await res.json(); populateBriefTypes(); populateStageDropdowns(); } catch (e) { console.error('Failed to load config.json:', e); alert('Error loading configuration. Please ensure config.json is in the same directory.'); } } // ---- Populate Dropdowns from Config ---- function populateBriefTypes() { const sel = document.getElementById('briefType'); CONFIG.briefTypes.forEach(bt => { const opt = document.createElement('option'); opt.value = bt.id; opt.textContent = bt.label; sel.appendChild(opt); }); } function populateStageDropdowns() { const cfg = CONFIG.stageConfigs; // Stage 1 complexity populateSelect('stage1Complexity', cfg.stage1.fields.complexity.options, 'value', 'label'); // Stage 2 populateSelect('stage2MasteringComplexity', cfg.stage2.fields.masteringComplexity.options, 'value', 'label'); populateSelect('stage2CopyComplexity', cfg.stage2.fields.copyComplexity.options, 'value', 'label'); // Stage 4/5 word count populateSelect('stage45WordCount', cfg.stage4_5.fields.wordCount.options, 'value', 'label'); // Stage 6 populateSelect('stage6Complexity', cfg.stage6.fields.complexity.options, 'value', 'label'); populateSelect('stage6AssetVolume', cfg.stage6.fields.assetVolume.options, 'value', 'label'); // Stage 8 populateSelect('stage8EanVolume', cfg.stage8.fields.eanVolume.options, 'value', 'label'); populateSelect('stage8Complexity', cfg.stage8.fields.complexity.options, 'value', 'label'); } function populateSelect(id, options, valueKey, labelKey) { const sel = document.getElementById(id); if (!sel) return; sel.innerHTML = ''; options.forEach(opt => { const o = document.createElement('option'); o.value = opt[valueKey]; o.textContent = opt[labelKey]; sel.appendChild(o); }); } // ---- Dark Mode ---- function initDarkMode() { const saved = localStorage.getItem('sla-dark-mode'); if (saved === 'true' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) { document.documentElement.classList.add('dark'); } document.getElementById('darkModeToggle').addEventListener('click', () => { document.documentElement.classList.toggle('dark'); localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark')); }); } // ---- Stepper ---- function initStepper() { document.querySelectorAll('.step-item').forEach(item => { item.addEventListener('click', () => { const step = parseInt(item.dataset.step); if (step <= currentStep || canAdvanceTo(step)) { goToStep(step); } }); }); } function canAdvanceTo(step) { if (step >= 2 && !document.getElementById('briefType').value) return false; if (step >= 4 && !document.getElementById('kickOffDate').value) return false; return true; } function goToStep(step) { if (step > currentStep + 1) return; // Validation gates if (step >= 2 && !document.getElementById('briefType').value) return; if (step >= 4 && !document.getElementById('kickOffDate').value) { alert('Please enter the Project Kick-Off Date before proceeding.'); return; } currentStep = step; // Show/hide step content document.querySelectorAll('.step-content').forEach(s => s.classList.add('hidden')); const target = document.getElementById('step' + step); if (target) { target.classList.remove('hidden'); target.classList.add('fade-in'); } // Update stepper UI document.querySelectorAll('.step-item').forEach(item => { const s = parseInt(item.dataset.step); const num = item.querySelector('.step-number'); if (s < step) { num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-green-500 text-white text-sm font-semibold shrink-0'; num.innerHTML = ''; } else if (s === step) { num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-brand-600 text-white text-sm font-semibold shrink-0'; num.textContent = s; } else { num.className = 'step-number flex items-center justify-center w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 text-gray-600 dark:text-gray-300 text-sm font-semibold shrink-0'; num.textContent = s; } }); // Run calculation when entering step 4 if (step === 4) { calculateSLA(); } window.scrollTo({ top: 0, behavior: 'smooth' }); } // ---- Date Pickers ---- function initDatePickers() { const fpConfig = { dateFormat: 'Y-m-d', altInput: true, altFormat: 'd M Y', disableMobile: true }; flatpickr('#kickOffDate', { ...fpConfig, onChange: () => validateStep3() }); flatpickr('#goLiveDate', { ...fpConfig, onChange: () => validateStep3() }); } function validateStep3() { const kick = document.getElementById('kickOffDate').value; const btn = document.getElementById('btnStep3Next'); btn.disabled = !kick; } // ---- Event Bindings ---- function bindEvents() { // Brief type change document.getElementById('briefType').addEventListener('change', onBriefTypeChange); // Stage toggles document.querySelectorAll('.stage-toggle').forEach(toggle => { toggle.addEventListener('change', onStageToggle); }); // Production complexity/volume change -> update base days and opera days const stage6Inputs = ['stage6Complexity', 'stage6AssetVolume']; stage6Inputs.forEach(id => { document.getElementById(id).addEventListener('change', () => { updateProductionBaseDays(); updateOperaDays(); }); }); // Speed up slider document.getElementById('stage6SpeedUp').addEventListener('input', (e) => { document.getElementById('stage6SpeedUpLabel').textContent = e.target.value + '%'; }); // Syndication complexity/EAN change -> update base days ['stage8Complexity', 'stage8EanVolume'].forEach(id => { document.getElementById(id).addEventListener('change', updateSyndicationBaseDays); }); } // ---- Brief Type Change ---- function onBriefTypeChange() { const briefId = document.getElementById('briefType').value; if (!briefId) { document.getElementById('projectTypeDisplay').textContent = 'Select a Brief Type first'; document.getElementById('briefDescription').classList.add('hidden'); document.getElementById('stageMatrixPreview').classList.add('hidden'); document.getElementById('btnStep1Next').disabled = true; return; } const bt = CONFIG.briefTypes.find(b => b.id === briefId); document.getElementById('projectTypeDisplay').textContent = bt.projectType; document.getElementById('projectTypeDisplay').classList.remove('text-gray-500'); document.getElementById('projectTypeDisplay').classList.add('font-semibold'); // Description const descEl = document.getElementById('briefDescription'); descEl.textContent = bt.description; descEl.classList.remove('hidden'); // Stage matrix const matrix = CONFIG.stageMatrix[briefId]; activeStages = [...matrix]; renderStageMatrixBadges(matrix); document.getElementById('stageMatrixPreview').classList.remove('hidden'); document.getElementById('btnStep1Next').disabled = false; // Update stage cards visibility and toggles updateStageCards(); updateProductionBaseDays(); updateOperaDays(); updateSyndicationBaseDays(); } function renderStageMatrixBadges(matrix) { const container = document.getElementById('stageMatrixBadges'); container.innerHTML = ''; CONFIG.stages.forEach((stage, i) => { const active = matrix[i]; const badge = document.createElement('div'); badge.className = `flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium cursor-pointer select-none transition-colors ${ active ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40' : 'bg-gray-50 dark:bg-gray-700/50 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600/50' }`; badge.innerHTML = ` ${active ? 'Y' : 'N'} ${stage.shortLabel} `; badge.addEventListener('click', () => { // Stages 4 & 5 (Translation) toggle together if (i === 3 || i === 4) { activeStages[3] = !activeStages[3]; activeStages[4] = activeStages[3]; } else { activeStages[i] = !activeStages[i]; } renderStageMatrixBadges(activeStages); updateStageCards(); updateProductionBaseDays(); updateOperaDays(); updateSyndicationBaseDays(); }); container.appendChild(badge); }); } // ---- Stage Cards ---- function updateStageCards() { const cardMap = { 0: 'stage1Card', 1: 'stage2Card', 2: 'stage3Card', 3: 'stage45Card', // stage 4 4: 'stage45Card', // stage 5 (same card) 5: 'stage6Card', 6: 'stage7Card', 7: 'stage8Card' }; // Show/hide and set toggles const shownCards = new Set(); for (let i = 0; i < 8; i++) { const cardId = cardMap[i]; if (shownCards.has(cardId)) continue; shownCards.add(cardId); const card = document.getElementById(cardId); if (!card) continue; // Determine if this card's stages are active let isActive; if (cardId === 'stage45Card') { isActive = activeStages[3] || activeStages[4]; } else { isActive = activeStages[i]; } card.classList.remove('hidden'); // Set toggle const toggle = card.querySelector('.stage-toggle'); if (toggle) { toggle.checked = isActive; // Update card appearance const fields = card.querySelector('.stage-fields'); if (fields) { if (isActive) { card.classList.remove('inactive'); fields.style.display = ''; } else { card.classList.add('inactive'); fields.style.display = 'none'; } } } } } function onStageToggle(e) { const stageKey = e.target.dataset.stage; const checked = e.target.checked; if (stageKey === '3_4') { // Translation stages 4 and 5 activeStages[3] = checked; activeStages[4] = checked; } else { const idx = parseInt(stageKey); activeStages[idx] = checked; } // Update card appearance const card = e.target.closest('.stage-card'); const fields = card.querySelector('.stage-fields'); if (fields) { if (checked) { card.classList.remove('inactive'); fields.style.display = ''; } else { card.classList.add('inactive'); fields.style.display = 'none'; } } // Update badges renderStageMatrixBadges(activeStages); } // ---- Dynamic Lookups ---- function updateProductionBaseDays() { const complexity = document.getElementById('stage6Complexity').value; const volume = document.getElementById('stage6AssetVolume').value; const key = complexity + '_' + volume; const table = CONFIG.stageConfigs.stage6.fields.crossReferenceTable; const days = table[key]; document.getElementById('stage6BaseDays').textContent = days !== undefined ? days + ' days' : '--'; } function updateOperaDays() { const volume = document.getElementById('stage6AssetVolume').value; const daysMap = CONFIG.stageConfigs.stage7.daysByAssetVolume; const days = daysMap[volume] || CONFIG.stageConfigs.stage7.defaultDays; document.getElementById('stage7Days').textContent = days + ' day' + (days > 1 ? 's' : ''); } function updateSyndicationBaseDays() { const complexity = document.getElementById('stage8Complexity').value; const eanVolume = document.getElementById('stage8EanVolume').value; const key = complexity + '_' + eanVolume; const table = CONFIG.stageConfigs.stage8.fields.crossReferenceTable; const days = table[key]; document.getElementById('stage8BaseDays').textContent = days !== undefined ? days + ' days' : '--'; } // ---- Business Day Arithmetic ---- function addBusinessDays(startDate, days) { const result = new Date(startDate); let added = 0; while (added < days) { result.setDate(result.getDate() + 1); const dow = result.getDay(); if (dow !== 0 && dow !== 6) { added++; } } return result; } function businessDaysBetween(start, end) { let count = 0; const d = new Date(start); while (d < end) { d.setDate(d.getDate() + 1); const dow = d.getDay(); if (dow !== 0 && dow !== 6) count++; } return count; } function formatDate(date) { if (!date) return 'n.a.'; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return date.getDate().toString().padStart(2, '0') + '/' + months[date.getMonth()]; } function formatDateLong(date) { if (!date) return '--'; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; return date.getDate() + ' ' + months[date.getMonth()] + ' ' + date.getFullYear(); } // ---- SLA Calculation Engine ---- function calculateSLA() { const kickOffStr = document.getElementById('kickOffDate').value; const goLiveStr = document.getElementById('goLiveDate').value; const briefName = document.getElementById('briefName').value || 'Unnamed Brief'; if (!kickOffStr) return; const kickOff = new Date(kickOffStr + 'T00:00:00'); const goLive = goLiveStr ? new Date(goLiveStr + 'T00:00:00') : null; const cfg = CONFIG.stageConfigs; const handoverDays = CONFIG.meta.handoverDays; const results = []; let currentDate = new Date(kickOff); // Stage 1: Missing DMI Asset Creation results.push(calcGenericStage(0, activeStages[0], currentDate, () => { const complexity = document.getElementById('stage1Complexity').value; const opt = cfg.stage1.fields.complexity.options.find(o => o.value === complexity); const wip = opt ? opt.days : 0; const feedback = intVal('stage1MarketApproval'); const rounds = intVal('stage1RevisionRounds'); const revDays = intVal('stage1RevisionDays'); return { handover: handoverDays, wip, feedback, revisions: rounds * revDays, rounds }; })); currentDate = results[0].completeDate; // Stage 2: Mastering, Copy Creation / Extraction results.push(calcGenericStage(1, activeStages[1], currentDate, () => { const masterOpt = cfg.stage2.fields.masteringComplexity.options.find(o => o.value === document.getElementById('stage2MasteringComplexity').value); const copyOpt = cfg.stage2.fields.copyComplexity.options.find(o => o.value === document.getElementById('stage2CopyComplexity').value); const masterDays = masterOpt ? masterOpt.days : 0; const copyDays = copyOpt ? copyOpt.days : 0; const wip = masterDays + copyDays; return { handover: handoverDays, wip, feedback: 0, revisions: 0, rounds: 0 }; })); currentDate = results[1].completeDate; // Stage 3: Global Rollout Invitation results.push(calcGenericStage(2, activeStages[2], currentDate, () => { const wip = intVal('stage3RolloutDays'); return { handover: handoverDays, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true }; })); currentDate = results[2].completeDate; // Stages 4 & 5: Translation (run in parallel, so counted once) const translationActive = activeStages[3] || activeStages[4]; const translationResult = calcGenericStage(3, translationActive, currentDate, () => { const wcOpt = cfg.stage4_5.fields.wordCount.options.find(o => o.value === document.getElementById('stage45WordCount').value); const wip = wcOpt ? wcOpt.days : 0; const feedback = intVal('stage45MarketApproval'); const rounds = intVal('stage45RevisionRounds'); const revDays = intVal('stage45RevisionDays'); return { handover: handoverDays, wip, feedback, revisions: rounds * revDays, rounds }; }); // Stage 4 result results.push({ ...translationResult, stageIndex: 3, label: CONFIG.stages[3].label }); // Stage 5 result (same dates since parallel) results.push({ ...translationResult, stageIndex: 4, label: CONFIG.stages[4].label, active: activeStages[4] }); currentDate = translationResult.completeDate; // Stage 6: Production results.push(calcGenericStage(5, activeStages[5], currentDate, () => { const complexity = document.getElementById('stage6Complexity').value; const volume = document.getElementById('stage6AssetVolume').value; const key = complexity + '_' + volume; const table = cfg.stage6.fields.crossReferenceTable; let wip = table[key] || 0; // Apply speed-up const speedUp = intVal('stage6SpeedUp'); if (speedUp > 0) { wip = Math.ceil(wip * (1 - speedUp / 100)); } const feedback = intVal('stage6MarketApproval'); const rounds = intVal('stage6RevisionRounds'); const revDays = intVal('stage6RevisionDays'); return { handover: handoverDays, wip, feedback, revisions: rounds * revDays, rounds }; })); currentDate = results[5].completeDate; // Stage 7: Opera Upload results.push(calcGenericStage(6, activeStages[6], currentDate, () => { const volume = document.getElementById('stage6AssetVolume').value; const daysMap = cfg.stage7.daysByAssetVolume; const wip = daysMap[volume] || cfg.stage7.defaultDays; return { handover: handoverDays, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true }; })); currentDate = results[6].completeDate; // Stage 8: Syndication results.push(calcGenericStage(7, activeStages[7], currentDate, () => { const complexity = document.getElementById('stage8Complexity').value; const eanVolume = document.getElementById('stage8EanVolume').value; const key = complexity + '_' + eanVolume; const table = cfg.stage8.fields.crossReferenceTable; const wip = table[key] || 0; return { handover: handoverDays, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true }; })); const projectEndDate = results[7].completeDate; // Suggested go-live: add syndication buffer if syndication is active let suggestedGoLive = new Date(projectEndDate); if (activeStages[7]) { suggestedGoLive = addBusinessDays(projectEndDate, CONFIG.meta.syndicationBufferDays); } // Verdict const canMeet = goLive ? projectEndDate <= goLive : null; renderResults({ briefName, kickOff, goLive, projectEndDate, suggestedGoLive, canMeet, stages: results }); } function calcGenericStage(stageIndex, isActive, prevDate, getParams) { const stage = CONFIG.stages[stageIndex]; if (!isActive) { return { stageIndex, label: stage.label, active: false, handover: 0, wip: 0, feedback: 0, revisions: 0, rounds: 0, noFeedback: false, completeDate: new Date(prevDate), kickOffDate: null, wipCompleteDate: null, feedbackByDate: null }; } const params = getParams(); const totalDays = params.handover + params.wip + params.feedback + params.revisions; const kickOffDate = addBusinessDays(prevDate, 0); // Next business day check not needed; start from prev // Actually kickoff is the day after previous stage completes const stageKickOff = addBusinessDays(prevDate, params.handover); const wipComplete = addBusinessDays(stageKickOff, params.wip); const feedbackBy = params.feedback > 0 ? addBusinessDays(wipComplete, params.feedback) : null; const completeDate = addBusinessDays(prevDate, totalDays); return { stageIndex, label: stage.label, active: true, handover: params.handover, wip: params.wip, feedback: params.feedback, revisions: params.revisions, rounds: params.rounds || 0, noFeedback: params.noFeedback || false, completeDate, kickOffDate: stageKickOff, wipCompleteDate: wipComplete, feedbackByDate: feedbackBy }; } function intVal(id) { const el = document.getElementById(id); return el ? parseInt(el.value) || 0 : 0; } // ---- Render Results ---- function renderResults(data) { lastCalculationData = data; // Verdict banner const banner = document.getElementById('verdictBanner'); const badge = document.getElementById('verdictBadge'); const msg = document.getElementById('verdictMessage'); document.getElementById('verdictBriefName').textContent = data.briefName; if (data.canMeet === null) { banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-gray-100 dark:bg-gray-800 border-gray-300 dark:border-gray-600'; badge.textContent = '?'; badge.className = 'text-5xl font-black text-gray-400'; msg.textContent = 'Enter a Go-Live Date to see the verdict.'; } else if (data.canMeet) { banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-800'; badge.textContent = 'Y'; badge.className = 'text-5xl font-black text-green-600 dark:text-green-400'; msg.textContent = CONFIG.verdictMessages.canMeet; } else { banner.className = 'rounded-xl shadow-sm border p-6 mb-4 bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-800'; badge.textContent = 'N'; badge.className = 'text-5xl font-black text-red-600 dark:text-red-400'; const gap = businessDaysBetween(data.goLive, data.projectEndDate); msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (${gap} business day${gap !== 1 ? 's' : ''} over deadline)`; } // Key dates document.getElementById('resultKickOff').textContent = formatDateLong(data.kickOff); document.getElementById('resultGoLive').textContent = data.goLive ? formatDateLong(data.goLive) : 'Not set'; document.getElementById('resultEndDate').textContent = formatDateLong(data.projectEndDate); document.getElementById('resultSuggestedGoLive').textContent = formatDateLong(data.suggestedGoLive); // PM instruction const pmEl = document.getElementById('pmInstruction'); if (data.canMeet === null) { pmEl.textContent = 'Enter a Required Go-Live Date to see the full verdict and PM instructions.'; } else { pmEl.textContent = data.canMeet ? CONFIG.pmInstructions.canMeet : CONFIG.pmInstructions.cannotMeet; } // Calculation table const calcBody = document.getElementById('calculationTable'); calcBody.innerHTML = ''; data.stages.forEach(s => { const tr = document.createElement('tr'); tr.className = 'border-b border-gray-100 dark:border-gray-700/50' + (!s.active ? ' text-gray-400 dark:text-gray-500' : ''); tr.innerHTML = ` ${s.label} ${s.active ? 'Y' : 'N'} ${s.active ? s.handover : 0} ${s.active ? s.wip : 0} ${s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0} ${s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0} ${formatDate(s.completeDate)} `; calcBody.appendChild(tr); }); // Key dates table const datesBody = document.getElementById('keyDatesTable'); datesBody.innerHTML = ''; data.stages.forEach(s => { const tr = document.createElement('tr'); tr.className = 'border-b border-gray-100 dark:border-gray-700/50' + (!s.active ? ' text-gray-400 dark:text-gray-500' : ''); tr.innerHTML = ` ${s.label} ${s.active ? 'Y' : 'N'} ${s.active ? formatDate(s.kickOffDate) : 'n.a.'} ${s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.'} ${s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.'} ${s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.'} ${s.active ? formatDate(s.completeDate) : 'n.a.'} `; datesBody.appendChild(tr); }); } // ---- CSV Export ---- function exportCSV() { if (!lastCalculationData) return; const data = lastCalculationData; const lines = []; // Summary section lines.push('SLA Calculator Report'); lines.push(''); lines.push('Brief Name,' + csvEscape(data.briefName)); lines.push('Brief Type,' + csvEscape(document.getElementById('briefType').selectedOptions[0]?.text || '')); lines.push('Kick-Off Date,' + formatDateLong(data.kickOff)); lines.push('Required Go-Live Date,' + (data.goLive ? formatDateLong(data.goLive) : 'Not set')); lines.push('Planned End Date (SLA),' + formatDateLong(data.projectEndDate)); lines.push('Suggested Go-Live (SLA),' + formatDateLong(data.suggestedGoLive)); lines.push('Can Meet Deadline?,' + (data.canMeet === null ? 'N/A' : data.canMeet ? 'YES' : 'NO')); if (data.canMeet === false && data.goLive) { const gap = businessDaysBetween(data.goLive, data.projectEndDate); lines.push('Business Days Over Deadline,' + gap); } // Calculation of Days table lines.push(''); lines.push('Calculation of Days'); lines.push('Stage,Active,Handover,WIP 1,Feedback,Revisions,Complete By'); data.stages.forEach(s => { lines.push([ csvEscape(s.label), s.active ? 'Y' : 'N', s.active ? s.handover : 0, s.active ? s.wip : 0, s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0, s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0, formatDate(s.completeDate) ].join(',')); }); // Key Dates table lines.push(''); lines.push('Key Dates'); lines.push('Stage,Active,Stage Kick-Off,WIP 1 To Approval,Receive Feedback By,Rounds,Stage Complete By'); data.stages.forEach(s => { lines.push([ csvEscape(s.label), s.active ? 'Y' : 'N', s.active ? formatDate(s.kickOffDate) : 'n.a.', s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.', s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.', s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.', s.active ? formatDate(s.completeDate) : 'n.a.' ].join(',')); }); const blob = new Blob([lines.join('\r\n')], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (data.briefName || 'SLA_Report').replace(/\s+/g, '_') + '_SLA.csv'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function csvEscape(str) { if (!str) return ''; str = String(str); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } // ---- Copy as Markdown ---- function copyMarkdown() { if (!lastCalculationData) return; const data = lastCalculationData; const briefType = document.getElementById('briefType').selectedOptions[0]?.text || ''; const lines = []; // Header & summary lines.push(`## SLA Summary — ${data.briefName || 'Untitled'}`); lines.push(''); lines.push(`**Brief Type:** ${briefType}`); lines.push(`**Kick-Off Date:** ${formatDateLong(data.kickOff)}`); lines.push(`**Required Go-Live:** ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}`); lines.push(`**Planned End Date (SLA):** ${formatDateLong(data.projectEndDate)}`); lines.push(`**Suggested Go-Live (SLA):** ${formatDateLong(data.suggestedGoLive)}`); if (data.canMeet === null) { lines.push('**Verdict:** N/A — No Go-Live date set'); } else if (data.canMeet) { lines.push('**Verdict:** ✅ YES — Can meet the deadline'); } else { const gap = businessDaysBetween(data.goLive, data.projectEndDate); lines.push(`**Verdict:** ❌ NO — Cannot meet deadline (${gap} business days over)`); } // Key Dates table lines.push(''); lines.push('### Key Dates'); lines.push(''); lines.push('| Stage | Active | Stage Kick-Off | WIP 1 To Approval | Receive Feedback By | Rounds | Stage Complete By |'); lines.push('|---|---|---|---|---|---|---|'); data.stages.forEach(s => { const active = s.active ? '✅ Y' : '⬜ N'; const kickOff = s.active ? formatDate(s.kickOffDate) : 'n.a.'; const wip = s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.'; const feedback = s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.'; const rounds = s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.'; const complete = s.active ? formatDate(s.completeDate) : 'n.a.'; lines.push(`| ${s.label} | ${active} | ${kickOff} | ${wip} | ${feedback} | ${rounds} | ${complete} |`); }); // Calculation of Days table lines.push(''); lines.push('### Calculation of Days'); lines.push(''); lines.push('| Stage | Active | Handover | WIP 1 | Feedback | Revisions | Complete By |'); lines.push('|---|---|---|---|---|---|---|'); data.stages.forEach(s => { const active = s.active ? '✅ Y' : '⬜ N'; const handover = s.active ? s.handover : 0; const wip = s.active ? s.wip : 0; const feedback = s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0; const revisions = s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0; const complete = formatDate(s.completeDate); lines.push(`| ${s.label} | ${active} | ${handover} | ${wip} | ${feedback} | ${revisions} | ${complete} |`); }); const markdown = lines.join('\n'); // Copy to clipboard with fallback const showCopied = () => { const btn = document.getElementById('btnCopyMd'); const originalHTML = btn.innerHTML; btn.innerHTML = ` Copied!`; setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); }; if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(markdown).then(showCopied).catch(() => { fallbackCopy(markdown); showCopied(); }); } else { fallbackCopy(markdown); showCopied(); } } function fallbackCopy(text) { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } // ---- iCal Export ---- function exportICal() { if (!lastCalculationData) return; const data = lastCalculationData; const formatICalDate = (date) => { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return y + m + d; }; const now = new Date(); const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, ''); const uid = () => 'sla-' + Math.random().toString(36).substr(2, 9) + '@ecf'; let events = []; // Project kick-off event events.push({ summary: `[Kick-Off] ${data.briefName}`, date: formatICalDate(data.kickOff), description: `Project kick-off for ${data.briefName}\\nBrief Type: ${document.getElementById('briefType').value.replace(/_/g, ' ')}` }); // Active stage completion milestones data.stages.forEach(s => { if (s.active && s.completeDate) { events.push({ summary: `[${s.label} Complete] ${data.briefName}`, date: formatICalDate(s.completeDate), description: `Stage "${s.label}" estimated completion for ${data.briefName}` }); } }); // Go-live deadline (if set) if (data.goLive) { events.push({ summary: `[Go-Live Deadline] ${data.briefName}`, date: formatICalDate(data.goLive), description: `Required go-live date for ${data.briefName}\\nVerdict: ${data.canMeet ? 'CAN meet deadline' : 'CANNOT meet deadline'}` }); } // Project planned end date events.push({ summary: `[SLA End Date] ${data.briefName}`, date: formatICalDate(data.projectEndDate), description: `Planned end date based on SLA calculation for ${data.briefName}` }); // Build .ics content let ics = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//eCom Content Factory//SLA Calculator//EN', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH' ]; events.forEach(evt => { ics.push( 'BEGIN:VEVENT', `DTSTART;VALUE=DATE:${evt.date}`, `DTEND;VALUE=DATE:${evt.date}`, `DTSTAMP:${stamp}`, `UID:${uid()}`, `SUMMARY:${evt.summary}`, `DESCRIPTION:${evt.description}`, 'END:VEVENT' ); }); ics.push('END:VCALENDAR'); const blob = new Blob([ics.join('\r\n')], { type: 'text/calendar;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (data.briefName || 'SLA_Milestones').replace(/\s+/g, '_') + '.ics'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }