Step 4 now shows a green checkmark like completed steps, since reaching Results means the wizard is complete. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
954 lines
37 KiB
JavaScript
954 lines
37 KiB
JavaScript
// =============================================================================
|
|
// 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 || (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 = '<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/></svg>';
|
|
} 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 = `
|
|
<span class="w-5 h-5 flex items-center justify-center rounded-full text-[10px] font-bold ${
|
|
active ? 'bg-green-500 text-white' : 'bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400'
|
|
}">${active ? 'Y' : 'N'}</span>
|
|
${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;
|
|
|
|
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 = `<span class="text-3xl sm:text-4xl font-black text-white">?</span>`;
|
|
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 = `<svg class="${icon}" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/></svg>`;
|
|
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 = `<svg class="${icon}" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`;
|
|
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 = `
|
|
<td class="py-2 pr-3 text-xs sm:text-sm">${s.label}</td>
|
|
<td class="py-2 px-2 text-center"><span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${s.active ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-400'}">${s.active ? 'Y' : 'N'}</span></td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? s.handover : 0}</td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? s.wip : 0}</td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : s.feedback) : 0}</td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : s.revisions) : 0}</td>
|
|
<td class="py-2 pl-2 text-center text-xs sm:text-sm font-medium">${formatDate(s.completeDate)}</td>
|
|
`;
|
|
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 = `
|
|
<td class="py-2 pr-3 text-xs sm:text-sm">${s.label}</td>
|
|
<td class="py-2 px-2 text-center"><span class="inline-flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold ${s.active ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : 'bg-gray-100 dark:bg-gray-700 text-gray-400'}">${s.active ? 'Y' : 'N'}</span></td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? formatDate(s.kickOffDate) : 'n.a.'}</td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : formatDate(s.wipCompleteDate)) : 'n.a.'}</td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : (s.feedbackByDate ? formatDate(s.feedbackByDate) : formatDate(s.wipCompleteDate))) : 'n.a.'}</td>
|
|
<td class="py-2 px-2 text-center text-xs sm:text-sm">${s.active ? (s.noFeedback ? 'n/r' : s.rounds) : 'n.a.'}</td>
|
|
<td class="py-2 pl-2 text-center text-xs sm:text-sm font-medium">${s.active ? formatDate(s.completeDate) : 'n.a.'}</td>
|
|
`;
|
|
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 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 = `<div style="font-family:Calibri,Arial,sans-serif;font-size:14px;color:#1f2937;">`;
|
|
html += `<h2 style="margin:0 0 12px 0;font-size:18px;">SLA Summary — ${data.briefName || 'Untitled'}</h2>`;
|
|
html += `<p style="margin:3px 0;"><strong>Brief Type:</strong> ${briefType}</p>`;
|
|
html += `<p style="margin:3px 0;"><strong>Kick-Off Date:</strong> ${formatDateLong(data.kickOff)}</p>`;
|
|
html += `<p style="margin:3px 0;"><strong>Required Go-Live:</strong> ${data.goLive ? formatDateLong(data.goLive) : 'Not set'}</p>`;
|
|
html += `<p style="margin:3px 0;"><strong>Planned End Date (SLA):</strong> ${formatDateLong(data.projectEndDate)}</p>`;
|
|
html += `<p style="margin:3px 0;"><strong>Suggested Go-Live (SLA):</strong> ${formatDateLong(data.suggestedGoLive)}</p>`;
|
|
html += `<p style="margin:3px 0 16px 0;"><strong>Verdict:</strong> <span style="color:${verdictColor};font-weight:700;">${verdictText}</span></p>`;
|
|
|
|
// Key Dates table
|
|
html += `<h3 style="margin:0 0 6px 0;font-size:15px;">Key Dates</h3>`;
|
|
html += `<table style="${tbl}">`;
|
|
html += `<tr><th style="${thL}">Stage</th><th style="${th}">Active</th><th style="${th}">Stage Kick-Off</th><th style="${th}">WIP 1 To Approval</th><th style="${th}">Receive Feedback By</th><th style="${th}">Rounds</th><th style="${th}">Stage Complete By</th></tr>`;
|
|
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 += `<tr><td style="${tdL}">${s.label}</td><td style="${aStyle}">${aText}</td><td style="${td}">${kickOff}</td><td style="${td}">${wip}</td><td style="${td}">${feedback}</td><td style="${td}">${rounds}</td><td style="${td}">${complete}</td></tr>`;
|
|
});
|
|
html += `</table>`;
|
|
|
|
// Calculation of Days table
|
|
html += `<h3 style="margin:16px 0 6px 0;font-size:15px;">Calculation of Days</h3>`;
|
|
html += `<table style="${tbl}">`;
|
|
html += `<tr><th style="${thL}">Stage</th><th style="${th}">Active</th><th style="${th}">Handover</th><th style="${th}">WIP 1</th><th style="${th}">Feedback</th><th style="${th}">Revisions</th><th style="${th}">Complete By</th></tr>`;
|
|
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 += `<tr><td style="${tdL}">${s.label}</td><td style="${aStyle}">${aText}</td><td style="${td}">${handover}</td><td style="${td}">${wip}</td><td style="${td}">${feedback}</td><td style="${td}">${revisions}</td><td style="${td}">${complete}</td></tr>`;
|
|
});
|
|
html += `</table></div>`;
|
|
|
|
// 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 = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> 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);
|
|
}
|