loreal-sla-calculator/script.js
Alessandro Benedetti Admin e23c887b0f Show step 4 (Results) as green checkmark in stepper
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>
2026-03-06 17:39:40 +00:00

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);
}