// ============================================================================= // SLA Calculator - Script // All business rules loaded from config.json. Zero hard-coded logic. // ============================================================================= let CONFIG = null; // ── API fetch helper ────────────────────────────────────────────────────────── // Automatically includes the in-memory access token as a Bearer header. // On 401, attempts one silent token refresh before giving up. async function apiFetch(path, options = {}) { const url = `/loreal-sla-calculator/api${path}`; const headers = { ...(options.headers || {}) }; if (currentAccessToken) headers['Authorization'] = `Bearer ${currentAccessToken}`; let res = await fetch(url, { ...options, credentials: 'include', headers }); if (res.status === 401) { // Try to refresh and retry once const refreshed = await tryRefresh(); if (refreshed && currentAccessToken) { headers['Authorization'] = `Bearer ${currentAccessToken}`; res = await fetch(url, { ...options, credentials: 'include', headers }); } } return res; } let currentStep = 1; let activeStages = [false, false, false, false, false, false, false, false]; let lastCalculationData = null; // ---- Bootstrap ---- document.addEventListener('DOMContentLoaded', async () => { await initAuth(); await loadConfig(); initDarkMode(); initStepper(); initDatePickers(); bindEvents(); }); async function loadConfig() { try { const res = await fetch('config.json?v=2026031302'); 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 || (s === step && step === 4)) { 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, minDate: 'today', disable: [ function(date) { // Disable weekends (Sat=6, Sun=0) return date.getDay() === 0 || date.getDay() === 6; } ] }; 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(); }); }); // 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'); if (Array.isArray(bt.description)) { descEl.innerHTML = ''; } else { descEl.textContent = bt.description; } descEl.classList.remove('hidden'); // Stage matrix const matrix = CONFIG.stageMatrix[briefId]; activeStages = [...matrix]; // Force always-active stages (e.g. Opera Upload) CONFIG.stages.forEach((stage, i) => { const cfg = CONFIG.stageConfigs['stage' + stage.number]; if (cfg && cfg.alwaysActive) activeStages[i] = true; }); renderStageMatrixBadges(activeStages); 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 cfg = CONFIG.stageConfigs['stage' + stage.number]; const locked = cfg && cfg.alwaysActive; const badge = document.createElement('div'); badge.className = `flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium select-none transition-colors ${ locked ? 'cursor-default' : 'cursor-pointer' } ${ 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}${locked ? ' (Required)' : ''} `; if (!locked) { 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'); // Check if this stage is always-active (locked) const stageNum = CONFIG.stages[i] ? CONFIG.stages[i].number : null; const stageCfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null; const locked = stageCfg && stageCfg.alwaysActive; // Set toggle const toggle = card.querySelector('.stage-toggle'); if (toggle) { toggle.checked = isActive; // Disable toggle for always-active stages if (locked) { toggle.disabled = true; toggle.closest('label').classList.add('opacity-50', 'cursor-not-allowed'); toggle.closest('label').classList.remove('cursor-pointer'); } // 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; // Prevent disabling always-active stages if (!checked) { const idx = parseInt(stageKey); const stageNum = CONFIG.stages[idx] ? CONFIG.stages[idx].number : null; const cfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null; if (cfg && cfg.alwaysActive) { e.target.checked = true; return; } } 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 ? parseFloat(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; const roundel = 'w-20 h-20 sm:w-24 sm:h-24 rounded-full flex items-center justify-center flex-shrink-0 shadow-lg'; const icon = 'w-10 h-10 sm:w-12 sm:h-12'; 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.className = `${roundel} bg-gray-300 dark:bg-gray-600`; badge.innerHTML = `?`; 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.className = `${roundel} bg-green-500 dark:bg-green-600`; badge.innerHTML = ``; 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.className = `${roundel} bg-red-500 dark:bg-red-600`; badge.innerHTML = ``; 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); }); } // ---- View Toggle (List / Gantt) ---- function setView(view) { const listView = document.getElementById('listView'); const ganttView = document.getElementById('ganttView'); const btnList = document.getElementById('btnViewList'); const btnGantt = document.getElementById('btnViewGantt'); const activeClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm'; const inactiveClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'; if (view === 'gantt') { listView.classList.add('hidden'); ganttView.classList.remove('hidden'); btnGantt.className = activeClass; btnList.className = inactiveClass; renderGantt(); } else { listView.classList.remove('hidden'); ganttView.classList.add('hidden'); btnList.className = activeClass; btnGantt.className = inactiveClass; } } function renderGantt() { if (!lastCalculationData) return; const data = lastCalculationData; const container = document.getElementById('ganttChart'); // Filter active stages const activeStgs = data.stages.filter(s => s.active); if (activeStgs.length === 0) { container.innerHTML = '

No active stages to display.

'; return; } // Calculate time range const minDate = data.kickOff; const maxDate = data.suggestedGoLive > data.projectEndDate ? data.suggestedGoLive : data.projectEndDate; const totalMs = maxDate.getTime() - minDate.getTime(); // Add 10% padding to the right const rangeMs = totalMs * 1.1 || 1; const toPercent = (date) => Math.max(0, ((date.getTime() - minDate.getTime()) / rangeMs) * 100); // Bar colors — progressively deeper purple const colors = [ 'bg-violet-300 dark:bg-violet-400', 'bg-violet-400 dark:bg-violet-500', 'bg-violet-400 dark:bg-violet-500', 'bg-violet-500 dark:bg-violet-600', 'bg-violet-500 dark:bg-violet-600', 'bg-violet-600 dark:bg-violet-700', 'bg-violet-600 dark:bg-violet-700', 'bg-violet-700 dark:bg-violet-800' ]; // Build month markers along the timeline const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; let monthMarkers = ''; const startMonth = new Date(minDate.getFullYear(), minDate.getMonth(), 1); const d = new Date(startMonth); while (d <= maxDate) { const pct = toPercent(d); if (pct >= 0 && pct <= 100) { monthMarkers += `
${months[d.getMonth()]} ${d.getFullYear()}
`; } d.setMonth(d.getMonth() + 1); } // Build gantt rows let rows = ''; activeStgs.forEach((s, idx) => { const left = toPercent(s.kickOffDate); const right = toPercent(s.completeDate); const width = Math.max(right - left, 2); // min 2% width for very short stages const color = colors[s.stageIndex] || 'bg-violet-500'; const dateLabel = formatDate(s.kickOffDate); const totalDays = s.handover + s.wip + s.feedback + s.revisions; rows += `
${s.label}
${dateLabel}
`; }); // Go-live marker let goLiveMarker = ''; if (data.goLive) { const goLivePct = toPercent(data.goLive); if (goLivePct > 0 && goLivePct <= 105) { goLiveMarker = `
GO-LIVE
`; } } container.innerHTML = `
${monthMarkers}
${goLiveMarker} ${rows}
Stage duration ${data.goLive ? ' Go-Live deadline' : ''}
`; } // ---- 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 for Email (Rich HTML) ---- function copyForEmail() { if (!lastCalculationData) return; const data = lastCalculationData; const briefType = document.getElementById('briefType').selectedOptions[0]?.text || ''; // Inline styles for Outlook compatibility const tbl = 'border-collapse:collapse;width:100%;font-family:Calibri,Arial,sans-serif;font-size:13px;'; const th = 'border:1px solid #d1d5db;padding:6px 10px;background:#f3f4f6;font-weight:600;text-align:center;'; const thL = 'border:1px solid #d1d5db;padding:6px 10px;background:#f3f4f6;font-weight:600;text-align:left;'; const td = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;'; const tdL = 'border:1px solid #d1d5db;padding:5px 10px;text-align:left;'; const tdActive = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;color:#16a34a;font-weight:600;'; const tdInactive = 'border:1px solid #d1d5db;padding:5px 10px;text-align:center;color:#9ca3af;'; // Verdict let verdictText, verdictColor; if (data.canMeet === null) { verdictText = 'N/A — No Go-Live date set'; verdictColor = '#6b7280'; } else if (data.canMeet) { verdictText = 'YES — Can meet the deadline'; verdictColor = '#16a34a'; } else { const gap = businessDaysBetween(data.goLive, data.projectEndDate); verdictText = `NO — Cannot meet deadline (${gap} business days over)`; verdictColor = '#dc2626'; } let html = `
`; html += `

SLA Summary — ${data.briefName || 'Untitled'}

`; html += `

Brief Type: ${briefType}

`; html += `

Kick-Off Date: ${formatDateLong(data.kickOff)}

`; html += `

Required Go-Live: ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}

`; html += `

Planned End Date (SLA): ${formatDateLong(data.projectEndDate)}

`; html += `

Suggested Go-Live (SLA): ${formatDateLong(data.suggestedGoLive)}

`; html += `

Verdict: ${verdictText}

`; // Key Dates table html += `

Key Dates

`; html += ``; html += ``; data.stages.forEach(s => { const aStyle = s.active ? tdActive : tdInactive; const aText = 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.'; html += ``; }); html += `
StageActiveStage Kick-OffWIP 1 To ApprovalReceive Feedback ByRoundsStage Complete By
${s.label}${aText}${kickOff}${wip}${feedback}${rounds}${complete}
`; // Calculation of Days table html += `

Calculation of Days

`; html += ``; html += ``; data.stages.forEach(s => { const aStyle = s.active ? tdActive : tdInactive; const aText = 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); html += ``; }); html += `
StageActiveHandoverWIP 1FeedbackRevisionsComplete By
${s.label}${aText}${handover}${wip}${feedback}${revisions}${complete}
`; // Plain text fallback let plain = `SLA Summary — ${data.briefName || 'Untitled'}\n`; plain += `Brief Type: ${briefType}\n`; plain += `Kick-Off: ${formatDateLong(data.kickOff)}\n`; plain += `Go-Live: ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}\n`; plain += `Planned End: ${formatDateLong(data.projectEndDate)}\n`; plain += `Verdict: ${verdictText}\n`; const showCopied = () => { const btn = document.getElementById('btnCopyEmail'); const originalHTML = btn.innerHTML; btn.innerHTML = ` Copied!`; setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); }; // Use ClipboardItem API for rich HTML copy if (navigator.clipboard && typeof ClipboardItem !== 'undefined') { const htmlBlob = new Blob([html], { type: 'text/html' }); const textBlob = new Blob([plain], { type: 'text/plain' }); navigator.clipboard.write([new ClipboardItem({ 'text/html': htmlBlob, 'text/plain': textBlob })]).then(showCopied).catch(() => { fallbackCopyHtml(html); showCopied(); }); } else { fallbackCopyHtml(html); showCopied(); } } function fallbackCopyHtml(html) { const el = document.createElement('div'); el.innerHTML = html; el.style.position = 'fixed'; el.style.left = '-9999px'; document.body.appendChild(el); const range = document.createRange(); range.selectNodeContents(el); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); document.execCommand('copy'); sel.removeAllRanges(); document.body.removeChild(el); } // ---- 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); }