adeo-maturity-tool/script.js
Phil Dore 8d0a618b68 Baseline: EN/FR toggle + question topic translations
Working state before improvements branch:
- Language toggle (EN/FR) for ADEO
- 59 question topics translated to French
- About tab translated and toggle visible on home screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:43:30 +01:00

2071 lines
98 KiB
JavaScript

'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 === 'fr' && base.translations?.fr) {
let v = base.translations.fr;
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 frPillars = currentLang === 'fr' ? activeClient?.config?.translations?.fr?.pillars : null;
return frPillars?.[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;
}
function applyStoredLang() {
if (!activeClient) { currentLang = 'en'; return; }
const key = `maturityLang2_${activeClient.config.id}`;
const stored = localStorage.getItem(key);
if (stored === 'en' || stored === 'fr') { currentLang = stored; return; }
currentLang = 'en';
localStorage.setItem(key, 'en');
}
function toggleLang() {
currentLang = currentLang === 'en' ? 'fr' : 'en';
const langKey = activeClient ? `maturityLang2_${activeClient.config.id}` : 'maturityLang2_home';
localStorage.setItem(langKey, currentLang);
document.documentElement.lang = currentLang;
updateLangToggleButton();
refreshCurrentView();
}
function updateLangToggleButton() {
const btn = document.getElementById('langToggle');
const lbl = document.getElementById('langToggleLabel');
if (!btn || !lbl) return;
const hasFr = activeClient?.config?.translations?.fr
|| allClients?.some(c => c.has_translations);
btn.style.display = hasFr ? '' : 'none';
lbl.textContent = currentLang === 'en' ? 'FR' : 'EN';
}
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-compare').textContent = t('tab_compare');
document.getElementById('tab-heatmap').textContent = t('tab_heatmap');
document.getElementById('tab-update').textContent = t('tab_update');
document.getElementById('headerSub').textContent = cfg('description') || activeClient.config.description;
renderSummaryBar();
if (activeTab === 'entities') {
if (detailEntity) openEntityDetail(detailEntity);
else renderEntityCards();
} else if (activeTab === 'compare') {
renderCompareSelector();
if (selectedForCompare.size >= 2) renderCompareTable();
} else if (activeTab === 'heatmap') {
renderHeatmap();
} else if (activeTab === 'update') {
updateUpdateTabText();
}
}
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 scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' };
const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, lname]) => {
const desc = about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : '';
return `
<div class="level-row">
<span class="badge score-${lvl}" style="flex-shrink:0;min-width:90px;text-align:center;">${escHtml(lname)}</span>
<div>
<div style="font-size:13px;font-weight:600;color:var(--text);">${escHtml(t('about_level', lvl))}</div>
${desc ? `<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>` : ''}
</div>
</div>`;
}).join('') : '';
const pillarRows = (client && client.pillars || []).map((pname, i) => {
const desc = about && about.pillar_descriptions ? about.pillar_descriptions[pname] : '';
const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛'];
return `
<div class="about-pillar-row">
<span style="font-size:20px;flex-shrink:0;width:28px;text-align:center;">${icons[i] || '▪'}</span>
<div>
<div style="font-size:12px;font-weight:700;color:var(--text);letter-spacing:0.04em;">${escHtml(pname)}</div>
${desc ? `<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>` : ''}
</div>
</div>`;
}).join('');
el.innerHTML = `
<div style="max-width:900px;">
<!-- Summary -->
<div class="panel fade-up" style="margin-bottom:16px;">
<p class="section-header">${escHtml(t('about_what_is'))}</p>
<p style="font-size:14px;color:var(--text-sub);line-height:1.7;margin:0;">
${about ? escHtml(about.summary) : 'A content maturity assessment dashboard for evaluating and comparing business units across key marketing capability pillars.'}
</p>
${about && about.question_count ? `
<div style="display:flex;gap:24px;margin-top:16px;flex-wrap:wrap;">
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${about.question_count}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Questions</div></div>
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${(client.pillars || []).length}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Pillars</div></div>
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${allClients[0] && allClients[0].entity_count || '—'}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Markets</div></div>
</div>` : ''}
</div>
<div class="about-grid">
<!-- Scoring scale -->
<div class="about-card fade-up" style="animation-delay:40ms;">
<p class="section-header" style="margin-top:0;">${escHtml(t('about_maturity_levels'))}</p>
${levelRows}
</div>
<!-- How to use -->
<div class="about-card fade-up" style="animation-delay:80ms;">
<p class="section-header" style="margin-top:0;">${escHtml(t('about_how_to'))}</p>
${t('how_to_items').map(([title, desc]) => `
<div style="padding:8px 0;border-bottom:1px solid var(--border-sub);">
<div style="font-size:12px;font-weight:700;color:var(--text);">${escHtml(title)}</div>
<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>
</div>`).join('')}
</div>
<!-- Pillars -->
<div class="about-card fade-up" style="animation-delay:120ms;grid-column:1/-1;">
<p class="section-header" style="margin-top:0;">${escHtml(t('about_the_pillars', (client && client.pillars || []).length))}</p>
${pillarRows}
</div>
</div>
</div>`;
}
// ── 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 = `
<div class="client-card fade-up" style="border-top:3px solid ${c.accent_color};cursor:default;padding:28px;">
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;">
${c.logo
? `<img src="${API_BASE}/clients/${c.id}/${c.logo}" alt="${escHtml(c.name)}" style="height:64px;max-width:180px;object-fit:contain;flex-shrink:0;">`
: `<div style="width:48px;height:48px;border-radius:12px;background:${c.accent_color}22;border:1px solid ${c.accent_color}55;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><span style="font-size:16px;font-weight:900;color:${c.accent_color};">${escHtml(c.name.slice(0,2).toUpperCase())}</span></div>`}
<div>
<h2 style="font-size:22px;font-weight:900;color:var(--text);margin:0;line-height:1.1;">${escHtml(c.name)}</h2>
<p style="font-size:13px;color:var(--text-muted);margin:3px 0 0;">${escHtml(c.description)}</p>
</div>
</div>
<p style="font-size:13px;color:var(--text-sub);margin:0 0 24px;line-height:1.65;border-bottom:1px solid var(--border);padding-bottom:20px;">
${escHtml(t('home_explore', c.entity_count, c.entity_label || 'markets'))}
</p>
<button class="btn-primary" onclick="loadClient('${escHtml(c.id)}')" style="font-size:14px;padding:12px 28px;">
${escHtml(t('home_open_btn', c.entity_count, c.entity_label || 'Markets'))}
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
<p style="font-size:11px;color:var(--text-faint);margin:16px 0 0;">${escHtml(t('home_last_updated', c.generated))}</p>
</div>
<div class="new-client-card admin-only fade-up" style="animation-delay:60ms;" onclick="openWizard()">
<div style="width:44px;height:44px;border-radius:12px;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;margin-bottom:8px;">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
<p style="font-size:14px;font-weight:700;color:var(--text-sub);margin:0;">${escHtml(t('home_new_client'))}</p>
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-align:center;line-height:1.4;">${escHtml(t('home_new_client_sub'))}</p>
</div>`;
} else {
document.getElementById('clientCards').innerHTML = allClients.map((c, i) => `
<div class="client-card fade-up" style="animation-delay:${i * 40}ms;border-color:${c.accent_color}22;"
onclick="loadClient('${escHtml(c.id)}')"
onmouseenter="this.style.borderColor='${c.accent_color}'"
onmouseleave="this.style.borderColor='${c.accent_color}22'">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:14px;">
${c.logo
? `<img src="${API_BASE}/clients/${c.id}/${c.logo}" alt="${escHtml(c.name)}" style="height:28px;max-width:90px;object-fit:contain;">`
: `<div style="width:36px;height:36px;border-radius:8px;background:${c.accent_color}22;border:1px solid ${c.accent_color}55;display:flex;align-items:center;justify-content:center;"><span style="font-size:13px;font-weight:800;color:${c.accent_color};">${escHtml(c.name.slice(0,2).toUpperCase())}</span></div>`}
<span style="font-size:11px;color:var(--text-muted);font-weight:600;">${c.entity_count} ${escHtml(c.entity_label || 'entities')}</span>
</div>
<h3 style="font-size:16px;font-weight:800;color:var(--text);margin:0 0 4px;">${escHtml(c.name)}</h3>
<p style="font-size:12px;color:var(--text-muted);margin:0 0 14px;">${escHtml(c.description)}</p>
<div style="padding-top:12px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:11px;color:var(--text-faint);">${escHtml(t('home_generated', c.generated))}</span>
<span style="font-size:12px;font-weight:600;color:${c.accent_color};">${escHtml(t('home_open_arrow'))}</span>
</div>
</div>
`).join('') + `
<div class="new-client-card admin-only fade-up" style="animation-delay:${allClients.length * 40}ms;" onclick="openWizard()">
<div style="width:44px;height:44px;border-radius:12px;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;margin-bottom:8px;">
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</div>
<p style="font-size:14px;font-weight:700;color:var(--text-sub);margin:0;">${escHtml(t('home_new_client'))}</p>
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-align:center;line-height:1.4;">${escHtml(t('home_new_client_sub'))}</p>
</div>`;
}
}
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 `
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border-sub);cursor:pointer;"
onclick="loadClient('${escHtml(id)}')" class="home-market-row">
<span style="font-size:12px;color:var(--text-muted);width:18px;text-align:right;flex-shrink:0;font-weight:600;">${i + 1}</span>
<span style="font-size:13px;font-weight:600;color:var(--text);width:160px;flex-shrink:0;">${escHtml(e.short)}</span>
<div style="flex:1;display:flex;align-items:center;gap:10px;">
<div class="pillar-bar-track" style="flex:1;">
<div class="pillar-bar-fill" style="width:${pct.toFixed(1)}%;"></div>
</div>
<span style="font-size:14px;font-weight:800;color:var(--text);width:36px;text-align:right;">${e.overall_score.toFixed(2)}</span>
</div>
<span class="badge ${lvl}" style="width:88px;text-align:center;flex-shrink:0;">${escHtml(scoreLabelDisplay(e.overall_level) || e.overall_label)}</span>
</div>`;
}).join('');
document.getElementById('clientCards').innerHTML = `
<div class="fade-up" style="max-width:800px;">
<!-- Hero panel -->
<div class="panel" style="margin-bottom:20px;border-top:3px solid var(--accent);padding:28px 32px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:20px;">
<div>
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
<div style="width:42px;height:42px;border-radius:10px;background:var(--accent);display:flex;align-items:center;justify-content:center;">
<span style="font-size:14px;font-weight:900;color:#111;">${escHtml(config.name.slice(0,2).toUpperCase())}</span>
</div>
<div>
<h2 style="font-size:22px;font-weight:900;color:var(--text);margin:0;line-height:1.1;">${escHtml(config.name)}</h2>
<p style="font-size:12px;color:var(--text-muted);margin:2px 0 0;">${escHtml(config.description)}</p>
</div>
</div>
</div>
<div style="display:flex;gap:0;flex-wrap:wrap;">
<div class="stat-box" style="border-right:1px solid var(--border);">
<div class="stat-num">${entities.length}</div>
<div class="stat-lbl">${escHtml(config.entity_label || 'Entities')}</div>
</div>
<div class="stat-box" style="border-right:1px solid var(--border);">
<div class="stat-num" style="color:var(--accent);">${avg}</div>
<div class="stat-lbl">${escHtml(t('summary_avg'))}</div>
</div>
<div class="stat-box" style="border-right:1px solid var(--border);text-align:left;padding:12px 20px;">
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_highest'))}</div>
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(best.short)}</div>
<div style="font-size:12px;color:var(--accent);font-weight:700;">${best.overall_score.toFixed(2)}</div>
</div>
<div class="stat-box" style="text-align:left;padding:12px 20px;">
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_lowest'))}</div>
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(worst.short)}</div>
<div style="font-size:12px;color:#C62828;font-weight:700;">${worst.overall_score.toFixed(2)}</div>
</div>
</div>
</div>
</div>
<!-- Market score list -->
<div class="panel fade-up" style="margin-bottom:20px;animation-delay:60ms;">
<p class="section-header">${escHtml(cfg('entity_label') || config.entity_label || 'Entities')} Overview</p>
<div>
${marketRows}
</div>
<p style="font-size:11px;color:var(--text-faint);margin:12px 0 0;">${escHtml(t('overview_generated', data.generated))}</p>
</div>
<!-- Action buttons -->
<div class="fade-up" style="display:flex;gap:10px;flex-wrap:wrap;animation-delay:100ms;margin-bottom:20px;">
<button class="btn-primary" onclick="loadClient('${escHtml(id)}')" style="font-size:14px;padding:11px 24px;">
${escHtml(t('overview_view', cfg('entity_label') || config.entity_label || 'Markets'))}
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
<button class="btn-ghost" onclick="loadClient('${escHtml(id)}').then(() => showTab('compare'))" style="font-size:14px;padding:11px 24px;">
${escHtml(t('overview_compare', cfg('entity_label') || config.entity_label || 'Markets'))}
</button>
</div>
${config.about ? renderAboutSection(config) : ''}
</div>
`;
// 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 `
<div style="padding:12px 14px;background:var(--bg-inset);border:1px solid var(--border);border-radius:8px;">
<p style="font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--accent);margin:0 0 4px;">${escHtml(displayName)}</p>
<p style="font-size:12px;color:var(--text-sub);margin:0;line-height:1.5;">${escHtml(desc)}</p>
</div>`;
}).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 `
<div style="display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:1px solid var(--border-sub);">
<span class="badge ${lvl}" style="min-width:100px;text-align:center;flex-shrink:0;">${n}${escHtml(label)}</span>
<span style="font-size:12px;color:var(--text-sub);line-height:1.55;">${escHtml(desc)}</span>
</div>`;
}).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 `
<div class="panel fade-up" style="animation-delay:140ms;">
<p class="section-header">${escHtml(t('about_assessment'))}</p>
${frSummary ? `<p style="font-size:13px;color:var(--text-sub);margin:0 0 20px;line-height:1.7;">${escHtml(frSummary)}</p>` : ''}
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin:0 0 10px;">
${escHtml(pillarsCountText)}
</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin-bottom:20px;">
${pillarGrid}
</div>
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin:0 0 2px;">${escHtml(t('about_scoring_scale'))}</p>
<div>${scoringRows}</div>
</div>`;
}
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) => `
<div style="display:flex;align-items:center;gap:4px;">
<div style="width:8px;height:8px;border-radius:50%;transition:background 0.2s;background:${i <= wizStep ? 'var(--accent)' : 'var(--border)'};"></div>
${i < WIZARD_STEPS.length - 1 ? `<div style="width:18px;height:2px;transition:background 0.2s;background:${i < wizStep ? 'var(--accent)' : 'var(--border)'};"></div>` : ''}
</div>
`).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 `<div style="margin-bottom:16px;">
<label class="field-label" for="${id}">${label}</label>
<input class="wizard-input" id="${id}" type="text" value="${escHtml(value)}" placeholder="${escHtml(placeholder)}" ${extra}>
</div>`;
}
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')}
<div style="margin-bottom:4px;">
<label class="field-label">Accent Colour</label>
<div style="display:flex;align-items:center;gap:10px;">
<input type="color" id="wiz-color" value="${escHtml(wizData.accent_color)}"
style="width:40px;height:36px;border:1px solid var(--border);border-radius:6px;background:none;cursor:pointer;padding:2px;"
oninput="wizData.accent_color=this.value;document.getElementById('wiz-color-hex').value=this.value;">
<input class="wizard-input" id="wiz-color-hex" type="text" value="${escHtml(wizData.accent_color)}" placeholder="#6366f1" style="width:110px;"
oninput="if(/^#[0-9A-Fa-f]{6}$/.test(this.value)){wizData.accent_color=this.value;document.getElementById('wiz-color').value=this.value;}">
<span style="font-size:12px;color:var(--text-muted);">Brand accent colour</span>
</div>
</div>`;
}
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 =>
`<option value="${escHtml(p.label)}"${p.label === wizData.entity_label ? ' selected' : ''}>${escHtml(p.label)}</option>`
).join('');
body.innerHTML = `
<div style="margin-bottom:16px;">
<label class="field-label">Preset</label>
<select class="field-select" onchange="applyEntityPreset(this.value)">
<option value="">Custom…</option>${opts}
</select>
</div>
${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')}
<p style="font-size:12px;color:var(--text-muted);line-height:1.5;">Used throughout the tool — tab label, summary bar, compare selector.</p>`;
}
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) => `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
<span style="font-size:13px;font-weight:700;color:var(--text);width:20px;text-align:center;">${i + 1}</span>
<input class="wizard-input" style="flex:1;" value="${escHtml(wizData.scoring_labels[i] || '')}"
placeholder="Level ${i + 1} name" oninput="wizData.scoring_labels[${i}]=this.value">
</div>`).join('');
body.innerHTML = `
<div style="margin-bottom:18px;">
<label class="field-label">Number of Scoring Levels</label>
<div style="display:flex;gap:8px;">
${[2,3,4,5,6].map(v => `
<button type="button" onclick="setWizardLevels(${v})"
style="padding:7px 16px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;
border:2px solid ${v===n?'var(--accent)':'var(--border)'};
background:${v===n?'var(--bg-inset)':'transparent'};
color:${v===n?'var(--accent)':'var(--text-muted)'};">${v}</button>`).join('')}
</div>
</div>
<p class="field-label" style="margin-bottom:10px;">Level names (lowest → highest)</p>
<div id="wizLevelInputs">${inputs}</div>`;
}
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) => `
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<input class="wizard-input" style="flex:1;" value="${escHtml(p)}" placeholder="Pillar name"
oninput="wizData.pillars[${i}]=this.value.toUpperCase()">
<button type="button" onclick="removeWizPillar(${i})"
style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:6px;border-radius:4px;flex-shrink:0;" title="Remove">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>`).join('');
body.innerHTML = `
<div id="wizPillarList">${rows}</div>
<button type="button" class="btn-ghost" style="margin-top:8px;width:100%;justify-content:center;" onclick="addWizPillar()">
+ Add Pillar
</button>
<p style="font-size:12px;color:var(--text-muted);margin:12px 0 0;line-height:1.5;">Pre-filled with ADEO's 7 pillars as a starting point — modify or replace as needed.</p>`;
}
function addWizPillar() { wizData.pillars.push(''); renderWizardStep(); }
function removeWizPillar(i) { wizData.pillars.splice(i, 1); renderWizardStep(); }
function renderWizStep4(body) {
body.innerHTML = `
<p style="font-size:13px;color:var(--text-sub);margin:0 0 14px;line-height:1.6;">Review your configuration. Go back to edit any step.</p>
<div style="background:var(--bg-inset);border:1px solid var(--border);border-radius:8px;padding:14px;
font-family:ui-monospace,monospace;font-size:12px;color:var(--text-sub);white-space:pre-wrap;
max-height:340px;overflow-y:auto;line-height:1.55;">${escHtml(JSON.stringify(buildWizardConfig(), null, 2))}</div>`;
}
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('update');
} 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 = `<img src="${API_BASE}/clients/${config.id}/${config.logo}" alt="${escHtml(config.name)}" style="height:30px;max-width:100px;object-fit:contain;vertical-align:middle;">`;
} 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-compare').textContent = t('tab_compare');
document.getElementById('tab-heatmap').textContent = t('tab_heatmap');
document.getElementById('tab-update').textContent = t('tab_update');
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(); updateUpdateTabText(); }
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 = cfg('entity_label') || activeClient.config.entity_label || 'Entities';
document.getElementById('summaryBar').innerHTML = `
<div style="display:flex;gap:0;flex-wrap:wrap;align-items:stretch;margin:-8px;">
<div class="stat-box" style="padding:12px 24px;border-right:1px solid var(--border);">
<div class="stat-num">${entities.length}</div>
<div class="stat-lbl">${escHtml(entityLabel)}</div>
</div>
<div class="stat-box" style="padding:12px 24px;border-right:1px solid var(--border);">
<div class="stat-num">${avg}</div>
<div class="stat-lbl">${escHtml(t('summary_avg'))}</div>
</div>
<div class="stat-box" style="padding:12px 24px;border-right:1px solid var(--border);text-align:left;">
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_highest'))}</div>
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(best.short)}</div>
<div style="font-size:12px;color:var(--accent);font-weight:700;">${best.overall_score.toFixed(2)}</div>
</div>
<div class="stat-box" style="padding:12px 24px;text-align:left;">
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_lowest'))}</div>
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(worst.short)}</div>
<div style="font-size:12px;color:#C62828;font-weight:700;">${worst.overall_score.toFixed(2)}</div>
</div>
<div style="margin-left:auto;padding:12px 0 12px 24px;display:flex;align-items:center;">
<span style="font-size:11px;color:var(--text-faint);">Data: ${escHtml(activeClient.data.generated)}</span>
</div>
</div>
`;
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) =>
`<option value="${escHtml(p)}"${sortPillar === p ? ' selected' : ''}>${escHtml(pillarDisplayName(i))}</option>`
).join('');
controls.innerHTML = `
<span style="font-size:12px;font-weight:600;color:var(--text-muted);">${escHtml(t('sort_by'))}</span>
<select class="ctrl-select" id="sortSel" onchange="onSortChange(this.value)">
<option value="overall"${sortPillar === 'overall' ? ' selected' : ''}>${escHtml(t('sort_overall'))}</option>
${pillarOpts}
</select>
<button class="ctrl-btn${groupByGroup ? ' active' : ''}" id="groupBtn" onclick="onGroupToggle()">
${escHtml(t('group_by_brand'))}
</button>
`;
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 `
<div>
<p class="section-header" style="margin-bottom:10px;">${escHtml(gname)}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
${cards}
</div>
</div>`;
}).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 `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
<span style="font-size:10px;color:var(--text-muted);width:52px;flex-shrink:0;font-weight:600;letter-spacing:0.03em;">${escHtml(short)}</span>
<div class="pillar-bar-track" style="flex:1;"><div class="pillar-bar-fill" style="width:${pct}%;"></div></div>
<span style="font-size:10px;color:var(--text-sub);width:28px;text-align:right;font-weight:600;">${p.avg.toFixed(1)}</span>
</div>`;
}).join('');
return `
<div class="entity-card fade-up" style="animation-delay:${Math.min(i * 30, 240)}ms;" data-id="${escHtml(entity.id)}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
<span class="badge ${lvlCls}">${escHtml(entity.group)}</span>
<span style="font-size:10px;color:var(--text-muted);font-weight:600;letter-spacing:0.04em;">#${i + 1}</span>
</div>
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;">
<p style="font-size:15px;font-weight:700;color:var(--text);margin:0;line-height:1.3;flex:1;">${escHtml(entity.label)}</p>
<div class="${bgCls}" style="border-radius:8px;padding:8px 12px;text-align:center;flex-shrink:0;">
<span style="font-size:22px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
<span class="badge ${lvlCls}" style="font-size:9px;margin-top:3px;letter-spacing:0.05em;">${escHtml(scoreLabelDisplay(entity.overall_level) || entity.overall_label)}</span>
</div>
</div>
<div>${pillarBars}</div>
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:8px;">
<span style="font-size:11px;color:var(--text-muted);">${escHtml(t('card_questions', entity.pillars.reduce((s, p) => s + p.questions.length, 0)))}</span>
${flags.length ? `<span class="entity-flag-badge" title="${escHtml(flagTip)}">${escHtml(t('card_issues', flags.length))}</span>` : ''}
<span style="font-size:12px;color:var(--accent);font-weight:600;white-space:nowrap;">${escHtml(t('card_view_detail'))}</span>
</div>
</div>`;
}
// ── 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 `<polygon points="${pts}" fill="none" stroke="#888" stroke-width="0.8" opacity="0.35"/>`;
}).join('');
// Axis lines
const axisLines = pillars.map((_, i) => {
const e = ptEdge(i);
return `<line x1="${cx}" y1="${cy}" x2="${e.x.toFixed(2)}" y2="${e.y.toFixed(2)}" stroke="#888" stroke-width="0.8" opacity="0.4"/>`;
}).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 `<circle cx="${p.x.toFixed(2)}" cy="${p.y.toFixed(2)}" r="3.5" fill="var(--accent)" stroke="none"/>`;
}).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 `<text x="${lx.toFixed(2)}" y="${(ly + 3).toFixed(2)}" font-size="8" fill="#9ca3af" text-anchor="${anchor}" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="600">${escHtml(short)}</text>`;
}).join('');
return `<svg viewBox="0 0 220 220" width="180" height="180" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
${gridRings}
${axisLines}
<polygon points="${scorePoints}" fill="var(--accent)" fill-opacity="0.18" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round"/>
${scoreDots}
${labels}
</svg>`;
}
// ── 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 `
<div class="question-row" data-pillar="${escHtml(pname)}" data-qi="${qi}">
<span class="badge ${qlvl}" style="font-size:11px;min-width:26px;text-align:center;flex-shrink:0;">${q.score}</span>
<span style="font-size:13px;color:var(--text);flex:1;line-height:1.45;">${escHtml(getQuestionTopic(q))}</span>
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="color:var(--text-faint);flex-shrink:0;margin-top:1px;"><path d="M9 18l6-6-6-6"/></svg>
</div>`;
}).join('');
return `
<div class="pillar-card fade-up">
<div class="pillar-card-header" onclick="togglePillar(this)">
<div style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;">
<span style="font-size:13px;font-weight:700;color:var(--text);">${escHtml(displayName)}</span>
<div class="pillar-bar-track" style="flex:1;max-width:120px;display:none;" id="bar-inline">
<div class="pillar-bar-fill" style="width:${pct}%;"></div>
</div>
</div>
<div style="display:flex;align-items:center;gap:10px;flex-shrink:0;">
<div class="${pbg}" style="border-radius:6px;padding:5px 12px;text-align:center;display:flex;align-items:center;gap:8px;">
<span style="font-size:17px;font-weight:800;color:var(--text);">${p.avg.toFixed(2)}</span>
<span class="badge ${plvl}" style="font-size:9px;">${escHtml(plabel)}</span>
</div>
<svg class="pillar-chevron" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
</div>
</div>
<div class="pillar-card-body">${qRows}</div>
</div>`;
}).join('');
// Deliverable buttons (if configured for this entity)
const del = (activeClient.deliverables || {})[entity.id] || {};
const deliverablesBtns = (del.pdf || del.xlsx) ? `
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);">${escHtml(t('deliverables_label'))}</span>
${del.pdf ? `
<button class="btn-ghost" id="delPdfBtn-${escHtml(entity.id)}" onclick="downloadDeliverable('${escHtml(entity.id)}','pdf')" title="Download client summary PDF">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
${escHtml(t('btn_summary_pdf'))}
</button>` : ''}
${del.xlsx ? `
<button class="btn-ghost" id="delXlsxBtn-${escHtml(entity.id)}" onclick="downloadDeliverable('${escHtml(entity.id)}','xlsx')" title="Download client scores file">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
${escHtml(t('btn_scores_xlsx'))}
</button>` : ''}
<span style="width:1px;height:20px;background:var(--border);margin:0 4px;flex-shrink:0;"></span>
` : '';
panel.innerHTML = `
<div class="back-row" style="flex-wrap:wrap;gap:8px;">
<button class="btn-ghost" onclick="closeEntityDetail()">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
${escHtml(t('back_to', cfg('entity_label') || activeClient.config.entity_label || 'entities'))}
</button>
<div style="margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
${deliverablesBtns}
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);">${escHtml(t('export_label'))}</span>
<button class="btn-ghost" onclick="exportXlsx('${escHtml(entity.id)}')" title="Download Excel for this entity">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
XLSX
</button>
<button class="btn-ghost" id="pdfBtn-${escHtml(entity.id)}" onclick="downloadPdf('${escHtml(entity.id)}')" title="Download PDF report">
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
PDF
</button>
</div>
</div>
<div class="panel fade-up" style="margin-bottom:20px;">
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:16px;">
<div>
<span class="badge ${lvlCls}" style="margin-bottom:8px;">${escHtml(entity.group)}</span>
<h2 style="font-size:22px;font-weight:800;color:var(--text);margin:6px 0 0;">${escHtml(entity.label)}</h2>
</div>
${radarSvg(entity, activeClient.config)}
<div class="${bgCls}" style="border-radius:10px;padding:14px 20px;text-align:center;">
<span style="font-size:36px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
<span class="badge ${lvlCls}" style="font-size:10px;margin-top:5px;">${escHtml(scoreLabelDisplay(entity.overall_level) || entity.overall_label)}</span>
</div>
</div>
</div>
${(() => {
const flags = getEntityFlags(entity);
if (!flags.length) return '';
return `<div class="flag-banner fade-up">
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;margin-top:1px;"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
<div>
<span style="font-weight:700;margin-right:8px;">${escHtml(t('data_quality'))}</span>
${flags.map(f => `<span style="opacity:.85;">${escHtml(f.label)}</span>`).join(' <span style="opacity:.4;margin:0 4px;">·</span> ')}
</div>
</div>`;
})()}
<p class="section-header">${escHtml(t('pillar_breakdown'))}</p>
<div id="pillarAccordion">${pillarAccordions}</div>
`;
// 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 = `
<span class="badge ${elvl}">${escHtml(entity.short)}</span>
<span class="badge" style="background:var(--bg-inset);color:var(--text-muted);border:1px solid var(--border);">${escHtml(pillar.name)}</span>
`;
document.getElementById('qModalTitle').textContent = getQuestionTopic(question);
document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillar.name} · ${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 `
<button onclick="updateScore('${escHtml(entity.id)}','${escHtml(pillar.name)}','${escHtml(String(question.q_num))}',${n})"
style="display:flex;flex-direction:column;align-items:center;gap:3px;padding:8px 12px;border-radius:6px;
border:2px solid ${isCurrent ? 'var(--accent)' : 'var(--border)'};
background:${isCurrent ? 'rgba(120,190,32,0.08)' : 'var(--bg-inset)'};
cursor:pointer;transition:all 0.15s;min-width:60px;"
id="score-btn-${n}"
title="${escHtml(scoreLabel)}">
<span class="badge score-${lvl}" style="font-size:14px;padding:3px 10px;">${n}</span>
<span style="font-size:10px;color:var(--text-muted);font-weight:600;">${escHtml(scoreLabel)}</span>
</button>`;
}).join('');
document.getElementById('qModalBody').innerHTML = `
<div class="spec-detail-row">
<span class="spec-detail-label">${escHtml(t('field_score'))}</span>
<div>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:4px;" id="scoreBtnRow">${scoreBtns}</div>
<p style="font-size:11px;color:var(--text-muted);margin:6px 0 0;" id="scoreEditHint">${escHtml(t('score_hint'))}</p>
</div>
</div>
${fields.map(f => {
const empty = !f.value || f.value === '—';
return `
<div class="spec-detail-row">
<span class="spec-detail-label">${escHtml(f.label)}</span>
<span class="spec-detail-value${empty ? ' empty' : ''}">${escHtml(f.value)}</span>
</div>`;
}).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 = `<span style="color:var(--accent);">${escHtml(t('score_updated', oldScore, newScore))}</span>`;
// 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 = `
<p class="section-header">${escHtml(t('compare_select', entityLabel))}</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px;margin-bottom:16px;">
${entities.map(e => {
const lvl = scoreClass(e.overall_level);
return `
<label style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:var(--bg-card);transition:border-color 0.15s;"
onmouseenter="this.style.borderColor='var(--accent)'" onmouseleave="this.style.borderColor='var(--border)'">
<input type="checkbox" value="${escHtml(e.id)}" onchange="onCompareSelect(this)" style="accent-color:var(--accent);width:14px;height:14px;flex-shrink:0;">
<div style="min-width:0;">
<p style="font-size:13px;font-weight:600;color:var(--text);margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(e.short)}</p>
<p style="font-size:11px;color:var(--text-muted);margin:2px 0 0;">
<span class="badge ${lvl}" style="font-size:9px;">${e.overall_score.toFixed(2)}</span>
<span style="margin-left:4px;">${escHtml(scoreLabelDisplay(e.overall_level) || e.overall_label)}</span>
</p>
</div>
</label>`;
}).join('')}
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-primary" onclick="renderCompareTable()">${escHtml(t('compare_btn'))}</button>
<button class="btn-ghost" onclick="clearCompare()">${escHtml(t('compare_clear'))}</button>
</div>
`;
}
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 => `
<th style="text-align:center;">
<div style="font-size:13px;font-weight:700;color:var(--text);text-transform:none;letter-spacing:0;">${escHtml(m.short)}</div>
<div style="margin-top:4px;">
<span class="badge ${scoreClass(m.overall_level)}" style="font-size:9px;">${m.overall_score.toFixed(2)}</span>
</div>
</th>`).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 '<td style="text-align:center;color:var(--text-faint);">—</td>';
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 `
<td class="${cls}" style="text-align:center;">
<div style="display:flex;align-items:center;gap:8px;justify-content:center;">
<div class="pillar-bar-track" style="width:60px;">
<div class="pillar-bar-fill" style="width:${pct}%;"></div>
</div>
<span style="font-weight:700;font-size:13px;">${v.toFixed(2)}</span>
</div>
</td>`;
}).join('');
return `<tr><td class="row-label">${escHtml(label)}</td>${cells}</tr>`;
};
// 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 = `<tr style="background:var(--bg-inset);">
<td class="row-label" style="font-weight:800;color:var(--text);">${escHtml(t('compare_overall'))}</td>
${markets.map(m => {
const cls = !allSameO && m.overall_score === maxO ? 'diff-hi' : !allSameO && m.overall_score === minO ? 'diff-lo' : '';
return `<td class="${cls}" style="text-align:center;">
<span class="badge ${scoreClass(m.overall_level)}" style="font-size:12px;padding:3px 10px;">${m.overall_score.toFixed(2)}</span>
</td>`;
}).join('')}
</tr>`;
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 = `
<p class="section-header">${escHtml(markets.map(m => m.short).join(' vs '))}</p>
<div style="overflow-x:auto;">
<table class="compare-table">
<thead><tr><th style="width:190px;"></th>${headerCols}</tr></thead>
<tbody>${overallRow}${pillarRows}</tbody>
</table>
</div>
<div style="margin-top:12px;display:flex;gap:20px;font-size:11px;color:var(--text-muted);">
<span><span style="display:inline-block;width:10px;height:10px;background:rgba(46,125,50,0.2);border:1px solid #2E7D32;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>${escHtml(t('compare_legend_high'))}</span>
<span><span style="display:inline-block;width:10px;height:10px;background:rgba(198,40,40,0.15);border:1px solid #C62828;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>${escHtml(t('compare_legend_low'))}</span>
</div>
`;
}
// ── 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 =>
`<th style="text-align:center;font-weight:700;font-size:11px;padding:10px 8px;min-width:70px;">${escHtml(e.short)}</th>`
).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 `<td style="text-align:center;color:var(--text-faint);">—</td>`;
const lvl = heatLevel(p.avg);
const bgCls = scoreBgClass(lvl);
return `<td style="text-align:center;"><span class="heatmap-cell ${bgCls}">${p.avg.toFixed(2)}</span></td>`;
}).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
? `<td style="text-align:center;"><span class="heatmap-cell ${ravgBg}" style="opacity:0.75;">${rowAvg.toFixed(2)}</span></td>`
: `<td style="text-align:center;color:var(--text-faint);">—</td>`;
const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);';
return `<tr style="${rowBg}">
<td style="font-weight:600;color:var(--text);min-width:180px;font-size:12px;padding:10px 14px;">${escHtml(displayName)}</td>
${cells}
${rowAvgCell}
</tr>`;
}).join('');
// Overall row
const overallCells = entities.map(e => {
const lvl = heatLevel(e.overall_score);
const bgCls = scoreBgClass(lvl);
return `<td style="text-align:center;"><span class="heatmap-cell ${bgCls}">${e.overall_score.toFixed(2)}</span></td>`;
}).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 = `<td style="text-align:center;"><span class="heatmap-cell ${grandBg}" style="opacity:0.75;">${grandAvg.toFixed(2)}</span></td>`;
panel.innerHTML = `
<p class="section-header" style="margin-bottom:16px;">${escHtml(t('heatmap_title'))}</p>
<div style="overflow-x:auto;">
<table class="heatmap-table">
<thead>
<tr>
<th style="text-align:left;min-width:180px;">${escHtml(t('heatmap_pillar'))}</th>
${headerCells}
<th style="text-align:center;color:var(--text-muted);">${escHtml(t('heatmap_avg'))}</th>
</tr>
</thead>
<tbody>
${pillarRows}
<tr style="border-top:2px solid var(--border);">
<td style="font-weight:800;color:var(--text);font-size:12px;padding:10px 14px;text-transform:uppercase;letter-spacing:0.05em;">${escHtml(t('heatmap_overall'))}</td>
${overallCells}
${grandAvgCell}
</tr>
</tbody>
</table>
</div>
<div style="margin-top:12px;display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--text-muted);">
${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 `<span><span class="heatmap-cell ${bgCls}" style="font-size:10px;padding:2px 8px;min-width:0;">${n}</span> ${escHtml(lbl)}</span>`;
}).join('')}
</div>
`;
}
// ── 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 = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>';
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 = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> 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 =>
`<option value="${escHtml(e.id)}">${escHtml(e.label)}</option>`
).join('');
document.getElementById('importEntitySel').innerHTML =
`<option value="">${escHtml(t('upload_placeholder'))}</option>` + options;
document.getElementById('syncEntitySel').innerHTML =
`<option value="">${escHtml(t('sync_all'))}</option>` + 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 = '<span style="opacity:0.7;">Syncing…</span>';
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 = `
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
</svg> 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), 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 = `
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
</svg> ${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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 ─────────────────────────────────────────────────────────────────────
document.getElementById('loginForm').addEventListener('submit', handleLoginSubmit);
init();