'use strict'; // ── API base ────────────────────────────────────────────────────────────────── // Auto-detect the URL path prefix the page was loaded under, so /api/ calls // stay under that prefix when the app is reverse-proxied at a sub-path // (e.g. https://host/adeo-maturity/). Captured once at script load — the SPA // does not mutate the URL so freezing here is safe. const API_BASE = (() => { const p = window.location.pathname; return p.replace(/\/(?:index\.html)?$/, ''); })(); // ── State ───────────────────────────────────────────────────────────────────── let allClients = []; let activeClient = null; // { config, data } let activeTab = 'entities'; let detailEntity = null; let currentUser = null; // { id, email, role } once authenticated 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 window.UI_STRINGS?.[currentLang]?.pillar_short?.[name] || PILLAR_SHORT[name] || name.slice(0, 6).toUpperCase(); } // ── Language state ───────────────────────────────────────────────────────────── let currentLang = 'en'; function t(key, ...args) { const strings = window.UI_STRINGS?.[currentLang] ?? window.UI_STRINGS?.en ?? {}; const val = (key in strings) ? strings[key] : window.UI_STRINGS?.en?.[key]; if (val === undefined) return key; return typeof val === 'function' ? val(...args) : val; } function cfg(...path) { const base = activeClient?.config; if (!base) return undefined; if (currentLang !== 'en' && base.translations?.[currentLang]) { let v = base.translations[currentLang]; for (const k of path) { if (v && typeof v === 'object') v = v[k]; else { v = undefined; break; } } if (v !== undefined) return v; } let v = base; for (const k of path) { if (v && typeof v === 'object') v = v[k]; else { v = undefined; break; } } return v; } function pillarDisplayName(i) { const translated = currentLang !== 'en' ? activeClient?.config?.translations?.[currentLang]?.pillars : null; return translated?.[i] ?? activeClient?.config?.pillars?.[i] ?? ''; } function pillarDisplayByName(englishName) { const pillars = activeClient?.config?.pillars ?? []; const i = pillars.indexOf(englishName); return i >= 0 ? pillarDisplayName(i) : englishName; } function scoreLabelDisplay(level) { return cfg('scoring', 'labels')?.[level] ?? activeClient?.config?.scoring?.labels?.[level] ?? ''; } function getQuestionTopic(q) { const topics = cfg('question_topics'); const idx = parseInt(q.q_num, 10) - 1; if (topics && idx >= 0 && topics[idx]) return topics[idx]; return q.topic; } const SUPPORTED_LANGS = ['en', 'fr', 'es', 'pt', 'it', 'pl']; function applyStoredLang() { if (!activeClient) { currentLang = 'en'; return; } const key = `maturityLang2_${activeClient.config.id}`; const stored = localStorage.getItem(key); currentLang = SUPPORTED_LANGS.includes(stored) ? stored : 'en'; if (!stored) localStorage.setItem(key, 'en'); } function setLang(lang) { if (!SUPPORTED_LANGS.includes(lang)) return; currentLang = lang; const langKey = activeClient ? `maturityLang2_${activeClient.config.id}` : 'maturityLang2_home'; localStorage.setItem(langKey, lang); document.documentElement.lang = lang; updateLangToggleButton(); refreshCurrentView(); } function toggleLang() { setLang(currentLang === 'en' ? 'fr' : 'en'); } function updateLangToggleButton() { const sel = document.getElementById('langToggle'); if (!sel) return; sel.style.display = 'inline-block'; sel.value = currentLang; } function refreshCurrentView() { if (!activeClient) { const aboutEl = document.getElementById('homeTab-about'); if (aboutEl && aboutEl.style.display !== 'none') renderAbout(); else renderHome(); return; } const entityLabel = cfg('entity_label') || activeClient.config.entity_label || 'Entities'; document.getElementById('tab-entities').textContent = entityLabel; document.getElementById('tab-focus').textContent = t('tab_focus'); document.getElementById('tab-compare').textContent = t('tab_compare'); document.getElementById('tab-heatmap').textContent = t('tab_heatmap'); document.getElementById('tab-admin').textContent = t('tab_admin'); document.getElementById('headerSub').textContent = cfg('description') || activeClient.config.description; renderSummaryBar(); if (activeTab === 'entities') { if (detailEntity) openEntityDetail(detailEntity); else renderEntityCards(); } else if (activeTab === 'focus') { renderFocusPanel(); } else if (activeTab === 'compare') { renderCompareSelector(); if (selectedForCompare.size >= 2) renderCompareTable(); } else if (activeTab === 'heatmap') { renderHeatmap(); } else if (activeTab === 'admin') { showAdminSub(activeAdminSub); } } function updateUpdateTabText() { const map = { syncTitle: t('sync_title'), syncDesc: t('sync_desc'), syncScopeLabel: t('sync_scope'), uploadTitle: t('upload_title'), uploadEntityLabel: t('upload_entity'), uploadFileLabel: t('upload_file'), uploadNote: t('upload_note'), exportAllLabel: t('export_all'), }; for (const [id, text] of Object.entries(map)) { const el = document.getElementById(id); if (el) el.textContent = text; } const syncSel = document.getElementById('syncEntitySel'); const importSel = document.getElementById('importEntitySel'); if (syncSel?.options[0]) syncSel.options[0].textContent = t('sync_all'); if (importSel?.options[0]) importSel.options[0].textContent = t('upload_placeholder'); const dropText = document.getElementById('dropZoneText'); if (dropText && !document.getElementById('dropZone').classList.contains('has-file')) { dropText.textContent = t('upload_drop'); } } // ── 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: t('flag_missing_score', zeroScore.length) }); const noRationale = qs.filter(q => !q.rationale || q.rationale.trim().length < 5); if (noRationale.length > 0) flags.push({ icon: '◌', label: t('flag_missing_rationale', noRationale.length) }); 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: t('flag_missing_gaps', noGaps.length) }); if (entity.synced) { const days = Math.floor((Date.now() - new Date(entity.synced)) / 86400000); if (days > 30) flags.push({ icon: '⏱', label: t('flag_stale', days) }); } return flags; } // ── Boot ────────────────────────────────────────────────────────────────────── async function init() { applyStoredTheme(); // Auth gate. /api/auth/me returns 200 with user info, or 401 if not signed in. let user = null; try { const res = await fetch(API_BASE + '/api/auth/me'); if (res.ok) user = await res.json(); } catch (_e) { /* fall through to login */ } if (!user) { showLogin(); return; } applyAuthedUser(user); try { const res = await fetch(API_BASE + '/api/clients'); allClients = await res.json(); // Restore home-screen language preference const stored = localStorage.getItem('maturityLang2_home'); currentLang = (stored === 'en' || stored === 'fr') ? stored : 'en'; document.documentElement.lang = currentLang; updateLangToggleButton(); renderHome(); } catch (e) { document.getElementById('headerSub').textContent = t('toast_connect_failed'); showToast(t('toast_load_failed'), 'error'); } } // ── Auth ────────────────────────────────────────────────────────────────────── function showLogin() { document.getElementById('loginScreen').style.display = ''; document.body.classList.remove('auth-loading'); document.getElementById('loginEmail').focus(); } function hideLogin() { document.getElementById('loginScreen').style.display = 'none'; document.body.classList.remove('auth-loading'); } function applyAuthedUser(user) { currentUser = user; document.body.classList.remove('role-admin', 'role-user'); document.body.classList.add(user.role === 'admin' ? 'role-admin' : 'role-user'); const badge = document.getElementById('userBadge'); if (badge) { badge.textContent = `${user.email} · ${user.role}`; badge.style.display = ''; } document.getElementById('logoutBtn').style.display = ''; hideLogin(); } async function handleLoginSubmit(e) { e.preventDefault(); const email = document.getElementById('loginEmail').value.trim(); const password = document.getElementById('loginPassword').value; const btn = document.getElementById('loginSubmit'); const errBox = document.getElementById('loginError'); errBox.classList.remove('show'); errBox.textContent = ''; btn.disabled = true; btn.textContent = 'Signing in…'; try { const res = await fetch(API_BASE + '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || 'Login failed'); document.getElementById('loginPassword').value = ''; applyAuthedUser(data); // Reload data now that we're authed const cRes = await fetch(API_BASE + '/api/clients'); allClients = await cRes.json(); renderHome(); } catch (err) { errBox.textContent = err.message; errBox.classList.add('show'); } finally { btn.disabled = false; btn.textContent = 'Sign in'; } } async function logout() { try { await fetch(API_BASE + '/api/auth/logout', { method: 'POST' }); } catch (_e) { /* best-effort */ } // Hard reload to drop in-memory state and let init() show the login screen. window.location.reload(); } // ── 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 fr = currentLang === 'fr' && client && client.translations && client.translations.fr; const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' }; const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, lname]) => { const frLabel = fr && fr.scoring && fr.scoring.labels && fr.scoring.labels[lvl]; const label = frLabel || lname; const frDesc = fr && fr.about && fr.about.scoring_descriptions && fr.about.scoring_descriptions[lvl]; const desc = frDesc || (about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : ''); return `
${escHtml(label)}
${escHtml(t('about_level', lvl))}
${desc ? `
${escHtml(desc)}
` : ''}
`; }).join('') : ''; const pillarRows = (client && client.pillars || []).map((pname, i) => { const frPillarName = fr && fr.pillars && fr.pillars[i] ? fr.pillars[i] : pname; const frDesc = fr && fr.about && fr.about.pillar_descriptions && fr.about.pillar_descriptions[frPillarName]; const desc = frDesc || (about && about.pillar_descriptions ? about.pillar_descriptions[pname] : ''); const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛']; return `
${icons[i] || '▪'}
${escHtml(frPillarName)}
${desc ? `
${escHtml(desc)}
` : ''}
`; }).join(''); el.innerHTML = `

${escHtml(t('about_what_is'))}

${escHtml((fr && fr.about && fr.about.summary) || (about && 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}
${escHtml(t('stat_questions'))}
${(client.pillars || []).length}
${escHtml(t('stat_pillars'))}
${allClients[0] && allClients[0].entity_count || '—'}
${escHtml((fr && fr.entity_label) || client.entity_label || 'Markets')}
` : ''}

${escHtml(t('about_maturity_levels'))}

${levelRows}

${escHtml(t('about_how_to'))}

${t('how_to_items').map(([title, desc]) => `
${escHtml(title)}
${escHtml(desc)}
`).join('')}

${escHtml(t('about_the_pillars', (client && client.pillars || []).length))}

${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 = t('header_title'); 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)}

${escHtml(t('home_explore', c.entity_count, c.entity_label || 'markets'))}

${escHtml(t('home_last_updated', c.generated))}

${escHtml(t('home_new_client'))}

${escHtml(t('home_new_client_sub'))}

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

${escHtml(t('home_generated', c.generated))} ${escHtml(t('home_open_arrow'))}
`).join('') + `

${escHtml(t('home_new_client'))}

${escHtml(t('home_new_client_sub'))}

`; } } async function renderSingleClientHome(id) { try { const [cfgRes, datRes] = await Promise.all([ fetch(`${API_BASE}/api/clients/${id}/config`), fetch(`${API_BASE}/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_BASE}/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(scoreLabelDisplay(e.overall_level) || 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}
${escHtml(t('summary_avg'))}
${escHtml(t('summary_highest'))}
${escHtml(best.short)}
${best.overall_score.toFixed(2)}
${escHtml(t('summary_lowest'))}
${escHtml(worst.short)}
${worst.overall_score.toFixed(2)}

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

${marketRows}

${escHtml(t('overview_generated', data.generated))}

${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(t('toast_overview_failed'), 'error'); } } function renderAboutSection(config) { const about = config.about; const scoring = config.scoring; const frAbout = cfg('about') || about; const frScoring = cfg('scoring') || scoring; const frPillars = cfg('pillars') || config.pillars; const frSummary = frAbout?.summary || about?.summary; const pillarGrid = config.pillars.map((pname, i) => { const displayName = pillarDisplayName(i); const desc = (frAbout?.pillar_descriptions || {})[displayName] || (about?.pillar_descriptions || {})[pname] || ''; return `

${escHtml(displayName)}

${escHtml(desc)}

`; }).join(''); const scoringRows = Object.keys(scoring.labels).sort((a,b) => a-b).map(n => { const lvl = scoreClass(parseInt(n)); const label = frScoring?.labels?.[n] || scoring.labels[n]; const desc = (frAbout?.scoring_descriptions || {})[n] || (about?.scoring_descriptions || {})[n] || ''; return `
${n} — ${escHtml(label)} ${escHtml(desc)}
`; }).join(''); const pillarsCountText = about?.question_count ? `${about.question_count} ${currentLang === 'fr' ? 'questions sur' : 'Questions across'} ${config.pillars.length} ${currentLang === 'fr' ? 'piliers' : 'Pillars'}` : `${config.pillars.length} ${currentLang === 'fr' ? 'Piliers' : 'Pillars'}`; return `

${escHtml(t('about_assessment'))}

${frSummary ? `

${escHtml(frSummary)}

` : ''}

${escHtml(pillarsCountText)}

${pillarGrid}

${escHtml(t('about_scoring_scale'))}

${scoringRows}
`; } function goHome() { activeClient = null; const stored = localStorage.getItem('maturityLang2_home'); currentLang = (stored === 'en' || stored === 'fr') ? stored : 'en'; document.documentElement.lang = currentLang; detailEntity = null; selectedForCompare.clear(); updateLangToggleButton(); 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(t('wiz_client_required'), 'error'); return false; } if (wizStep === 0 && !wizData.id) { showToast(t('wiz_id_failed'), 'error'); return false; } if (wizStep === 3 && !wizData.pillars.filter(p => p.trim()).length) { showToast(t('wiz_pillar_required'), '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_BASE + '/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(t('wiz_created', cfg.name), 'success'); const r2 = await fetch(API_BASE + '/api/clients'); allClients = await r2.json(); await loadClient(data.id); showTab('admin'); } catch (e) { showToast(t('wiz_create_failed', 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_BASE}/api/clients/${id}/config`), fetch(`${API_BASE}/api/clients/${id}/data`), fetch(`${API_BASE}/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 }; applyStoredLang(); document.documentElement.lang = currentLang; updateLangToggleButton(); applyAccent(config.accent_color); enterClientView(config); renderSummaryBar(); renderEntityCards(); renderCompareSelector(); document.getElementById('exportRow').style.display = 'flex'; } catch (e) { showToast(t('toast_client_failed'), '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 = cfg('entity_label') || config.entity_label || 'Entities'; document.getElementById('tab-entities').textContent = entityLabel; document.getElementById('tab-focus').textContent = t('tab_focus'); document.getElementById('tab-compare').textContent = t('tab_compare'); document.getElementById('tab-heatmap').textContent = t('tab_heatmap'); document.getElementById('tab-admin').textContent = t('tab_admin'); showTab('entities'); } // ── Tab navigation ──────────────────────────────────────────────────────────── function showTab(name) { ['entities', 'focus', 'compare', 'heatmap', 'admin'].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 === 'admin') showAdminSub(activeAdminSub); if (name === 'heatmap') renderHeatmap(); if (name === 'focus') renderFocusPanel(); } let activeAdminSub = 'economics'; function showAdminSub(sub) { activeAdminSub = sub; ['economics', 'update', 'access'].forEach(s => { document.getElementById(`admin-${s}-content`).style.display = s === sub ? '' : 'none'; document.getElementById(`admin-sub-${s}`).classList.toggle('active', s === sub); }); if (sub === 'economics') renderEconomicTab(); if (sub === 'update') { populateImportEntitySelect(); updateUpdateTabText(); activityLoaded = false; document.getElementById('activityPanel').style.display = 'none'; document.getElementById('activityToggleBtn').style.color = 'var(--accent)'; } if (sub === 'access') loadAccessLog(false); } // ── 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 = cfg('entity_label') || activeClient.config.entity_label || 'Entities'; document.getElementById('summaryBar').innerHTML = `
${entities.length}
${escHtml(entityLabel)}
${avg}
${escHtml(t('summary_avg'))}
${escHtml(t('summary_highest'))}
${escHtml(best.short)}
${best.overall_score.toFixed(2)}
${escHtml(t('summary_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, i) => `` ).join(''); controls.innerHTML = ` ${escHtml(t('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)); } // ── Focus Areas panel ───────────────────────────────────────────────────────── function computeFocusData() { const entities = activeClient.data.entities; const pillars = activeClient.config.pillars; // Pillar averages across all entities const pillarAvgs = pillars.map((pname, i) => { const avgs = entities.map(e => e.pillars.find(p => p.name === pname)?.avg ?? null).filter(v => v !== null); const avg = avgs.length ? avgs.reduce((s, v) => s + v, 0) / avgs.length : 0; return { name: pname, displayName: pillarDisplayName(i), avg, count: avgs.length }; }); // Question averages across all entities (by q_num) const qMap = {}; entities.forEach(e => { e.pillars.forEach(p => { p.questions.forEach(q => { if (!qMap[q.q_num]) qMap[q.q_num] = { q_num: q.q_num, pillar: p.name, scores: [] }; if (q.score != null) qMap[q.q_num].scores.push(q.score); }); }); }); const qAvgs = Object.values(qMap) .map(q => ({ ...q, avg: q.scores.length ? q.scores.reduce((s, v) => s + v, 0) / q.scores.length : 0 })) .sort((a, b) => a.avg - b.avg) .slice(0, 10); return { pillarAvgs: pillarAvgs.sort((a, b) => a.avg - b.avg), weakQuestions: qAvgs, }; } function renderFocusPanel() { const container = document.getElementById('tab-focus-content'); if (!container) return; const { pillarAvgs, weakQuestions } = computeFocusData(); const scoring = activeClient.config.scoring; const { min, max } = scoring; const entityLabel = cfg('entity_label') || activeClient.config.entity_label || 'entities'; const n = activeClient.data.entities.length; const pillarRows = pillarAvgs.map(({ name, displayName, avg }) => { const pct = Math.max(0, Math.min(100, ((avg - min) / (max - min)) * 100)).toFixed(1); const level = scoreToLevel(Math.round(avg), scoring); const lvl = scoreClass(level); const label = scoreLabelDisplay(level) || scoring.labels[level] || ''; return `
${escHtml(displayName)}
${avg.toFixed(2)} ${escHtml(label)}
`; }).join(''); const qRows = weakQuestions.map(q => { const level = scoreToLevel(Math.round(q.avg), scoring); const lvl = scoreClass(level); const topic = getQuestionTopic(q); const pi = activeClient.config.pillars.indexOf(q.pillar); const pillarName = pi >= 0 ? pillarDisplayName(pi) : q.pillar; return `
${q.avg.toFixed(1)}
${escHtml(topic)}
Q${escHtml(String(q.q_num))} · ${escHtml(pillarName)}
${q.scores.length} ${escHtml(entityLabel)}
`; }).join(''); container.innerHTML = `

${escHtml(t('focus_pillar_title'))}

${escHtml(t('focus_pillar_sub', n, entityLabel))}

${pillarRows}

${escHtml(t('focus_q_title'))}

${escHtml(t('focus_q_sub', n, entityLabel))}

${qRows}
`; } // ── 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, pi) => { const p = entity.pillars.find(x => x.name === pname); if (!p) return ''; const pct = pillarBarPct(p.avg).toFixed(1); const displayName = pillarDisplayName(pi); const short = pillarShort(displayName); return `
${escHtml(short)}
${p.avg.toFixed(1)}
`; }).join(''); return `
${escHtml(entity.group)} #${i + 1}

${escHtml(entity.label)}

${entity.overall_score.toFixed(2)} ${escHtml(scoreLabelDisplay(entity.overall_level) || entity.overall_label)}
${pillarBars}
${escHtml(t('card_questions', entity.pillars.reduce((s, p) => s + p.questions.length, 0)))} ${flags.length ? `${escHtml(t('card_issues', flags.length))}` : ''} ${escHtml(t('card_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 displayName = pillarDisplayName(i); const short = pillarShort(displayName); 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, pi) => { const p = entity.pillars.find(x => x.name === pname); if (!p) return ''; const plvl = scoreClass(p.level); const pbg = scoreBgClass(p.level); const displayName = pillarDisplayName(pi); const plabel = scoreLabelDisplay(p.level) || p.label; const pct = pillarBarPct(p.avg).toFixed(1); const qRows = p.questions.map((q, qi) => { const qlvl = scoreClass(q.level); return `
${q.score} ${escHtml(getQuestionTopic(q))}
`; }).join(''); return `
${escHtml(displayName)}
${p.avg.toFixed(2)} ${escHtml(plabel)}
${qRows}
`; }).join(''); // Deliverable buttons (if configured for this entity) const del = (activeClient.deliverables || {})[entity.id] || {}; const deliverablesBtns = (del.pdf || del.xlsx) ? ` ${escHtml(t('deliverables_label'))} ${del.pdf ? ` ` : ''} ${del.xlsx ? ` ` : ''} ` : ''; panel.innerHTML = `
${deliverablesBtns} ${escHtml(t('export_label'))}
${escHtml(entity.group)}

${escHtml(entity.label)}

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

${escHtml(t('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); const pi = activeClient.config.pillars.indexOf(pillar.name); const pillarDisplayed = pi >= 0 ? pillarDisplayName(pi) : pillar.name; document.getElementById('qModalBadges').innerHTML = ` ${escHtml(entity.short)} ${escHtml(pillarDisplayed)} `; document.getElementById('qModalTitle').textContent = getQuestionTopic(question); document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillarDisplayed} · ${entity.label}`; const fields = [ { label: t('field_rationale'), value: question.rationale }, { label: t('field_gaps'), value: question.gaps || '—' }, { label: t('field_refs'), 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; const scoreLabel = scoreLabelDisplay(lvl) || scoring.labels[n]; return ` `; }).join(''); document.getElementById('qModalBody').innerHTML = `
${escHtml(t('field_score'))}
${scoreBtns}

${escHtml(t('score_hint'))}

${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 = `${escHtml(t('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_BASE}/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(t('toast_score_saved', qNum, newScore, scoreLabelDisplay(question.level) || question.label), 'success'); } catch (e) { showToast(t('toast_save_failed'), '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 = `

${escHtml(t('compare_select', entityLabel))}

${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(t('toast_select_two'), '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 = ` ${escHtml(t('compare_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, pi) => { const vals = markets.map(m => { const p = m.pillars.find(x => x.name === pname); return p ? p.avg : null; }); return buildRow(pillarDisplayName(pi), vals); }).join(''); wrap.innerHTML = `

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

${headerCols}${overallRow}${pillarRows}
${escHtml(t('compare_legend_high'))} ${escHtml(t('compare_legend_low'))}
`; } // ── 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 displayName = pillarDisplayName(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(displayName)} ${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 = `

${escHtml(t('heatmap_title'))}

${headerCells} ${pillarRows} ${overallCells} ${grandAvgCell}
${escHtml(t('heatmap_pillar'))}${escHtml(t('heatmap_avg'))}
${escHtml(t('heatmap_overall'))}
${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => { const bgCls = scoreBgClass(parseInt(n)); const lbl = scoreLabelDisplay(parseInt(n)) || scoring.labels[n]; return `${n} ${escHtml(lbl)}`; }).join('')}
`; } // ── Economics tab (admin only) ──────────────────────────────────────────────── let economicData = null; let economicDetailId = null; async function renderEconomicTab() { const panel = document.getElementById('economicPanel'); if (!panel || !activeClient) return; if (!economicData) { panel.innerHTML = `

Loading…

`; try { const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/economic`, { credentials: 'include' }); economicData = await res.json(); } catch (_) { panel.innerHTML = `

Failed to load economic data.

`; return; } } if (economicDetailId) { renderEconomicDetail(economicDetailId); } else { renderEconomicCards(); } } function renderEconomicCards() { const panel = document.getElementById('economicPanel'); const entities = activeClient.data.entities; const cards = entities.map((entity, i) => { const econ = economicData[entity.id]; const lvlCls = scoreClass(entity.overall_level); const hasData = econ && econ.metrics && econ.metrics.length > 0; if (!hasData) { return `
${escHtml(entity.group)}

${escHtml(entity.label)}

No economic data available

`; } const metricsHtml = econ.metrics.map(m => `
${escHtml(m.label)} ${escHtml(m.value)}
`).join(''); const period = econ.data_period ? `${escHtml(econ.data_period)}` : ''; const hasChars = econ.characteristics && econ.characteristics.length > 0; return `
${escHtml(entity.group)} ${period}

${escHtml(entity.label)}

${metricsHtml}
${escHtml(econ.data_period || '')} ${hasChars ? `View characteristics →` : ''}
`; }).join(''); panel.innerHTML = `

Economic Overview

${cards}
`; } function openEconomicDetail(entityId) { economicDetailId = entityId; renderEconomicDetail(entityId); } function renderEconomicDetail(entityId) { const panel = document.getElementById('economicPanel'); const entity = activeClient.data.entities.find(e => e.id === entityId); const econ = economicData && economicData[entityId]; if (!panel || !entity || !econ) return; const lvlCls = scoreClass(entity.overall_level); const metricsHtml = (econ.metrics || []).map(m => `
${escHtml(m.label)} ${escHtml(m.value)}
`).join(''); const charsHtml = (econ.characteristics || []).map(c => `
${escHtml(c.title)}: ${escHtml(c.text)}
`).join(''); panel.innerHTML = `
${escHtml(entity.group)} ${escHtml(entity.label)} ${econ.data_period ? `${escHtml(econ.data_period)}` : ''}

Key Metrics

${metricsHtml}
${charsHtml ? `

Key Characteristics

${charsHtml}
` : ''}
`; } function closeEconomicDetail() { economicDetailId = null; renderEconomicCards(); } // ── 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_BASE}/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(type === 'pdf' ? t('toast_pdf_downloaded') : t('toast_xlsx_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(t('toast_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(t('toast_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_BASE}/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(t('toast_pdf_downloaded')); } catch (e) { showToast(t('toast_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_BASE}/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(t('toast_sync_failed'), 'error'); } else { log.textContent = (data.log || 'Done.').trim(); showToast(t('toast_sync_complete'), 'success'); setTimeout(async () => { await loadClient(activeClient.config.id); showTab('entities'); }, 1000); } } catch (e) { log.textContent = 'Network error: ' + e.message; showToast(t('toast_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(t('toast_select_entity'), 'error'); return; } if (!importFile) { showToast(t('toast_select_file'), '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_BASE}/api/clients/${activeClient.config.id}/import/file`, { method: 'POST', body: formData, }); const data = await res.json(); if (!res.ok) { showToast(t('toast_import_failed', data.error || res.statusText), 'error'); } else { const e = data.entity; showToast(t('toast_import_done', e.label, e.overall_score.toFixed(2), scoreLabelDisplay(e.overall_level) || e.overall_label), 'success'); await loadClient(activeClient.config.id); showTab('entities'); } } catch (e) { showToast(t('toast_import_failed', e.message), 'error'); } finally { btn.disabled = false; btn.innerHTML = ` ${escHtml(t('upload_btn'))}`; } } // ── 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); } // ── Access log ─────────────────────────────────────────────────────────────── let accessLogLoaded = false; async function loadAccessLog(force) { if (accessLogLoaded && !force) return; const el = document.getElementById('accessLogContent'); if (!el) return; el.textContent = 'Loading…'; try { const res = await fetch(`${API_BASE}/api/admin/access-log`, { credentials: 'include' }); const rows = await res.json(); accessLogLoaded = true; if (!rows.length) { el.textContent = 'No access events recorded yet.'; return; } el.innerHTML = ` ${rows.map(r => ` `).join('')}
User Action Detail IP Time
${escHtml(r.user_email)} ${escHtml(r.action)} ${escHtml(r.detail || '—')} ${escHtml(r.ip || '—')} ${new Date(r.logged_at).toLocaleString()}
`; } catch (_) { el.textContent = 'Failed to load access log.'; } } // ── Activity log ───────────────────────────────────────────────────────────────────────────── let activityLoaded = false; function toggleActivity() { const panel = document.getElementById('activityPanel'); const btn = document.getElementById('activityToggleBtn'); const open = panel.style.display === 'none'; panel.style.display = open ? 'block' : 'none'; btn.style.color = open ? 'var(--text)' : 'var(--text-muted)'; if (open && !activityLoaded) loadActivity(); } async function loadActivity() { const el = document.getElementById('activityContent'); try { const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/activity`); const rows = await res.json(); if (!res.ok) throw new Error(rows.error || res.statusText); activityLoaded = true; if (!rows.length) { el.textContent = 'No activity recorded yet.'; return; } const fmt = ts => new Date(ts).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); const scoreCell = (prev, next) => { if (prev == null) return escHtml(String(next)); const arrow = next > prev ? '▲' : next < prev ? '▼' : '→'; const color = next > prev ? '#2e7d32' : next < prev ? '#c62828' : 'var(--text-muted)'; return `${escHtml(String(prev))} ${arrow} ${escHtml(String(next))}`; }; el.innerHTML = ` ${rows.map(r => ` `).join('')}
When User Market Score File
${fmt(r.logged_at)} ${escHtml(r.user_email)} ${escHtml(r.entity_id)} ${scoreCell(r.prev_score, r.new_score)} ${escHtml(r.filename || '—')}
`; } catch (e) { el.textContent = 'Failed to load activity.'; } } // ── Keyboard shortcuts ──────────────────────────────────────────────────────── document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeWizard(); } }); // ── Start ───────────────────────────────────────────────────────────────────── document.getElementById('loginForm').addEventListener('submit', handleLoginSubmit); init();