'use strict';
// ── State ─────────────────────────────────────────────────────────────────────
let allClients = [];
let activeClient = null; // { config, data }
let activeTab = 'entities';
let detailEntity = null;
const selectedForCompare = new Set();
let sortPillar = 'overall'; // 'overall' | pillar name
let groupByGroup = false;
// ── Pillar short labels ───────────────────────────────────────────────────────
const PILLAR_SHORT = {
'OMNICHANNEL': 'OMNI',
'CLIENT CENTRICITY': 'CLIENT',
'MEASUREMENT': 'MEASURE',
'TECH CAPABILITIES': 'TECH',
'AUTOMATION & INDUSTRIALIZATION': 'AUTO',
'INNOVATION': 'INNOV',
'ORGANISATION': 'ORG',
};
function pillarShort(name) {
return PILLAR_SHORT[name] || name.slice(0, 6).toUpperCase();
}
// ── Data quality flags ────────────────────────────────────────────────────────
function getEntityFlags(entity) {
const flags = [];
const qs = entity.pillars.flatMap(p => p.questions);
const zeroScore = qs.filter(q => !q.score || q.score === 0);
if (zeroScore.length > 0)
flags.push({ icon: '✕', label: `${zeroScore.length} question${zeroScore.length > 1 ? 's' : ''} missing score` });
const noRationale = qs.filter(q => !q.rationale || q.rationale.trim().length < 5);
if (noRationale.length > 0)
flags.push({ icon: '◌', label: `${noRationale.length} question${noRationale.length > 1 ? 's' : ''} missing rationale` });
const noGaps = qs.filter(q => !q.gaps || q.gaps.trim().length < 5);
if (noGaps.length > Math.floor(qs.length * 0.2))
flags.push({ icon: '◌', label: `${noGaps.length} questions missing gap analysis` });
if (entity.synced) {
const days = Math.floor((Date.now() - new Date(entity.synced)) / 86400000);
if (days > 30)
flags.push({ icon: '⏱', label: `Data last synced ${days} days ago` });
}
return flags;
}
// ── Boot ──────────────────────────────────────────────────────────────────────
async function init() {
applyStoredTheme();
try {
const res = await fetch('/api/clients');
allClients = await res.json();
renderHome();
} catch (e) {
document.getElementById('headerSub').textContent = 'Could not connect to server';
showToast('Failed to load data', 'error');
}
}
// ── Home screen tabs ──────────────────────────────────────────────────────────
function showHomeTab(name) {
['clients', 'about'].forEach(t => {
document.getElementById(`homeTab-${t}`).style.display = t === name ? '' : 'none';
document.getElementById(`home-tab-${t}`).classList.toggle('active', t === name);
});
if (name === 'about') renderAbout();
}
function renderAbout() {
const el = document.getElementById('homeTab-about');
// Build content from all loaded clients' config.about (use first client that has it)
const client = allClients.find(c => c.about) || allClients[0];
const about = client && client.about;
const scoring = client && client.scoring;
const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' };
const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, name]) => {
const desc = about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : '';
return `
${escHtml(name)}
Level ${escHtml(lvl)}
${desc ? `
${escHtml(desc)}
` : ''}
`;
}).join('') : '';
const pillarRows = (client && client.pillars || []).map((pname, i) => {
const desc = about && about.pillar_descriptions ? about.pillar_descriptions[pname] : '';
const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛'];
return `
${icons[i] || '▪'}
${escHtml(pname)}
${desc ? `
${escHtml(desc)}
` : ''}
`;
}).join('');
el.innerHTML = `
${about ? escHtml(about.summary) : 'A content maturity assessment dashboard for evaluating and comparing business units across key marketing capability pillars.'}
${about && about.question_count ? `
${about.question_count}
Questions
${(client.pillars || []).length}
Pillars
${allClients[0] && allClients[0].entity_count || '—'}
Markets
` : ''}
${levelRows}
${[
['Markets tab', 'Browse all markets sorted by overall score. Click any card to drill into pillar scores and individual question rationale.'],
['Heatmap tab', 'See all markets and pillars in one grid — instantly spot which pillars are strong or weak across the board.'],
['Compare tab', 'Select 2 or more markets to put them side by side. Green = highest score in that pillar, red = lowest.'],
['Radar chart', 'Each market detail view shows a spider chart of its 7 pillar scores — useful for understanding the shape of a market\'s maturity.'],
['⚠ Flag badge', 'An amber warning on a card means that market has incomplete data (missing scores, rationale, or gap analysis). Click the card to see detail.'],
['Update Data tab', 'Re-sync data from Box source files or upload a new CSV/XLSX directly.'],
].map(([title, desc]) => `
${escHtml(title)}
${escHtml(desc)}
`).join('')}
${pillarRows}
`;
}
// ── Home screen ───────────────────────────────────────────────────────────────
function renderHome() {
document.getElementById('homeBtn').style.display = 'none';
document.getElementById('tabBar').style.display = 'none';
document.getElementById('homeScreen').style.display = '';
document.getElementById('clientView').style.display = 'none';
document.getElementById('headerTitle').textContent = 'Maturity Tool';
document.getElementById('headerSub').textContent = `${allClients.length} client${allClients.length !== 1 ? 's' : ''}`;
if (allClients.length === 1) {
const c = allClients[0];
applyAccent(c.accent_color);
document.getElementById('clientCards').innerHTML = `
${c.logo
? `
`
: `
${escHtml(c.name.slice(0,2).toUpperCase())}
`}
${escHtml(c.name)}
${escHtml(c.description)}
Click below to explore the maturity scores, pillar breakdowns, and deliverables for all ${c.entity_count} ${escHtml(c.entity_label || 'markets')}.
Open ${c.entity_count} ${escHtml(c.entity_label || 'Markets')}
Last updated ${escHtml(c.generated)}
New Client
Set up a new maturity assessment client
`;
} else {
document.getElementById('clientCards').innerHTML = allClients.map((c, i) => `
${c.logo
? `
`
: `
${escHtml(c.name.slice(0,2).toUpperCase())}
`}
${c.entity_count} ${escHtml(c.entity_label || 'entities')}
${escHtml(c.name)}
${escHtml(c.description)}
Generated ${escHtml(c.generated)}
Open →
`).join('') + `
New Client
Set up a new maturity assessment client
`;
}
}
async function renderSingleClientHome(id) {
try {
const [cfgRes, datRes] = await Promise.all([
fetch(`/api/clients/${id}/config`),
fetch(`/api/clients/${id}/data`),
]);
const config = await cfgRes.json();
const data = await datRes.json();
// Cache so entering client view doesn't re-fetch unnecessarily
if (!activeClient || activeClient.config.id !== id) {
const delRes = await fetch(`/api/clients/${id}/deliverables`);
activeClient = { config, data, deliverables: delRes.ok ? await delRes.json() : {} };
}
applyAccent(config.accent_color);
const entities = data.entities;
const scoring = config.scoring;
const scores = entities.map(e => e.overall_score);
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2);
const best = entities[0];
const worst = entities[entities.length - 1];
const avgLevel = scoreToLevel(Math.round(parseFloat(avg)), scoring);
const marketRows = entities.map((e, i) => {
const pct = pillarBarPct(e.overall_score);
const lvl = scoreClass(e.overall_level);
return `
${i + 1}
${escHtml(e.short)}
${e.overall_score.toFixed(2)}
${escHtml(e.overall_label)}
`;
}).join('');
document.getElementById('clientCards').innerHTML = `
${escHtml(config.name.slice(0,2).toUpperCase())}
${escHtml(config.name)}
${escHtml(config.description)}
${entities.length}
${escHtml(config.entity_label || 'Entities')}
Highest
${escHtml(best.short)}
${best.overall_score.toFixed(2)}
Lowest
${escHtml(worst.short)}
${worst.overall_score.toFixed(2)}
${marketRows}
Data generated ${escHtml(data.generated)} · Click any row to view details
View ${escHtml(config.entity_label || 'Markets')}
Compare ${escHtml(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('Failed to load overview', 'error');
}
}
function renderAboutSection(config) {
const about = config.about;
const scoring = config.scoring;
const pillarGrid = config.pillars.map(pname => {
const desc = (about.pillar_descriptions || {})[pname] || '';
return `
${escHtml(pname)}
${escHtml(desc)}
`;
}).join('');
const scoringRows = Object.keys(scoring.labels).sort((a,b) => a-b).map(n => {
const lvl = scoreClass(parseInt(n));
const desc = (about.scoring_descriptions || {})[n] || '';
return `
${n} — ${escHtml(scoring.labels[n])}
${escHtml(desc)}
`;
}).join('');
return `
${about.summary ? `
${escHtml(about.summary)}
` : ''}
${about.question_count ? `${about.question_count} Questions across ` : ''}${config.pillars.length} Pillars
${pillarGrid}
Scoring Scale
${scoringRows}
`;
}
function goHome() {
activeClient = null;
detailEntity = null;
selectedForCompare.clear();
renderHome();
}
// ── New Client Wizard ─────────────────────────────────────────────────────────
const WIZARD_STEPS = ['Client Info', 'Entity Labels', 'Scoring', 'Pillars', 'Review'];
const ENTITY_PRESETS = [
{ label: 'Markets', singular: 'Market' },
{ label: 'Regions', singular: 'Region' },
{ label: 'Business Units', singular: 'Business Unit' },
{ label: 'Countries', singular: 'Country' },
{ label: 'Brands', singular: 'Brand' },
{ label: 'Teams', singular: 'Team' },
{ label: 'Divisions', singular: 'Division' },
];
let wizStep = 0;
let wizData = {};
function wizardReset() {
wizStep = 0;
wizData = {
name: '', id: '', description: '', accent_color: '#6366f1',
entity_label: 'Markets', entity_label_singular: 'Market',
scoring_levels: 4,
scoring_labels: ['Learner', 'Intermediate', 'Master', 'Expert'],
pillars: ['OMNICHANNEL', 'CLIENT CENTRICITY', 'MEASUREMENT', 'TECH CAPABILITIES',
'AUTOMATION & INDUSTRIALIZATION', 'INNOVATION', 'ORGANISATION'],
};
}
function openWizard() {
wizardReset();
document.getElementById('wizardModal').classList.remove('hidden');
renderWizardStep();
}
function closeWizard() {
document.getElementById('wizardModal').classList.add('hidden');
}
function handleWizardOverlayClick(e) {
if (e.target === document.getElementById('wizardModal')) closeWizard();
}
function renderWizardDots() {
document.getElementById('wizardDots').innerHTML = WIZARD_STEPS.map((_, i) => `
${i < WIZARD_STEPS.length - 1 ? `
` : ''}
`).join('');
}
function renderWizardStep() {
renderWizardDots();
document.getElementById('wizardStepLabel').textContent =
`Step ${wizStep + 1} of ${WIZARD_STEPS.length} — ${WIZARD_STEPS[wizStep]}`;
document.getElementById('wizardBackBtn').style.display =
wizStep === 0 ? 'none' : '';
document.getElementById('wizardNextBtn').textContent =
wizStep === WIZARD_STEPS.length - 1 ? '✓ Create Client' : 'Next →';
const body = document.getElementById('wizardBody');
[renderWizStep0, renderWizStep1, renderWizStep2, renderWizStep3, renderWizStep4][wizStep](body);
}
function wizField(id, label, value, placeholder, extra = '') {
return `
${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('Client name is required', 'error'); return false; }
if (wizStep === 0 && !wizData.id) { showToast('Could not derive a client ID from that name', 'error'); return false; }
if (wizStep === 3 && !wizData.pillars.filter(p => p.trim()).length) {
showToast('Add at least one pillar', 'error'); return false;
}
return true;
}
function wizardNext() {
wizardSaveStep();
if (!wizardValidate()) return;
if (wizStep === WIZARD_STEPS.length - 1) { createClient(); return; }
wizStep++;
renderWizardStep();
}
function wizardBack() {
wizardSaveStep();
if (wizStep > 0) { wizStep--; renderWizardStep(); }
}
async function createClient() {
const cfg = buildWizardConfig();
const btn = document.getElementById('wizardNextBtn');
btn.disabled = true; btn.textContent = 'Creating…';
try {
const res = await fetch('/api/clients', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(cfg),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
closeWizard();
showToast(`"${cfg.name}" created! Set up data in the Update tab.`, 'success');
const r2 = await fetch('/api/clients');
allClients = await r2.json();
await loadClient(data.id);
showTab('update');
} catch (e) {
showToast('Failed to create: ' + e.message, 'error');
btn.disabled = false; btn.textContent = '✓ Create Client';
}
}
// ── Load client ───────────────────────────────────────────────────────────────
async function loadClient(id) {
try {
const [cfgRes, datRes, delRes] = await Promise.all([
fetch(`/api/clients/${id}/config`),
fetch(`/api/clients/${id}/data`),
fetch(`/api/clients/${id}/deliverables`),
]);
const config = await cfgRes.json();
const data = await datRes.json();
const deliverables = delRes.ok ? await delRes.json() : {};
activeClient = { config, data, deliverables };
applyAccent(config.accent_color);
enterClientView(config);
renderSummaryBar();
renderEntityCards();
renderCompareSelector();
document.getElementById('exportRow').style.display = 'flex';
} catch (e) {
showToast('Failed to load client data', 'error');
}
}
function applyAccent(color) {
document.documentElement.style.setProperty('--accent', color);
}
function enterClientView(config) {
if (config.logo) {
document.getElementById('headerTitle').innerHTML = ` `;
} else {
document.getElementById('headerTitle').textContent = config.name;
}
document.getElementById('headerSub').textContent = config.description;
document.getElementById('homeBtn').style.display = '';
document.getElementById('tabBar').style.display = '';
document.getElementById('homeScreen').style.display = 'none';
document.getElementById('clientView').style.display = '';
// Update tab label to match entity type
const entityLabel = config.entity_label || 'Entities';
document.getElementById('tab-entities').textContent = entityLabel;
showTab('entities');
}
// ── Tab navigation ────────────────────────────────────────────────────────────
function showTab(name) {
['entities', 'compare', 'heatmap', 'update'].forEach(t => {
document.getElementById(`tab-${t}-content`).style.display = t === name ? '' : 'none';
document.getElementById(`tab-${t}`).classList.toggle('active', t === name);
});
activeTab = name;
if (name === 'update') populateImportEntitySelect();
if (name === 'heatmap') renderHeatmap();
}
// ── Summary bar ───────────────────────────────────────────────────────────────
function renderSummaryBar() {
const entities = activeClient.data.entities;
const scores = entities.map(e => e.overall_score);
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2);
const best = entities[0];
const worst = entities[entities.length - 1];
const entityLabel = activeClient.config.entity_label || 'Entities';
document.getElementById('summaryBar').innerHTML = `
${entities.length}
${escHtml(entityLabel)}
Highest
${escHtml(best.short)}
${best.overall_score.toFixed(2)}
Lowest
${escHtml(worst.short)}
${worst.overall_score.toFixed(2)}
Data: ${escHtml(activeClient.data.generated)}
`;
renderCardControls();
}
// ── Card controls (sort + group) ──────────────────────────────────────────────
function renderCardControls() {
const controls = document.getElementById('cardControls');
if (!controls) return;
const pillars = activeClient.config.pillars;
const pillarOpts = pillars.map(p =>
`${escHtml(p)} `
).join('');
controls.innerHTML = `
Sort by:
Overall Score
${pillarOpts}
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));
}
// ── 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 => {
const p = entity.pillars.find(x => x.name === pname);
if (!p) return '';
const pct = pillarBarPct(p.avg).toFixed(1);
const short = pillarShort(pname);
return `
${escHtml(short)}
${p.avg.toFixed(1)}
`;
}).join('');
return `
${escHtml(entity.group)}
#${i + 1}
${escHtml(entity.label)}
${entity.overall_score.toFixed(2)}
${escHtml(entity.overall_label)}
${pillarBars}
${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions
${flags.length ? `⚠ ${flags.length} issue${flags.length > 1 ? 's' : ''} ` : ''}
View detail →
`;
}
// ── Radar chart (pure SVG) ────────────────────────────────────────────────────
function radarSvg(entity, config) {
const pillars = config.pillars;
const n = pillars.length;
const cx = 110, cy = 110, r = 80;
const { min, max } = config.scoring;
const range = max - min;
// Get scores for each pillar
const scores = pillars.map(pname => {
const p = entity.pillars.find(x => x.name === pname);
return p ? p.avg : min;
});
// Polygon point helper
function pt(idx, val) {
const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
const ratio = Math.max(0, Math.min(1, (val - min) / range));
const rr = ratio * r;
return { x: cx + rr * Math.cos(angle), y: cy + rr * Math.sin(angle) };
}
function ptEdge(idx) {
const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
}
// Grid rings (4 levels)
const levels = Object.keys(config.scoring.labels).map(Number).sort((a, b) => a - b);
const ringValues = levels.map(l => l);
const gridRings = ringValues.map(val => {
const pts = pillars.map((_, i) => {
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
const ratio = Math.max(0, Math.min(1, (val - min) / range));
const rr = ratio * r;
return `${(cx + rr * Math.cos(angle)).toFixed(2)},${(cy + rr * Math.sin(angle)).toFixed(2)}`;
}).join(' ');
return ` `;
}).join('');
// Axis lines
const axisLines = pillars.map((_, i) => {
const e = ptEdge(i);
return ` `;
}).join('');
// Score polygon
const scorePoints = scores.map((s, i) => {
const p = pt(i, s);
return `${p.x.toFixed(2)},${p.y.toFixed(2)}`;
}).join(' ');
// Score dots
const scoreDots = scores.map((s, i) => {
const p = pt(i, s);
return ` `;
}).join('');
// Labels
const labels = pillars.map((pname, i) => {
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
const labelR = r + 22;
const lx = cx + labelR * Math.cos(angle);
const ly = cy + labelR * Math.sin(angle);
let anchor = 'middle';
if (lx < cx - 8) anchor = 'end';
else if (lx > cx + 8) anchor = 'start';
const short = pillarShort(pname);
return `${escHtml(short)} `;
}).join('');
return `
${gridRings}
${axisLines}
${scoreDots}
${labels}
`;
}
// ── Entity detail ─────────────────────────────────────────────────────────────
function openEntityDetail(entity) {
detailEntity = entity;
document.getElementById('entityGrid').style.display = 'none';
const panel = document.getElementById('detailPanel');
panel.style.display = '';
const pillars = activeClient.config.pillars;
const lvlCls = scoreClass(entity.overall_level);
const bgCls = scoreBgClass(entity.overall_level);
const pillarAccordions = pillars.map(pname => {
const p = entity.pillars.find(x => x.name === pname);
if (!p) return '';
const plvl = scoreClass(p.level);
const pbg = scoreBgClass(p.level);
const pct = pillarBarPct(p.avg).toFixed(1);
const qRows = p.questions.map((q, qi) => {
const qlvl = scoreClass(q.level);
return `
${q.score}
${escHtml(q.topic)}
`;
}).join('');
return `
`;
}).join('');
// Deliverable buttons (if configured for this entity)
const del = (activeClient.deliverables || {})[entity.id] || {};
const deliverablesBtns = (del.pdf || del.xlsx) ? `
Deliverables
${del.pdf ? `
Summary PDF
` : ''}
${del.xlsx ? `
Scores XLSX
` : ''}
` : '';
panel.innerHTML = `
Back to ${escHtml(activeClient.config.entity_label || 'entities')}
${deliverablesBtns}
Export
XLSX
PDF
${escHtml(entity.group)}
${escHtml(entity.label)}
${radarSvg(entity, activeClient.config)}
${entity.overall_score.toFixed(2)}
${escHtml(entity.overall_label)}
${(() => {
const flags = getEntityFlags(entity);
if (!flags.length) return '';
return `
Data quality
${flags.map(f => `${escHtml(f.label)} `).join(' · ')}
`;
})()}
${pillarAccordions}
`;
// Attach question row click handlers
panel.querySelectorAll('.question-row').forEach(row => {
row.addEventListener('click', () => {
const p = entity.pillars.find(x => x.name === row.dataset.pillar);
if (p) openQuestionModal(entity, p, p.questions[parseInt(row.dataset.qi)]);
});
});
}
function closeEntityDetail() {
detailEntity = null;
document.getElementById('detailPanel').style.display = 'none';
document.getElementById('entityGrid').style.display = '';
}
function togglePillar(header) {
header.closest('.pillar-card').classList.toggle('open');
}
// ── Question modal ────────────────────────────────────────────────────────────
function openQuestionModal(entity, pillar, question) {
const qlvl = scoreClass(question.level);
const elvl = scoreClass(entity.overall_level);
document.getElementById('qModalBadges').innerHTML = `
${escHtml(entity.short)}
${escHtml(pillar.name)}
`;
document.getElementById('qModalTitle').textContent = question.topic;
document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillar.name} · ${entity.label}`;
const fields = [
{ label: 'Rationale', value: question.rationale },
{ label: 'Gaps Identified', value: question.gaps || '—' },
{ label: 'References', value: question.refs || '—' },
];
const scoring = activeClient.config.scoring;
const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b);
const scoreBtns = scoreNums.map(n => {
const lvl = scoreToLevel(n, scoring);
const isCurrent = n === question.score;
return `
${n}
${escHtml(scoring.labels[n])}
`;
}).join('');
document.getElementById('qModalBody').innerHTML = `
Score
${scoreBtns}
Click a score to update it
${fields.map(f => {
const empty = !f.value || f.value === '—';
return `
${escHtml(f.label)}
${escHtml(f.value)}
`;
}).join('')}
`;
document.getElementById('questionModal').classList.remove('hidden');
}
// ── Score editing ─────────────────────────────────────────────────────────────
function scoreToLevel(score, scoring) {
const levels = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b);
const idx = levels.indexOf(score);
return idx >= 0 ? idx + 1 : Math.round(((score - scoring.min) / (scoring.max - scoring.min)) * (levels.length - 1)) + 1;
}
async function updateScore(entityId, pillarName, qNum, newScore) {
const scoring = activeClient.config.scoring;
const entity = activeClient.data.entities.find(e => e.id === entityId);
const pillar = entity.pillars.find(p => p.name === pillarName);
const question = pillar.questions.find(q => String(q.q_num) === String(qNum));
if (question.score === newScore) return;
const oldScore = question.score;
const lvl = scoreToLevel(newScore, scoring);
// Update question
question.score = newScore;
question.level = lvl;
question.label = scoring.labels[newScore] || '';
// Recalculate pillar avg
const pillarScores = pillar.questions.map(q => q.score);
pillar.avg = Math.round((pillarScores.reduce((a, b) => a + b, 0) / pillarScores.length) * 100) / 100;
pillar.level = scoreToLevel(Math.round(pillar.avg), scoring);
pillar.label = scoring.labels[Math.round(pillar.avg)] || scoring.labels[Math.ceil(pillar.avg)] || '';
// Recalculate entity overall
const allScores = entity.pillars.flatMap(p => p.questions.map(q => q.score));
entity.overall_score = Math.round((allScores.reduce((a, b) => a + b, 0) / allScores.length) * 100) / 100;
entity.overall_level = scoreToLevel(Math.round(entity.overall_score), scoring);
entity.overall_label = scoring.labels[Math.round(entity.overall_score)] || scoring.labels[Math.ceil(entity.overall_score)] || '';
// Update score button highlight in modal
const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b);
scoreNums.forEach(n => {
const btn = document.getElementById(`score-btn-${n}`);
if (!btn) return;
const isCurrent = n === newScore;
btn.style.borderColor = isCurrent ? 'var(--accent)' : 'var(--border)';
btn.style.background = isCurrent ? 'rgba(120,190,32,0.08)' : 'var(--bg-inset)';
});
const hint = document.getElementById('scoreEditHint');
if (hint) hint.innerHTML = `✓ Score updated ${oldScore} → ${newScore} `;
// Re-render cards/detail if visible
if (detailEntity && detailEntity.id === entityId) {
openEntityDetail(entity); // refreshes detail panel
document.getElementById('questionModal').classList.remove('hidden'); // keep modal open
} else {
renderEntityCards();
}
if (activeTab === 'compare') renderCompareTable();
// Persist to server
try {
const res = await fetch(`/api/clients/${activeClient.config.id}/data`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activeClient.data),
});
if (!res.ok) throw new Error(await res.text());
showToast(`Q${qNum} score saved: ${newScore} (${question.label})`, 'success');
} catch (e) {
showToast('Save failed — changes kept in memory only', 'error');
}
}
function closeModal() {
document.getElementById('questionModal').classList.add('hidden');
}
function handleModalOverlayClick(e) {
if (e.target === document.getElementById('questionModal')) closeModal();
}
// ── Compare tab ───────────────────────────────────────────────────────────────
function renderCompareSelector() {
const entities = activeClient.data.entities;
const entityLabel = activeClient.config.entity_label || 'Entities';
document.getElementById('compareSelector').innerHTML = `
${entities.map(e => {
const lvl = scoreClass(e.overall_level);
return `
${escHtml(e.short)}
${e.overall_score.toFixed(2)}
${escHtml(e.overall_label)}
`;
}).join('')}
Compare Selected
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('Select at least 2 to compare', 'error');
return;
}
const markets = [...selectedForCompare]
.map(id => activeClient.data.entities.find(e => e.id === id))
.filter(Boolean);
const pillars = activeClient.config.pillars;
const scoring = activeClient.config.scoring;
const wrap = document.getElementById('compareResult');
wrap.style.display = '';
// Header
const headerCols = markets.map(m => `
${escHtml(m.short)}
${m.overall_score.toFixed(2)}
`).join('');
// Build rows: overall + per pillar
const buildRow = (label, values) => {
const defined = values.filter(v => v !== null);
const maxVal = defined.length ? Math.max(...defined) : null;
const minVal = defined.length ? Math.min(...defined) : null;
const allSame = defined.every(v => v === defined[0]);
const cells = values.map((v, i) => {
if (v === null) return '— ';
const cls = !allSame && v === maxVal ? 'diff-hi' : !allSame && v === minVal ? 'diff-lo' : '';
const level = Math.round(((v - scoring.min) / (scoring.max - scoring.min)) * (Object.keys(scoring.labels).length - 1)) + 1;
const pct = ((v - scoring.min) / (scoring.max - scoring.min) * 100).toFixed(0);
return `
`;
}).join('');
return `${escHtml(label)} ${cells} `;
};
// Overall row
const overallVals = markets.map(m => m.overall_score);
const overallDef = overallVals.filter(v => v !== null);
const maxO = Math.max(...overallDef), minO = Math.min(...overallDef);
const allSameO = overallDef.every(v => v === overallDef[0]);
const overallRow = `
OVERALL
${markets.map(m => {
const cls = !allSameO && m.overall_score === maxO ? 'diff-hi' : !allSameO && m.overall_score === minO ? 'diff-lo' : '';
return `
${m.overall_score.toFixed(2)}
`;
}).join('')}
`;
const pillarRows = pillars.map(pname => {
const vals = markets.map(m => {
const p = m.pillars.find(x => x.name === pname);
return p ? p.avg : null;
});
return buildRow(pname, vals);
}).join('');
wrap.innerHTML = `
${headerCols}
${overallRow}${pillarRows}
Highest in row
Lowest in row
`;
}
// ── Heatmap tab ───────────────────────────────────────────────────────────────
function renderHeatmap() {
const panel = document.getElementById('heatmapPanel');
if (!panel || !activeClient) return;
const entities = activeClient.data.entities;
const pillars = activeClient.config.pillars;
const scoring = activeClient.config.scoring;
// Helper: map numeric score to color level (1-4 buckets)
function heatLevel(score) {
return scoreToLevel(Math.round(score), scoring);
}
// Header row
const headerCells = entities.map(e =>
`${escHtml(e.short)} `
).join('');
// Pillar rows
const pillarRows = pillars.map((pname, ri) => {
const cells = entities.map(e => {
const p = e.pillars.find(x => x.name === pname);
if (!p) return `— `;
const lvl = heatLevel(p.avg);
const bgCls = scoreBgClass(lvl);
return `${p.avg.toFixed(2)} `;
}).join('');
// Row average
const vals = entities.map(e => { const p = e.pillars.find(x => x.name === pname); return p ? p.avg : null; }).filter(v => v !== null);
const rowAvg = vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length) : null;
const ravgLvl = rowAvg !== null ? heatLevel(rowAvg) : 1;
const ravgBg = scoreBgClass(ravgLvl);
const rowAvgCell = rowAvg !== null
? `${rowAvg.toFixed(2)} `
: `— `;
const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);';
return `
${escHtml(pname)}
${cells}
${rowAvgCell}
`;
}).join('');
// Overall row
const overallCells = entities.map(e => {
const lvl = heatLevel(e.overall_score);
const bgCls = scoreBgClass(lvl);
return `${e.overall_score.toFixed(2)} `;
}).join('');
const allScores = entities.map(e => e.overall_score);
const grandAvg = allScores.length ? (allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0;
const grandLvl = heatLevel(grandAvg);
const grandBg = scoreBgClass(grandLvl);
const grandAvgCell = `${grandAvg.toFixed(2)} `;
panel.innerHTML = `
Pillar
${headerCells}
Avg
${pillarRows}
Overall
${overallCells}
${grandAvgCell}
${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => {
const bgCls = scoreBgClass(parseInt(n));
return `${n} ${escHtml(scoring.labels[n])} `;
}).join('')}
`;
}
// ── Deliverable downloads (pre-existing client PDFs / XLSXs) ─────────────────
async function downloadDeliverable(entityId, type) {
const btnId = type === 'pdf' ? `delPdfBtn-${entityId}` : `delXlsxBtn-${entityId}`;
const btn = document.getElementById(btnId);
const label = type === 'pdf' ? 'Summary PDF' : 'Scores XLSX';
if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
try {
const res = await fetch(`/api/clients/${activeClient.config.id}/deliverables/${entityId}/${type}`);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
const nameMatch = disposition.match(/filename="([^"]+)"/);
const filename = nameMatch ? nameMatch[1] : `${entityId}_${label.replace(' ', '_')}.${type}`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
showToast(`${label} downloaded`);
} catch (e) {
showToast(`${label} unavailable: ${e.message}`, 'error');
} finally {
if (btn) {
btn.disabled = false;
const icon = ' ';
btn.innerHTML = `${icon} ${label}`;
}
}
}
// ── Exports ───────────────────────────────────────────────────────────────────
// CSV — flat, all entities
function exportCsv() {
const { config, data } = activeClient;
const rows = [['Client', 'Entity', 'Entity Short', 'Group', 'Overall Score', 'Overall Level', 'Pillar', 'Q#', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']];
for (const entity of data.entities) {
for (const pillar of entity.pillars) {
for (const q of pillar.questions) {
rows.push([
config.name,
entity.label,
entity.short,
entity.group,
entity.overall_score,
entity.overall_label,
pillar.name,
q.q_num,
q.topic,
q.score,
q.label,
q.rationale || '',
q.gaps || '',
q.refs || '',
]);
}
}
}
const csv = rows.map(r =>
r.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',')
).join('\r\n');
triggerDownload(`${config.id}_maturity_all_entities.csv`, 'text/csv;charset=utf-8;', csv);
showToast('CSV downloaded');
}
// XLSX — multi-sheet workbook
function exportXlsx(entityId) {
const { config, data } = activeClient;
const wb = XLSX.utils.book_new();
const entities = entityId
? data.entities.filter(e => e.id === entityId)
: data.entities;
const SCORE_COLORS = { 1: 'C62828', 2: 'E65100', 3: '2E7D32', 4: '1B5E20' };
// Summary sheet (always included)
const summaryRows = [['Entity', 'Group', 'Overall Score', 'Overall Level', ...config.pillars]];
for (const e of entities) {
const row = [e.label, e.group, e.overall_score, e.overall_label];
for (const pname of config.pillars) {
const p = e.pillars.find(x => x.name === pname);
row.push(p ? p.avg : '');
}
summaryRows.push(row);
}
const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows);
// Style header row
const summaryRange = XLSX.utils.decode_range(summaryWs['!ref']);
for (let C = summaryRange.s.c; C <= summaryRange.e.c; C++) {
const cell = summaryWs[XLSX.utils.encode_cell({ r: 0, c: C })];
if (cell) {
cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true } };
}
}
// Column widths
summaryWs['!cols'] = [{ wch: 28 }, { wch: 16 }, { wch: 14 }, { wch: 14 },
...config.pillars.map(() => ({ wch: 12 }))];
XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary');
// One sheet per entity
for (const entity of entities) {
const sheetRows = [['Q#', 'Pillar', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']];
for (const pillar of entity.pillars) {
for (const q of pillar.questions) {
sheetRows.push([q.q_num, pillar.name, q.topic, q.score, q.label, q.rationale || '', q.gaps || '', q.refs || '']);
}
// Pillar average row
sheetRows.push(['', pillar.name + ' — AVG', '', pillar.avg, pillar.label, '', '', '']);
}
const ws = XLSX.utils.aoa_to_sheet(sheetRows);
const range = XLSX.utils.decode_range(ws['!ref']);
for (let R = 0; R <= range.e.r; R++) {
for (let C = 0; C <= range.e.c; C++) {
const cellRef = XLSX.utils.encode_cell({ r: R, c: C });
const cell = ws[cellRef];
if (!cell) continue;
if (R === 0) {
cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true, vertical: 'top' } };
} else {
cell.s = cell.s || {};
cell.s.alignment = { wrapText: true, vertical: 'top' };
// Colour score cells (col 3)
if (C === 3 && typeof cell.v === 'number') {
const lvl = entity.pillars
.flatMap(p => p.questions)
.find(q => String(q.q_num) === String(sheetRows[R][0]))?.level || 0;
const hex = SCORE_COLORS[lvl];
if (hex) {
cell.s.fill = { fgColor: { rgb: hex } };
cell.s.font = { bold: true, color: { rgb: 'FFFFFF' } };
}
}
// Pillar avg rows
if (String(sheetRows[R][1] || '').includes('— AVG')) {
cell.s.fill = { fgColor: { rgb: 'F7F7F7' } };
cell.s.font = { bold: true };
}
}
}
}
ws['!cols'] = [{ wch: 6 }, { wch: 22 }, { wch: 40 }, { wch: 8 }, { wch: 14 }, { wch: 55 }, { wch: 40 }, { wch: 30 }];
ws['!rows'] = sheetRows.map(() => ({ hpt: 40 }));
const sheetName = entity.short.replace(/[\\\/\*\?\[\]]/g, '').slice(0, 31);
XLSX.utils.book_append_sheet(wb, ws, sheetName);
}
const filename = entityId
? `${entityId}_Maturity_Report.xlsx`
: `${config.id}_Maturity_All_Entities.xlsx`;
XLSX.writeFile(wb, filename);
showToast('XLSX downloaded');
}
// PDF — server-generated via Python/ReportLab
async function downloadPdf(entityId) {
const btn = document.getElementById(`pdfBtn-${entityId}`);
if (btn) { btn.textContent = '⏳ Generating…'; btn.disabled = true; }
try {
const res = await fetch(`/api/clients/${activeClient.config.id}/export/pdf/${entityId}`);
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${entityId}_Maturity_Report.pdf`;
a.click();
URL.revokeObjectURL(url);
showToast('PDF downloaded');
} catch (e) {
showToast('PDF failed: ' + e.message, 'error');
} finally {
if (btn) { btn.innerHTML = ' PDF'; btn.disabled = false; }
}
}
function triggerDownload(filename, mime, content) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
// ── Import / Update Data tab ──────────────────────────────────────────────────
let importFile = null;
function populateImportEntitySelect() {
if (!activeClient) return;
const options = activeClient.data.entities.map(e =>
`${escHtml(e.label)} `
).join('');
document.getElementById('importEntitySel').innerHTML =
'-- Select entity -- ' + options;
document.getElementById('syncEntitySel').innerHTML =
'All entities ' + options;
}
function onFileSelected(file) {
if (!file) return;
importFile = file;
const dz = document.getElementById('dropZone');
dz.classList.add('has-file');
dz.classList.remove('drag-over');
document.getElementById('dropZoneText').textContent =
`${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
}
function handleFileDrop(e) {
e.preventDefault();
document.getElementById('dropZone').classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) onFileSelected(file);
}
async function runBoxSync() {
const btn = document.getElementById('syncRunBtn');
const log = document.getElementById('syncLog');
const entityId = document.getElementById('syncEntitySel').value;
btn.disabled = true;
btn.innerHTML = 'Syncing… ';
log.style.display = '';
log.textContent = entityId
? `Syncing ${activeClient.data.entities.find(e => e.id === entityId)?.label || entityId}…\n`
: 'Running converter (all entities)…\n';
try {
const body = entityId ? JSON.stringify({ entity_id: entityId }) : undefined;
const res = await fetch(`/api/clients/${activeClient.config.id}/sync`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : {},
body,
});
const data = await res.json();
if (!res.ok) {
log.textContent = `Error:\n${data.detail || data.error}`;
showToast('Sync failed', 'error');
} else {
log.textContent = (data.log || 'Done.').trim();
showToast('Sync complete — reloading…', 'success');
setTimeout(async () => {
await loadClient(activeClient.config.id);
showTab('entities');
}, 1000);
}
} catch (e) {
log.textContent = 'Network error: ' + e.message;
showToast('Sync failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = `
Sync from Box`;
}
}
async function runFileImport() {
const entityId = document.getElementById('importEntitySel').value;
if (!entityId) { showToast('Select an entity first', 'error'); return; }
if (!importFile) { showToast('Select a file to upload', 'error'); return; }
const btn = document.getElementById('importRunBtn');
btn.disabled = true;
btn.textContent = 'Importing…';
const formData = new FormData();
formData.append('file', importFile);
formData.append('entity_id', entityId);
try {
const res = await fetch(`/api/clients/${activeClient.config.id}/import/file`, {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) {
showToast('Import failed: ' + (data.error || res.statusText), 'error');
} else {
const e = data.entity;
showToast(`Imported ${escHtml(e.label)}: ${e.overall_score.toFixed(2)} (${e.overall_label})`, 'success');
await loadClient(activeClient.config.id);
showTab('entities');
}
} catch (e) {
showToast('Import failed: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = `
Import`;
}
}
// ── Theme ─────────────────────────────────────────────────────────────────────
function toggleTheme() {
const isLight = document.body.classList.toggle('light');
localStorage.setItem('maturityTheme', isLight ? 'light' : 'dark');
document.getElementById('iconDark').style.display = isLight ? 'none' : '';
document.getElementById('iconLight').style.display = isLight ? '' : 'none';
}
function applyStoredTheme() {
if (localStorage.getItem('maturityTheme') === 'light') {
document.body.classList.add('light');
document.getElementById('iconDark').style.display = 'none';
document.getElementById('iconLight').style.display = '';
}
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function escHtml(s) {
return String(s ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
clearTimeout(t._timer);
t._timer = setTimeout(() => { t.className = ''; }, 3000);
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeModal(); closeWizard(); }
});
// ── Start ─────────────────────────────────────────────────────────────────────
init();