- Stage 8: replace 3x3 complexity x EAN grid with single syndicationType dropdown -- Salsify Prep Only (4d), Syndication PDP (5d), Syndication Non-PDP (3d). Advisor maps contentType to PDP/Non-PDP only; Salsify Prep Only is not surfaced in the Advisor (team decision). - Stage 6 (Advisor): derive complexity from staticWorkType / videoWorkType / needsHTML toggles via deriveProductionComplexity helper. Precedence bespoke > creation > complex > simple, max across enabled toggles. HTML-only falls back to complex (placeholder, may revisit). - Bump config.json cache-bust to 2026050801. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1272 lines
51 KiB
JavaScript
Executable file
1272 lines
51 KiB
JavaScript
Executable file
// =============================================================================
|
|
// SLA Calculator - Script
|
|
// All business rules loaded from config.json. Zero hard-coded logic.
|
|
// =============================================================================
|
|
|
|
let CONFIG = null;
|
|
|
|
// ── API fetch helper ──────────────────────────────────────────────────────────
|
|
// Automatically includes the in-memory access token as a Bearer header.
|
|
// On 401, attempts one silent token refresh before giving up.
|
|
async function apiFetch(path, options = {}) {
|
|
const url = `/loreal-sla-calculator/api${path}`;
|
|
const headers = { ...(options.headers || {}) };
|
|
if (currentAccessToken) headers['Authorization'] = `Bearer ${currentAccessToken}`;
|
|
|
|
let res = await fetch(url, { ...options, credentials: 'include', headers });
|
|
|
|
if (res.status === 401) {
|
|
// Try to refresh and retry once
|
|
const refreshed = await tryRefresh();
|
|
if (refreshed && currentAccessToken) {
|
|
headers['Authorization'] = `Bearer ${currentAccessToken}`;
|
|
res = await fetch(url, { ...options, credentials: 'include', headers });
|
|
}
|
|
}
|
|
return res;
|
|
}
|
|
let currentStep = 1;
|
|
let activeStages = [false, false, false, false, false, false, false, false];
|
|
let lastCalculationData = null;
|
|
|
|
// ---- Usage Tracking ----
|
|
const TRACK_API = (() => {
|
|
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return null;
|
|
const base = location.pathname.replace(/\/[^/]*$/, '');
|
|
return `${base}/api/events`;
|
|
})();
|
|
|
|
async function getVisitorId() {
|
|
const raw = [
|
|
navigator.language,
|
|
screen.width + 'x' + screen.height,
|
|
screen.colorDepth,
|
|
Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
navigator.hardwareConcurrency || '',
|
|
].join('|');
|
|
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
|
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function trackEvent(event, metadata = {}) {
|
|
if (!TRACK_API) return;
|
|
getVisitorId().then(visitor_id => {
|
|
fetch(TRACK_API, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ event, page: 'calculator', visitor_id, metadata }),
|
|
}).catch(() => {});
|
|
});
|
|
}
|
|
|
|
// ---- Bootstrap ----
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await initAuth();
|
|
await loadConfig();
|
|
initDarkMode();
|
|
initStepper();
|
|
initDatePickers();
|
|
bindEvents();
|
|
trackEvent('page_view');
|
|
});
|
|
|
|
async function loadConfig() {
|
|
try {
|
|
const res = await fetch('config.json?v=2026050801');
|
|
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('stage8SyndicationType', cfg.stage8.fields.syndicationType.options, 'value', 'label');
|
|
document.getElementById('stage8SyndicationType').value = cfg.stage8.fields.syndicationType.default;
|
|
}
|
|
|
|
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,
|
|
minDate: 'today',
|
|
disable: [
|
|
function(date) {
|
|
// Disable weekends (Sat=6, Sun=0)
|
|
return date.getDay() === 0 || date.getDay() === 6;
|
|
}
|
|
]
|
|
};
|
|
flatpickr('#kickOffDate', { ...fpConfig, onChange: () => validateStep3() });
|
|
flatpickr('#goLiveDate', { ...fpConfig, onChange: () => validateStep3() });
|
|
}
|
|
|
|
function validateStep3() {
|
|
const kick = document.getElementById('kickOffDate').value;
|
|
const btn = document.getElementById('btnStep3Next');
|
|
btn.disabled = !kick;
|
|
}
|
|
|
|
// ---- Event Bindings ----
|
|
function bindEvents() {
|
|
// Brief type change
|
|
document.getElementById('briefType').addEventListener('change', onBriefTypeChange);
|
|
|
|
// Stage toggles
|
|
document.querySelectorAll('.stage-toggle').forEach(toggle => {
|
|
toggle.addEventListener('change', onStageToggle);
|
|
});
|
|
|
|
// Production complexity/volume change -> update base days and opera days
|
|
const stage6Inputs = ['stage6Complexity', 'stage6AssetVolume'];
|
|
stage6Inputs.forEach(id => {
|
|
document.getElementById(id).addEventListener('change', () => {
|
|
updateProductionBaseDays();
|
|
updateOperaDays();
|
|
});
|
|
});
|
|
|
|
// Syndication type change -> update base days
|
|
document.getElementById('stage8SyndicationType').addEventListener('change', updateSyndicationBaseDays);
|
|
}
|
|
|
|
// ---- Brief Type Change ----
|
|
function onBriefTypeChange() {
|
|
const briefId = document.getElementById('briefType').value;
|
|
if (!briefId) {
|
|
document.getElementById('projectTypeDisplay').textContent = 'Select a Brief Type first';
|
|
document.getElementById('briefDescription').classList.add('hidden');
|
|
document.getElementById('stageMatrixPreview').classList.add('hidden');
|
|
document.getElementById('btnStep1Next').disabled = true;
|
|
return;
|
|
}
|
|
|
|
const bt = CONFIG.briefTypes.find(b => b.id === briefId);
|
|
document.getElementById('projectTypeDisplay').textContent = bt.projectType;
|
|
document.getElementById('projectTypeDisplay').classList.remove('text-gray-500');
|
|
document.getElementById('projectTypeDisplay').classList.add('font-semibold');
|
|
|
|
// Description
|
|
const descEl = document.getElementById('briefDescription');
|
|
if (Array.isArray(bt.description)) {
|
|
descEl.innerHTML = '<ul class="list-disc list-inside space-y-1">' +
|
|
bt.description.map(item => `<li>${item}</li>`).join('') + '</ul>';
|
|
} else {
|
|
descEl.textContent = bt.description;
|
|
}
|
|
descEl.classList.remove('hidden');
|
|
|
|
// Stage matrix
|
|
const matrix = CONFIG.stageMatrix[briefId];
|
|
activeStages = [...matrix];
|
|
|
|
// Force always-active stages (e.g. Opera Upload)
|
|
CONFIG.stages.forEach((stage, i) => {
|
|
const cfg = CONFIG.stageConfigs['stage' + stage.number];
|
|
if (cfg && cfg.alwaysActive) activeStages[i] = true;
|
|
});
|
|
|
|
renderStageMatrixBadges(activeStages);
|
|
document.getElementById('stageMatrixPreview').classList.remove('hidden');
|
|
document.getElementById('btnStep1Next').disabled = false;
|
|
|
|
// Update stage cards visibility and toggles
|
|
updateStageCards();
|
|
updateProductionBaseDays();
|
|
updateOperaDays();
|
|
updateSyndicationBaseDays();
|
|
}
|
|
|
|
function renderStageMatrixBadges(matrix) {
|
|
const container = document.getElementById('stageMatrixBadges');
|
|
container.innerHTML = '';
|
|
CONFIG.stages.forEach((stage, i) => {
|
|
const active = matrix[i];
|
|
const cfg = CONFIG.stageConfigs['stage' + stage.number];
|
|
const locked = cfg && cfg.alwaysActive;
|
|
const badge = document.createElement('div');
|
|
badge.className = `flex items-center gap-2 px-3 py-2 rounded-lg text-xs font-medium select-none transition-colors ${
|
|
locked ? 'cursor-default' : 'cursor-pointer'
|
|
} ${
|
|
active
|
|
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40'
|
|
: 'bg-gray-50 dark:bg-gray-700/50 text-gray-400 dark:text-gray-500 border border-gray-200 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-600/50'
|
|
}`;
|
|
badge.innerHTML = `
|
|
<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}${locked ? ' <span class="text-[9px] opacity-60">(Required)</span>' : ''}
|
|
`;
|
|
if (!locked) {
|
|
badge.addEventListener('click', () => {
|
|
// Stages 4 & 5 (Translation) toggle together
|
|
if (i === 3 || i === 4) {
|
|
activeStages[3] = !activeStages[3];
|
|
activeStages[4] = activeStages[3];
|
|
} else {
|
|
activeStages[i] = !activeStages[i];
|
|
}
|
|
renderStageMatrixBadges(activeStages);
|
|
updateStageCards();
|
|
updateProductionBaseDays();
|
|
updateOperaDays();
|
|
updateSyndicationBaseDays();
|
|
});
|
|
}
|
|
container.appendChild(badge);
|
|
});
|
|
}
|
|
|
|
// ---- Stage Cards ----
|
|
function updateStageCards() {
|
|
const cardMap = {
|
|
0: 'stage1Card',
|
|
1: 'stage2Card',
|
|
2: 'stage3Card',
|
|
3: 'stage45Card', // stage 4
|
|
4: 'stage45Card', // stage 5 (same card)
|
|
5: 'stage6Card',
|
|
6: 'stage7Card',
|
|
7: 'stage8Card'
|
|
};
|
|
|
|
// Show/hide and set toggles
|
|
const shownCards = new Set();
|
|
for (let i = 0; i < 8; i++) {
|
|
const cardId = cardMap[i];
|
|
if (shownCards.has(cardId)) continue;
|
|
shownCards.add(cardId);
|
|
|
|
const card = document.getElementById(cardId);
|
|
if (!card) continue;
|
|
|
|
// Determine if this card's stages are active
|
|
let isActive;
|
|
if (cardId === 'stage45Card') {
|
|
isActive = activeStages[3] || activeStages[4];
|
|
} else {
|
|
isActive = activeStages[i];
|
|
}
|
|
|
|
card.classList.remove('hidden');
|
|
|
|
// Check if this stage is always-active (locked)
|
|
const stageNum = CONFIG.stages[i] ? CONFIG.stages[i].number : null;
|
|
const stageCfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null;
|
|
const locked = stageCfg && stageCfg.alwaysActive;
|
|
|
|
// Set toggle
|
|
const toggle = card.querySelector('.stage-toggle');
|
|
if (toggle) {
|
|
toggle.checked = isActive;
|
|
// Disable toggle for always-active stages
|
|
if (locked) {
|
|
toggle.disabled = true;
|
|
toggle.closest('label').classList.add('opacity-50', 'cursor-not-allowed');
|
|
toggle.closest('label').classList.remove('cursor-pointer');
|
|
}
|
|
// Update card appearance
|
|
const fields = card.querySelector('.stage-fields');
|
|
if (fields) {
|
|
if (isActive) {
|
|
card.classList.remove('inactive');
|
|
fields.style.display = '';
|
|
} else {
|
|
card.classList.add('inactive');
|
|
fields.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onStageToggle(e) {
|
|
const stageKey = e.target.dataset.stage;
|
|
const checked = e.target.checked;
|
|
|
|
// Prevent disabling always-active stages
|
|
if (!checked) {
|
|
const idx = parseInt(stageKey);
|
|
const stageNum = CONFIG.stages[idx] ? CONFIG.stages[idx].number : null;
|
|
const cfg = stageNum ? CONFIG.stageConfigs['stage' + stageNum] : null;
|
|
if (cfg && cfg.alwaysActive) {
|
|
e.target.checked = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (stageKey === '3_4') {
|
|
// Translation stages 4 and 5
|
|
activeStages[3] = checked;
|
|
activeStages[4] = checked;
|
|
} else {
|
|
const idx = parseInt(stageKey);
|
|
activeStages[idx] = checked;
|
|
}
|
|
|
|
// Update card appearance
|
|
const card = e.target.closest('.stage-card');
|
|
const fields = card.querySelector('.stage-fields');
|
|
if (fields) {
|
|
if (checked) {
|
|
card.classList.remove('inactive');
|
|
fields.style.display = '';
|
|
} else {
|
|
card.classList.add('inactive');
|
|
fields.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Update badges
|
|
renderStageMatrixBadges(activeStages);
|
|
}
|
|
|
|
// ---- Dynamic Lookups ----
|
|
function updateProductionBaseDays() {
|
|
const complexity = document.getElementById('stage6Complexity').value;
|
|
const volume = document.getElementById('stage6AssetVolume').value;
|
|
const key = complexity + '_' + volume;
|
|
const table = CONFIG.stageConfigs.stage6.fields.crossReferenceTable;
|
|
const days = table[key];
|
|
document.getElementById('stage6BaseDays').textContent = days !== undefined ? days + ' days' : '--';
|
|
}
|
|
|
|
function updateOperaDays() {
|
|
const volume = document.getElementById('stage6AssetVolume').value;
|
|
const daysMap = CONFIG.stageConfigs.stage7.daysByAssetVolume;
|
|
const days = daysMap[volume] || CONFIG.stageConfigs.stage7.defaultDays;
|
|
document.getElementById('stage7Days').textContent = days + ' day' + (days > 1 ? 's' : '');
|
|
}
|
|
|
|
function updateSyndicationBaseDays() {
|
|
const value = document.getElementById('stage8SyndicationType').value;
|
|
const opt = CONFIG.stageConfigs.stage8.fields.syndicationType.options.find(o => o.value === value);
|
|
const days = opt ? opt.days : undefined;
|
|
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 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: 0, 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: 0, 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: 0, 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: 0, 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: 0, 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: 0, wip, feedback: 0, revisions: 0, rounds: 0, noFeedback: true };
|
|
}));
|
|
currentDate = results[6].completeDate;
|
|
|
|
// Stage 8: Syndication
|
|
results.push(calcGenericStage(7, activeStages[7], currentDate, () => {
|
|
const value = document.getElementById('stage8SyndicationType').value;
|
|
const opt = cfg.stage8.fields.syndicationType.options.find(o => o.value === value);
|
|
const wip = opt ? opt.days : 0;
|
|
return { handover: 0, 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: compare against suggestedGoLive (includes syndication buffer)
|
|
// so that retailer lead times are factored into the Y/N decision
|
|
const canMeet = goLive ? suggestedGoLive <= 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);
|
|
|
|
// Build sub-phase array for Gantt detail view
|
|
const phases = [];
|
|
if (params.handover > 0) {
|
|
phases.push({ label: 'Handover', type: 'handover', start: new Date(prevDate), end: new Date(stageKickOff) });
|
|
}
|
|
if (params.wip > 0) {
|
|
phases.push({ label: 'WIP 1', type: 'wip', start: new Date(stageKickOff), end: new Date(wipComplete) });
|
|
}
|
|
if (!params.noFeedback && params.rounds > 0) {
|
|
const revDaysPerRound = params.revisions / params.rounds;
|
|
let cursor = new Date(wipComplete);
|
|
// First feedback period
|
|
phases.push({ label: 'Feedback 1', type: 'feedback', start: new Date(cursor), end: new Date(feedbackBy) });
|
|
cursor = new Date(feedbackBy);
|
|
// Each revision round (WIP 2, WIP 3, ...)
|
|
for (let r = 1; r <= params.rounds; r++) {
|
|
const revEnd = addBusinessDays(cursor, revDaysPerRound);
|
|
phases.push({ label: `WIP ${r + 1}`, type: 'revision', start: new Date(cursor), end: revEnd });
|
|
cursor = revEnd;
|
|
}
|
|
} else if (!params.noFeedback && params.feedback > 0) {
|
|
phases.push({ label: 'Feedback', type: 'feedback', start: new Date(wipComplete), end: new Date(completeDate) });
|
|
}
|
|
|
|
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,
|
|
phases
|
|
};
|
|
}
|
|
|
|
function intVal(id) {
|
|
const el = document.getElementById(id);
|
|
return el ? parseFloat(el.value) || 0 : 0;
|
|
}
|
|
|
|
// ---- Render Results ----
|
|
function renderResults(data) {
|
|
lastCalculationData = data;
|
|
const briefType = document.getElementById('briefType').selectedOptions[0]?.text;
|
|
trackEvent('show_results', { briefType });
|
|
|
|
// 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.suggestedGoLive);
|
|
const prodGap = businessDaysBetween(data.goLive, data.projectEndDate);
|
|
if (prodGap > 0) {
|
|
msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (${gap} business day${gap !== 1 ? 's' : ''} over deadline)`;
|
|
} else {
|
|
msg.textContent = CONFIG.verdictMessages.cannotMeet + ` (production completes in time, but ${gap} business day${gap !== 1 ? 's' : ''} short for syndication retailer lead times)`;
|
|
}
|
|
}
|
|
|
|
// 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.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);
|
|
});
|
|
}
|
|
|
|
// ---- View Toggle (List / Gantt) ----
|
|
function setView(view) {
|
|
const listView = document.getElementById('listView');
|
|
const ganttView = document.getElementById('ganttView');
|
|
const btnList = document.getElementById('btnViewList');
|
|
const btnGantt = document.getElementById('btnViewGantt');
|
|
|
|
const activeClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-sm';
|
|
const inactiveClass = 'px-4 py-2 rounded-md font-medium text-sm transition-colors text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200';
|
|
|
|
if (view === 'gantt') {
|
|
listView.classList.add('hidden');
|
|
ganttView.classList.remove('hidden');
|
|
btnGantt.className = activeClass;
|
|
btnList.className = inactiveClass;
|
|
renderGantt();
|
|
} else {
|
|
listView.classList.remove('hidden');
|
|
ganttView.classList.add('hidden');
|
|
btnList.className = activeClass;
|
|
btnGantt.className = inactiveClass;
|
|
}
|
|
}
|
|
|
|
function renderGantt() {
|
|
if (!lastCalculationData) return;
|
|
const data = lastCalculationData;
|
|
const container = document.getElementById('ganttChart');
|
|
|
|
// Filter active stages
|
|
const activeStgs = data.stages.filter(s => s.active);
|
|
if (activeStgs.length === 0) {
|
|
container.innerHTML = '<p class="text-gray-500 text-sm">No active stages to display.</p>';
|
|
return;
|
|
}
|
|
|
|
// ── Day-grid setup ──────────────────────────────────────────────────────────
|
|
const DAY_W = 28; // px per calendar day
|
|
const LABEL_W = 192; // px for left stage-label column
|
|
|
|
const minDate = data.kickOff;
|
|
const maxDate = data.suggestedGoLive > data.projectEndDate ? data.suggestedGoLive : data.projectEndDate;
|
|
|
|
// Snap grid start to the Sunday of the kick-off week
|
|
const gridStart = new Date(minDate);
|
|
gridStart.setHours(0, 0, 0, 0);
|
|
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
|
|
|
|
// Pad end by one full week
|
|
const gridEnd = new Date(maxDate);
|
|
gridEnd.setHours(0, 0, 0, 0);
|
|
gridEnd.setDate(gridEnd.getDate() + 7);
|
|
|
|
// All calendar days in range
|
|
const days = [];
|
|
const cur = new Date(gridStart);
|
|
while (cur <= gridEnd) {
|
|
days.push(new Date(cur));
|
|
cur.setDate(cur.getDate() + 1);
|
|
}
|
|
const timelineWidth = days.length * DAY_W;
|
|
|
|
// Day-index from gridStart (0-based)
|
|
const toDayIdx = (date) => {
|
|
const d = new Date(date);
|
|
d.setHours(0, 0, 0, 0);
|
|
return Math.max(0, Math.round((d - gridStart) / 86400000));
|
|
};
|
|
|
|
// ── Phase colours ───────────────────────────────────────────────────────────
|
|
const phaseColors = {
|
|
wip: 'bg-violet-600 dark:bg-violet-700',
|
|
revision: 'bg-violet-400 dark:bg-violet-500',
|
|
feedback: 'bg-amber-400 dark:bg-amber-500',
|
|
};
|
|
|
|
// ── Header rows ─────────────────────────────────────────────────────────────
|
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
const DAY_LETTERS = ['S','M','T','W','T','F','S']; // Sun=0
|
|
|
|
// Row 1: week-start date labels (every 7 days)
|
|
let weekRow = '';
|
|
for (let i = 0; i < days.length; i += 7) {
|
|
const day = days[i];
|
|
weekRow += `<div class="absolute border-l border-gray-200 dark:border-gray-600 text-[10px] font-semibold text-gray-500 dark:text-gray-400 pl-1 truncate leading-4" style="left:${i * DAY_W}px;width:${7 * DAY_W}px">${day.getDate()} ${MONTHS[day.getMonth()]}</div>`;
|
|
}
|
|
|
|
// Row 2: individual day letters + weekend band collector
|
|
let dayRow = '';
|
|
let weekendBands = '';
|
|
days.forEach((day, i) => {
|
|
const isWeekend = day.getDay() === 0 || day.getDay() === 6;
|
|
const letter = DAY_LETTERS[day.getDay()];
|
|
const textCls = isWeekend
|
|
? 'text-gray-300 dark:text-gray-600 font-medium'
|
|
: 'text-gray-400 dark:text-gray-500 font-medium';
|
|
dayRow += `<div class="absolute border-l border-gray-100 dark:border-gray-700/60 text-[9px] text-center leading-4 ${textCls}" style="left:${i * DAY_W}px;width:${DAY_W}px">${letter}</div>`;
|
|
if (isWeekend) {
|
|
weekendBands += `<div class="absolute top-0 bottom-0 bg-gray-50 dark:bg-gray-800/40 pointer-events-none" style="left:${i * DAY_W}px;width:${DAY_W}px"></div>`;
|
|
}
|
|
});
|
|
|
|
// ── Stage rows ──────────────────────────────────────────────────────────────
|
|
let rows = '';
|
|
activeStgs.forEach((s, idx) => {
|
|
let barsHtml = '';
|
|
if (s.phases && s.phases.length > 0) {
|
|
s.phases.forEach(phase => {
|
|
const leftPx = toDayIdx(phase.start) * DAY_W;
|
|
const widthDays = Math.max(toDayIdx(phase.end) - toDayIdx(phase.start), 1);
|
|
const widthPx = widthDays * DAY_W;
|
|
const color = phaseColors[phase.type] || 'bg-violet-600 dark:bg-violet-700';
|
|
barsHtml += `<div class="absolute ${color} rounded h-full flex items-center px-1.5 text-white text-[10px] font-semibold shadow-sm overflow-hidden" style="left:${leftPx}px;width:${widthPx}px" title="${phase.label}: ${formatDate(phase.start)} → ${formatDate(phase.end)}">${phase.label}</div>`;
|
|
});
|
|
} else {
|
|
const leftPx = toDayIdx(s.kickOffDate || minDate) * DAY_W;
|
|
const widthPx = Math.max((toDayIdx(s.completeDate) - toDayIdx(s.kickOffDate || minDate)) * DAY_W, DAY_W);
|
|
barsHtml = `<div class="absolute bg-violet-600 dark:bg-violet-700 rounded h-full flex items-center px-1.5 text-white text-[10px] font-semibold shadow-sm overflow-hidden" style="left:${leftPx}px;width:${widthPx}px" title="${s.label}: ${formatDate(s.kickOffDate)} → ${formatDate(s.completeDate)}">${formatDate(s.kickOffDate)}</div>`;
|
|
}
|
|
|
|
rows += `
|
|
<div class="flex items-center ${idx > 0 ? 'border-t border-gray-100 dark:border-gray-700/50' : ''}">
|
|
<div class="shrink-0 text-right pr-3 py-2" style="width:${LABEL_W}px">
|
|
<span class="text-[11px] font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wide leading-tight">${s.label}</span>
|
|
</div>
|
|
<div class="relative overflow-hidden" style="width:${timelineWidth}px;height:28px">
|
|
${weekendBands}
|
|
${barsHtml}
|
|
</div>
|
|
</div>`;
|
|
});
|
|
|
|
// ── Suggested go-live marker ────────────────────────────────────────────────
|
|
const suggestedGoLivePx = LABEL_W + toDayIdx(data.suggestedGoLive) * DAY_W;
|
|
const goLiveMarker = `
|
|
<div class="absolute top-0 bottom-0 z-10 pointer-events-none" style="left:${suggestedGoLivePx}px">
|
|
<div class="w-0 h-full border-l-2 border-dashed border-red-400 dark:border-red-500"></div>
|
|
<div class="absolute -top-5 -translate-x-1/2 text-[9px] font-bold text-red-500 dark:text-red-400 whitespace-nowrap">SUGGESTED LIVE</div>
|
|
</div>`;
|
|
|
|
container.innerHTML = `
|
|
<div class="overflow-x-auto">
|
|
<div style="min-width:${LABEL_W + timelineWidth}px">
|
|
<!-- Header row 1: week dates -->
|
|
<div class="flex items-stretch">
|
|
<div class="shrink-0" style="width:${LABEL_W}px"></div>
|
|
<div class="relative" style="width:${timelineWidth}px;height:16px">${weekRow}</div>
|
|
</div>
|
|
<!-- Header row 2: day letters -->
|
|
<div class="flex items-stretch mb-1">
|
|
<div class="shrink-0" style="width:${LABEL_W}px"></div>
|
|
<div class="relative" style="width:${timelineWidth}px;height:14px">${dayRow}</div>
|
|
</div>
|
|
<!-- Stage rows -->
|
|
<div class="relative border-t border-gray-200 dark:border-gray-700">
|
|
${goLiveMarker}
|
|
${rows}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-4 mt-4 pt-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-400 dark:text-gray-500">
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-violet-600 inline-block"></span> WIP</span>
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-amber-400 inline-block"></span> Feedback</span>
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-violet-400 inline-block"></span> Revision WIP</span>
|
|
|
|
<span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded bg-gray-100 border border-gray-200 inline-block"></span> Weekend</span>
|
|
<span class="flex items-center gap-1.5"><span class="w-4 border-t-2 border-dashed border-red-400 inline-block"></span> Suggested Live</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ---- 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.suggestedGoLive);
|
|
lines.push('Business Days Over Deadline,' + gap);
|
|
}
|
|
|
|
// Calculation of Days table
|
|
lines.push('');
|
|
lines.push('Calculation of Days');
|
|
lines.push('Stage,Active,WIP 1,Feedback,Revisions,Complete By');
|
|
data.stages.forEach(s => {
|
|
lines.push([
|
|
csvEscape(s.label),
|
|
s.active ? 'Y' : 'N',
|
|
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);
|
|
}
|
|
|
|
// ---- Download Gantt as PNG ----
|
|
function downloadGanttPNG() {
|
|
const el = document.getElementById('ganttChart');
|
|
if (!el || !lastCalculationData) return;
|
|
|
|
const isDark = document.documentElement.classList.contains('dark');
|
|
const btn = el.closest('#ganttView').querySelector('button');
|
|
if (btn) btn.style.visibility = 'hidden'; // hide button from screenshot
|
|
|
|
html2canvas(el, {
|
|
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
|
scale: 2,
|
|
useCORS: true,
|
|
logging: false,
|
|
}).then(canvas => {
|
|
if (btn) btn.style.visibility = '';
|
|
const a = document.createElement('a');
|
|
a.download = (lastCalculationData.briefName || 'SLA_Gantt').replace(/\s+/g, '_') + '_Gantt.png';
|
|
a.href = canvas.toDataURL('image/png');
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
}).catch(() => {
|
|
if (btn) btn.style.visibility = '';
|
|
alert('Could not generate image. Please try again.');
|
|
});
|
|
}
|
|
|
|
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 briefType = document.getElementById('briefType').selectedOptions[0]?.text || '';
|
|
trackEvent('copy_email', { briefType });
|
|
const data = lastCalculationData;
|
|
|
|
// 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.suggestedGoLive);
|
|
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}">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 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}">${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);
|
|
}
|