'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((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')}
` : ''}
${levelRows}
${t('how_to_items').map(([title, desc]) => `
${escHtml(title)}
${escHtml(desc)}
`).join('')}
${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.slice(0,2).toUpperCase())}
`}
${escHtml(c.name)}
${escHtml(c.description)}
${escHtml(t('home_explore', c.entity_count, c.entity_label || 'markets'))}
${escHtml(t('home_open_btn', 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.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)}
${marketRows}
${escHtml(t('overview_generated', data.generated))}
${escHtml(t('overview_view', cfg('entity_label') || config.entity_label || 'Markets'))}
${escHtml(t('overview_compare', cfg('entity_label') || config.entity_label || 'Markets'))}
${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 `
${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 `
${label}
`;
}
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')}
`;
}
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 =>
`${escHtml(p.label)} `
).join('');
body.innerHTML = `
Preset
Custom… ${opts}
${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 = `
Number of Scoring Levels
${[2,3,4,5,6].map(v => `
${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}
+ Add Pillar
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 = ` `;
} 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) =>
`${escHtml(pillarDisplayName(i))} `
).join('');
controls.innerHTML = `
${escHtml(t('sort_by'))}
${escHtml(t('sort_overall'))}
${pillarOpts}
${escHtml(t('group_by_brand'))}
`;
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_sub', n, entityLabel))}
${pillarRows}
${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 `
`;
}).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 `
`;
}).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 ? `
${escHtml(t('btn_summary_pdf'))}
` : ''}
${del.xlsx ? `
${escHtml(t('btn_scores_xlsx'))}
` : ''}
` : '';
panel.innerHTML = `
${escHtml(t('back_to', cfg('entity_label') || activeClient.config.entity_label || 'entities'))}
${deliverablesBtns}
${escHtml(t('export_label'))}
XLSX
PDF
${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(' · ')}
`;
})()}
${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 `
${n}
${escHtml(scoreLabel)}
`;
}).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 = `
${entities.map(e => {
const lvl = scoreClass(e.overall_level);
return `
${escHtml(e.short)}
${e.overall_score.toFixed(2)}
${escHtml(scoreLabelDisplay(e.overall_level) || e.overall_label)}
`;
}).join('')}
${escHtml(t('compare_btn'))}
${escHtml(t('compare_clear'))}
`;
}
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 `
`;
}).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 = `
${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_pillar'))}
${headerCells}
${escHtml(t('heatmap_avg'))}
${pillarRows}
${escHtml(t('heatmap_overall'))}
${overallCells}
${grandAvgCell}
${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 = `
${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 = `
← Economic Overview
${escHtml(entity.group)}
${escHtml(entity.label)}
${econ.data_period ? `${escHtml(econ.data_period)} ` : ''}
${metricsHtml}
${charsHtml ? `
${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 =>
`${escHtml(e.label)} `
).join('');
document.getElementById('importEntitySel').innerHTML =
`${escHtml(t('upload_placeholder'))} ` + options;
document.getElementById('syncEntitySel').innerHTML =
`${escHtml(t('sync_all'))} ` + 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 = `
User
Action
Detail
IP
Time
${rows.map(r => `
${escHtml(r.user_email)}
${escHtml(r.action)}
${escHtml(r.detail || '—')}
${escHtml(r.ip || '—')}
${new Date(r.logged_at).toLocaleString()}
`).join('')}
`;
} 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 = `
When
User
Market
Score
File
${rows.map(r => `
${fmt(r.logged_at)}
${escHtml(r.user_email)}
${escHtml(r.entity_id)}
${scoreCell(r.prev_score, r.new_score)}
${escHtml(r.filename || '—')}
`).join('')}
`;
} 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();