- 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>
930 lines
42 KiB
JavaScript
930 lines
42 KiB
JavaScript
// =============================================================================
|
||
// SLA Brief Advisor — Client-Facing Form
|
||
// Client answers practical questions → we determine the brief type
|
||
// and calculate the submission deadline from their go-live date.
|
||
// All business rules loaded from config.json.
|
||
// =============================================================================
|
||
|
||
let CONFIG = null;
|
||
let lastResultData = null;
|
||
|
||
// Brief types available when Video / Motion is selected
|
||
const VIDEO_BRIEF_TYPE_IDS = ['country_pull_adaptation', 'country_pull_creation'];
|
||
// Video-specific descriptions override config.json descriptions
|
||
const VIDEO_BRIEF_DESCRIPTIONS = {
|
||
country_pull_adaptation: [
|
||
'Minor localisation and copy updates applied to existing motion timelines.',
|
||
'No structural changes.'
|
||
],
|
||
country_pull_creation: [
|
||
'Product swaps and dynamic content updates (up to 4 elements) made within existing motion structure'
|
||
]
|
||
};
|
||
|
||
// ---- Usage Tracking ----
|
||
const TRACK_API = (() => {
|
||
// In production behind reverse proxy, use relative path
|
||
if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') return null;
|
||
const base = location.pathname.replace(/\/[^/]*$/, '');
|
||
return `${base}/api/events`;
|
||
})();
|
||
|
||
async function getVisitorId() {
|
||
// Stable anonymous fingerprint — no PII, no cookies
|
||
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; // skip on localhost
|
||
getVisitorId().then(visitor_id => {
|
||
fetch(TRACK_API, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ event, page: 'market', visitor_id, metadata }),
|
||
}).catch(() => {}); // fire-and-forget, never block UI
|
||
});
|
||
}
|
||
|
||
// ---- Bootstrap ----
|
||
document.addEventListener('DOMContentLoaded', async () => {
|
||
await loadConfig();
|
||
initDarkMode();
|
||
initDatePicker();
|
||
bindEvents();
|
||
trackEvent('page_view');
|
||
});
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const res = await fetch('config.json?v=2026050801');
|
||
CONFIG = await res.json();
|
||
} catch (e) {
|
||
console.error('Failed to load config.json:', e);
|
||
}
|
||
}
|
||
|
||
// ---- 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');
|
||
}
|
||
updateDarkIcons();
|
||
document.getElementById('darkToggle').addEventListener('click', () => {
|
||
document.documentElement.classList.toggle('dark');
|
||
localStorage.setItem('sla-dark-mode', document.documentElement.classList.contains('dark'));
|
||
updateDarkIcons();
|
||
});
|
||
}
|
||
|
||
function updateDarkIcons() {
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
document.getElementById('sunIcon').classList.toggle('hidden', !isDark);
|
||
document.getElementById('moonIcon').classList.toggle('hidden', isDark);
|
||
}
|
||
|
||
// ---- Date Picker ----
|
||
function initDatePicker() {
|
||
flatpickr('#golive', {
|
||
dateFormat: 'Y-m-d',
|
||
altInput: true,
|
||
altFormat: 'd M Y',
|
||
disableMobile: true,
|
||
minDate: 'today',
|
||
disable: [function(date) { return date.getDay() === 0 || date.getDay() === 6; }],
|
||
onChange: () => validateForm()
|
||
});
|
||
}
|
||
|
||
// ---- Event Bindings ----
|
||
function bindEvents() {
|
||
document.getElementById('contentType').addEventListener('change', validateForm);
|
||
document.getElementById('golive').addEventListener('change', validateForm);
|
||
document.getElementById('clientForm').addEventListener('submit', onSubmit);
|
||
document.getElementById('resetBtn').addEventListener('click', resetForm);
|
||
|
||
// Toggle expand/collapse for feedback day fields
|
||
document.querySelectorAll('.toggle-with-feedback').forEach(cb => {
|
||
cb.addEventListener('change', () => {
|
||
const panel = document.getElementById(cb.dataset.feedback);
|
||
if (panel) panel.classList.toggle('hidden', !cb.checked);
|
||
});
|
||
});
|
||
|
||
// Brief type override dropdown in results → recalculate
|
||
document.getElementById('briefTypeOverride').addEventListener('change', (e) => {
|
||
calculateAndRender(e.target.value);
|
||
});
|
||
}
|
||
|
||
function validateForm() {
|
||
const contentType = document.getElementById('contentType').value;
|
||
const golive = document.getElementById('golive').value;
|
||
document.getElementById('calcBtn').disabled = !(contentType && golive);
|
||
}
|
||
|
||
function onSubmit(e) {
|
||
e.preventDefault();
|
||
calculateAndRender();
|
||
// Track after render so we can capture the resolved brief type
|
||
if (lastResultData) {
|
||
trackEvent('show_results', {
|
||
briefType: lastResultData.briefType?.label || '',
|
||
contentType: document.getElementById('contentType').value,
|
||
});
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
document.getElementById('contentType').value = '';
|
||
document.getElementById('assetVolume').value = '0_50';
|
||
document.getElementById('needsStatic').checked = false;
|
||
document.getElementById('needsVideo').checked = false;
|
||
document.getElementById('needsHTML').checked = false;
|
||
document.getElementById('needsTranslation').checked = false;
|
||
document.getElementById('needsSyndication').checked = false;
|
||
// Reset static work type sub-selector
|
||
const defaultRadio = document.querySelector('input[name="staticWorkType"][value="creation"]');
|
||
if (defaultRadio) defaultRadio.checked = true;
|
||
// Reset video work type sub-selector
|
||
const defaultVideoRadio = document.querySelector('input[name="videoWorkType"][value="new_asset"]');
|
||
if (defaultVideoRadio) defaultVideoRadio.checked = true;
|
||
// Reset feedback day inputs and collapse panels
|
||
document.querySelectorAll('.toggle-with-feedback').forEach(cb => {
|
||
const panel = document.getElementById(cb.dataset.feedback);
|
||
if (panel) panel.classList.add('hidden');
|
||
});
|
||
const feedbackDefaults = { feedbackDaysStatic: 3, feedbackDaysVideo: 3, feedbackDaysHTML: 3, feedbackDaysTranslation: 5, amendRoundsStatic: 1, amendRoundsVideo: 1, amendRoundsHTML: 1, amendRoundsTranslation: 1 };
|
||
Object.entries(feedbackDefaults).forEach(([id, val]) => {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = val;
|
||
});
|
||
const goliveEl = document.getElementById('golive');
|
||
if (goliveEl._flatpickr) goliveEl._flatpickr.clear();
|
||
document.getElementById('calcBtn').disabled = true;
|
||
document.getElementById('results').classList.add('hidden');
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
// ---- Determine Brief Type from Form Answers ----
|
||
// Maps practical client needs → config brief type ID
|
||
function determineBriefType() {
|
||
const contentType = document.getElementById('contentType').value; // pdp | eventing
|
||
const needsStatic = document.getElementById('needsStatic').checked;
|
||
const needsVideo = document.getElementById('needsVideo').checked;
|
||
const needsHTML = document.getElementById('needsHTML').checked;
|
||
const needsTranslation = document.getElementById('needsTranslation').checked;
|
||
const needsSyndication = document.getElementById('needsSyndication').checked;
|
||
|
||
const needsProduction = needsStatic || needsVideo || needsHTML;
|
||
|
||
// Syndication only — no production, no translation
|
||
if (needsSyndication && !needsProduction && !needsTranslation) {
|
||
return 'country_retailer_request';
|
||
}
|
||
|
||
// Has production needs
|
||
if (needsProduction) {
|
||
// HTML → always Creation
|
||
if (needsHTML) return 'country_pull_creation';
|
||
|
||
const staticRadio = document.querySelector('input[name="staticWorkType"]:checked');
|
||
const staticWorkType = staticRadio ? staticRadio.value : 'creation';
|
||
const videoRadio = document.querySelector('input[name="videoWorkType"]:checked');
|
||
const videoWorkType = videoRadio ? videoRadio.value : 'adapting';
|
||
|
||
// If any "new asset" selection exists across static or video → Creation
|
||
if ((needsStatic && staticWorkType === 'creation') || (needsVideo && videoWorkType === 'new_asset')) {
|
||
return 'country_pull_creation';
|
||
}
|
||
// Resizing/cropping only (static, no video) → Simple
|
||
if (needsStatic && !needsVideo && staticWorkType === 'simple') {
|
||
return 'country_pull_simple';
|
||
}
|
||
// Everything else → Adaptation
|
||
return 'country_pull_adaptation';
|
||
}
|
||
|
||
// Translation only or simple resize/crop
|
||
if (needsTranslation) {
|
||
return 'country_pull_simple';
|
||
}
|
||
|
||
// Syndication + translation but no production
|
||
if (needsSyndication) {
|
||
if (contentType === 'pdp') return 'local_push_pdp';
|
||
return 'local_push_eventing';
|
||
}
|
||
|
||
// Fallback: simple pull
|
||
return 'country_pull_simple';
|
||
}
|
||
|
||
// ---- Derive Stage 6 production complexity from Advisor toggles ----
|
||
// Precedence: bespoke > creation > complex > simple — take the max across enabled toggles.
|
||
// HTML has no sub-type, so when HTML is the ONLY production toggle it falls back to 'complex'
|
||
// (team decision 2026-05-08; revisit if HTML needs its own production track).
|
||
function deriveProductionComplexity({ needsStatic, needsVideo, needsHTML, staticWorkType, videoWorkType }) {
|
||
const order = ['simple', 'complex', 'creation', 'bespoke'];
|
||
const rank = { simple: 0, complex: 1, creation: 2, bespoke: 3 };
|
||
let level = -1;
|
||
|
||
if (needsStatic) {
|
||
const map = { simple: 'simple', adaptation: 'complex', creation: 'creation' };
|
||
level = Math.max(level, rank[map[staticWorkType] || 'simple']);
|
||
}
|
||
if (needsVideo) {
|
||
const map = { adapting: 'complex', new_asset: 'creation' };
|
||
level = Math.max(level, rank[map[videoWorkType] || 'complex']);
|
||
}
|
||
if (needsHTML && level < 0) {
|
||
level = rank.complex;
|
||
}
|
||
|
||
return level >= 0 ? order[level] : 'simple';
|
||
}
|
||
|
||
// ---- Business Day Arithmetic ----
|
||
function addBusinessDays(startDate, days) {
|
||
const result = new Date(startDate);
|
||
let added = 0;
|
||
while (added < days) {
|
||
result.setDate(result.getDate() + 1);
|
||
if (result.getDay() !== 0 && result.getDay() !== 6) added++;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function subtractBusinessDays(endDate, days) {
|
||
const result = new Date(endDate);
|
||
let removed = 0;
|
||
while (removed < days) {
|
||
result.setDate(result.getDate() - 1);
|
||
if (result.getDay() !== 0 && result.getDay() !== 6) removed++;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function businessDaysBetween(start, end) {
|
||
let count = 0;
|
||
const d = new Date(start);
|
||
while (d < end) {
|
||
d.setDate(d.getDate() + 1);
|
||
if (d.getDay() !== 0 && d.getDay() !== 6) count++;
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function formatDate(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();
|
||
}
|
||
|
||
// ---- Read user-supplied feedback days and amend rounds (defaults to 0/1 if toggle is off) ----
|
||
function getUserFeedbackDays() {
|
||
const videoChecked = document.getElementById('needsVideo').checked;
|
||
const videoRadio = document.querySelector('input[name="videoWorkType"]:checked');
|
||
const videoWorkDays = videoChecked ? (videoRadio && videoRadio.value === 'adapting' ? 4 : 6) : 0;
|
||
const getRounds = (id) => parseInt(document.getElementById(id)?.value) || 1;
|
||
return {
|
||
static: document.getElementById('needsStatic').checked ? parseFloat(document.getElementById('feedbackDaysStatic').value) || 0 : 0,
|
||
staticRounds: document.getElementById('needsStatic').checked ? getRounds('amendRoundsStatic') : 0,
|
||
video: videoChecked ? parseFloat(document.getElementById('feedbackDaysVideo').value) || 0 : 0,
|
||
videoRounds: videoChecked ? getRounds('amendRoundsVideo') : 0,
|
||
videoWorkDays,
|
||
html: document.getElementById('needsHTML').checked ? parseFloat(document.getElementById('feedbackDaysHTML').value) || 0 : 0,
|
||
htmlRounds: document.getElementById('needsHTML').checked ? getRounds('amendRoundsHTML') : 0,
|
||
translation: document.getElementById('needsTranslation').checked ? parseFloat(document.getElementById('feedbackDaysTranslation').value) || 0 : 0,
|
||
translationRounds: document.getElementById('needsTranslation').checked ? getRounds('amendRoundsTranslation') : 0,
|
||
};
|
||
}
|
||
|
||
// ---- Calculation Engine ----
|
||
function calculateAndRender(overrideBriefId) {
|
||
const briefId = overrideBriefId || determineBriefType();
|
||
const assetVolume = document.getElementById('assetVolume').value;
|
||
const goliveStr = document.getElementById('golive').value;
|
||
const needsTranslation = document.getElementById('needsTranslation').checked;
|
||
const needsSyndication = document.getElementById('needsSyndication').checked;
|
||
const fb = getUserFeedbackDays();
|
||
|
||
if (!briefId || !goliveStr) return;
|
||
|
||
const goLive = new Date(goliveStr + 'T00:00:00');
|
||
const cfg = CONFIG.stageConfigs;
|
||
|
||
// Get brief type info
|
||
const bt = CONFIG.briefTypes.find(b => b.id === briefId);
|
||
|
||
// Start from the stage matrix, then overlay client toggles
|
||
const matrix = [...CONFIG.stageMatrix[briefId]];
|
||
|
||
// Force always-active stages (Opera Upload)
|
||
CONFIG.stages.forEach((stage, i) => {
|
||
const sCfg = cfg['stage' + stage.number];
|
||
if (sCfg && sCfg.alwaysActive) matrix[i] = true;
|
||
});
|
||
|
||
// In the Brief Advisor the user's toggles are the source of truth
|
||
// for translation and syndication — override the base matrix
|
||
matrix[3] = needsTranslation; // Translation (Salsify PDP)
|
||
matrix[4] = needsTranslation; // Translation (Asset)
|
||
matrix[7] = needsSyndication; // Syndication
|
||
|
||
// Production toggles + work-type radios — needed for Stage 6 complexity derivation
|
||
const contentType = document.getElementById('contentType').value;
|
||
const needsStatic = document.getElementById('needsStatic').checked;
|
||
const needsVideo = document.getElementById('needsVideo').checked;
|
||
const needsHTML = document.getElementById('needsHTML').checked;
|
||
const staticWorkType = document.querySelector('input[name="staticWorkType"]:checked')?.value;
|
||
const videoWorkType = document.querySelector('input[name="videoWorkType"]:checked')?.value;
|
||
|
||
// Urgent Brief scenario: eventing + 0–30 assets + static resizing/cropping only
|
||
const isUrgentScenario = briefId === 'urgent_brief' &&
|
||
contentType === 'eventing' &&
|
||
assetVolume === '0_50' &&
|
||
needsStatic &&
|
||
staticWorkType === 'simple';
|
||
|
||
// Combined feedback days from production toggles (static/video/html)
|
||
// Uses the max of the user-supplied feedback values for production stages
|
||
const productionFeedback = Math.max(fb.static, fb.video, fb.html);
|
||
|
||
// Calculate total project days using a temp start date
|
||
const tempStart = new Date('2026-01-05T00:00:00'); // Arbitrary Monday
|
||
const stages = [];
|
||
let currentDate = new Date(tempStart);
|
||
|
||
// Combined rounds from production toggles — use the max across static/video/html
|
||
const productionRounds = Math.max(fb.staticRounds || 0, fb.videoRounds || 0, fb.htmlRounds || 0);
|
||
|
||
// Stage 1: DMI Asset Creation
|
||
const s1 = calcStage(0, matrix[0], currentDate, () => {
|
||
const opt = cfg.stage1.fields.complexity.options.find(o => o.value === cfg.stage1.fields.complexity.default);
|
||
const rounds = cfg.stage1.fields.revisionRounds.default;
|
||
return { wip: opt ? opt.days : 0, feedback: cfg.stage1.fields.marketApprovalDays.default, revisions: rounds * cfg.stage1.fields.revisionDays.default, rounds };
|
||
});
|
||
stages.push(s1);
|
||
currentDate = s1.end;
|
||
|
||
// Stage 2: Mastering / Copy
|
||
const s2 = calcStage(1, matrix[1], currentDate, () => {
|
||
const masterOpt = cfg.stage2.fields.masteringComplexity.options.find(o => o.value === cfg.stage2.fields.masteringComplexity.default);
|
||
const copyOpt = cfg.stage2.fields.copyComplexity.options.find(o => o.value === cfg.stage2.fields.copyComplexity.default);
|
||
return { wip: (masterOpt ? masterOpt.days : 0) + (copyOpt ? copyOpt.days : 0), feedback: 0, revisions: 0, rounds: 0 };
|
||
});
|
||
stages.push(s2);
|
||
currentDate = s2.end;
|
||
|
||
// Stage 3: Global Rollout
|
||
const s3 = calcStage(2, matrix[2], currentDate, () => {
|
||
return { wip: cfg.stage3.fields.rolloutDays.default, feedback: 0, revisions: 0, rounds: 0 };
|
||
});
|
||
stages.push(s3);
|
||
currentDate = s3.end;
|
||
|
||
// Stages 4 & 5: Translation (parallel) — use user feedback days if translation is toggled
|
||
const translationActive = matrix[3] || matrix[4];
|
||
const s45 = calcStage(3, translationActive, currentDate, () => {
|
||
const wcOpt = cfg.stage4_5.fields.wordCount.options.find(o => o.value === cfg.stage4_5.fields.wordCount.default);
|
||
const translationFeedback = fb.translation > 0 ? fb.translation : cfg.stage4_5.fields.marketApprovalDays.default;
|
||
const rounds = fb.translationRounds || cfg.stage4_5.fields.revisionRounds.default;
|
||
return { wip: wcOpt ? wcOpt.days : 0, feedback: translationFeedback, revisions: rounds * cfg.stage4_5.fields.revisionDays.default, rounds };
|
||
});
|
||
stages.push({ ...s45, label: 'Translation' });
|
||
currentDate = s45.end;
|
||
|
||
// Stage 6: Production — use user feedback days from production toggles
|
||
const s6 = calcStage(5, matrix[5], currentDate, () => {
|
||
if (isUrgentScenario) {
|
||
return { wip: 1, feedback: fb.static, revisions: 0, rounds: 0 };
|
||
}
|
||
const complexity = deriveProductionComplexity({ needsStatic, needsVideo, needsHTML, staticWorkType, videoWorkType });
|
||
const key = complexity + '_' + assetVolume;
|
||
const table = cfg.stage6.fields.crossReferenceTable;
|
||
const prodFeedback = productionFeedback > 0 ? productionFeedback : cfg.stage6.fields.marketApprovalDays.default;
|
||
const rounds = productionRounds > 0 ? productionRounds : cfg.stage6.fields.revisionRounds.default;
|
||
return { wip: (table[key] || 0) + (fb.videoWorkDays || 0), feedback: prodFeedback, revisions: rounds * cfg.stage6.fields.revisionDays.default, rounds };
|
||
});
|
||
if (isUrgentScenario) s6.label = 'Production (6 hrs + confirmation)';
|
||
stages.push(s6);
|
||
currentDate = s6.end;
|
||
|
||
// Stage 7: Opera Upload
|
||
const s7 = calcStage(6, matrix[6], currentDate, () => {
|
||
const daysMap = cfg.stage7.daysByAssetVolume;
|
||
return { wip: daysMap[assetVolume] || cfg.stage7.defaultDays, feedback: 0, revisions: 0, rounds: 0 };
|
||
});
|
||
stages.push(s7);
|
||
currentDate = s7.end;
|
||
|
||
// Stage 8: Syndication — Advisor only chooses PDP vs Non-PDP from contentType.
|
||
// Salsify Prep Only is not surfaced here (team decision 2026-05-08).
|
||
const s8 = calcStage(7, matrix[7], currentDate, () => {
|
||
const typeKey = contentType === 'pdp' ? 'syndication_pdp' : 'syndication_non_pdp';
|
||
const opt = cfg.stage8.fields.syndicationType.options.find(o => o.value === typeKey);
|
||
return { wip: opt ? opt.days : 0, feedback: 0, revisions: 0, rounds: 0 };
|
||
});
|
||
stages.push(s8);
|
||
|
||
// Total business days
|
||
const totalBizDays = businessDaysBetween(tempStart, s8.end);
|
||
|
||
// Work backwards from go-live — no assumed syndication buffer
|
||
const briefAcceptedBy = subtractBusinessDays(goLive, totalBizDays);
|
||
const submitBy = subtractBusinessDays(briefAcceptedBy, 2);
|
||
const estimatedCompletion = addBusinessDays(briefAcceptedBy, totalBizDays);
|
||
|
||
// Earliest completion: if brief accepted today, when could we finish?
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const earliestCompletion = addBusinessDays(today, totalBizDays);
|
||
|
||
// Compute real calendar dates + phases for each active stage (relative to briefAcceptedBy)
|
||
const activeStages = stages.filter(s => s.active);
|
||
let runningDate = new Date(briefAcceptedBy);
|
||
activeStages.forEach(s => {
|
||
s.startDate = new Date(runningDate);
|
||
s.endDate = addBusinessDays(runningDate, s.days);
|
||
s.phases = buildPhases(s.params, s.startDate);
|
||
runningDate = s.endDate;
|
||
});
|
||
|
||
renderResults({
|
||
briefType: bt,
|
||
overrideBriefId: overrideBriefId || null,
|
||
suggestedBriefId: determineBriefType(),
|
||
stages: activeStages,
|
||
goLive,
|
||
briefAcceptedBy,
|
||
submitBy,
|
||
estimatedCompletion,
|
||
totalBizDays,
|
||
earliestCompletion,
|
||
needsVideo: document.getElementById('needsVideo').checked,
|
||
isUrgentScenario
|
||
});
|
||
}
|
||
|
||
function calcStage(stageIndex, isActive, prevDate, getParams) {
|
||
const stage = CONFIG.stages[stageIndex];
|
||
if (!isActive) {
|
||
return { index: stageIndex, label: stage ? stage.label : '', active: false, days: 0, end: new Date(prevDate), params: null, phases: [] };
|
||
}
|
||
const p = getParams();
|
||
const totalDays = p.wip + p.feedback + p.revisions;
|
||
return { index: stageIndex, label: stage ? stage.label : '', active: true, days: totalDays, end: addBusinessDays(prevDate, totalDays), params: p, phases: [] };
|
||
}
|
||
|
||
function buildPhases(params, startDate) {
|
||
if (!params) return [];
|
||
const phases = [];
|
||
let cursor = new Date(startDate);
|
||
|
||
if (params.wip > 0) {
|
||
const wipEnd = addBusinessDays(cursor, params.wip);
|
||
phases.push({ label: 'WIP 1', type: 'wip', start: new Date(cursor), end: wipEnd });
|
||
cursor = wipEnd;
|
||
}
|
||
|
||
const rounds = params.rounds || 0;
|
||
if (params.feedback > 0) {
|
||
const feedbackEnd = addBusinessDays(cursor, params.feedback);
|
||
phases.push({ label: rounds > 0 ? 'Feedback 1' : 'Feedback', type: 'feedback', start: new Date(cursor), end: feedbackEnd });
|
||
cursor = feedbackEnd;
|
||
}
|
||
|
||
if (rounds > 0 && params.revisions > 0) {
|
||
const revDaysPerRound = params.revisions / rounds;
|
||
for (let r = 1; r <= rounds; r++) {
|
||
const revEnd = addBusinessDays(cursor, revDaysPerRound);
|
||
phases.push({ label: `WIP ${r + 1}`, type: 'revision', start: new Date(cursor), end: revEnd });
|
||
cursor = revEnd;
|
||
}
|
||
}
|
||
|
||
return phases;
|
||
}
|
||
|
||
// ---- Render Results ----
|
||
function renderResults(data) {
|
||
lastResultData = data;
|
||
const isUrgentBrief = data.briefType?.id === 'urgent_brief';
|
||
const resultsEl = document.getElementById('results');
|
||
resultsEl.classList.remove('hidden');
|
||
|
||
// Brief type dropdown — show all brief types for all content types
|
||
const overrideSelect = document.getElementById('briefTypeOverride');
|
||
overrideSelect.innerHTML = '';
|
||
const briefTypesToShow = CONFIG.briefTypes;
|
||
briefTypesToShow.forEach(bt => {
|
||
const opt = document.createElement('option');
|
||
opt.value = bt.id;
|
||
opt.textContent = bt.label;
|
||
if (bt.id === data.briefType.id) opt.selected = true;
|
||
overrideSelect.appendChild(opt);
|
||
});
|
||
|
||
// Show description in blue info box — use video-specific descriptions when video is active
|
||
const descEl = document.getElementById('resultBriefDesc');
|
||
const descriptions = (data.needsVideo && VIDEO_BRIEF_DESCRIPTIONS[data.briefType.id])
|
||
? VIDEO_BRIEF_DESCRIPTIONS[data.briefType.id]
|
||
: data.briefType.description;
|
||
if (Array.isArray(descriptions)) {
|
||
descEl.innerHTML = '<ul class="list-disc list-inside space-y-0.5">' +
|
||
descriptions.map(item => `<li>${item}</li>`).join('') + '</ul>';
|
||
} else {
|
||
descEl.textContent = descriptions;
|
||
}
|
||
descEl.classList.remove('hidden');
|
||
|
||
// Show "Suggested" or "Overridden" hint
|
||
if (data.overrideBriefId && data.overrideBriefId !== data.suggestedBriefId) {
|
||
descEl.innerHTML += `<p class="mt-1.5 text-xs italic text-amber-600 dark:text-amber-400">You changed from the suggested type: ${CONFIG.briefTypes.find(b => b.id === data.suggestedBriefId)?.label || ''}</p>`;
|
||
}
|
||
|
||
// Deadline banner — hidden for urgent brief
|
||
const banner = document.getElementById('deadlineBanner');
|
||
if (isUrgentBrief) {
|
||
banner.className = 'hidden';
|
||
banner.innerHTML = '';
|
||
} else {
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
const acceptDate = new Date(data.briefAcceptedBy); acceptDate.setHours(0, 0, 0, 0);
|
||
const isPast = acceptDate < today;
|
||
|
||
if (isPast) {
|
||
banner.className = 'rounded-xl p-5 mb-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800';
|
||
banner.innerHTML = `
|
||
<div class="flex items-center gap-4">
|
||
<div class="w-14 h-14 rounded-full bg-red-500 dark:bg-red-600 flex items-center justify-center flex-shrink-0 shadow">
|
||
<svg class="w-8 h-8" fill="none" stroke="white" stroke-width="3" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||
</div>
|
||
<div>
|
||
<p class="font-bold text-red-800 dark:text-red-300 text-lg">The brief acceptance deadline has already passed</p>
|
||
<p class="text-sm text-red-600 dark:text-red-400 mt-0.5">To complete work by <strong>${formatDate(data.goLive)}</strong>, the brief needed to be accepted by <strong>${formatDate(data.briefAcceptedBy)}</strong>.</p>
|
||
<p class="text-xs text-red-500 dark:text-red-400 mt-1">Please discuss an adjusted timeline with your project manager.</p>
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
banner.className = 'rounded-xl p-5 mb-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800';
|
||
banner.innerHTML = `
|
||
<div class="flex items-center gap-4">
|
||
<div class="w-14 h-14 rounded-full bg-green-500 dark:bg-green-600 flex items-center justify-center flex-shrink-0 shadow">
|
||
<svg class="w-8 h-8" fill="none" stroke="white" stroke-width="2.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||
</div>
|
||
<div>
|
||
<p class="font-bold text-green-800 dark:text-green-300 text-lg">Brief must be accepted by ${formatDate(data.briefAcceptedBy)}</p>
|
||
<p class="text-sm text-green-600 dark:text-green-400 mt-0.5">Submit by <strong>${formatDate(data.submitBy)}</strong> to allow processing time. This gives us <strong>${data.totalBizDays} business days</strong> to complete by <strong>${formatDate(data.goLive)}</strong>.</p>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
} // end isUrgentBrief banner guard
|
||
|
||
// Urgent brief callout
|
||
const urgentNote = document.getElementById('urgentNote');
|
||
if (urgentNote) {
|
||
if (data.isUrgentScenario) {
|
||
const confirmDays = lastResultData ? document.getElementById('feedbackDaysStatic').value : 3;
|
||
urgentNote.innerHTML = `
|
||
<div class="flex items-start gap-2.5">
|
||
<svg class="w-4 h-4 text-orange-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||
<p class="text-xs text-orange-800 dark:text-orange-200"><strong>Urgent Brief:</strong> Brief production time is <strong>6 hours</strong> + <strong>${confirmDays} day(s)</strong> brief confirmation time.</p>
|
||
</div>`;
|
||
urgentNote.className = 'rounded-lg border border-orange-300 dark:border-orange-700 bg-orange-50 dark:bg-orange-900/20 px-3 py-2.5 mb-4';
|
||
} else {
|
||
urgentNote.className = 'hidden';
|
||
urgentNote.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
// Timeline summary + summary cards — hidden for urgent brief
|
||
const summary = document.getElementById('timelineSummary');
|
||
const summaryCards = document.getElementById('summaryCards');
|
||
const toggleDetails = document.getElementById('toggleDetails');
|
||
|
||
if (isUrgentBrief) {
|
||
summary.classList.add('hidden');
|
||
summaryCards.classList.add('hidden');
|
||
toggleDetails.classList.remove('hidden');
|
||
toggleDetails.innerHTML = '<a href="#" id="showHideTimeline" class="text-xs text-brand-600 dark:text-brand-400 underline hover:text-brand-800">Show timeline details</a>';
|
||
document.getElementById('showHideTimeline').addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
const isHidden = summary.classList.contains('hidden');
|
||
summary.classList.toggle('hidden', !isHidden);
|
||
summaryCards.classList.toggle('hidden', !isHidden);
|
||
e.target.textContent = isHidden ? 'Hide timeline details' : 'Show timeline details';
|
||
});
|
||
} else {
|
||
summary.classList.remove('hidden');
|
||
summaryCards.classList.remove('hidden');
|
||
toggleDetails.classList.add('hidden');
|
||
toggleDetails.innerHTML = '';
|
||
summary.innerHTML = '<h3 class="text-sm font-bold text-gray-700 dark:text-gray-300 mb-3 uppercase tracking-wide">What we need to do</h3>';
|
||
|
||
data.stages.forEach(s => {
|
||
const row = document.createElement('div');
|
||
row.className = 'flex items-center justify-between py-2.5 px-3 rounded-lg bg-gray-50 dark:bg-gray-700/40 text-sm';
|
||
row.innerHTML = `
|
||
<div class="flex items-center gap-2">
|
||
<span class="w-2 h-2 rounded-full bg-brand-500"></span>
|
||
<span class="font-medium text-gray-700 dark:text-gray-300">${s.label}</span>
|
||
</div>
|
||
<div class="text-right">
|
||
<span class="text-gray-500 dark:text-gray-400 text-xs">${s.days} day${s.days !== 1 ? 's' : ''}</span>
|
||
<span class="text-gray-400 dark:text-gray-500 text-xs ml-2">${formatDate(s.startDate)} → ${formatDate(s.endDate)}</span>
|
||
</div>
|
||
`;
|
||
summary.appendChild(row);
|
||
});
|
||
|
||
}
|
||
|
||
// Summary cards — always populated (used by copy-for-email)
|
||
document.getElementById('summarySubmitBy').textContent = formatDate(data.submitBy);
|
||
document.getElementById('summaryBriefAcceptedBy').textContent = formatDate(data.briefAcceptedBy);
|
||
document.getElementById('summaryCompletion').textContent = formatDate(data.estimatedCompletion);
|
||
document.getElementById('summaryDays').textContent = data.totalBizDays;
|
||
document.getElementById('summaryEarliest').textContent = formatDate(data.earliestCompletion);
|
||
|
||
renderGantt();
|
||
|
||
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
|
||
// ---- Copy for Email (Rich HTML) ----
|
||
function copyForEmail() {
|
||
if (!lastResultData) return;
|
||
trackEvent('copy_email', {
|
||
briefType: lastResultData.briefType?.label || '',
|
||
contentType: document.getElementById('contentType').value,
|
||
});
|
||
const data = lastResultData;
|
||
|
||
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 manualNote = (data.overrideBriefId && data.overrideBriefId !== data.suggestedBriefId) ? ' (manually selected)' : '';
|
||
|
||
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 Brief Advisor — Summary</h2>`;
|
||
html += `<p style="margin:3px 0;"><strong>Brief Type:</strong> ${data.briefType.label}${manualNote}</p>`;
|
||
html += `<p style="margin:3px 0;"><strong>Oliver Tasks Completed By:</strong> ${formatDate(data.goLive)}</p>`;
|
||
html += `<p style="margin:3px 0;"><strong>Submit Brief By:</strong> ${formatDate(data.submitBy)}</p>`;
|
||
html += `<p style="margin:3px 0;"><strong>Brief Accepted By:</strong> ${formatDate(data.briefAcceptedBy)}</p>`;
|
||
html += `<p style="margin:3px 0;"><strong>Estimated Completion:</strong> ${formatDate(data.estimatedCompletion)}</p>`;
|
||
html += `<p style="margin:3px 0;"><strong>Total Business Days:</strong> ${data.totalBizDays}</p>`;
|
||
html += `<p style="margin:3px 0 16px 0;"><strong>Earliest Completion (if accepted today):</strong> ${formatDate(data.earliestCompletion)}</p>`;
|
||
|
||
// Timeline table
|
||
html += `<h3 style="margin:0 0 6px 0;font-size:15px;">Timeline Breakdown</h3>`;
|
||
html += `<table style="${tbl}">`;
|
||
html += `<tr><th style="${thL}">Stage</th><th style="${th}">Days</th><th style="${th}">Start</th><th style="${th}">End</th></tr>`;
|
||
data.stages.forEach(s => {
|
||
html += `<tr><td style="${tdL}">${s.label}</td><td style="${td}">${s.days}</td><td style="${td}">${formatDate(s.startDate)}</td><td style="${td}">${formatDate(s.endDate)}</td></tr>`;
|
||
});
|
||
html += `</table>`;
|
||
|
||
// Disclaimer
|
||
html += `<p style="margin:12px 0 0;font-size:12px;color:#92400e;"><strong>These timings are for reference only and assume suitable working files have been provided.</strong></p>`;
|
||
html += `<p style="margin:4px 0;font-size:12px;color:#92400e;"><strong>Please confirm final deadlines with your BCM after studio evaluation. Creative requests follow ad-hoc production timelines.</strong></p>`;
|
||
html += `</div>`;
|
||
|
||
// Plain text fallback
|
||
let plain = `SLA Brief Advisor — Summary\n`;
|
||
plain += `Brief Type: ${data.briefType.label}${manualNote}\n`;
|
||
plain += `Oliver Tasks Completed By: ${formatDate(data.goLive)}\n`;
|
||
plain += `Submit Brief By: ${formatDate(data.submitBy)}\n`;
|
||
plain += `Brief Accepted By: ${formatDate(data.briefAcceptedBy)}\n`;
|
||
plain += `Estimated Completion: ${formatDate(data.estimatedCompletion)}\n`;
|
||
plain += `Total Business Days: ${data.totalBizDays}\n`;
|
||
plain += `Earliest Completion (if accepted today): ${formatDate(data.earliestCompletion)}\n\n`;
|
||
plain += `Timeline:\n`;
|
||
data.stages.forEach(s => { plain += ` ${s.label}: ${s.days} days (${formatDate(s.startDate)} → ${formatDate(s.endDate)})\n`; });
|
||
plain += `\nThese timings are for reference only and assume suitable working files have been provided.\n`;
|
||
plain += `Please confirm final deadlines with your BCM after studio evaluation.\n`;
|
||
|
||
const btn = document.getElementById('btnCopyEmail');
|
||
const originalHTML = btn.innerHTML;
|
||
const showCopied = () => {
|
||
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);
|
||
};
|
||
|
||
try {
|
||
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();
|
||
}
|
||
} catch (e) {
|
||
console.error('Copy failed:', e);
|
||
fallbackCopyHtml(html);
|
||
showCopied();
|
||
}
|
||
}
|
||
|
||
// ---- Gantt Chart ----
|
||
function toggleGantt() {
|
||
const panel = document.getElementById('ganttView');
|
||
const btn = document.getElementById('toggleGanttBtn');
|
||
const isHidden = panel.classList.contains('hidden');
|
||
panel.classList.toggle('hidden', !isHidden);
|
||
btn.textContent = isHidden ? 'Hide Gantt' : 'Show Gantt';
|
||
if (!isHidden) {
|
||
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="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/></svg> Show Gantt`;
|
||
} else {
|
||
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="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2h-2a2 2 0 00-2 2"/></svg> Hide Gantt`;
|
||
}
|
||
}
|
||
|
||
function renderGantt() {
|
||
if (!lastResultData) return;
|
||
const data = lastResultData;
|
||
const container = document.getElementById('ganttChart');
|
||
if (!container) return;
|
||
|
||
const activeStgs = data.stages;
|
||
if (!activeStgs || activeStgs.length === 0) {
|
||
container.innerHTML = '<p class="text-gray-500 text-sm">No active stages to display.</p>';
|
||
return;
|
||
}
|
||
|
||
const DAY_W = 28;
|
||
const LABEL_W = 176;
|
||
|
||
const minDate = data.briefAcceptedBy;
|
||
const maxDate = data.goLive;
|
||
|
||
const gridStart = new Date(minDate);
|
||
gridStart.setHours(0, 0, 0, 0);
|
||
gridStart.setDate(gridStart.getDate() - gridStart.getDay());
|
||
|
||
const gridEnd = new Date(maxDate);
|
||
gridEnd.setHours(0, 0, 0, 0);
|
||
gridEnd.setDate(gridEnd.getDate() + 7);
|
||
|
||
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;
|
||
|
||
const toDayIdx = (date) => {
|
||
const d = new Date(date);
|
||
d.setHours(0, 0, 0, 0);
|
||
return Math.max(0, Math.round((d - gridStart) / 86400000));
|
||
};
|
||
|
||
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',
|
||
};
|
||
|
||
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||
const DAY_LETTERS = ['S','M','T','W','T','F','S'];
|
||
|
||
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>`;
|
||
}
|
||
|
||
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-100 dark:bg-gray-800/40 pointer-events-none" style="left:${i * DAY_W}px;width:${DAY_W}px"></div>`;
|
||
}
|
||
});
|
||
|
||
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.startDate) * DAY_W;
|
||
const widthPx = Math.max((toDayIdx(s.endDate) - toDayIdx(s.startDate)) * 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.startDate)} → ${formatDate(s.endDate)}">${s.label}</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>`;
|
||
});
|
||
|
||
const estCompletionPx = LABEL_W + toDayIdx(data.estimatedCompletion) * DAY_W;
|
||
const goLiveMarker = `
|
||
<div class="absolute top-0 bottom-0 z-10 pointer-events-none" style="left:${estCompletionPx}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">EST. COMPLETION</div>
|
||
</div>`;
|
||
|
||
container.innerHTML = `
|
||
<div class="overflow-x-auto">
|
||
<div style="min-width:${LABEL_W + timelineWidth}px">
|
||
<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>
|
||
<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>
|
||
<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> Est. Completion</span>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function downloadGanttPNG() {
|
||
const el = document.getElementById('ganttChart');
|
||
if (!el || !lastResultData) return;
|
||
|
||
const isDark = document.documentElement.classList.contains('dark');
|
||
const btn = el.closest('#ganttView')?.querySelector('button[onclick="downloadGanttPNG()"]');
|
||
if (btn) btn.style.visibility = 'hidden';
|
||
|
||
html2canvas(el, {
|
||
backgroundColor: isDark ? '#1f2937' : '#ffffff',
|
||
scale: 2,
|
||
useCORS: true,
|
||
logging: false,
|
||
}).then(canvas => {
|
||
if (btn) btn.style.visibility = '';
|
||
const a = document.createElement('a');
|
||
const briefLabel = (lastResultData.briefType?.label || 'SLA_Brief').replace(/\s+/g, '_');
|
||
a.download = briefLabel + '_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 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);
|
||
}
|