'use strict'; // ── State ───────────────────────────────────────────────────────────────────── let allClients = []; let activeClient = null; // { config, data } let activeTab = 'entities'; let detailEntity = null; const selectedForCompare = new Set(); let sortPillar = 'overall'; // 'overall' | pillar name let groupByGroup = false; // ── Pillar short labels ─────────────────────────────────────────────────────── const PILLAR_SHORT = { 'OMNICHANNEL': 'OMNI', 'CLIENT CENTRICITY': 'CLIENT', 'MEASUREMENT': 'MEASURE', 'TECH CAPABILITIES': 'TECH', 'AUTOMATION & INDUSTRIALIZATION': 'AUTO', 'INNOVATION': 'INNOV', 'ORGANISATION': 'ORG', }; function pillarShort(name) { return PILLAR_SHORT[name] || name.slice(0, 6).toUpperCase(); } // ── Data quality flags ──────────────────────────────────────────────────────── function getEntityFlags(entity) { const flags = []; const qs = entity.pillars.flatMap(p => p.questions); const zeroScore = qs.filter(q => !q.score || q.score === 0); if (zeroScore.length > 0) flags.push({ icon: '✕', label: `${zeroScore.length} question${zeroScore.length > 1 ? 's' : ''} missing score` }); const noRationale = qs.filter(q => !q.rationale || q.rationale.trim().length < 5); if (noRationale.length > 0) flags.push({ icon: '◌', label: `${noRationale.length} question${noRationale.length > 1 ? 's' : ''} missing rationale` }); const noGaps = qs.filter(q => !q.gaps || q.gaps.trim().length < 5); if (noGaps.length > Math.floor(qs.length * 0.2)) flags.push({ icon: '◌', label: `${noGaps.length} questions missing gap analysis` }); if (entity.synced) { const days = Math.floor((Date.now() - new Date(entity.synced)) / 86400000); if (days > 30) flags.push({ icon: '⏱', label: `Data last synced ${days} days ago` }); } return flags; } // ── Boot ────────────────────────────────────────────────────────────────────── async function init() { applyStoredTheme(); try { const res = await fetch('/api/clients'); allClients = await res.json(); renderHome(); } catch (e) { document.getElementById('headerSub').textContent = 'Could not connect to server'; showToast('Failed to load data', 'error'); } } // ── Home screen tabs ────────────────────────────────────────────────────────── function showHomeTab(name) { ['clients', 'about'].forEach(t => { document.getElementById(`homeTab-${t}`).style.display = t === name ? '' : 'none'; document.getElementById(`home-tab-${t}`).classList.toggle('active', t === name); }); if (name === 'about') renderAbout(); } function renderAbout() { const el = document.getElementById('homeTab-about'); // Build content from all loaded clients' config.about (use first client that has it) const client = allClients.find(c => c.about) || allClients[0]; const about = client && client.about; const scoring = client && client.scoring; const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' }; const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, name]) => { const desc = about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : ''; return `
${escHtml(name)}
Level ${escHtml(lvl)}
${desc ? `
${escHtml(desc)}
` : ''}
`; }).join('') : ''; const pillarRows = (client && client.pillars || []).map((pname, i) => { const desc = about && about.pillar_descriptions ? about.pillar_descriptions[pname] : ''; const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛']; return `
${icons[i] || '▪'}
${escHtml(pname)}
${desc ? `
${escHtml(desc)}
` : ''}
`; }).join(''); el.innerHTML = `

What is this tool?

${about ? escHtml(about.summary) : 'A content maturity assessment dashboard for evaluating and comparing business units across key marketing capability pillars.'}

${about && about.question_count ? `
${about.question_count}
Questions
${(client.pillars || []).length}
Pillars
${allClients[0] && allClients[0].entity_count || '—'}
Markets
` : ''}

Maturity Levels

${levelRows}

How to use this tool

${[ ['Markets tab', 'Browse all markets sorted by overall score. Click any card to drill into pillar scores and individual question rationale.'], ['Heatmap tab', 'See all markets and pillars in one grid — instantly spot which pillars are strong or weak across the board.'], ['Compare tab', 'Select 2 or more markets to put them side by side. Green = highest score in that pillar, red = lowest.'], ['Radar chart', 'Each market detail view shows a spider chart of its 7 pillar scores — useful for understanding the shape of a market\'s maturity.'], ['⚠ Flag badge', 'An amber warning on a card means that market has incomplete data (missing scores, rationale, or gap analysis). Click the card to see detail.'], ['Update Data tab', 'Re-sync data from Box source files or upload a new CSV/XLSX directly.'], ].map(([title, desc]) => `
${escHtml(title)}
${escHtml(desc)}
`).join('')}

The ${(client && client.pillars || []).length} Pillars

${pillarRows}
`; } // ── Home screen ─────────────────────────────────────────────────────────────── function renderHome() { document.getElementById('homeBtn').style.display = 'none'; document.getElementById('tabBar').style.display = 'none'; document.getElementById('homeScreen').style.display = ''; document.getElementById('clientView').style.display = 'none'; document.getElementById('headerTitle').textContent = 'Maturity Tool'; document.getElementById('headerSub').textContent = `${allClients.length} client${allClients.length !== 1 ? 's' : ''}`; if (allClients.length === 1) { const c = allClients[0]; applyAccent(c.accent_color); document.getElementById('clientCards').innerHTML = `
${c.logo ? `${escHtml(c.name)}` : `
${escHtml(c.name.slice(0,2).toUpperCase())}
`}

${escHtml(c.name)}

${escHtml(c.description)}

Click below to explore the maturity scores, pillar breakdowns, and deliverables for all ${c.entity_count} ${escHtml(c.entity_label || 'markets')}.

Last updated ${escHtml(c.generated)}

New Client

Set up a new maturity assessment client

`; } else { document.getElementById('clientCards').innerHTML = allClients.map((c, i) => `
${c.logo ? `${escHtml(c.name)}` : `
${escHtml(c.name.slice(0,2).toUpperCase())}
`} ${c.entity_count} ${escHtml(c.entity_label || 'entities')}

${escHtml(c.name)}

${escHtml(c.description)}

Generated ${escHtml(c.generated)} Open →
`).join('') + `

New Client

Set up a new maturity assessment client

`; } } async function renderSingleClientHome(id) { try { const [cfgRes, datRes] = await Promise.all([ fetch(`/api/clients/${id}/config`), fetch(`/api/clients/${id}/data`), ]); const config = await cfgRes.json(); const data = await datRes.json(); // Cache so entering client view doesn't re-fetch unnecessarily if (!activeClient || activeClient.config.id !== id) { const delRes = await fetch(`/api/clients/${id}/deliverables`); activeClient = { config, data, deliverables: delRes.ok ? await delRes.json() : {} }; } applyAccent(config.accent_color); const entities = data.entities; const scoring = config.scoring; const scores = entities.map(e => e.overall_score); const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2); const best = entities[0]; const worst = entities[entities.length - 1]; const avgLevel = scoreToLevel(Math.round(parseFloat(avg)), scoring); const marketRows = entities.map((e, i) => { const pct = pillarBarPct(e.overall_score); const lvl = scoreClass(e.overall_level); return `
${i + 1} ${escHtml(e.short)}
${e.overall_score.toFixed(2)}
${escHtml(e.overall_label)}
`; }).join(''); document.getElementById('clientCards').innerHTML = `
${escHtml(config.name.slice(0,2).toUpperCase())}

${escHtml(config.name)}

${escHtml(config.description)}

${entities.length}
${escHtml(config.entity_label || 'Entities')}
${avg}
Avg Score
Highest
${escHtml(best.short)}
${best.overall_score.toFixed(2)}
Lowest
${escHtml(worst.short)}
${worst.overall_score.toFixed(2)}

${escHtml(config.entity_label || 'Entities')} Overview

${marketRows}

Data generated ${escHtml(data.generated)} · Click any row to view details

${config.about ? renderAboutSection(config) : ''}
`; // Hover effect on market rows document.querySelectorAll('.home-market-row').forEach(row => { row.addEventListener('mouseenter', () => row.style.background = 'var(--bg-inset)'); row.addEventListener('mouseleave', () => row.style.background = ''); }); } catch (e) { showToast('Failed to load overview', 'error'); } } function renderAboutSection(config) { const about = config.about; const scoring = config.scoring; const pillarGrid = config.pillars.map(pname => { const desc = (about.pillar_descriptions || {})[pname] || ''; return `

${escHtml(pname)}

${escHtml(desc)}

`; }).join(''); const scoringRows = Object.keys(scoring.labels).sort((a,b) => a-b).map(n => { const lvl = scoreClass(parseInt(n)); const desc = (about.scoring_descriptions || {})[n] || ''; return `
${n} — ${escHtml(scoring.labels[n])} ${escHtml(desc)}
`; }).join(''); return `

About the Assessment

${about.summary ? `

${escHtml(about.summary)}

` : ''}

${about.question_count ? `${about.question_count} Questions across ` : ''}${config.pillars.length} Pillars

${pillarGrid}

Scoring Scale

${scoringRows}
`; } function goHome() { activeClient = null; detailEntity = null; selectedForCompare.clear(); renderHome(); } // ── New Client Wizard ───────────────────────────────────────────────────────── const WIZARD_STEPS = ['Client Info', 'Entity Labels', 'Scoring', 'Pillars', 'Review']; const ENTITY_PRESETS = [ { label: 'Markets', singular: 'Market' }, { label: 'Regions', singular: 'Region' }, { label: 'Business Units', singular: 'Business Unit' }, { label: 'Countries', singular: 'Country' }, { label: 'Brands', singular: 'Brand' }, { label: 'Teams', singular: 'Team' }, { label: 'Divisions', singular: 'Division' }, ]; let wizStep = 0; let wizData = {}; function wizardReset() { wizStep = 0; wizData = { name: '', id: '', description: '', accent_color: '#6366f1', entity_label: 'Markets', entity_label_singular: 'Market', scoring_levels: 4, scoring_labels: ['Learner', 'Intermediate', 'Master', 'Expert'], pillars: ['OMNICHANNEL', 'CLIENT CENTRICITY', 'MEASUREMENT', 'TECH CAPABILITIES', 'AUTOMATION & INDUSTRIALIZATION', 'INNOVATION', 'ORGANISATION'], }; } function openWizard() { wizardReset(); document.getElementById('wizardModal').classList.remove('hidden'); renderWizardStep(); } function closeWizard() { document.getElementById('wizardModal').classList.add('hidden'); } function handleWizardOverlayClick(e) { if (e.target === document.getElementById('wizardModal')) closeWizard(); } function renderWizardDots() { document.getElementById('wizardDots').innerHTML = WIZARD_STEPS.map((_, i) => `
${i < WIZARD_STEPS.length - 1 ? `
` : ''}
`).join(''); } function renderWizardStep() { renderWizardDots(); document.getElementById('wizardStepLabel').textContent = `Step ${wizStep + 1} of ${WIZARD_STEPS.length} — ${WIZARD_STEPS[wizStep]}`; document.getElementById('wizardBackBtn').style.display = wizStep === 0 ? 'none' : ''; document.getElementById('wizardNextBtn').textContent = wizStep === WIZARD_STEPS.length - 1 ? '✓ Create Client' : 'Next →'; const body = document.getElementById('wizardBody'); [renderWizStep0, renderWizStep1, renderWizStep2, renderWizStep3, renderWizStep4][wizStep](body); } function wizField(id, label, value, placeholder, extra = '') { return `
`; } function renderWizStep0(body) { body.innerHTML = ` ${wizField('wiz-name', 'Client Name *', wizData.name, 'e.g. ACME Corp', 'oninput="wizAutoId()"')} ${wizField('wiz-id', 'Client ID (auto-generated)', wizData.id, 'e.g. acme-corp')} ${wizField('wiz-desc', 'Description', wizData.description, 'e.g. Content Maturity Assessment')}
Brand accent colour
`; } function wizAutoId() { wizData.name = document.getElementById('wiz-name').value; wizData.id = wizData.name.toLowerCase() .replace(/[^a-z0-9\s-]/g, '').trim() .replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, ''); const el = document.getElementById('wiz-id'); if (el) el.value = wizData.id; } function renderWizStep1(body) { const opts = ENTITY_PRESETS.map(p => `` ).join(''); body.innerHTML = `
${wizField('wiz-entity-label', 'Entity label (plural) *', wizData.entity_label, 'e.g. Markets')} ${wizField('wiz-entity-singular', 'Entity label (singular)', wizData.entity_label_singular, 'e.g. Market')}

Used throughout the tool — tab label, summary bar, compare selector.

`; } function applyEntityPreset(label) { const p = ENTITY_PRESETS.find(x => x.label === label); if (!p) return; wizData.entity_label = p.label; wizData.entity_label_singular = p.singular; const el = document.getElementById('wiz-entity-label'); const es = document.getElementById('wiz-entity-singular'); if (el) el.value = p.label; if (es) es.value = p.singular; } function renderWizStep2(body) { const n = wizData.scoring_levels; const inputs = Array.from({ length: n }, (_, i) => `
${i + 1}
`).join(''); body.innerHTML = `
${[2,3,4,5,6].map(v => ` `).join('')}

Level names (lowest → highest)

${inputs}
`; } function setWizardLevels(n) { const defaults = ['Beginner', 'Developing', 'Proficient', 'Expert', 'Advanced', 'World Class']; while (wizData.scoring_labels.length < n) wizData.scoring_labels.push(defaults[wizData.scoring_labels.length] || `Level ${wizData.scoring_labels.length + 1}`); wizData.scoring_labels = wizData.scoring_labels.slice(0, n); wizData.scoring_levels = n; renderWizardStep(); } function renderWizStep3(body) { const rows = wizData.pillars.map((p, i) => `
`).join(''); body.innerHTML = `
${rows}

Pre-filled with ADEO's 7 pillars as a starting point — modify or replace as needed.

`; } function addWizPillar() { wizData.pillars.push(''); renderWizardStep(); } function removeWizPillar(i) { wizData.pillars.splice(i, 1); renderWizardStep(); } function renderWizStep4(body) { body.innerHTML = `

Review your configuration. Go back to edit any step.

${escHtml(JSON.stringify(buildWizardConfig(), null, 2))}
`; } function buildWizardConfig() { const n = wizData.scoring_levels; const labels = {}; for (let i = 0; i < n; i++) labels[String(i + 1)] = wizData.scoring_labels[i] || `Level ${i + 1}`; return { id: wizData.id, name: wizData.name, description: wizData.description, accent_color: wizData.accent_color, scoring: { min: 1, max: n, labels }, pillars: wizData.pillars.filter(p => p.trim()), entity_label: wizData.entity_label, entity_label_singular: wizData.entity_label_singular, }; } function wizardSaveStep() { if (wizStep === 0) { wizData.name = document.getElementById('wiz-name')?.value?.trim() || wizData.name; wizData.id = document.getElementById('wiz-id')?.value?.trim() || wizData.id; wizData.description = document.getElementById('wiz-desc')?.value?.trim() || wizData.description; wizData.accent_color = document.getElementById('wiz-color')?.value || wizData.accent_color; } else if (wizStep === 1) { wizData.entity_label = document.getElementById('wiz-entity-label')?.value?.trim() || wizData.entity_label; wizData.entity_label_singular = document.getElementById('wiz-entity-singular')?.value?.trim() || wizData.entity_label_singular; } else if (wizStep === 3) { document.querySelectorAll('#wizPillarList input').forEach((inp, i) => { wizData.pillars[i] = inp.value.toUpperCase(); }); wizData.pillars = wizData.pillars.filter(p => p.trim()); } } function wizardValidate() { if (wizStep === 0 && !wizData.name) { showToast('Client name is required', 'error'); return false; } if (wizStep === 0 && !wizData.id) { showToast('Could not derive a client ID from that name', 'error'); return false; } if (wizStep === 3 && !wizData.pillars.filter(p => p.trim()).length) { showToast('Add at least one pillar', 'error'); return false; } return true; } function wizardNext() { wizardSaveStep(); if (!wizardValidate()) return; if (wizStep === WIZARD_STEPS.length - 1) { createClient(); return; } wizStep++; renderWizardStep(); } function wizardBack() { wizardSaveStep(); if (wizStep > 0) { wizStep--; renderWizardStep(); } } async function createClient() { const cfg = buildWizardConfig(); const btn = document.getElementById('wizardNextBtn'); btn.disabled = true; btn.textContent = 'Creating…'; try { const res = await fetch('/api/clients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || res.statusText); closeWizard(); showToast(`"${cfg.name}" created! Set up data in the Update tab.`, 'success'); const r2 = await fetch('/api/clients'); allClients = await r2.json(); await loadClient(data.id); showTab('update'); } catch (e) { showToast('Failed to create: ' + e.message, 'error'); btn.disabled = false; btn.textContent = '✓ Create Client'; } } // ── Load client ─────────────────────────────────────────────────────────────── async function loadClient(id) { try { const [cfgRes, datRes, delRes] = await Promise.all([ fetch(`/api/clients/${id}/config`), fetch(`/api/clients/${id}/data`), fetch(`/api/clients/${id}/deliverables`), ]); const config = await cfgRes.json(); const data = await datRes.json(); const deliverables = delRes.ok ? await delRes.json() : {}; activeClient = { config, data, deliverables }; applyAccent(config.accent_color); enterClientView(config); renderSummaryBar(); renderEntityCards(); renderCompareSelector(); document.getElementById('exportRow').style.display = 'flex'; } catch (e) { showToast('Failed to load client data', 'error'); } } function applyAccent(color) { document.documentElement.style.setProperty('--accent', color); } function enterClientView(config) { if (config.logo) { document.getElementById('headerTitle').innerHTML = `${escHtml(config.name)}`; } else { document.getElementById('headerTitle').textContent = config.name; } document.getElementById('headerSub').textContent = config.description; document.getElementById('homeBtn').style.display = ''; document.getElementById('tabBar').style.display = ''; document.getElementById('homeScreen').style.display = 'none'; document.getElementById('clientView').style.display = ''; // Update tab label to match entity type const entityLabel = config.entity_label || 'Entities'; document.getElementById('tab-entities').textContent = entityLabel; showTab('entities'); } // ── Tab navigation ──────────────────────────────────────────────────────────── function showTab(name) { ['entities', 'compare', 'heatmap', 'update'].forEach(t => { document.getElementById(`tab-${t}-content`).style.display = t === name ? '' : 'none'; document.getElementById(`tab-${t}`).classList.toggle('active', t === name); }); activeTab = name; if (name === 'update') populateImportEntitySelect(); if (name === 'heatmap') renderHeatmap(); } // ── Summary bar ─────────────────────────────────────────────────────────────── function renderSummaryBar() { const entities = activeClient.data.entities; const scores = entities.map(e => e.overall_score); const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2); const best = entities[0]; const worst = entities[entities.length - 1]; const entityLabel = activeClient.config.entity_label || 'Entities'; document.getElementById('summaryBar').innerHTML = `
${entities.length}
${escHtml(entityLabel)}
${avg}
Avg Score
Highest
${escHtml(best.short)}
${best.overall_score.toFixed(2)}
Lowest
${escHtml(worst.short)}
${worst.overall_score.toFixed(2)}
Data: ${escHtml(activeClient.data.generated)}
`; renderCardControls(); } // ── Card controls (sort + group) ────────────────────────────────────────────── function renderCardControls() { const controls = document.getElementById('cardControls'); if (!controls) return; const pillars = activeClient.config.pillars; const pillarOpts = pillars.map(p => `` ).join(''); controls.innerHTML = ` Sort by: `; controls.style.display = 'flex'; } function onSortChange(val) { sortPillar = val; renderEntityCards(); } function onGroupToggle() { groupByGroup = !groupByGroup; renderEntityCards(); // Update button appearance const btn = document.getElementById('groupBtn'); if (btn) btn.className = 'ctrl-btn' + (groupByGroup ? ' active' : ''); } // ── Score helpers ───────────────────────────────────────────────────────────── function scoreClass(level) { return `score-${Math.max(1, Math.min(4, level || 1))}`; } function scoreBgClass(level) { return `score-bg-${Math.max(1, Math.min(4, level || 1))}`; } function pillarBarPct(avg) { if (!activeClient) return 0; const { min, max } = activeClient.config.scoring; return Math.max(0, Math.min(100, ((avg - min) / (max - min)) * 100)); } // ── Entity cards ────────────────────────────────────────────────────────────── function renderEntityCards() { document.getElementById('entityGrid').style.display = ''; document.getElementById('detailPanel').style.display = 'none'; // Sort entities let entities = [...activeClient.data.entities]; if (sortPillar === 'overall') { entities.sort((a, b) => b.overall_score - a.overall_score); } else { entities.sort((a, b) => { const pa = a.pillars.find(x => x.name === sortPillar); const pb = b.pillars.find(x => x.name === sortPillar); if (!pa && !pb) return 0; if (!pa) return 1; if (!pb) return -1; return pb.avg - pa.avg; }); } const grid = document.getElementById('entityGrid'); if (!groupByGroup) { // Normal flat grid grid.style.display = ''; grid.className = 'cards-grid'; grid.innerHTML = entities.map((entity, i) => buildEntityCard(entity, i)).join(''); } else { // Group by entity.group const groups = []; const seen = {}; entities.forEach(e => { if (!seen[e.group]) { seen[e.group] = []; groups.push(e.group); } seen[e.group].push(e); }); grid.className = ''; grid.style.display = 'flex'; grid.style.flexDirection = 'column'; grid.style.gap = '20px'; grid.innerHTML = groups.map(gname => { const groupEntities = seen[gname]; const cards = groupEntities.map((entity, i) => buildEntityCard(entity, i)).join(''); return `

${escHtml(gname)}

${cards}
`; }).join(''); } document.querySelectorAll('.entity-card').forEach(card => { card.addEventListener('click', () => { const entity = activeClient.data.entities.find(e => e.id === card.dataset.id); if (entity) openEntityDetail(entity); }); }); } function buildEntityCard(entity, i) { const pillars = activeClient.config.pillars; const lvlCls = scoreClass(entity.overall_level); const bgCls = scoreBgClass(entity.overall_level); const flags = getEntityFlags(entity); const flagTip = flags.map(f => f.label).join(' · '); const pillarBars = pillars.map(pname => { const p = entity.pillars.find(x => x.name === pname); if (!p) return ''; const pct = pillarBarPct(p.avg).toFixed(1); const short = pillarShort(pname); return `
${escHtml(short)}
${p.avg.toFixed(1)}
`; }).join(''); return `
${escHtml(entity.group)} #${i + 1}

${escHtml(entity.label)}

${entity.overall_score.toFixed(2)} ${escHtml(entity.overall_label)}
${pillarBars}
${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions ${flags.length ? `⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''}` : ''} View detail →
`; } // ── Radar chart (pure SVG) ──────────────────────────────────────────────────── function radarSvg(entity, config) { const pillars = config.pillars; const n = pillars.length; const cx = 110, cy = 110, r = 80; const { min, max } = config.scoring; const range = max - min; // Get scores for each pillar const scores = pillars.map(pname => { const p = entity.pillars.find(x => x.name === pname); return p ? p.avg : min; }); // Polygon point helper function pt(idx, val) { const angle = (Math.PI * 2 * idx / n) - Math.PI / 2; const ratio = Math.max(0, Math.min(1, (val - min) / range)); const rr = ratio * r; return { x: cx + rr * Math.cos(angle), y: cy + rr * Math.sin(angle) }; } function ptEdge(idx) { const angle = (Math.PI * 2 * idx / n) - Math.PI / 2; return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) }; } // Grid rings (4 levels) const levels = Object.keys(config.scoring.labels).map(Number).sort((a, b) => a - b); const ringValues = levels.map(l => l); const gridRings = ringValues.map(val => { const pts = pillars.map((_, i) => { const angle = (Math.PI * 2 * i / n) - Math.PI / 2; const ratio = Math.max(0, Math.min(1, (val - min) / range)); const rr = ratio * r; return `${(cx + rr * Math.cos(angle)).toFixed(2)},${(cy + rr * Math.sin(angle)).toFixed(2)}`; }).join(' '); return ``; }).join(''); // Axis lines const axisLines = pillars.map((_, i) => { const e = ptEdge(i); return ``; }).join(''); // Score polygon const scorePoints = scores.map((s, i) => { const p = pt(i, s); return `${p.x.toFixed(2)},${p.y.toFixed(2)}`; }).join(' '); // Score dots const scoreDots = scores.map((s, i) => { const p = pt(i, s); return ``; }).join(''); // Labels const labels = pillars.map((pname, i) => { const angle = (Math.PI * 2 * i / n) - Math.PI / 2; const labelR = r + 22; const lx = cx + labelR * Math.cos(angle); const ly = cy + labelR * Math.sin(angle); let anchor = 'middle'; if (lx < cx - 8) anchor = 'end'; else if (lx > cx + 8) anchor = 'start'; const short = pillarShort(pname); return `${escHtml(short)}`; }).join(''); return ` ${gridRings} ${axisLines} ${scoreDots} ${labels} `; } // ── Entity detail ───────────────────────────────────────────────────────────── function openEntityDetail(entity) { detailEntity = entity; document.getElementById('entityGrid').style.display = 'none'; const panel = document.getElementById('detailPanel'); panel.style.display = ''; const pillars = activeClient.config.pillars; const lvlCls = scoreClass(entity.overall_level); const bgCls = scoreBgClass(entity.overall_level); const pillarAccordions = pillars.map(pname => { const p = entity.pillars.find(x => x.name === pname); if (!p) return ''; const plvl = scoreClass(p.level); const pbg = scoreBgClass(p.level); const pct = pillarBarPct(p.avg).toFixed(1); const qRows = p.questions.map((q, qi) => { const qlvl = scoreClass(q.level); return `
${q.score} ${escHtml(q.topic)}
`; }).join(''); return `
${escHtml(pname)}
${p.avg.toFixed(2)} ${escHtml(p.label)}
${qRows}
`; }).join(''); // Deliverable buttons (if configured for this entity) const del = (activeClient.deliverables || {})[entity.id] || {}; const deliverablesBtns = (del.pdf || del.xlsx) ? ` Deliverables ${del.pdf ? ` ` : ''} ${del.xlsx ? ` ` : ''} ` : ''; panel.innerHTML = `
${deliverablesBtns} Export
${escHtml(entity.group)}

${escHtml(entity.label)}

${radarSvg(entity, activeClient.config)}
${entity.overall_score.toFixed(2)} ${escHtml(entity.overall_label)}
${(() => { const flags = getEntityFlags(entity); if (!flags.length) return ''; return `
Data quality ${flags.map(f => `${escHtml(f.label)}`).join(' · ')}
`; })()}

Pillar Breakdown

${pillarAccordions}
`; // Attach question row click handlers panel.querySelectorAll('.question-row').forEach(row => { row.addEventListener('click', () => { const p = entity.pillars.find(x => x.name === row.dataset.pillar); if (p) openQuestionModal(entity, p, p.questions[parseInt(row.dataset.qi)]); }); }); } function closeEntityDetail() { detailEntity = null; document.getElementById('detailPanel').style.display = 'none'; document.getElementById('entityGrid').style.display = ''; } function togglePillar(header) { header.closest('.pillar-card').classList.toggle('open'); } // ── Question modal ──────────────────────────────────────────────────────────── function openQuestionModal(entity, pillar, question) { const qlvl = scoreClass(question.level); const elvl = scoreClass(entity.overall_level); document.getElementById('qModalBadges').innerHTML = ` ${escHtml(entity.short)} ${escHtml(pillar.name)} `; document.getElementById('qModalTitle').textContent = question.topic; document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillar.name} · ${entity.label}`; const fields = [ { label: 'Rationale', value: question.rationale }, { label: 'Gaps Identified', value: question.gaps || '—' }, { label: 'References', value: question.refs || '—' }, ]; const scoring = activeClient.config.scoring; const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b); const scoreBtns = scoreNums.map(n => { const lvl = scoreToLevel(n, scoring); const isCurrent = n === question.score; return ` `; }).join(''); document.getElementById('qModalBody').innerHTML = `
Score
${scoreBtns}

Click a score to update it

${fields.map(f => { const empty = !f.value || f.value === '—'; return `
${escHtml(f.label)} ${escHtml(f.value)}
`; }).join('')} `; document.getElementById('questionModal').classList.remove('hidden'); } // ── Score editing ───────────────────────────────────────────────────────────── function scoreToLevel(score, scoring) { const levels = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b); const idx = levels.indexOf(score); return idx >= 0 ? idx + 1 : Math.round(((score - scoring.min) / (scoring.max - scoring.min)) * (levels.length - 1)) + 1; } async function updateScore(entityId, pillarName, qNum, newScore) { const scoring = activeClient.config.scoring; const entity = activeClient.data.entities.find(e => e.id === entityId); const pillar = entity.pillars.find(p => p.name === pillarName); const question = pillar.questions.find(q => String(q.q_num) === String(qNum)); if (question.score === newScore) return; const oldScore = question.score; const lvl = scoreToLevel(newScore, scoring); // Update question question.score = newScore; question.level = lvl; question.label = scoring.labels[newScore] || ''; // Recalculate pillar avg const pillarScores = pillar.questions.map(q => q.score); pillar.avg = Math.round((pillarScores.reduce((a, b) => a + b, 0) / pillarScores.length) * 100) / 100; pillar.level = scoreToLevel(Math.round(pillar.avg), scoring); pillar.label = scoring.labels[Math.round(pillar.avg)] || scoring.labels[Math.ceil(pillar.avg)] || ''; // Recalculate entity overall const allScores = entity.pillars.flatMap(p => p.questions.map(q => q.score)); entity.overall_score = Math.round((allScores.reduce((a, b) => a + b, 0) / allScores.length) * 100) / 100; entity.overall_level = scoreToLevel(Math.round(entity.overall_score), scoring); entity.overall_label = scoring.labels[Math.round(entity.overall_score)] || scoring.labels[Math.ceil(entity.overall_score)] || ''; // Update score button highlight in modal const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b); scoreNums.forEach(n => { const btn = document.getElementById(`score-btn-${n}`); if (!btn) return; const isCurrent = n === newScore; btn.style.borderColor = isCurrent ? 'var(--accent)' : 'var(--border)'; btn.style.background = isCurrent ? 'rgba(120,190,32,0.08)' : 'var(--bg-inset)'; }); const hint = document.getElementById('scoreEditHint'); if (hint) hint.innerHTML = `✓ Score updated ${oldScore} → ${newScore}`; // Re-render cards/detail if visible if (detailEntity && detailEntity.id === entityId) { openEntityDetail(entity); // refreshes detail panel document.getElementById('questionModal').classList.remove('hidden'); // keep modal open } else { renderEntityCards(); } if (activeTab === 'compare') renderCompareTable(); // Persist to server try { const res = await fetch(`/api/clients/${activeClient.config.id}/data`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(activeClient.data), }); if (!res.ok) throw new Error(await res.text()); showToast(`Q${qNum} score saved: ${newScore} (${question.label})`, 'success'); } catch (e) { showToast('Save failed — changes kept in memory only', 'error'); } } function closeModal() { document.getElementById('questionModal').classList.add('hidden'); } function handleModalOverlayClick(e) { if (e.target === document.getElementById('questionModal')) closeModal(); } // ── Compare tab ─────────────────────────────────────────────────────────────── function renderCompareSelector() { const entities = activeClient.data.entities; const entityLabel = activeClient.config.entity_label || 'Entities'; document.getElementById('compareSelector').innerHTML = `

Select ${escHtml(entityLabel)} to Compare

${entities.map(e => { const lvl = scoreClass(e.overall_level); return ` `; }).join('')}
`; } function onCompareSelect(cb) { cb.checked ? selectedForCompare.add(cb.value) : selectedForCompare.delete(cb.value); } function clearCompare() { selectedForCompare.clear(); document.querySelectorAll('#compareSelector input[type=checkbox]').forEach(cb => cb.checked = false); document.getElementById('compareResult').style.display = 'none'; } function renderCompareTable() { if (selectedForCompare.size < 2) { showToast('Select at least 2 to compare', 'error'); return; } const markets = [...selectedForCompare] .map(id => activeClient.data.entities.find(e => e.id === id)) .filter(Boolean); const pillars = activeClient.config.pillars; const scoring = activeClient.config.scoring; const wrap = document.getElementById('compareResult'); wrap.style.display = ''; // Header const headerCols = markets.map(m => `
${escHtml(m.short)}
${m.overall_score.toFixed(2)}
`).join(''); // Build rows: overall + per pillar const buildRow = (label, values) => { const defined = values.filter(v => v !== null); const maxVal = defined.length ? Math.max(...defined) : null; const minVal = defined.length ? Math.min(...defined) : null; const allSame = defined.every(v => v === defined[0]); const cells = values.map((v, i) => { if (v === null) return '—'; const cls = !allSame && v === maxVal ? 'diff-hi' : !allSame && v === minVal ? 'diff-lo' : ''; const level = Math.round(((v - scoring.min) / (scoring.max - scoring.min)) * (Object.keys(scoring.labels).length - 1)) + 1; const pct = ((v - scoring.min) / (scoring.max - scoring.min) * 100).toFixed(0); return `
${v.toFixed(2)}
`; }).join(''); return `${escHtml(label)}${cells}`; }; // Overall row const overallVals = markets.map(m => m.overall_score); const overallDef = overallVals.filter(v => v !== null); const maxO = Math.max(...overallDef), minO = Math.min(...overallDef); const allSameO = overallDef.every(v => v === overallDef[0]); const overallRow = ` OVERALL ${markets.map(m => { const cls = !allSameO && m.overall_score === maxO ? 'diff-hi' : !allSameO && m.overall_score === minO ? 'diff-lo' : ''; return ` ${m.overall_score.toFixed(2)} `; }).join('')} `; const pillarRows = pillars.map(pname => { const vals = markets.map(m => { const p = m.pillars.find(x => x.name === pname); return p ? p.avg : null; }); return buildRow(pname, vals); }).join(''); wrap.innerHTML = `

${escHtml(markets.map(m => m.short).join(' vs '))}

${headerCols}${overallRow}${pillarRows}
Highest in row Lowest in row
`; } // ── Heatmap tab ─────────────────────────────────────────────────────────────── function renderHeatmap() { const panel = document.getElementById('heatmapPanel'); if (!panel || !activeClient) return; const entities = activeClient.data.entities; const pillars = activeClient.config.pillars; const scoring = activeClient.config.scoring; // Helper: map numeric score to color level (1-4 buckets) function heatLevel(score) { return scoreToLevel(Math.round(score), scoring); } // Header row const headerCells = entities.map(e => `${escHtml(e.short)}` ).join(''); // Pillar rows const pillarRows = pillars.map((pname, ri) => { const cells = entities.map(e => { const p = e.pillars.find(x => x.name === pname); if (!p) return `—`; const lvl = heatLevel(p.avg); const bgCls = scoreBgClass(lvl); return `${p.avg.toFixed(2)}`; }).join(''); // Row average const vals = entities.map(e => { const p = e.pillars.find(x => x.name === pname); return p ? p.avg : null; }).filter(v => v !== null); const rowAvg = vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length) : null; const ravgLvl = rowAvg !== null ? heatLevel(rowAvg) : 1; const ravgBg = scoreBgClass(ravgLvl); const rowAvgCell = rowAvg !== null ? `${rowAvg.toFixed(2)}` : `—`; const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);'; return ` ${escHtml(pname)} ${cells} ${rowAvgCell} `; }).join(''); // Overall row const overallCells = entities.map(e => { const lvl = heatLevel(e.overall_score); const bgCls = scoreBgClass(lvl); return `${e.overall_score.toFixed(2)}`; }).join(''); const allScores = entities.map(e => e.overall_score); const grandAvg = allScores.length ? (allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0; const grandLvl = heatLevel(grandAvg); const grandBg = scoreBgClass(grandLvl); const grandAvgCell = `${grandAvg.toFixed(2)}`; panel.innerHTML = `

Pillar Heatmap

${headerCells} ${pillarRows} ${overallCells} ${grandAvgCell}
PillarAvg
Overall
${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => { const bgCls = scoreBgClass(parseInt(n)); return `${n} ${escHtml(scoring.labels[n])}`; }).join('')}
`; } // ── Deliverable downloads (pre-existing client PDFs / XLSXs) ───────────────── async function downloadDeliverable(entityId, type) { const btnId = type === 'pdf' ? `delPdfBtn-${entityId}` : `delXlsxBtn-${entityId}`; const btn = document.getElementById(btnId); const label = type === 'pdf' ? 'Summary PDF' : 'Scores XLSX'; if (btn) { btn.disabled = true; btn.textContent = '⏳…'; } try { const res = await fetch(`/api/clients/${activeClient.config.id}/deliverables/${entityId}/${type}`); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || res.statusText); } const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') || ''; const nameMatch = disposition.match(/filename="([^"]+)"/); const filename = nameMatch ? nameMatch[1] : `${entityId}_${label.replace(' ', '_')}.${type}`; const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); showToast(`${label} downloaded`); } catch (e) { showToast(`${label} unavailable: ${e.message}`, 'error'); } finally { if (btn) { btn.disabled = false; const icon = ''; btn.innerHTML = `${icon} ${label}`; } } } // ── Exports ─────────────────────────────────────────────────────────────────── // CSV — flat, all entities function exportCsv() { const { config, data } = activeClient; const rows = [['Client', 'Entity', 'Entity Short', 'Group', 'Overall Score', 'Overall Level', 'Pillar', 'Q#', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']]; for (const entity of data.entities) { for (const pillar of entity.pillars) { for (const q of pillar.questions) { rows.push([ config.name, entity.label, entity.short, entity.group, entity.overall_score, entity.overall_label, pillar.name, q.q_num, q.topic, q.score, q.label, q.rationale || '', q.gaps || '', q.refs || '', ]); } } } const csv = rows.map(r => r.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',') ).join('\r\n'); triggerDownload(`${config.id}_maturity_all_entities.csv`, 'text/csv;charset=utf-8;', csv); showToast('CSV downloaded'); } // XLSX — multi-sheet workbook function exportXlsx(entityId) { const { config, data } = activeClient; const wb = XLSX.utils.book_new(); const entities = entityId ? data.entities.filter(e => e.id === entityId) : data.entities; const SCORE_COLORS = { 1: 'C62828', 2: 'E65100', 3: '2E7D32', 4: '1B5E20' }; // Summary sheet (always included) const summaryRows = [['Entity', 'Group', 'Overall Score', 'Overall Level', ...config.pillars]]; for (const e of entities) { const row = [e.label, e.group, e.overall_score, e.overall_label]; for (const pname of config.pillars) { const p = e.pillars.find(x => x.name === pname); row.push(p ? p.avg : ''); } summaryRows.push(row); } const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows); // Style header row const summaryRange = XLSX.utils.decode_range(summaryWs['!ref']); for (let C = summaryRange.s.c; C <= summaryRange.e.c; C++) { const cell = summaryWs[XLSX.utils.encode_cell({ r: 0, c: C })]; if (cell) { cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true } }; } } // Column widths summaryWs['!cols'] = [{ wch: 28 }, { wch: 16 }, { wch: 14 }, { wch: 14 }, ...config.pillars.map(() => ({ wch: 12 }))]; XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary'); // One sheet per entity for (const entity of entities) { const sheetRows = [['Q#', 'Pillar', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']]; for (const pillar of entity.pillars) { for (const q of pillar.questions) { sheetRows.push([q.q_num, pillar.name, q.topic, q.score, q.label, q.rationale || '', q.gaps || '', q.refs || '']); } // Pillar average row sheetRows.push(['', pillar.name + ' — AVG', '', pillar.avg, pillar.label, '', '', '']); } const ws = XLSX.utils.aoa_to_sheet(sheetRows); const range = XLSX.utils.decode_range(ws['!ref']); for (let R = 0; R <= range.e.r; R++) { for (let C = 0; C <= range.e.c; C++) { const cellRef = XLSX.utils.encode_cell({ r: R, c: C }); const cell = ws[cellRef]; if (!cell) continue; if (R === 0) { cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true, vertical: 'top' } }; } else { cell.s = cell.s || {}; cell.s.alignment = { wrapText: true, vertical: 'top' }; // Colour score cells (col 3) if (C === 3 && typeof cell.v === 'number') { const lvl = entity.pillars .flatMap(p => p.questions) .find(q => String(q.q_num) === String(sheetRows[R][0]))?.level || 0; const hex = SCORE_COLORS[lvl]; if (hex) { cell.s.fill = { fgColor: { rgb: hex } }; cell.s.font = { bold: true, color: { rgb: 'FFFFFF' } }; } } // Pillar avg rows if (String(sheetRows[R][1] || '').includes('— AVG')) { cell.s.fill = { fgColor: { rgb: 'F7F7F7' } }; cell.s.font = { bold: true }; } } } } ws['!cols'] = [{ wch: 6 }, { wch: 22 }, { wch: 40 }, { wch: 8 }, { wch: 14 }, { wch: 55 }, { wch: 40 }, { wch: 30 }]; ws['!rows'] = sheetRows.map(() => ({ hpt: 40 })); const sheetName = entity.short.replace(/[\\\/\*\?\[\]]/g, '').slice(0, 31); XLSX.utils.book_append_sheet(wb, ws, sheetName); } const filename = entityId ? `${entityId}_Maturity_Report.xlsx` : `${config.id}_Maturity_All_Entities.xlsx`; XLSX.writeFile(wb, filename); showToast('XLSX downloaded'); } // PDF — server-generated via Python/ReportLab async function downloadPdf(entityId) { const btn = document.getElementById(`pdfBtn-${entityId}`); if (btn) { btn.textContent = '⏳ Generating…'; btn.disabled = true; } try { const res = await fetch(`/api/clients/${activeClient.config.id}/export/pdf/${entityId}`); if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })); throw new Error(err.error || res.statusText); } const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${entityId}_Maturity_Report.pdf`; a.click(); URL.revokeObjectURL(url); showToast('PDF downloaded'); } catch (e) { showToast('PDF failed: ' + e.message, 'error'); } finally { if (btn) { btn.innerHTML = ' PDF'; btn.disabled = false; } } } function triggerDownload(filename, mime, content) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } // ── Import / Update Data tab ────────────────────────────────────────────────── let importFile = null; function populateImportEntitySelect() { if (!activeClient) return; const options = activeClient.data.entities.map(e => `` ).join(''); document.getElementById('importEntitySel').innerHTML = '' + options; document.getElementById('syncEntitySel').innerHTML = '' + options; } function onFileSelected(file) { if (!file) return; importFile = file; const dz = document.getElementById('dropZone'); dz.classList.add('has-file'); dz.classList.remove('drag-over'); document.getElementById('dropZoneText').textContent = `${file.name} (${(file.size / 1024).toFixed(0)} KB)`; } function handleFileDrop(e) { e.preventDefault(); document.getElementById('dropZone').classList.remove('drag-over'); const file = e.dataTransfer.files[0]; if (file) onFileSelected(file); } async function runBoxSync() { const btn = document.getElementById('syncRunBtn'); const log = document.getElementById('syncLog'); const entityId = document.getElementById('syncEntitySel').value; btn.disabled = true; btn.innerHTML = 'Syncing…'; log.style.display = ''; log.textContent = entityId ? `Syncing ${activeClient.data.entities.find(e => e.id === entityId)?.label || entityId}…\n` : 'Running converter (all entities)…\n'; try { const body = entityId ? JSON.stringify({ entity_id: entityId }) : undefined; const res = await fetch(`/api/clients/${activeClient.config.id}/sync`, { method: 'POST', headers: body ? { 'Content-Type': 'application/json' } : {}, body, }); const data = await res.json(); if (!res.ok) { log.textContent = `Error:\n${data.detail || data.error}`; showToast('Sync failed', 'error'); } else { log.textContent = (data.log || 'Done.').trim(); showToast('Sync complete — reloading…', 'success'); setTimeout(async () => { await loadClient(activeClient.config.id); showTab('entities'); }, 1000); } } catch (e) { log.textContent = 'Network error: ' + e.message; showToast('Sync failed: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = ` Sync from Box`; } } async function runFileImport() { const entityId = document.getElementById('importEntitySel').value; if (!entityId) { showToast('Select an entity first', 'error'); return; } if (!importFile) { showToast('Select a file to upload', 'error'); return; } const btn = document.getElementById('importRunBtn'); btn.disabled = true; btn.textContent = 'Importing…'; const formData = new FormData(); formData.append('file', importFile); formData.append('entity_id', entityId); try { const res = await fetch(`/api/clients/${activeClient.config.id}/import/file`, { method: 'POST', body: formData, }); const data = await res.json(); if (!res.ok) { showToast('Import failed: ' + (data.error || res.statusText), 'error'); } else { const e = data.entity; showToast(`Imported ${escHtml(e.label)}: ${e.overall_score.toFixed(2)} (${e.overall_label})`, 'success'); await loadClient(activeClient.config.id); showTab('entities'); } } catch (e) { showToast('Import failed: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = ` Import`; } } // ── Theme ───────────────────────────────────────────────────────────────────── function toggleTheme() { const isLight = document.body.classList.toggle('light'); localStorage.setItem('maturityTheme', isLight ? 'light' : 'dark'); document.getElementById('iconDark').style.display = isLight ? 'none' : ''; document.getElementById('iconLight').style.display = isLight ? '' : 'none'; } function applyStoredTheme() { if (localStorage.getItem('maturityTheme') === 'light') { document.body.classList.add('light'); document.getElementById('iconDark').style.display = 'none'; document.getElementById('iconLight').style.display = ''; } } // ── Utilities ───────────────────────────────────────────────────────────────── function escHtml(s) { return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function showToast(msg, type = 'success') { const t = document.getElementById('toast'); t.textContent = msg; t.className = `show ${type}`; clearTimeout(t._timer); t._timer = setTimeout(() => { t.className = ''; }, 3000); } // ── Keyboard shortcuts ──────────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeWizard(); } }); // ── Start ───────────────────────────────────────────────────────────────────── init();