// ============================================================================= // 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 = `