Working state before improvements branch: - Language toggle (EN/FR) for ADEO - 59 question topics translated to French - About tab translated and toggle visible on home screen Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2071 lines
98 KiB
JavaScript
2071 lines
98 KiB
JavaScript
'use strict';
|
|
|
|
// ── API base ──────────────────────────────────────────────────────────────────
|
|
// Auto-detect the URL path prefix the page was loaded under, so /api/ calls
|
|
// stay under that prefix when the app is reverse-proxied at a sub-path
|
|
// (e.g. https://host/adeo-maturity/). Captured once at script load — the SPA
|
|
// does not mutate the URL so freezing here is safe.
|
|
const API_BASE = (() => {
|
|
const p = window.location.pathname;
|
|
return p.replace(/\/(?:index\.html)?$/, '');
|
|
})();
|
|
|
|
// ── State ─────────────────────────────────────────────────────────────────────
|
|
let allClients = [];
|
|
let activeClient = null; // { config, data }
|
|
let activeTab = 'entities';
|
|
let detailEntity = null;
|
|
let currentUser = null; // { id, email, role } once authenticated
|
|
const selectedForCompare = new Set();
|
|
let sortPillar = 'overall'; // 'overall' | pillar name
|
|
let groupByGroup = false;
|
|
|
|
// ── Pillar short labels ───────────────────────────────────────────────────────
|
|
const PILLAR_SHORT = {
|
|
'OMNICHANNEL': 'OMNI',
|
|
'CLIENT CENTRICITY': 'CLIENT',
|
|
'MEASUREMENT': 'MEASURE',
|
|
'TECH CAPABILITIES': 'TECH',
|
|
'AUTOMATION & INDUSTRIALIZATION': 'AUTO',
|
|
'INNOVATION': 'INNOV',
|
|
'ORGANISATION': 'ORG',
|
|
};
|
|
|
|
function pillarShort(name) {
|
|
return window.UI_STRINGS?.[currentLang]?.pillar_short?.[name]
|
|
|| PILLAR_SHORT[name]
|
|
|| name.slice(0, 6).toUpperCase();
|
|
}
|
|
|
|
// ── Language state ─────────────────────────────────────────────────────────────
|
|
let currentLang = 'en';
|
|
|
|
function t(key, ...args) {
|
|
const strings = window.UI_STRINGS?.[currentLang] ?? window.UI_STRINGS?.en ?? {};
|
|
const val = (key in strings) ? strings[key] : window.UI_STRINGS?.en?.[key];
|
|
if (val === undefined) return key;
|
|
return typeof val === 'function' ? val(...args) : val;
|
|
}
|
|
|
|
function cfg(...path) {
|
|
const base = activeClient?.config;
|
|
if (!base) return undefined;
|
|
if (currentLang === 'fr' && base.translations?.fr) {
|
|
let v = base.translations.fr;
|
|
for (const k of path) { if (v && typeof v === 'object') v = v[k]; else { v = undefined; break; } }
|
|
if (v !== undefined) return v;
|
|
}
|
|
let v = base;
|
|
for (const k of path) { if (v && typeof v === 'object') v = v[k]; else { v = undefined; break; } }
|
|
return v;
|
|
}
|
|
|
|
function pillarDisplayName(i) {
|
|
const frPillars = currentLang === 'fr' ? activeClient?.config?.translations?.fr?.pillars : null;
|
|
return frPillars?.[i] ?? activeClient?.config?.pillars?.[i] ?? '';
|
|
}
|
|
|
|
function pillarDisplayByName(englishName) {
|
|
const pillars = activeClient?.config?.pillars ?? [];
|
|
const i = pillars.indexOf(englishName);
|
|
return i >= 0 ? pillarDisplayName(i) : englishName;
|
|
}
|
|
|
|
function scoreLabelDisplay(level) {
|
|
return cfg('scoring', 'labels')?.[level] ?? activeClient?.config?.scoring?.labels?.[level] ?? '';
|
|
}
|
|
|
|
function getQuestionTopic(q) {
|
|
const topics = cfg('question_topics');
|
|
const idx = parseInt(q.q_num, 10) - 1;
|
|
if (topics && idx >= 0 && topics[idx]) return topics[idx];
|
|
return q.topic;
|
|
}
|
|
|
|
function applyStoredLang() {
|
|
if (!activeClient) { currentLang = 'en'; return; }
|
|
const key = `maturityLang2_${activeClient.config.id}`;
|
|
const stored = localStorage.getItem(key);
|
|
if (stored === 'en' || stored === 'fr') { currentLang = stored; return; }
|
|
currentLang = 'en';
|
|
localStorage.setItem(key, 'en');
|
|
}
|
|
|
|
function toggleLang() {
|
|
currentLang = currentLang === 'en' ? 'fr' : 'en';
|
|
const langKey = activeClient ? `maturityLang2_${activeClient.config.id}` : 'maturityLang2_home';
|
|
localStorage.setItem(langKey, currentLang);
|
|
document.documentElement.lang = currentLang;
|
|
updateLangToggleButton();
|
|
refreshCurrentView();
|
|
}
|
|
|
|
function updateLangToggleButton() {
|
|
const btn = document.getElementById('langToggle');
|
|
const lbl = document.getElementById('langToggleLabel');
|
|
if (!btn || !lbl) return;
|
|
const hasFr = activeClient?.config?.translations?.fr
|
|
|| allClients?.some(c => c.has_translations);
|
|
btn.style.display = hasFr ? '' : 'none';
|
|
lbl.textContent = currentLang === 'en' ? 'FR' : 'EN';
|
|
}
|
|
|
|
function refreshCurrentView() {
|
|
if (!activeClient) {
|
|
const aboutEl = document.getElementById('homeTab-about');
|
|
if (aboutEl && aboutEl.style.display !== 'none') renderAbout();
|
|
else renderHome();
|
|
return;
|
|
}
|
|
const entityLabel = cfg('entity_label') || activeClient.config.entity_label || 'Entities';
|
|
document.getElementById('tab-entities').textContent = entityLabel;
|
|
document.getElementById('tab-compare').textContent = t('tab_compare');
|
|
document.getElementById('tab-heatmap').textContent = t('tab_heatmap');
|
|
document.getElementById('tab-update').textContent = t('tab_update');
|
|
document.getElementById('headerSub').textContent = cfg('description') || activeClient.config.description;
|
|
renderSummaryBar();
|
|
if (activeTab === 'entities') {
|
|
if (detailEntity) openEntityDetail(detailEntity);
|
|
else renderEntityCards();
|
|
} else if (activeTab === 'compare') {
|
|
renderCompareSelector();
|
|
if (selectedForCompare.size >= 2) renderCompareTable();
|
|
} else if (activeTab === 'heatmap') {
|
|
renderHeatmap();
|
|
} else if (activeTab === 'update') {
|
|
updateUpdateTabText();
|
|
}
|
|
}
|
|
|
|
function updateUpdateTabText() {
|
|
const map = {
|
|
syncTitle: t('sync_title'),
|
|
syncDesc: t('sync_desc'),
|
|
syncScopeLabel: t('sync_scope'),
|
|
uploadTitle: t('upload_title'),
|
|
uploadEntityLabel: t('upload_entity'),
|
|
uploadFileLabel: t('upload_file'),
|
|
uploadNote: t('upload_note'),
|
|
exportAllLabel: t('export_all'),
|
|
};
|
|
for (const [id, text] of Object.entries(map)) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = text;
|
|
}
|
|
const syncSel = document.getElementById('syncEntitySel');
|
|
const importSel = document.getElementById('importEntitySel');
|
|
if (syncSel?.options[0]) syncSel.options[0].textContent = t('sync_all');
|
|
if (importSel?.options[0]) importSel.options[0].textContent = t('upload_placeholder');
|
|
const dropText = document.getElementById('dropZoneText');
|
|
if (dropText && !document.getElementById('dropZone').classList.contains('has-file')) {
|
|
dropText.textContent = t('upload_drop');
|
|
}
|
|
}
|
|
|
|
// ── Data quality flags ────────────────────────────────────────────────────────
|
|
function getEntityFlags(entity) {
|
|
const flags = [];
|
|
const qs = entity.pillars.flatMap(p => p.questions);
|
|
|
|
const zeroScore = qs.filter(q => !q.score || q.score === 0);
|
|
if (zeroScore.length > 0)
|
|
flags.push({ icon: '✕', label: t('flag_missing_score', zeroScore.length) });
|
|
|
|
const noRationale = qs.filter(q => !q.rationale || q.rationale.trim().length < 5);
|
|
if (noRationale.length > 0)
|
|
flags.push({ icon: '◌', label: t('flag_missing_rationale', noRationale.length) });
|
|
|
|
const noGaps = qs.filter(q => !q.gaps || q.gaps.trim().length < 5);
|
|
if (noGaps.length > Math.floor(qs.length * 0.2))
|
|
flags.push({ icon: '◌', label: t('flag_missing_gaps', noGaps.length) });
|
|
|
|
if (entity.synced) {
|
|
const days = Math.floor((Date.now() - new Date(entity.synced)) / 86400000);
|
|
if (days > 30)
|
|
flags.push({ icon: '⏱', label: t('flag_stale', days) });
|
|
}
|
|
|
|
return flags;
|
|
}
|
|
|
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
async function init() {
|
|
applyStoredTheme();
|
|
|
|
// Auth gate. /api/auth/me returns 200 with user info, or 401 if not signed in.
|
|
let user = null;
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/auth/me');
|
|
if (res.ok) user = await res.json();
|
|
} catch (_e) { /* fall through to login */ }
|
|
|
|
if (!user) {
|
|
showLogin();
|
|
return;
|
|
}
|
|
|
|
applyAuthedUser(user);
|
|
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/clients');
|
|
allClients = await res.json();
|
|
// Restore home-screen language preference
|
|
const stored = localStorage.getItem('maturityLang2_home');
|
|
currentLang = (stored === 'en' || stored === 'fr') ? stored : 'en';
|
|
document.documentElement.lang = currentLang;
|
|
updateLangToggleButton();
|
|
renderHome();
|
|
} catch (e) {
|
|
document.getElementById('headerSub').textContent = t('toast_connect_failed');
|
|
showToast(t('toast_load_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
function showLogin() {
|
|
document.getElementById('loginScreen').style.display = '';
|
|
document.body.classList.remove('auth-loading');
|
|
document.getElementById('loginEmail').focus();
|
|
}
|
|
|
|
function hideLogin() {
|
|
document.getElementById('loginScreen').style.display = 'none';
|
|
document.body.classList.remove('auth-loading');
|
|
}
|
|
|
|
function applyAuthedUser(user) {
|
|
currentUser = user;
|
|
document.body.classList.remove('role-admin', 'role-user');
|
|
document.body.classList.add(user.role === 'admin' ? 'role-admin' : 'role-user');
|
|
const badge = document.getElementById('userBadge');
|
|
if (badge) {
|
|
badge.textContent = `${user.email} · ${user.role}`;
|
|
badge.style.display = '';
|
|
}
|
|
document.getElementById('logoutBtn').style.display = '';
|
|
hideLogin();
|
|
}
|
|
|
|
async function handleLoginSubmit(e) {
|
|
e.preventDefault();
|
|
const email = document.getElementById('loginEmail').value.trim();
|
|
const password = document.getElementById('loginPassword').value;
|
|
const btn = document.getElementById('loginSubmit');
|
|
const errBox = document.getElementById('loginError');
|
|
|
|
errBox.classList.remove('show');
|
|
errBox.textContent = '';
|
|
btn.disabled = true;
|
|
btn.textContent = 'Signing in…';
|
|
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || 'Login failed');
|
|
|
|
document.getElementById('loginPassword').value = '';
|
|
applyAuthedUser(data);
|
|
// Reload data now that we're authed
|
|
const cRes = await fetch(API_BASE + '/api/clients');
|
|
allClients = await cRes.json();
|
|
renderHome();
|
|
} catch (err) {
|
|
errBox.textContent = err.message;
|
|
errBox.classList.add('show');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Sign in';
|
|
}
|
|
}
|
|
|
|
async function logout() {
|
|
try {
|
|
await fetch(API_BASE + '/api/auth/logout', { method: 'POST' });
|
|
} catch (_e) { /* best-effort */ }
|
|
// Hard reload to drop in-memory state and let init() show the login screen.
|
|
window.location.reload();
|
|
}
|
|
|
|
// ── Home screen tabs ──────────────────────────────────────────────────────────
|
|
function showHomeTab(name) {
|
|
['clients', 'about'].forEach(t => {
|
|
document.getElementById(`homeTab-${t}`).style.display = t === name ? '' : 'none';
|
|
document.getElementById(`home-tab-${t}`).classList.toggle('active', t === name);
|
|
});
|
|
if (name === 'about') renderAbout();
|
|
}
|
|
|
|
function renderAbout() {
|
|
const el = document.getElementById('homeTab-about');
|
|
// Build content from all loaded clients' config.about (use first client that has it)
|
|
const client = allClients.find(c => c.about) || allClients[0];
|
|
const about = client && client.about;
|
|
const scoring = client && client.scoring;
|
|
|
|
const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' };
|
|
|
|
const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, lname]) => {
|
|
const desc = about && about.scoring_descriptions ? about.scoring_descriptions[lvl] : '';
|
|
return `
|
|
<div class="level-row">
|
|
<span class="badge score-${lvl}" style="flex-shrink:0;min-width:90px;text-align:center;">${escHtml(lname)}</span>
|
|
<div>
|
|
<div style="font-size:13px;font-weight:600;color:var(--text);">${escHtml(t('about_level', lvl))}</div>
|
|
${desc ? `<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('') : '';
|
|
|
|
const pillarRows = (client && client.pillars || []).map((pname, i) => {
|
|
const desc = about && about.pillar_descriptions ? about.pillar_descriptions[pname] : '';
|
|
const icons = ['🔁','👤','📊','🛠','⚙️','💡','🏛'];
|
|
return `
|
|
<div class="about-pillar-row">
|
|
<span style="font-size:20px;flex-shrink:0;width:28px;text-align:center;">${icons[i] || '▪'}</span>
|
|
<div>
|
|
<div style="font-size:12px;font-weight:700;color:var(--text);letter-spacing:0.04em;">${escHtml(pname)}</div>
|
|
${desc ? `<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
el.innerHTML = `
|
|
<div style="max-width:900px;">
|
|
|
|
<!-- Summary -->
|
|
<div class="panel fade-up" style="margin-bottom:16px;">
|
|
<p class="section-header">${escHtml(t('about_what_is'))}</p>
|
|
<p style="font-size:14px;color:var(--text-sub);line-height:1.7;margin:0;">
|
|
${about ? escHtml(about.summary) : 'A content maturity assessment dashboard for evaluating and comparing business units across key marketing capability pillars.'}
|
|
</p>
|
|
${about && about.question_count ? `
|
|
<div style="display:flex;gap:24px;margin-top:16px;flex-wrap:wrap;">
|
|
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${about.question_count}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Questions</div></div>
|
|
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${(client.pillars || []).length}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Pillars</div></div>
|
|
<div style="text-align:center;"><div style="font-size:28px;font-weight:900;color:var(--accent);">${allClients[0] && allClients[0].entity_count || '—'}</div><div style="font-size:11px;color:var(--text-muted);font-weight:600;text-transform:uppercase;letter-spacing:0.06em;">Markets</div></div>
|
|
</div>` : ''}
|
|
</div>
|
|
|
|
<div class="about-grid">
|
|
|
|
<!-- Scoring scale -->
|
|
<div class="about-card fade-up" style="animation-delay:40ms;">
|
|
<p class="section-header" style="margin-top:0;">${escHtml(t('about_maturity_levels'))}</p>
|
|
${levelRows}
|
|
</div>
|
|
|
|
<!-- How to use -->
|
|
<div class="about-card fade-up" style="animation-delay:80ms;">
|
|
<p class="section-header" style="margin-top:0;">${escHtml(t('about_how_to'))}</p>
|
|
${t('how_to_items').map(([title, desc]) => `
|
|
<div style="padding:8px 0;border-bottom:1px solid var(--border-sub);">
|
|
<div style="font-size:12px;font-weight:700;color:var(--text);">${escHtml(title)}</div>
|
|
<div style="font-size:12px;color:var(--text-sub);margin-top:2px;line-height:1.5;">${escHtml(desc)}</div>
|
|
</div>`).join('')}
|
|
</div>
|
|
|
|
<!-- Pillars -->
|
|
<div class="about-card fade-up" style="animation-delay:120ms;grid-column:1/-1;">
|
|
<p class="section-header" style="margin-top:0;">${escHtml(t('about_the_pillars', (client && client.pillars || []).length))}</p>
|
|
${pillarRows}
|
|
</div>
|
|
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Home screen ───────────────────────────────────────────────────────────────
|
|
function renderHome() {
|
|
document.getElementById('homeBtn').style.display = 'none';
|
|
document.getElementById('tabBar').style.display = 'none';
|
|
document.getElementById('homeScreen').style.display = '';
|
|
document.getElementById('clientView').style.display = 'none';
|
|
document.getElementById('headerTitle').textContent = t('header_title');
|
|
document.getElementById('headerSub').textContent = `${allClients.length} client${allClients.length !== 1 ? 's' : ''}`;
|
|
|
|
if (allClients.length === 1) {
|
|
const c = allClients[0];
|
|
applyAccent(c.accent_color);
|
|
document.getElementById('clientCards').innerHTML = `
|
|
<div class="client-card fade-up" style="border-top:3px solid ${c.accent_color};cursor:default;padding:28px;">
|
|
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;">
|
|
${c.logo
|
|
? `<img src="${API_BASE}/clients/${c.id}/${c.logo}" alt="${escHtml(c.name)}" style="height:64px;max-width:180px;object-fit:contain;flex-shrink:0;">`
|
|
: `<div style="width:48px;height:48px;border-radius:12px;background:${c.accent_color}22;border:1px solid ${c.accent_color}55;display:flex;align-items:center;justify-content:center;flex-shrink:0;"><span style="font-size:16px;font-weight:900;color:${c.accent_color};">${escHtml(c.name.slice(0,2).toUpperCase())}</span></div>`}
|
|
<div>
|
|
<h2 style="font-size:22px;font-weight:900;color:var(--text);margin:0;line-height:1.1;">${escHtml(c.name)}</h2>
|
|
<p style="font-size:13px;color:var(--text-muted);margin:3px 0 0;">${escHtml(c.description)}</p>
|
|
</div>
|
|
</div>
|
|
<p style="font-size:13px;color:var(--text-sub);margin:0 0 24px;line-height:1.65;border-bottom:1px solid var(--border);padding-bottom:20px;">
|
|
${escHtml(t('home_explore', c.entity_count, c.entity_label || 'markets'))}
|
|
</p>
|
|
<button class="btn-primary" onclick="loadClient('${escHtml(c.id)}')" style="font-size:14px;padding:12px 28px;">
|
|
${escHtml(t('home_open_btn', c.entity_count, c.entity_label || 'Markets'))}
|
|
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
|
</button>
|
|
<p style="font-size:11px;color:var(--text-faint);margin:16px 0 0;">${escHtml(t('home_last_updated', c.generated))}</p>
|
|
</div>
|
|
<div class="new-client-card admin-only fade-up" style="animation-delay:60ms;" onclick="openWizard()">
|
|
<div style="width:44px;height:44px;border-radius:12px;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;margin-bottom:8px;">
|
|
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</div>
|
|
<p style="font-size:14px;font-weight:700;color:var(--text-sub);margin:0;">${escHtml(t('home_new_client'))}</p>
|
|
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-align:center;line-height:1.4;">${escHtml(t('home_new_client_sub'))}</p>
|
|
</div>`;
|
|
} else {
|
|
document.getElementById('clientCards').innerHTML = allClients.map((c, i) => `
|
|
<div class="client-card fade-up" style="animation-delay:${i * 40}ms;border-color:${c.accent_color}22;"
|
|
onclick="loadClient('${escHtml(c.id)}')"
|
|
onmouseenter="this.style.borderColor='${c.accent_color}'"
|
|
onmouseleave="this.style.borderColor='${c.accent_color}22'">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:14px;">
|
|
${c.logo
|
|
? `<img src="${API_BASE}/clients/${c.id}/${c.logo}" alt="${escHtml(c.name)}" style="height:28px;max-width:90px;object-fit:contain;">`
|
|
: `<div style="width:36px;height:36px;border-radius:8px;background:${c.accent_color}22;border:1px solid ${c.accent_color}55;display:flex;align-items:center;justify-content:center;"><span style="font-size:13px;font-weight:800;color:${c.accent_color};">${escHtml(c.name.slice(0,2).toUpperCase())}</span></div>`}
|
|
<span style="font-size:11px;color:var(--text-muted);font-weight:600;">${c.entity_count} ${escHtml(c.entity_label || 'entities')}</span>
|
|
</div>
|
|
<h3 style="font-size:16px;font-weight:800;color:var(--text);margin:0 0 4px;">${escHtml(c.name)}</h3>
|
|
<p style="font-size:12px;color:var(--text-muted);margin:0 0 14px;">${escHtml(c.description)}</p>
|
|
<div style="padding-top:12px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;">
|
|
<span style="font-size:11px;color:var(--text-faint);">${escHtml(t('home_generated', c.generated))}</span>
|
|
<span style="font-size:12px;font-weight:600;color:${c.accent_color};">${escHtml(t('home_open_arrow'))}</span>
|
|
</div>
|
|
</div>
|
|
`).join('') + `
|
|
<div class="new-client-card admin-only fade-up" style="animation-delay:${allClients.length * 40}ms;" onclick="openWizard()">
|
|
<div style="width:44px;height:44px;border-radius:12px;border:2px dashed var(--border);display:flex;align-items:center;justify-content:center;margin-bottom:8px;">
|
|
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
</div>
|
|
<p style="font-size:14px;font-weight:700;color:var(--text-sub);margin:0;">${escHtml(t('home_new_client'))}</p>
|
|
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-align:center;line-height:1.4;">${escHtml(t('home_new_client_sub'))}</p>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
async function renderSingleClientHome(id) {
|
|
try {
|
|
const [cfgRes, datRes] = await Promise.all([
|
|
fetch(`${API_BASE}/api/clients/${id}/config`),
|
|
fetch(`${API_BASE}/api/clients/${id}/data`),
|
|
]);
|
|
const config = await cfgRes.json();
|
|
const data = await datRes.json();
|
|
|
|
// Cache so entering client view doesn't re-fetch unnecessarily
|
|
if (!activeClient || activeClient.config.id !== id) {
|
|
const delRes = await fetch(`${API_BASE}/api/clients/${id}/deliverables`);
|
|
activeClient = { config, data, deliverables: delRes.ok ? await delRes.json() : {} };
|
|
}
|
|
|
|
applyAccent(config.accent_color);
|
|
|
|
const entities = data.entities;
|
|
const scoring = config.scoring;
|
|
const scores = entities.map(e => e.overall_score);
|
|
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2);
|
|
const best = entities[0];
|
|
const worst = entities[entities.length - 1];
|
|
const avgLevel = scoreToLevel(Math.round(parseFloat(avg)), scoring);
|
|
|
|
const marketRows = entities.map((e, i) => {
|
|
const pct = pillarBarPct(e.overall_score);
|
|
const lvl = scoreClass(e.overall_level);
|
|
return `
|
|
<div style="display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border-sub);cursor:pointer;"
|
|
onclick="loadClient('${escHtml(id)}')" class="home-market-row">
|
|
<span style="font-size:12px;color:var(--text-muted);width:18px;text-align:right;flex-shrink:0;font-weight:600;">${i + 1}</span>
|
|
<span style="font-size:13px;font-weight:600;color:var(--text);width:160px;flex-shrink:0;">${escHtml(e.short)}</span>
|
|
<div style="flex:1;display:flex;align-items:center;gap:10px;">
|
|
<div class="pillar-bar-track" style="flex:1;">
|
|
<div class="pillar-bar-fill" style="width:${pct.toFixed(1)}%;"></div>
|
|
</div>
|
|
<span style="font-size:14px;font-weight:800;color:var(--text);width:36px;text-align:right;">${e.overall_score.toFixed(2)}</span>
|
|
</div>
|
|
<span class="badge ${lvl}" style="width:88px;text-align:center;flex-shrink:0;">${escHtml(scoreLabelDisplay(e.overall_level) || e.overall_label)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
document.getElementById('clientCards').innerHTML = `
|
|
<div class="fade-up" style="max-width:800px;">
|
|
|
|
<!-- Hero panel -->
|
|
<div class="panel" style="margin-bottom:20px;border-top:3px solid var(--accent);padding:28px 32px;">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:20px;">
|
|
<div>
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:10px;">
|
|
<div style="width:42px;height:42px;border-radius:10px;background:var(--accent);display:flex;align-items:center;justify-content:center;">
|
|
<span style="font-size:14px;font-weight:900;color:#111;">${escHtml(config.name.slice(0,2).toUpperCase())}</span>
|
|
</div>
|
|
<div>
|
|
<h2 style="font-size:22px;font-weight:900;color:var(--text);margin:0;line-height:1.1;">${escHtml(config.name)}</h2>
|
|
<p style="font-size:12px;color:var(--text-muted);margin:2px 0 0;">${escHtml(config.description)}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:0;flex-wrap:wrap;">
|
|
<div class="stat-box" style="border-right:1px solid var(--border);">
|
|
<div class="stat-num">${entities.length}</div>
|
|
<div class="stat-lbl">${escHtml(config.entity_label || 'Entities')}</div>
|
|
</div>
|
|
<div class="stat-box" style="border-right:1px solid var(--border);">
|
|
<div class="stat-num" style="color:var(--accent);">${avg}</div>
|
|
<div class="stat-lbl">${escHtml(t('summary_avg'))}</div>
|
|
</div>
|
|
<div class="stat-box" style="border-right:1px solid var(--border);text-align:left;padding:12px 20px;">
|
|
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_highest'))}</div>
|
|
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(best.short)}</div>
|
|
<div style="font-size:12px;color:var(--accent);font-weight:700;">${best.overall_score.toFixed(2)}</div>
|
|
</div>
|
|
<div class="stat-box" style="text-align:left;padding:12px 20px;">
|
|
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_lowest'))}</div>
|
|
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(worst.short)}</div>
|
|
<div style="font-size:12px;color:#C62828;font-weight:700;">${worst.overall_score.toFixed(2)}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Market score list -->
|
|
<div class="panel fade-up" style="margin-bottom:20px;animation-delay:60ms;">
|
|
<p class="section-header">${escHtml(cfg('entity_label') || config.entity_label || 'Entities')} Overview</p>
|
|
<div>
|
|
${marketRows}
|
|
</div>
|
|
<p style="font-size:11px;color:var(--text-faint);margin:12px 0 0;">${escHtml(t('overview_generated', data.generated))}</p>
|
|
</div>
|
|
|
|
<!-- Action buttons -->
|
|
<div class="fade-up" style="display:flex;gap:10px;flex-wrap:wrap;animation-delay:100ms;margin-bottom:20px;">
|
|
<button class="btn-primary" onclick="loadClient('${escHtml(id)}')" style="font-size:14px;padding:11px 24px;">
|
|
${escHtml(t('overview_view', cfg('entity_label') || config.entity_label || 'Markets'))}
|
|
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
|
</button>
|
|
<button class="btn-ghost" onclick="loadClient('${escHtml(id)}').then(() => showTab('compare'))" style="font-size:14px;padding:11px 24px;">
|
|
${escHtml(t('overview_compare', cfg('entity_label') || config.entity_label || 'Markets'))}
|
|
</button>
|
|
</div>
|
|
|
|
${config.about ? renderAboutSection(config) : ''}
|
|
|
|
</div>
|
|
`;
|
|
|
|
// Hover effect on market rows
|
|
document.querySelectorAll('.home-market-row').forEach(row => {
|
|
row.addEventListener('mouseenter', () => row.style.background = 'var(--bg-inset)');
|
|
row.addEventListener('mouseleave', () => row.style.background = '');
|
|
});
|
|
|
|
} catch (e) {
|
|
showToast(t('toast_overview_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
function renderAboutSection(config) {
|
|
const about = config.about;
|
|
const scoring = config.scoring;
|
|
const frAbout = cfg('about') || about;
|
|
const frScoring = cfg('scoring') || scoring;
|
|
const frPillars = cfg('pillars') || config.pillars;
|
|
const frSummary = frAbout?.summary || about?.summary;
|
|
|
|
const pillarGrid = config.pillars.map((pname, i) => {
|
|
const displayName = pillarDisplayName(i);
|
|
const desc = (frAbout?.pillar_descriptions || {})[displayName]
|
|
|| (about?.pillar_descriptions || {})[pname]
|
|
|| '';
|
|
return `
|
|
<div style="padding:12px 14px;background:var(--bg-inset);border:1px solid var(--border);border-radius:8px;">
|
|
<p style="font-size:10px;font-weight:800;text-transform:uppercase;letter-spacing:0.07em;color:var(--accent);margin:0 0 4px;">${escHtml(displayName)}</p>
|
|
<p style="font-size:12px;color:var(--text-sub);margin:0;line-height:1.5;">${escHtml(desc)}</p>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const scoringRows = Object.keys(scoring.labels).sort((a,b) => a-b).map(n => {
|
|
const lvl = scoreClass(parseInt(n));
|
|
const label = frScoring?.labels?.[n] || scoring.labels[n];
|
|
const desc = (frAbout?.scoring_descriptions || {})[n]
|
|
|| (about?.scoring_descriptions || {})[n]
|
|
|| '';
|
|
return `
|
|
<div style="display:flex;align-items:flex-start;gap:12px;padding:8px 0;border-bottom:1px solid var(--border-sub);">
|
|
<span class="badge ${lvl}" style="min-width:100px;text-align:center;flex-shrink:0;">${n} — ${escHtml(label)}</span>
|
|
<span style="font-size:12px;color:var(--text-sub);line-height:1.55;">${escHtml(desc)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
const pillarsCountText = about?.question_count
|
|
? `${about.question_count} ${currentLang === 'fr' ? 'questions sur' : 'Questions across'} ${config.pillars.length} ${currentLang === 'fr' ? 'piliers' : 'Pillars'}`
|
|
: `${config.pillars.length} ${currentLang === 'fr' ? 'Piliers' : 'Pillars'}`;
|
|
|
|
return `
|
|
<div class="panel fade-up" style="animation-delay:140ms;">
|
|
<p class="section-header">${escHtml(t('about_assessment'))}</p>
|
|
${frSummary ? `<p style="font-size:13px;color:var(--text-sub);margin:0 0 20px;line-height:1.7;">${escHtml(frSummary)}</p>` : ''}
|
|
|
|
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin:0 0 10px;">
|
|
${escHtml(pillarsCountText)}
|
|
</p>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px;margin-bottom:20px;">
|
|
${pillarGrid}
|
|
</div>
|
|
|
|
<p style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin:0 0 2px;">${escHtml(t('about_scoring_scale'))}</p>
|
|
<div>${scoringRows}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function goHome() {
|
|
activeClient = null;
|
|
const stored = localStorage.getItem('maturityLang2_home');
|
|
currentLang = (stored === 'en' || stored === 'fr') ? stored : 'en';
|
|
document.documentElement.lang = currentLang;
|
|
detailEntity = null;
|
|
selectedForCompare.clear();
|
|
updateLangToggleButton();
|
|
renderHome();
|
|
}
|
|
|
|
// ── New Client Wizard ─────────────────────────────────────────────────────────
|
|
const WIZARD_STEPS = ['Client Info', 'Entity Labels', 'Scoring', 'Pillars', 'Review'];
|
|
const ENTITY_PRESETS = [
|
|
{ label: 'Markets', singular: 'Market' },
|
|
{ label: 'Regions', singular: 'Region' },
|
|
{ label: 'Business Units', singular: 'Business Unit' },
|
|
{ label: 'Countries', singular: 'Country' },
|
|
{ label: 'Brands', singular: 'Brand' },
|
|
{ label: 'Teams', singular: 'Team' },
|
|
{ label: 'Divisions', singular: 'Division' },
|
|
];
|
|
|
|
let wizStep = 0;
|
|
let wizData = {};
|
|
|
|
function wizardReset() {
|
|
wizStep = 0;
|
|
wizData = {
|
|
name: '', id: '', description: '', accent_color: '#6366f1',
|
|
entity_label: 'Markets', entity_label_singular: 'Market',
|
|
scoring_levels: 4,
|
|
scoring_labels: ['Learner', 'Intermediate', 'Master', 'Expert'],
|
|
pillars: ['OMNICHANNEL', 'CLIENT CENTRICITY', 'MEASUREMENT', 'TECH CAPABILITIES',
|
|
'AUTOMATION & INDUSTRIALIZATION', 'INNOVATION', 'ORGANISATION'],
|
|
};
|
|
}
|
|
|
|
function openWizard() {
|
|
wizardReset();
|
|
document.getElementById('wizardModal').classList.remove('hidden');
|
|
renderWizardStep();
|
|
}
|
|
|
|
function closeWizard() {
|
|
document.getElementById('wizardModal').classList.add('hidden');
|
|
}
|
|
|
|
function handleWizardOverlayClick(e) {
|
|
if (e.target === document.getElementById('wizardModal')) closeWizard();
|
|
}
|
|
|
|
function renderWizardDots() {
|
|
document.getElementById('wizardDots').innerHTML = WIZARD_STEPS.map((_, i) => `
|
|
<div style="display:flex;align-items:center;gap:4px;">
|
|
<div style="width:8px;height:8px;border-radius:50%;transition:background 0.2s;background:${i <= wizStep ? 'var(--accent)' : 'var(--border)'};"></div>
|
|
${i < WIZARD_STEPS.length - 1 ? `<div style="width:18px;height:2px;transition:background 0.2s;background:${i < wizStep ? 'var(--accent)' : 'var(--border)'};"></div>` : ''}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderWizardStep() {
|
|
renderWizardDots();
|
|
document.getElementById('wizardStepLabel').textContent =
|
|
`Step ${wizStep + 1} of ${WIZARD_STEPS.length} — ${WIZARD_STEPS[wizStep]}`;
|
|
document.getElementById('wizardBackBtn').style.display =
|
|
wizStep === 0 ? 'none' : '';
|
|
document.getElementById('wizardNextBtn').textContent =
|
|
wizStep === WIZARD_STEPS.length - 1 ? '✓ Create Client' : 'Next →';
|
|
const body = document.getElementById('wizardBody');
|
|
[renderWizStep0, renderWizStep1, renderWizStep2, renderWizStep3, renderWizStep4][wizStep](body);
|
|
}
|
|
|
|
function wizField(id, label, value, placeholder, extra = '') {
|
|
return `<div style="margin-bottom:16px;">
|
|
<label class="field-label" for="${id}">${label}</label>
|
|
<input class="wizard-input" id="${id}" type="text" value="${escHtml(value)}" placeholder="${escHtml(placeholder)}" ${extra}>
|
|
</div>`;
|
|
}
|
|
|
|
function renderWizStep0(body) {
|
|
body.innerHTML = `
|
|
${wizField('wiz-name', 'Client Name *', wizData.name, 'e.g. ACME Corp', 'oninput="wizAutoId()"')}
|
|
${wizField('wiz-id', 'Client ID (auto-generated)', wizData.id, 'e.g. acme-corp')}
|
|
${wizField('wiz-desc', 'Description', wizData.description, 'e.g. Content Maturity Assessment')}
|
|
<div style="margin-bottom:4px;">
|
|
<label class="field-label">Accent Colour</label>
|
|
<div style="display:flex;align-items:center;gap:10px;">
|
|
<input type="color" id="wiz-color" value="${escHtml(wizData.accent_color)}"
|
|
style="width:40px;height:36px;border:1px solid var(--border);border-radius:6px;background:none;cursor:pointer;padding:2px;"
|
|
oninput="wizData.accent_color=this.value;document.getElementById('wiz-color-hex').value=this.value;">
|
|
<input class="wizard-input" id="wiz-color-hex" type="text" value="${escHtml(wizData.accent_color)}" placeholder="#6366f1" style="width:110px;"
|
|
oninput="if(/^#[0-9A-Fa-f]{6}$/.test(this.value)){wizData.accent_color=this.value;document.getElementById('wiz-color').value=this.value;}">
|
|
<span style="font-size:12px;color:var(--text-muted);">Brand accent colour</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function wizAutoId() {
|
|
wizData.name = document.getElementById('wiz-name').value;
|
|
wizData.id = wizData.name.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '').trim()
|
|
.replace(/\s+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
|
|
const el = document.getElementById('wiz-id');
|
|
if (el) el.value = wizData.id;
|
|
}
|
|
|
|
function renderWizStep1(body) {
|
|
const opts = ENTITY_PRESETS.map(p =>
|
|
`<option value="${escHtml(p.label)}"${p.label === wizData.entity_label ? ' selected' : ''}>${escHtml(p.label)}</option>`
|
|
).join('');
|
|
body.innerHTML = `
|
|
<div style="margin-bottom:16px;">
|
|
<label class="field-label">Preset</label>
|
|
<select class="field-select" onchange="applyEntityPreset(this.value)">
|
|
<option value="">Custom…</option>${opts}
|
|
</select>
|
|
</div>
|
|
${wizField('wiz-entity-label', 'Entity label (plural) *', wizData.entity_label, 'e.g. Markets')}
|
|
${wizField('wiz-entity-singular', 'Entity label (singular)', wizData.entity_label_singular, 'e.g. Market')}
|
|
<p style="font-size:12px;color:var(--text-muted);line-height:1.5;">Used throughout the tool — tab label, summary bar, compare selector.</p>`;
|
|
}
|
|
|
|
function applyEntityPreset(label) {
|
|
const p = ENTITY_PRESETS.find(x => x.label === label);
|
|
if (!p) return;
|
|
wizData.entity_label = p.label;
|
|
wizData.entity_label_singular = p.singular;
|
|
const el = document.getElementById('wiz-entity-label');
|
|
const es = document.getElementById('wiz-entity-singular');
|
|
if (el) el.value = p.label;
|
|
if (es) es.value = p.singular;
|
|
}
|
|
|
|
function renderWizStep2(body) {
|
|
const n = wizData.scoring_levels;
|
|
const inputs = Array.from({ length: n }, (_, i) => `
|
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px;">
|
|
<span style="font-size:13px;font-weight:700;color:var(--text);width:20px;text-align:center;">${i + 1}</span>
|
|
<input class="wizard-input" style="flex:1;" value="${escHtml(wizData.scoring_labels[i] || '')}"
|
|
placeholder="Level ${i + 1} name" oninput="wizData.scoring_labels[${i}]=this.value">
|
|
</div>`).join('');
|
|
body.innerHTML = `
|
|
<div style="margin-bottom:18px;">
|
|
<label class="field-label">Number of Scoring Levels</label>
|
|
<div style="display:flex;gap:8px;">
|
|
${[2,3,4,5,6].map(v => `
|
|
<button type="button" onclick="setWizardLevels(${v})"
|
|
style="padding:7px 16px;border-radius:6px;font-size:13px;font-weight:600;cursor:pointer;
|
|
border:2px solid ${v===n?'var(--accent)':'var(--border)'};
|
|
background:${v===n?'var(--bg-inset)':'transparent'};
|
|
color:${v===n?'var(--accent)':'var(--text-muted)'};">${v}</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
<p class="field-label" style="margin-bottom:10px;">Level names (lowest → highest)</p>
|
|
<div id="wizLevelInputs">${inputs}</div>`;
|
|
}
|
|
|
|
function setWizardLevels(n) {
|
|
const defaults = ['Beginner', 'Developing', 'Proficient', 'Expert', 'Advanced', 'World Class'];
|
|
while (wizData.scoring_labels.length < n)
|
|
wizData.scoring_labels.push(defaults[wizData.scoring_labels.length] || `Level ${wizData.scoring_labels.length + 1}`);
|
|
wizData.scoring_labels = wizData.scoring_labels.slice(0, n);
|
|
wizData.scoring_levels = n;
|
|
renderWizardStep();
|
|
}
|
|
|
|
function renderWizStep3(body) {
|
|
const rows = wizData.pillars.map((p, i) => `
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
|
<input class="wizard-input" style="flex:1;" value="${escHtml(p)}" placeholder="Pillar name"
|
|
oninput="wizData.pillars[${i}]=this.value.toUpperCase()">
|
|
<button type="button" onclick="removeWizPillar(${i})"
|
|
style="background:none;border:none;cursor:pointer;color:var(--text-muted);padding:6px;border-radius:4px;flex-shrink:0;" title="Remove">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>`).join('');
|
|
body.innerHTML = `
|
|
<div id="wizPillarList">${rows}</div>
|
|
<button type="button" class="btn-ghost" style="margin-top:8px;width:100%;justify-content:center;" onclick="addWizPillar()">
|
|
+ Add Pillar
|
|
</button>
|
|
<p style="font-size:12px;color:var(--text-muted);margin:12px 0 0;line-height:1.5;">Pre-filled with ADEO's 7 pillars as a starting point — modify or replace as needed.</p>`;
|
|
}
|
|
|
|
function addWizPillar() { wizData.pillars.push(''); renderWizardStep(); }
|
|
function removeWizPillar(i) { wizData.pillars.splice(i, 1); renderWizardStep(); }
|
|
|
|
function renderWizStep4(body) {
|
|
body.innerHTML = `
|
|
<p style="font-size:13px;color:var(--text-sub);margin:0 0 14px;line-height:1.6;">Review your configuration. Go back to edit any step.</p>
|
|
<div style="background:var(--bg-inset);border:1px solid var(--border);border-radius:8px;padding:14px;
|
|
font-family:ui-monospace,monospace;font-size:12px;color:var(--text-sub);white-space:pre-wrap;
|
|
max-height:340px;overflow-y:auto;line-height:1.55;">${escHtml(JSON.stringify(buildWizardConfig(), null, 2))}</div>`;
|
|
}
|
|
|
|
function buildWizardConfig() {
|
|
const n = wizData.scoring_levels;
|
|
const labels = {};
|
|
for (let i = 0; i < n; i++) labels[String(i + 1)] = wizData.scoring_labels[i] || `Level ${i + 1}`;
|
|
return {
|
|
id: wizData.id,
|
|
name: wizData.name,
|
|
description: wizData.description,
|
|
accent_color: wizData.accent_color,
|
|
scoring: { min: 1, max: n, labels },
|
|
pillars: wizData.pillars.filter(p => p.trim()),
|
|
entity_label: wizData.entity_label,
|
|
entity_label_singular: wizData.entity_label_singular,
|
|
};
|
|
}
|
|
|
|
function wizardSaveStep() {
|
|
if (wizStep === 0) {
|
|
wizData.name = document.getElementById('wiz-name')?.value?.trim() || wizData.name;
|
|
wizData.id = document.getElementById('wiz-id')?.value?.trim() || wizData.id;
|
|
wizData.description = document.getElementById('wiz-desc')?.value?.trim() || wizData.description;
|
|
wizData.accent_color = document.getElementById('wiz-color')?.value || wizData.accent_color;
|
|
} else if (wizStep === 1) {
|
|
wizData.entity_label = document.getElementById('wiz-entity-label')?.value?.trim() || wizData.entity_label;
|
|
wizData.entity_label_singular = document.getElementById('wiz-entity-singular')?.value?.trim() || wizData.entity_label_singular;
|
|
} else if (wizStep === 3) {
|
|
document.querySelectorAll('#wizPillarList input').forEach((inp, i) => {
|
|
wizData.pillars[i] = inp.value.toUpperCase();
|
|
});
|
|
wizData.pillars = wizData.pillars.filter(p => p.trim());
|
|
}
|
|
}
|
|
|
|
function wizardValidate() {
|
|
if (wizStep === 0 && !wizData.name) { showToast(t('wiz_client_required'), 'error'); return false; }
|
|
if (wizStep === 0 && !wizData.id) { showToast(t('wiz_id_failed'), 'error'); return false; }
|
|
if (wizStep === 3 && !wizData.pillars.filter(p => p.trim()).length) {
|
|
showToast(t('wiz_pillar_required'), 'error'); return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function wizardNext() {
|
|
wizardSaveStep();
|
|
if (!wizardValidate()) return;
|
|
if (wizStep === WIZARD_STEPS.length - 1) { createClient(); return; }
|
|
wizStep++;
|
|
renderWizardStep();
|
|
}
|
|
|
|
function wizardBack() {
|
|
wizardSaveStep();
|
|
if (wizStep > 0) { wizStep--; renderWizardStep(); }
|
|
}
|
|
|
|
async function createClient() {
|
|
const cfg = buildWizardConfig();
|
|
const btn = document.getElementById('wizardNextBtn');
|
|
btn.disabled = true; btn.textContent = 'Creating…';
|
|
try {
|
|
const res = await fetch(API_BASE + '/api/clients', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(cfg),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
|
closeWizard();
|
|
showToast(t('wiz_created', cfg.name), 'success');
|
|
const r2 = await fetch(API_BASE + '/api/clients');
|
|
allClients = await r2.json();
|
|
await loadClient(data.id);
|
|
showTab('update');
|
|
} catch (e) {
|
|
showToast(t('wiz_create_failed', e.message), 'error');
|
|
btn.disabled = false; btn.textContent = '✓ Create Client';
|
|
}
|
|
}
|
|
|
|
// ── Load client ───────────────────────────────────────────────────────────────
|
|
async function loadClient(id) {
|
|
try {
|
|
const [cfgRes, datRes, delRes] = await Promise.all([
|
|
fetch(`${API_BASE}/api/clients/${id}/config`),
|
|
fetch(`${API_BASE}/api/clients/${id}/data`),
|
|
fetch(`${API_BASE}/api/clients/${id}/deliverables`),
|
|
]);
|
|
const config = await cfgRes.json();
|
|
const data = await datRes.json();
|
|
const deliverables = delRes.ok ? await delRes.json() : {};
|
|
activeClient = { config, data, deliverables };
|
|
applyStoredLang();
|
|
document.documentElement.lang = currentLang;
|
|
updateLangToggleButton();
|
|
|
|
applyAccent(config.accent_color);
|
|
enterClientView(config);
|
|
renderSummaryBar();
|
|
renderEntityCards();
|
|
renderCompareSelector();
|
|
document.getElementById('exportRow').style.display = 'flex';
|
|
} catch (e) {
|
|
showToast(t('toast_client_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
function applyAccent(color) {
|
|
document.documentElement.style.setProperty('--accent', color);
|
|
}
|
|
|
|
function enterClientView(config) {
|
|
if (config.logo) {
|
|
document.getElementById('headerTitle').innerHTML = `<img src="${API_BASE}/clients/${config.id}/${config.logo}" alt="${escHtml(config.name)}" style="height:30px;max-width:100px;object-fit:contain;vertical-align:middle;">`;
|
|
} else {
|
|
document.getElementById('headerTitle').textContent = config.name;
|
|
}
|
|
document.getElementById('headerSub').textContent = config.description;
|
|
document.getElementById('homeBtn').style.display = '';
|
|
document.getElementById('tabBar').style.display = '';
|
|
document.getElementById('homeScreen').style.display = 'none';
|
|
document.getElementById('clientView').style.display = '';
|
|
|
|
// Update tab label to match entity type
|
|
const entityLabel = cfg('entity_label') || config.entity_label || 'Entities';
|
|
document.getElementById('tab-entities').textContent = entityLabel;
|
|
document.getElementById('tab-compare').textContent = t('tab_compare');
|
|
document.getElementById('tab-heatmap').textContent = t('tab_heatmap');
|
|
document.getElementById('tab-update').textContent = t('tab_update');
|
|
|
|
showTab('entities');
|
|
}
|
|
|
|
// ── Tab navigation ────────────────────────────────────────────────────────────
|
|
function showTab(name) {
|
|
['entities', 'compare', 'heatmap', 'update'].forEach(t => {
|
|
document.getElementById(`tab-${t}-content`).style.display = t === name ? '' : 'none';
|
|
document.getElementById(`tab-${t}`).classList.toggle('active', t === name);
|
|
});
|
|
activeTab = name;
|
|
if (name === 'update') { populateImportEntitySelect(); updateUpdateTabText(); }
|
|
if (name === 'heatmap') renderHeatmap();
|
|
}
|
|
|
|
// ── Summary bar ───────────────────────────────────────────────────────────────
|
|
function renderSummaryBar() {
|
|
const entities = activeClient.data.entities;
|
|
const scores = entities.map(e => e.overall_score);
|
|
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2);
|
|
const best = entities[0];
|
|
const worst = entities[entities.length - 1];
|
|
const entityLabel = cfg('entity_label') || activeClient.config.entity_label || 'Entities';
|
|
|
|
document.getElementById('summaryBar').innerHTML = `
|
|
<div style="display:flex;gap:0;flex-wrap:wrap;align-items:stretch;margin:-8px;">
|
|
<div class="stat-box" style="padding:12px 24px;border-right:1px solid var(--border);">
|
|
<div class="stat-num">${entities.length}</div>
|
|
<div class="stat-lbl">${escHtml(entityLabel)}</div>
|
|
</div>
|
|
<div class="stat-box" style="padding:12px 24px;border-right:1px solid var(--border);">
|
|
<div class="stat-num">${avg}</div>
|
|
<div class="stat-lbl">${escHtml(t('summary_avg'))}</div>
|
|
</div>
|
|
<div class="stat-box" style="padding:12px 24px;border-right:1px solid var(--border);text-align:left;">
|
|
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_highest'))}</div>
|
|
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(best.short)}</div>
|
|
<div style="font-size:12px;color:var(--accent);font-weight:700;">${best.overall_score.toFixed(2)}</div>
|
|
</div>
|
|
<div class="stat-box" style="padding:12px 24px;text-align:left;">
|
|
<div style="font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-bottom:4px;">${escHtml(t('summary_lowest'))}</div>
|
|
<div style="font-size:14px;font-weight:700;color:var(--text);">${escHtml(worst.short)}</div>
|
|
<div style="font-size:12px;color:#C62828;font-weight:700;">${worst.overall_score.toFixed(2)}</div>
|
|
</div>
|
|
<div style="margin-left:auto;padding:12px 0 12px 24px;display:flex;align-items:center;">
|
|
<span style="font-size:11px;color:var(--text-faint);">Data: ${escHtml(activeClient.data.generated)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
renderCardControls();
|
|
}
|
|
|
|
// ── Card controls (sort + group) ──────────────────────────────────────────────
|
|
function renderCardControls() {
|
|
const controls = document.getElementById('cardControls');
|
|
if (!controls) return;
|
|
const pillars = activeClient.config.pillars;
|
|
|
|
const pillarOpts = pillars.map((p, i) =>
|
|
`<option value="${escHtml(p)}"${sortPillar === p ? ' selected' : ''}>${escHtml(pillarDisplayName(i))}</option>`
|
|
).join('');
|
|
|
|
controls.innerHTML = `
|
|
<span style="font-size:12px;font-weight:600;color:var(--text-muted);">${escHtml(t('sort_by'))}</span>
|
|
<select class="ctrl-select" id="sortSel" onchange="onSortChange(this.value)">
|
|
<option value="overall"${sortPillar === 'overall' ? ' selected' : ''}>${escHtml(t('sort_overall'))}</option>
|
|
${pillarOpts}
|
|
</select>
|
|
<button class="ctrl-btn${groupByGroup ? ' active' : ''}" id="groupBtn" onclick="onGroupToggle()">
|
|
${escHtml(t('group_by_brand'))}
|
|
</button>
|
|
`;
|
|
|
|
controls.style.display = 'flex';
|
|
}
|
|
|
|
function onSortChange(val) {
|
|
sortPillar = val;
|
|
renderEntityCards();
|
|
}
|
|
|
|
function onGroupToggle() {
|
|
groupByGroup = !groupByGroup;
|
|
renderEntityCards();
|
|
// Update button appearance
|
|
const btn = document.getElementById('groupBtn');
|
|
if (btn) btn.className = 'ctrl-btn' + (groupByGroup ? ' active' : '');
|
|
}
|
|
|
|
// ── Score helpers ─────────────────────────────────────────────────────────────
|
|
function scoreClass(level) {
|
|
return `score-${Math.max(1, Math.min(4, level || 1))}`;
|
|
}
|
|
|
|
function scoreBgClass(level) {
|
|
return `score-bg-${Math.max(1, Math.min(4, level || 1))}`;
|
|
}
|
|
|
|
function pillarBarPct(avg) {
|
|
if (!activeClient) return 0;
|
|
const { min, max } = activeClient.config.scoring;
|
|
return Math.max(0, Math.min(100, ((avg - min) / (max - min)) * 100));
|
|
}
|
|
|
|
// ── Entity cards ──────────────────────────────────────────────────────────────
|
|
function renderEntityCards() {
|
|
document.getElementById('entityGrid').style.display = '';
|
|
document.getElementById('detailPanel').style.display = 'none';
|
|
|
|
// Sort entities
|
|
let entities = [...activeClient.data.entities];
|
|
if (sortPillar === 'overall') {
|
|
entities.sort((a, b) => b.overall_score - a.overall_score);
|
|
} else {
|
|
entities.sort((a, b) => {
|
|
const pa = a.pillars.find(x => x.name === sortPillar);
|
|
const pb = b.pillars.find(x => x.name === sortPillar);
|
|
if (!pa && !pb) return 0;
|
|
if (!pa) return 1;
|
|
if (!pb) return -1;
|
|
return pb.avg - pa.avg;
|
|
});
|
|
}
|
|
|
|
const grid = document.getElementById('entityGrid');
|
|
|
|
if (!groupByGroup) {
|
|
// Normal flat grid
|
|
grid.style.display = '';
|
|
grid.className = 'cards-grid';
|
|
grid.innerHTML = entities.map((entity, i) => buildEntityCard(entity, i)).join('');
|
|
} else {
|
|
// Group by entity.group
|
|
const groups = [];
|
|
const seen = {};
|
|
entities.forEach(e => {
|
|
if (!seen[e.group]) { seen[e.group] = []; groups.push(e.group); }
|
|
seen[e.group].push(e);
|
|
});
|
|
|
|
grid.className = '';
|
|
grid.style.display = 'flex';
|
|
grid.style.flexDirection = 'column';
|
|
grid.style.gap = '20px';
|
|
|
|
grid.innerHTML = groups.map(gname => {
|
|
const groupEntities = seen[gname];
|
|
const cards = groupEntities.map((entity, i) => buildEntityCard(entity, i)).join('');
|
|
return `
|
|
<div>
|
|
<p class="section-header" style="margin-bottom:10px;">${escHtml(gname)}</p>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;">
|
|
${cards}
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
document.querySelectorAll('.entity-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const entity = activeClient.data.entities.find(e => e.id === card.dataset.id);
|
|
if (entity) openEntityDetail(entity);
|
|
});
|
|
});
|
|
}
|
|
|
|
function buildEntityCard(entity, i) {
|
|
const pillars = activeClient.config.pillars;
|
|
const lvlCls = scoreClass(entity.overall_level);
|
|
const bgCls = scoreBgClass(entity.overall_level);
|
|
const flags = getEntityFlags(entity);
|
|
const flagTip = flags.map(f => f.label).join(' · ');
|
|
|
|
const pillarBars = pillars.map((pname, pi) => {
|
|
const p = entity.pillars.find(x => x.name === pname);
|
|
if (!p) return '';
|
|
const pct = pillarBarPct(p.avg).toFixed(1);
|
|
const displayName = pillarDisplayName(pi);
|
|
const short = pillarShort(displayName);
|
|
return `
|
|
<div style="display:flex;align-items:center;gap:6px;margin-bottom:5px;">
|
|
<span style="font-size:10px;color:var(--text-muted);width:52px;flex-shrink:0;font-weight:600;letter-spacing:0.03em;">${escHtml(short)}</span>
|
|
<div class="pillar-bar-track" style="flex:1;"><div class="pillar-bar-fill" style="width:${pct}%;"></div></div>
|
|
<span style="font-size:10px;color:var(--text-sub);width:28px;text-align:right;font-weight:600;">${p.avg.toFixed(1)}</span>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="entity-card fade-up" style="animation-delay:${Math.min(i * 30, 240)}ms;" data-id="${escHtml(entity.id)}">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:12px;">
|
|
<span class="badge ${lvlCls}">${escHtml(entity.group)}</span>
|
|
<span style="font-size:10px;color:var(--text-muted);font-weight:600;letter-spacing:0.04em;">#${i + 1}</span>
|
|
</div>
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px;">
|
|
<p style="font-size:15px;font-weight:700;color:var(--text);margin:0;line-height:1.3;flex:1;">${escHtml(entity.label)}</p>
|
|
<div class="${bgCls}" style="border-radius:8px;padding:8px 12px;text-align:center;flex-shrink:0;">
|
|
<span style="font-size:22px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
|
|
<span class="badge ${lvlCls}" style="font-size:9px;margin-top:3px;letter-spacing:0.05em;">${escHtml(scoreLabelDisplay(entity.overall_level) || entity.overall_label)}</span>
|
|
</div>
|
|
</div>
|
|
<div>${pillarBars}</div>
|
|
<div style="margin-top:12px;padding-top:10px;border-top:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
|
<span style="font-size:11px;color:var(--text-muted);">${escHtml(t('card_questions', entity.pillars.reduce((s, p) => s + p.questions.length, 0)))}</span>
|
|
${flags.length ? `<span class="entity-flag-badge" title="${escHtml(flagTip)}">${escHtml(t('card_issues', flags.length))}</span>` : ''}
|
|
<span style="font-size:12px;color:var(--accent);font-weight:600;white-space:nowrap;">${escHtml(t('card_view_detail'))}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
// ── Radar chart (pure SVG) ────────────────────────────────────────────────────
|
|
function radarSvg(entity, config) {
|
|
const pillars = config.pillars;
|
|
const n = pillars.length;
|
|
const cx = 110, cy = 110, r = 80;
|
|
const { min, max } = config.scoring;
|
|
const range = max - min;
|
|
|
|
// Get scores for each pillar
|
|
const scores = pillars.map(pname => {
|
|
const p = entity.pillars.find(x => x.name === pname);
|
|
return p ? p.avg : min;
|
|
});
|
|
|
|
// Polygon point helper
|
|
function pt(idx, val) {
|
|
const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
|
|
const ratio = Math.max(0, Math.min(1, (val - min) / range));
|
|
const rr = ratio * r;
|
|
return { x: cx + rr * Math.cos(angle), y: cy + rr * Math.sin(angle) };
|
|
}
|
|
|
|
function ptEdge(idx) {
|
|
const angle = (Math.PI * 2 * idx / n) - Math.PI / 2;
|
|
return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
|
|
}
|
|
|
|
// Grid rings (4 levels)
|
|
const levels = Object.keys(config.scoring.labels).map(Number).sort((a, b) => a - b);
|
|
const ringValues = levels.map(l => l);
|
|
|
|
const gridRings = ringValues.map(val => {
|
|
const pts = pillars.map((_, i) => {
|
|
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
|
|
const ratio = Math.max(0, Math.min(1, (val - min) / range));
|
|
const rr = ratio * r;
|
|
return `${(cx + rr * Math.cos(angle)).toFixed(2)},${(cy + rr * Math.sin(angle)).toFixed(2)}`;
|
|
}).join(' ');
|
|
return `<polygon points="${pts}" fill="none" stroke="#888" stroke-width="0.8" opacity="0.35"/>`;
|
|
}).join('');
|
|
|
|
// Axis lines
|
|
const axisLines = pillars.map((_, i) => {
|
|
const e = ptEdge(i);
|
|
return `<line x1="${cx}" y1="${cy}" x2="${e.x.toFixed(2)}" y2="${e.y.toFixed(2)}" stroke="#888" stroke-width="0.8" opacity="0.4"/>`;
|
|
}).join('');
|
|
|
|
// Score polygon
|
|
const scorePoints = scores.map((s, i) => {
|
|
const p = pt(i, s);
|
|
return `${p.x.toFixed(2)},${p.y.toFixed(2)}`;
|
|
}).join(' ');
|
|
|
|
// Score dots
|
|
const scoreDots = scores.map((s, i) => {
|
|
const p = pt(i, s);
|
|
return `<circle cx="${p.x.toFixed(2)}" cy="${p.y.toFixed(2)}" r="3.5" fill="var(--accent)" stroke="none"/>`;
|
|
}).join('');
|
|
|
|
// Labels
|
|
const labels = pillars.map((pname, i) => {
|
|
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
|
|
const labelR = r + 22;
|
|
const lx = cx + labelR * Math.cos(angle);
|
|
const ly = cy + labelR * Math.sin(angle);
|
|
let anchor = 'middle';
|
|
if (lx < cx - 8) anchor = 'end';
|
|
else if (lx > cx + 8) anchor = 'start';
|
|
const displayName = pillarDisplayName(i);
|
|
const short = pillarShort(displayName);
|
|
return `<text x="${lx.toFixed(2)}" y="${(ly + 3).toFixed(2)}" font-size="8" fill="#9ca3af" text-anchor="${anchor}" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif" font-weight="600">${escHtml(short)}</text>`;
|
|
}).join('');
|
|
|
|
return `<svg viewBox="0 0 220 220" width="180" height="180" xmlns="http://www.w3.org/2000/svg" style="flex-shrink:0;">
|
|
${gridRings}
|
|
${axisLines}
|
|
<polygon points="${scorePoints}" fill="var(--accent)" fill-opacity="0.18" stroke="var(--accent)" stroke-width="2" stroke-linejoin="round"/>
|
|
${scoreDots}
|
|
${labels}
|
|
</svg>`;
|
|
}
|
|
|
|
// ── Entity detail ─────────────────────────────────────────────────────────────
|
|
function openEntityDetail(entity) {
|
|
detailEntity = entity;
|
|
document.getElementById('entityGrid').style.display = 'none';
|
|
const panel = document.getElementById('detailPanel');
|
|
panel.style.display = '';
|
|
|
|
const pillars = activeClient.config.pillars;
|
|
const lvlCls = scoreClass(entity.overall_level);
|
|
const bgCls = scoreBgClass(entity.overall_level);
|
|
|
|
const pillarAccordions = pillars.map((pname, pi) => {
|
|
const p = entity.pillars.find(x => x.name === pname);
|
|
if (!p) return '';
|
|
const plvl = scoreClass(p.level);
|
|
const pbg = scoreBgClass(p.level);
|
|
const displayName = pillarDisplayName(pi);
|
|
const plabel = scoreLabelDisplay(p.level) || p.label;
|
|
const pct = pillarBarPct(p.avg).toFixed(1);
|
|
|
|
const qRows = p.questions.map((q, qi) => {
|
|
const qlvl = scoreClass(q.level);
|
|
return `
|
|
<div class="question-row" data-pillar="${escHtml(pname)}" data-qi="${qi}">
|
|
<span class="badge ${qlvl}" style="font-size:11px;min-width:26px;text-align:center;flex-shrink:0;">${q.score}</span>
|
|
<span style="font-size:13px;color:var(--text);flex:1;line-height:1.45;">${escHtml(getQuestionTopic(q))}</span>
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="color:var(--text-faint);flex-shrink:0;margin-top:1px;"><path d="M9 18l6-6-6-6"/></svg>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div class="pillar-card fade-up">
|
|
<div class="pillar-card-header" onclick="togglePillar(this)">
|
|
<div style="display:flex;align-items:center;gap:12px;flex:1;min-width:0;">
|
|
<span style="font-size:13px;font-weight:700;color:var(--text);">${escHtml(displayName)}</span>
|
|
<div class="pillar-bar-track" style="flex:1;max-width:120px;display:none;" id="bar-inline">
|
|
<div class="pillar-bar-fill" style="width:${pct}%;"></div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:10px;flex-shrink:0;">
|
|
<div class="${pbg}" style="border-radius:6px;padding:5px 12px;text-align:center;display:flex;align-items:center;gap:8px;">
|
|
<span style="font-size:17px;font-weight:800;color:var(--text);">${p.avg.toFixed(2)}</span>
|
|
<span class="badge ${plvl}" style="font-size:9px;">${escHtml(plabel)}</span>
|
|
</div>
|
|
<svg class="pillar-chevron" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M6 9l6 6 6-6"/></svg>
|
|
</div>
|
|
</div>
|
|
<div class="pillar-card-body">${qRows}</div>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
// Deliverable buttons (if configured for this entity)
|
|
const del = (activeClient.deliverables || {})[entity.id] || {};
|
|
const deliverablesBtns = (del.pdf || del.xlsx) ? `
|
|
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);">${escHtml(t('deliverables_label'))}</span>
|
|
${del.pdf ? `
|
|
<button class="btn-ghost" id="delPdfBtn-${escHtml(entity.id)}" onclick="downloadDeliverable('${escHtml(entity.id)}','pdf')" title="Download client summary PDF">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
|
${escHtml(t('btn_summary_pdf'))}
|
|
</button>` : ''}
|
|
${del.xlsx ? `
|
|
<button class="btn-ghost" id="delXlsxBtn-${escHtml(entity.id)}" onclick="downloadDeliverable('${escHtml(entity.id)}','xlsx')" title="Download client scores file">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
|
${escHtml(t('btn_scores_xlsx'))}
|
|
</button>` : ''}
|
|
<span style="width:1px;height:20px;background:var(--border);margin:0 4px;flex-shrink:0;"></span>
|
|
` : '';
|
|
|
|
panel.innerHTML = `
|
|
<div class="back-row" style="flex-wrap:wrap;gap:8px;">
|
|
<button class="btn-ghost" onclick="closeEntityDetail()">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
|
${escHtml(t('back_to', cfg('entity_label') || activeClient.config.entity_label || 'entities'))}
|
|
</button>
|
|
<div style="margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
|
${deliverablesBtns}
|
|
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);">${escHtml(t('export_label'))}</span>
|
|
<button class="btn-ghost" onclick="exportXlsx('${escHtml(entity.id)}')" title="Download Excel for this entity">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
XLSX
|
|
</button>
|
|
<button class="btn-ghost" id="pdfBtn-${escHtml(entity.id)}" onclick="downloadPdf('${escHtml(entity.id)}')" title="Download PDF report">
|
|
<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
PDF
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel fade-up" style="margin-bottom:20px;">
|
|
<div style="display:flex;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;gap:16px;">
|
|
<div>
|
|
<span class="badge ${lvlCls}" style="margin-bottom:8px;">${escHtml(entity.group)}</span>
|
|
<h2 style="font-size:22px;font-weight:800;color:var(--text);margin:6px 0 0;">${escHtml(entity.label)}</h2>
|
|
</div>
|
|
${radarSvg(entity, activeClient.config)}
|
|
<div class="${bgCls}" style="border-radius:10px;padding:14px 20px;text-align:center;">
|
|
<span style="font-size:36px;font-weight:900;display:block;line-height:1;color:var(--text);">${entity.overall_score.toFixed(2)}</span>
|
|
<span class="badge ${lvlCls}" style="font-size:10px;margin-top:5px;">${escHtml(scoreLabelDisplay(entity.overall_level) || entity.overall_label)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${(() => {
|
|
const flags = getEntityFlags(entity);
|
|
if (!flags.length) return '';
|
|
return `<div class="flag-banner fade-up">
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="flex-shrink:0;margin-top:1px;"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
<div>
|
|
<span style="font-weight:700;margin-right:8px;">${escHtml(t('data_quality'))}</span>
|
|
${flags.map(f => `<span style="opacity:.85;">${escHtml(f.label)}</span>`).join(' <span style="opacity:.4;margin:0 4px;">·</span> ')}
|
|
</div>
|
|
</div>`;
|
|
})()}
|
|
<p class="section-header">${escHtml(t('pillar_breakdown'))}</p>
|
|
<div id="pillarAccordion">${pillarAccordions}</div>
|
|
`;
|
|
|
|
// Attach question row click handlers
|
|
panel.querySelectorAll('.question-row').forEach(row => {
|
|
row.addEventListener('click', () => {
|
|
const p = entity.pillars.find(x => x.name === row.dataset.pillar);
|
|
if (p) openQuestionModal(entity, p, p.questions[parseInt(row.dataset.qi)]);
|
|
});
|
|
});
|
|
}
|
|
|
|
function closeEntityDetail() {
|
|
detailEntity = null;
|
|
document.getElementById('detailPanel').style.display = 'none';
|
|
document.getElementById('entityGrid').style.display = '';
|
|
}
|
|
|
|
function togglePillar(header) {
|
|
header.closest('.pillar-card').classList.toggle('open');
|
|
}
|
|
|
|
// ── Question modal ────────────────────────────────────────────────────────────
|
|
function openQuestionModal(entity, pillar, question) {
|
|
const qlvl = scoreClass(question.level);
|
|
const elvl = scoreClass(entity.overall_level);
|
|
|
|
document.getElementById('qModalBadges').innerHTML = `
|
|
<span class="badge ${elvl}">${escHtml(entity.short)}</span>
|
|
<span class="badge" style="background:var(--bg-inset);color:var(--text-muted);border:1px solid var(--border);">${escHtml(pillar.name)}</span>
|
|
`;
|
|
document.getElementById('qModalTitle').textContent = getQuestionTopic(question);
|
|
document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillar.name} · ${entity.label}`;
|
|
|
|
const fields = [
|
|
{ label: t('field_rationale'), value: question.rationale },
|
|
{ label: t('field_gaps'), value: question.gaps || '—' },
|
|
{ label: t('field_refs'), value: question.refs || '—' },
|
|
];
|
|
|
|
const scoring = activeClient.config.scoring;
|
|
const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b);
|
|
const scoreBtns = scoreNums.map(n => {
|
|
const lvl = scoreToLevel(n, scoring);
|
|
const isCurrent = n === question.score;
|
|
const scoreLabel = scoreLabelDisplay(lvl) || scoring.labels[n];
|
|
return `
|
|
<button onclick="updateScore('${escHtml(entity.id)}','${escHtml(pillar.name)}','${escHtml(String(question.q_num))}',${n})"
|
|
style="display:flex;flex-direction:column;align-items:center;gap:3px;padding:8px 12px;border-radius:6px;
|
|
border:2px solid ${isCurrent ? 'var(--accent)' : 'var(--border)'};
|
|
background:${isCurrent ? 'rgba(120,190,32,0.08)' : 'var(--bg-inset)'};
|
|
cursor:pointer;transition:all 0.15s;min-width:60px;"
|
|
id="score-btn-${n}"
|
|
title="${escHtml(scoreLabel)}">
|
|
<span class="badge score-${lvl}" style="font-size:14px;padding:3px 10px;">${n}</span>
|
|
<span style="font-size:10px;color:var(--text-muted);font-weight:600;">${escHtml(scoreLabel)}</span>
|
|
</button>`;
|
|
}).join('');
|
|
|
|
document.getElementById('qModalBody').innerHTML = `
|
|
<div class="spec-detail-row">
|
|
<span class="spec-detail-label">${escHtml(t('field_score'))}</span>
|
|
<div>
|
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:4px;" id="scoreBtnRow">${scoreBtns}</div>
|
|
<p style="font-size:11px;color:var(--text-muted);margin:6px 0 0;" id="scoreEditHint">${escHtml(t('score_hint'))}</p>
|
|
</div>
|
|
</div>
|
|
${fields.map(f => {
|
|
const empty = !f.value || f.value === '—';
|
|
return `
|
|
<div class="spec-detail-row">
|
|
<span class="spec-detail-label">${escHtml(f.label)}</span>
|
|
<span class="spec-detail-value${empty ? ' empty' : ''}">${escHtml(f.value)}</span>
|
|
</div>`;
|
|
}).join('')}
|
|
`;
|
|
|
|
document.getElementById('questionModal').classList.remove('hidden');
|
|
}
|
|
|
|
// ── Score editing ─────────────────────────────────────────────────────────────
|
|
function scoreToLevel(score, scoring) {
|
|
const levels = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b);
|
|
const idx = levels.indexOf(score);
|
|
return idx >= 0 ? idx + 1 : Math.round(((score - scoring.min) / (scoring.max - scoring.min)) * (levels.length - 1)) + 1;
|
|
}
|
|
|
|
async function updateScore(entityId, pillarName, qNum, newScore) {
|
|
const scoring = activeClient.config.scoring;
|
|
const entity = activeClient.data.entities.find(e => e.id === entityId);
|
|
const pillar = entity.pillars.find(p => p.name === pillarName);
|
|
const question = pillar.questions.find(q => String(q.q_num) === String(qNum));
|
|
|
|
if (question.score === newScore) return;
|
|
|
|
const oldScore = question.score;
|
|
const lvl = scoreToLevel(newScore, scoring);
|
|
|
|
// Update question
|
|
question.score = newScore;
|
|
question.level = lvl;
|
|
question.label = scoring.labels[newScore] || '';
|
|
|
|
// Recalculate pillar avg
|
|
const pillarScores = pillar.questions.map(q => q.score);
|
|
pillar.avg = Math.round((pillarScores.reduce((a, b) => a + b, 0) / pillarScores.length) * 100) / 100;
|
|
pillar.level = scoreToLevel(Math.round(pillar.avg), scoring);
|
|
pillar.label = scoring.labels[Math.round(pillar.avg)] || scoring.labels[Math.ceil(pillar.avg)] || '';
|
|
|
|
// Recalculate entity overall
|
|
const allScores = entity.pillars.flatMap(p => p.questions.map(q => q.score));
|
|
entity.overall_score = Math.round((allScores.reduce((a, b) => a + b, 0) / allScores.length) * 100) / 100;
|
|
entity.overall_level = scoreToLevel(Math.round(entity.overall_score), scoring);
|
|
entity.overall_label = scoring.labels[Math.round(entity.overall_score)] || scoring.labels[Math.ceil(entity.overall_score)] || '';
|
|
|
|
// Update score button highlight in modal
|
|
const scoreNums = Object.keys(scoring.labels).map(Number).sort((a, b) => a - b);
|
|
scoreNums.forEach(n => {
|
|
const btn = document.getElementById(`score-btn-${n}`);
|
|
if (!btn) return;
|
|
const isCurrent = n === newScore;
|
|
btn.style.borderColor = isCurrent ? 'var(--accent)' : 'var(--border)';
|
|
btn.style.background = isCurrent ? 'rgba(120,190,32,0.08)' : 'var(--bg-inset)';
|
|
});
|
|
const hint = document.getElementById('scoreEditHint');
|
|
if (hint) hint.innerHTML = `<span style="color:var(--accent);">${escHtml(t('score_updated', oldScore, newScore))}</span>`;
|
|
|
|
// Re-render cards/detail if visible
|
|
if (detailEntity && detailEntity.id === entityId) {
|
|
openEntityDetail(entity); // refreshes detail panel
|
|
document.getElementById('questionModal').classList.remove('hidden'); // keep modal open
|
|
} else {
|
|
renderEntityCards();
|
|
}
|
|
if (activeTab === 'compare') renderCompareTable();
|
|
|
|
// Persist to server
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/data`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(activeClient.data),
|
|
});
|
|
if (!res.ok) throw new Error(await res.text());
|
|
showToast(t('toast_score_saved', qNum, newScore, scoreLabelDisplay(question.level) || question.label), 'success');
|
|
} catch (e) {
|
|
showToast(t('toast_save_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('questionModal').classList.add('hidden');
|
|
}
|
|
|
|
function handleModalOverlayClick(e) {
|
|
if (e.target === document.getElementById('questionModal')) closeModal();
|
|
}
|
|
|
|
// ── Compare tab ───────────────────────────────────────────────────────────────
|
|
function renderCompareSelector() {
|
|
const entities = activeClient.data.entities;
|
|
const entityLabel = activeClient.config.entity_label || 'Entities';
|
|
|
|
document.getElementById('compareSelector').innerHTML = `
|
|
<p class="section-header">${escHtml(t('compare_select', entityLabel))}</p>
|
|
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:10px;margin-bottom:16px;">
|
|
${entities.map(e => {
|
|
const lvl = scoreClass(e.overall_level);
|
|
return `
|
|
<label style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;cursor:pointer;background:var(--bg-card);transition:border-color 0.15s;"
|
|
onmouseenter="this.style.borderColor='var(--accent)'" onmouseleave="this.style.borderColor='var(--border)'">
|
|
<input type="checkbox" value="${escHtml(e.id)}" onchange="onCompareSelect(this)" style="accent-color:var(--accent);width:14px;height:14px;flex-shrink:0;">
|
|
<div style="min-width:0;">
|
|
<p style="font-size:13px;font-weight:600;color:var(--text);margin:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${escHtml(e.short)}</p>
|
|
<p style="font-size:11px;color:var(--text-muted);margin:2px 0 0;">
|
|
<span class="badge ${lvl}" style="font-size:9px;">${e.overall_score.toFixed(2)}</span>
|
|
<span style="margin-left:4px;">${escHtml(scoreLabelDisplay(e.overall_level) || e.overall_label)}</span>
|
|
</p>
|
|
</div>
|
|
</label>`;
|
|
}).join('')}
|
|
</div>
|
|
<div style="display:flex;gap:8px;align-items:center;">
|
|
<button class="btn-primary" onclick="renderCompareTable()">${escHtml(t('compare_btn'))}</button>
|
|
<button class="btn-ghost" onclick="clearCompare()">${escHtml(t('compare_clear'))}</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function onCompareSelect(cb) {
|
|
cb.checked ? selectedForCompare.add(cb.value) : selectedForCompare.delete(cb.value);
|
|
}
|
|
|
|
function clearCompare() {
|
|
selectedForCompare.clear();
|
|
document.querySelectorAll('#compareSelector input[type=checkbox]').forEach(cb => cb.checked = false);
|
|
document.getElementById('compareResult').style.display = 'none';
|
|
}
|
|
|
|
function renderCompareTable() {
|
|
if (selectedForCompare.size < 2) {
|
|
showToast(t('toast_select_two'), 'error');
|
|
return;
|
|
}
|
|
|
|
const markets = [...selectedForCompare]
|
|
.map(id => activeClient.data.entities.find(e => e.id === id))
|
|
.filter(Boolean);
|
|
const pillars = activeClient.config.pillars;
|
|
const scoring = activeClient.config.scoring;
|
|
const wrap = document.getElementById('compareResult');
|
|
wrap.style.display = '';
|
|
|
|
// Header
|
|
const headerCols = markets.map(m => `
|
|
<th style="text-align:center;">
|
|
<div style="font-size:13px;font-weight:700;color:var(--text);text-transform:none;letter-spacing:0;">${escHtml(m.short)}</div>
|
|
<div style="margin-top:4px;">
|
|
<span class="badge ${scoreClass(m.overall_level)}" style="font-size:9px;">${m.overall_score.toFixed(2)}</span>
|
|
</div>
|
|
</th>`).join('');
|
|
|
|
// Build rows: overall + per pillar
|
|
const buildRow = (label, values) => {
|
|
const defined = values.filter(v => v !== null);
|
|
const maxVal = defined.length ? Math.max(...defined) : null;
|
|
const minVal = defined.length ? Math.min(...defined) : null;
|
|
const allSame = defined.every(v => v === defined[0]);
|
|
|
|
const cells = values.map((v, i) => {
|
|
if (v === null) return '<td style="text-align:center;color:var(--text-faint);">—</td>';
|
|
const cls = !allSame && v === maxVal ? 'diff-hi' : !allSame && v === minVal ? 'diff-lo' : '';
|
|
const level = Math.round(((v - scoring.min) / (scoring.max - scoring.min)) * (Object.keys(scoring.labels).length - 1)) + 1;
|
|
const pct = ((v - scoring.min) / (scoring.max - scoring.min) * 100).toFixed(0);
|
|
return `
|
|
<td class="${cls}" style="text-align:center;">
|
|
<div style="display:flex;align-items:center;gap:8px;justify-content:center;">
|
|
<div class="pillar-bar-track" style="width:60px;">
|
|
<div class="pillar-bar-fill" style="width:${pct}%;"></div>
|
|
</div>
|
|
<span style="font-weight:700;font-size:13px;">${v.toFixed(2)}</span>
|
|
</div>
|
|
</td>`;
|
|
}).join('');
|
|
|
|
return `<tr><td class="row-label">${escHtml(label)}</td>${cells}</tr>`;
|
|
};
|
|
|
|
// Overall row
|
|
const overallVals = markets.map(m => m.overall_score);
|
|
const overallDef = overallVals.filter(v => v !== null);
|
|
const maxO = Math.max(...overallDef), minO = Math.min(...overallDef);
|
|
const allSameO = overallDef.every(v => v === overallDef[0]);
|
|
const overallRow = `<tr style="background:var(--bg-inset);">
|
|
<td class="row-label" style="font-weight:800;color:var(--text);">${escHtml(t('compare_overall'))}</td>
|
|
${markets.map(m => {
|
|
const cls = !allSameO && m.overall_score === maxO ? 'diff-hi' : !allSameO && m.overall_score === minO ? 'diff-lo' : '';
|
|
return `<td class="${cls}" style="text-align:center;">
|
|
<span class="badge ${scoreClass(m.overall_level)}" style="font-size:12px;padding:3px 10px;">${m.overall_score.toFixed(2)}</span>
|
|
</td>`;
|
|
}).join('')}
|
|
</tr>`;
|
|
|
|
const pillarRows = pillars.map((pname, pi) => {
|
|
const vals = markets.map(m => {
|
|
const p = m.pillars.find(x => x.name === pname);
|
|
return p ? p.avg : null;
|
|
});
|
|
return buildRow(pillarDisplayName(pi), vals);
|
|
}).join('');
|
|
|
|
wrap.innerHTML = `
|
|
<p class="section-header">${escHtml(markets.map(m => m.short).join(' vs '))}</p>
|
|
<div style="overflow-x:auto;">
|
|
<table class="compare-table">
|
|
<thead><tr><th style="width:190px;"></th>${headerCols}</tr></thead>
|
|
<tbody>${overallRow}${pillarRows}</tbody>
|
|
</table>
|
|
</div>
|
|
<div style="margin-top:12px;display:flex;gap:20px;font-size:11px;color:var(--text-muted);">
|
|
<span><span style="display:inline-block;width:10px;height:10px;background:rgba(46,125,50,0.2);border:1px solid #2E7D32;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>${escHtml(t('compare_legend_high'))}</span>
|
|
<span><span style="display:inline-block;width:10px;height:10px;background:rgba(198,40,40,0.15);border:1px solid #C62828;border-radius:2px;vertical-align:middle;margin-right:4px;"></span>${escHtml(t('compare_legend_low'))}</span>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Heatmap tab ───────────────────────────────────────────────────────────────
|
|
function renderHeatmap() {
|
|
const panel = document.getElementById('heatmapPanel');
|
|
if (!panel || !activeClient) return;
|
|
|
|
const entities = activeClient.data.entities;
|
|
const pillars = activeClient.config.pillars;
|
|
const scoring = activeClient.config.scoring;
|
|
|
|
// Helper: map numeric score to color level (1-4 buckets)
|
|
function heatLevel(score) {
|
|
return scoreToLevel(Math.round(score), scoring);
|
|
}
|
|
|
|
// Header row
|
|
const headerCells = entities.map(e =>
|
|
`<th style="text-align:center;font-weight:700;font-size:11px;padding:10px 8px;min-width:70px;">${escHtml(e.short)}</th>`
|
|
).join('');
|
|
|
|
// Pillar rows
|
|
const pillarRows = pillars.map((pname, ri) => {
|
|
const displayName = pillarDisplayName(ri);
|
|
const cells = entities.map(e => {
|
|
const p = e.pillars.find(x => x.name === pname);
|
|
if (!p) return `<td style="text-align:center;color:var(--text-faint);">—</td>`;
|
|
const lvl = heatLevel(p.avg);
|
|
const bgCls = scoreBgClass(lvl);
|
|
return `<td style="text-align:center;"><span class="heatmap-cell ${bgCls}">${p.avg.toFixed(2)}</span></td>`;
|
|
}).join('');
|
|
|
|
// Row average
|
|
const vals = entities.map(e => { const p = e.pillars.find(x => x.name === pname); return p ? p.avg : null; }).filter(v => v !== null);
|
|
const rowAvg = vals.length ? (vals.reduce((a, b) => a + b, 0) / vals.length) : null;
|
|
const ravgLvl = rowAvg !== null ? heatLevel(rowAvg) : 1;
|
|
const ravgBg = scoreBgClass(ravgLvl);
|
|
const rowAvgCell = rowAvg !== null
|
|
? `<td style="text-align:center;"><span class="heatmap-cell ${ravgBg}" style="opacity:0.75;">${rowAvg.toFixed(2)}</span></td>`
|
|
: `<td style="text-align:center;color:var(--text-faint);">—</td>`;
|
|
|
|
const rowBg = ri % 2 === 0 ? '' : 'background:var(--bg-inset);';
|
|
return `<tr style="${rowBg}">
|
|
<td style="font-weight:600;color:var(--text);min-width:180px;font-size:12px;padding:10px 14px;">${escHtml(displayName)}</td>
|
|
${cells}
|
|
${rowAvgCell}
|
|
</tr>`;
|
|
}).join('');
|
|
|
|
// Overall row
|
|
const overallCells = entities.map(e => {
|
|
const lvl = heatLevel(e.overall_score);
|
|
const bgCls = scoreBgClass(lvl);
|
|
return `<td style="text-align:center;"><span class="heatmap-cell ${bgCls}">${e.overall_score.toFixed(2)}</span></td>`;
|
|
}).join('');
|
|
|
|
const allScores = entities.map(e => e.overall_score);
|
|
const grandAvg = allScores.length ? (allScores.reduce((a, b) => a + b, 0) / allScores.length) : 0;
|
|
const grandLvl = heatLevel(grandAvg);
|
|
const grandBg = scoreBgClass(grandLvl);
|
|
const grandAvgCell = `<td style="text-align:center;"><span class="heatmap-cell ${grandBg}" style="opacity:0.75;">${grandAvg.toFixed(2)}</span></td>`;
|
|
|
|
panel.innerHTML = `
|
|
<p class="section-header" style="margin-bottom:16px;">${escHtml(t('heatmap_title'))}</p>
|
|
<div style="overflow-x:auto;">
|
|
<table class="heatmap-table">
|
|
<thead>
|
|
<tr>
|
|
<th style="text-align:left;min-width:180px;">${escHtml(t('heatmap_pillar'))}</th>
|
|
${headerCells}
|
|
<th style="text-align:center;color:var(--text-muted);">${escHtml(t('heatmap_avg'))}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${pillarRows}
|
|
<tr style="border-top:2px solid var(--border);">
|
|
<td style="font-weight:800;color:var(--text);font-size:12px;padding:10px 14px;text-transform:uppercase;letter-spacing:0.05em;">${escHtml(t('heatmap_overall'))}</td>
|
|
${overallCells}
|
|
${grandAvgCell}
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div style="margin-top:12px;display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--text-muted);">
|
|
${Object.keys(scoring.labels).sort((a,b)=>a-b).map(n => {
|
|
const bgCls = scoreBgClass(parseInt(n));
|
|
const lbl = scoreLabelDisplay(parseInt(n)) || scoring.labels[n];
|
|
return `<span><span class="heatmap-cell ${bgCls}" style="font-size:10px;padding:2px 8px;min-width:0;">${n}</span> ${escHtml(lbl)}</span>`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── Deliverable downloads (pre-existing client PDFs / XLSXs) ─────────────────
|
|
|
|
async function downloadDeliverable(entityId, type) {
|
|
const btnId = type === 'pdf' ? `delPdfBtn-${entityId}` : `delXlsxBtn-${entityId}`;
|
|
const btn = document.getElementById(btnId);
|
|
const label = type === 'pdf' ? 'Summary PDF' : 'Scores XLSX';
|
|
if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/deliverables/${entityId}/${type}`);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || res.statusText);
|
|
}
|
|
const blob = await res.blob();
|
|
const disposition = res.headers.get('Content-Disposition') || '';
|
|
const nameMatch = disposition.match(/filename="([^"]+)"/);
|
|
const filename = nameMatch ? nameMatch[1] : `${entityId}_${label.replace(' ', '_')}.${type}`;
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast(type === 'pdf' ? t('toast_pdf_downloaded') : t('toast_xlsx_downloaded'));
|
|
} catch (e) {
|
|
showToast(`${label} unavailable: ${e.message}`, 'error');
|
|
} finally {
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
const icon = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>';
|
|
btn.innerHTML = `${icon} ${label}`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Exports ───────────────────────────────────────────────────────────────────
|
|
|
|
// CSV — flat, all entities
|
|
function exportCsv() {
|
|
const { config, data } = activeClient;
|
|
const rows = [['Client', 'Entity', 'Entity Short', 'Group', 'Overall Score', 'Overall Level', 'Pillar', 'Q#', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']];
|
|
for (const entity of data.entities) {
|
|
for (const pillar of entity.pillars) {
|
|
for (const q of pillar.questions) {
|
|
rows.push([
|
|
config.name,
|
|
entity.label,
|
|
entity.short,
|
|
entity.group,
|
|
entity.overall_score,
|
|
entity.overall_label,
|
|
pillar.name,
|
|
q.q_num,
|
|
q.topic,
|
|
q.score,
|
|
q.label,
|
|
q.rationale || '',
|
|
q.gaps || '',
|
|
q.refs || '',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
const csv = rows.map(r =>
|
|
r.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',')
|
|
).join('\r\n');
|
|
triggerDownload(`${config.id}_maturity_all_entities.csv`, 'text/csv;charset=utf-8;', csv);
|
|
showToast(t('toast_csv_downloaded'));
|
|
}
|
|
|
|
// XLSX — multi-sheet workbook
|
|
function exportXlsx(entityId) {
|
|
const { config, data } = activeClient;
|
|
const wb = XLSX.utils.book_new();
|
|
const entities = entityId
|
|
? data.entities.filter(e => e.id === entityId)
|
|
: data.entities;
|
|
|
|
const SCORE_COLORS = { 1: 'C62828', 2: 'E65100', 3: '2E7D32', 4: '1B5E20' };
|
|
|
|
// Summary sheet (always included)
|
|
const summaryRows = [['Entity', 'Group', 'Overall Score', 'Overall Level', ...config.pillars]];
|
|
for (const e of entities) {
|
|
const row = [e.label, e.group, e.overall_score, e.overall_label];
|
|
for (const pname of config.pillars) {
|
|
const p = e.pillars.find(x => x.name === pname);
|
|
row.push(p ? p.avg : '');
|
|
}
|
|
summaryRows.push(row);
|
|
}
|
|
const summaryWs = XLSX.utils.aoa_to_sheet(summaryRows);
|
|
// Style header row
|
|
const summaryRange = XLSX.utils.decode_range(summaryWs['!ref']);
|
|
for (let C = summaryRange.s.c; C <= summaryRange.e.c; C++) {
|
|
const cell = summaryWs[XLSX.utils.encode_cell({ r: 0, c: C })];
|
|
if (cell) {
|
|
cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true } };
|
|
}
|
|
}
|
|
// Column widths
|
|
summaryWs['!cols'] = [{ wch: 28 }, { wch: 16 }, { wch: 14 }, { wch: 14 },
|
|
...config.pillars.map(() => ({ wch: 12 }))];
|
|
XLSX.utils.book_append_sheet(wb, summaryWs, 'Summary');
|
|
|
|
// One sheet per entity
|
|
for (const entity of entities) {
|
|
const sheetRows = [['Q#', 'Pillar', 'Question Topic', 'Score', 'Level', 'Rationale', 'Gaps', 'References']];
|
|
for (const pillar of entity.pillars) {
|
|
for (const q of pillar.questions) {
|
|
sheetRows.push([q.q_num, pillar.name, q.topic, q.score, q.label, q.rationale || '', q.gaps || '', q.refs || '']);
|
|
}
|
|
// Pillar average row
|
|
sheetRows.push(['', pillar.name + ' — AVG', '', pillar.avg, pillar.label, '', '', '']);
|
|
}
|
|
|
|
const ws = XLSX.utils.aoa_to_sheet(sheetRows);
|
|
const range = XLSX.utils.decode_range(ws['!ref']);
|
|
|
|
for (let R = 0; R <= range.e.r; R++) {
|
|
for (let C = 0; C <= range.e.c; C++) {
|
|
const cellRef = XLSX.utils.encode_cell({ r: R, c: C });
|
|
const cell = ws[cellRef];
|
|
if (!cell) continue;
|
|
if (R === 0) {
|
|
cell.s = { font: { bold: true, color: { rgb: 'FFFFFF' } }, fill: { fgColor: { rgb: '1A2B3C' } }, alignment: { wrapText: true, vertical: 'top' } };
|
|
} else {
|
|
cell.s = cell.s || {};
|
|
cell.s.alignment = { wrapText: true, vertical: 'top' };
|
|
// Colour score cells (col 3)
|
|
if (C === 3 && typeof cell.v === 'number') {
|
|
const lvl = entity.pillars
|
|
.flatMap(p => p.questions)
|
|
.find(q => String(q.q_num) === String(sheetRows[R][0]))?.level || 0;
|
|
const hex = SCORE_COLORS[lvl];
|
|
if (hex) {
|
|
cell.s.fill = { fgColor: { rgb: hex } };
|
|
cell.s.font = { bold: true, color: { rgb: 'FFFFFF' } };
|
|
}
|
|
}
|
|
// Pillar avg rows
|
|
if (String(sheetRows[R][1] || '').includes('— AVG')) {
|
|
cell.s.fill = { fgColor: { rgb: 'F7F7F7' } };
|
|
cell.s.font = { bold: true };
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ws['!cols'] = [{ wch: 6 }, { wch: 22 }, { wch: 40 }, { wch: 8 }, { wch: 14 }, { wch: 55 }, { wch: 40 }, { wch: 30 }];
|
|
ws['!rows'] = sheetRows.map(() => ({ hpt: 40 }));
|
|
|
|
const sheetName = entity.short.replace(/[\\\/\*\?\[\]]/g, '').slice(0, 31);
|
|
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
|
}
|
|
|
|
const filename = entityId
|
|
? `${entityId}_Maturity_Report.xlsx`
|
|
: `${config.id}_Maturity_All_Entities.xlsx`;
|
|
|
|
XLSX.writeFile(wb, filename);
|
|
showToast(t('toast_xlsx_downloaded'));
|
|
}
|
|
|
|
// PDF — server-generated via Python/ReportLab
|
|
async function downloadPdf(entityId) {
|
|
const btn = document.getElementById(`pdfBtn-${entityId}`);
|
|
if (btn) { btn.textContent = '⏳ Generating…'; btn.disabled = true; }
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/export/pdf/${entityId}`);
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || res.statusText);
|
|
}
|
|
const blob = await res.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${entityId}_Maturity_Report.pdf`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast(t('toast_pdf_downloaded'));
|
|
} catch (e) {
|
|
showToast(t('toast_pdf_failed', e.message), 'error');
|
|
} finally {
|
|
if (btn) { btn.innerHTML = '<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> PDF'; btn.disabled = false; }
|
|
}
|
|
}
|
|
|
|
function triggerDownload(filename, mime, content) {
|
|
const blob = new Blob([content], { type: mime });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url; a.download = filename; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ── Import / Update Data tab ──────────────────────────────────────────────────
|
|
|
|
let importFile = null;
|
|
|
|
function populateImportEntitySelect() {
|
|
if (!activeClient) return;
|
|
const options = activeClient.data.entities.map(e =>
|
|
`<option value="${escHtml(e.id)}">${escHtml(e.label)}</option>`
|
|
).join('');
|
|
document.getElementById('importEntitySel').innerHTML =
|
|
`<option value="">${escHtml(t('upload_placeholder'))}</option>` + options;
|
|
document.getElementById('syncEntitySel').innerHTML =
|
|
`<option value="">${escHtml(t('sync_all'))}</option>` + options;
|
|
}
|
|
|
|
function onFileSelected(file) {
|
|
if (!file) return;
|
|
importFile = file;
|
|
const dz = document.getElementById('dropZone');
|
|
dz.classList.add('has-file');
|
|
dz.classList.remove('drag-over');
|
|
document.getElementById('dropZoneText').textContent =
|
|
`${file.name} (${(file.size / 1024).toFixed(0)} KB)`;
|
|
}
|
|
|
|
function handleFileDrop(e) {
|
|
e.preventDefault();
|
|
document.getElementById('dropZone').classList.remove('drag-over');
|
|
const file = e.dataTransfer.files[0];
|
|
if (file) onFileSelected(file);
|
|
}
|
|
|
|
async function runBoxSync() {
|
|
const btn = document.getElementById('syncRunBtn');
|
|
const log = document.getElementById('syncLog');
|
|
const entityId = document.getElementById('syncEntitySel').value;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span style="opacity:0.7;">Syncing…</span>';
|
|
log.style.display = '';
|
|
log.textContent = entityId
|
|
? `Syncing ${activeClient.data.entities.find(e => e.id === entityId)?.label || entityId}…\n`
|
|
: 'Running converter (all entities)…\n';
|
|
|
|
try {
|
|
const body = entityId ? JSON.stringify({ entity_id: entityId }) : undefined;
|
|
const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/sync`, {
|
|
method: 'POST',
|
|
headers: body ? { 'Content-Type': 'application/json' } : {},
|
|
body,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
log.textContent = `Error:\n${data.detail || data.error}`;
|
|
showToast(t('toast_sync_failed'), 'error');
|
|
} else {
|
|
log.textContent = (data.log || 'Done.').trim();
|
|
showToast(t('toast_sync_complete'), 'success');
|
|
setTimeout(async () => {
|
|
await loadClient(activeClient.config.id);
|
|
showTab('entities');
|
|
}, 1000);
|
|
}
|
|
} catch (e) {
|
|
log.textContent = 'Network error: ' + e.message;
|
|
showToast(t('toast_sync_failed') + ': ' + e.message, 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/>
|
|
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15"/>
|
|
</svg> Sync from Box`;
|
|
}
|
|
}
|
|
|
|
async function runFileImport() {
|
|
const entityId = document.getElementById('importEntitySel').value;
|
|
if (!entityId) { showToast(t('toast_select_entity'), 'error'); return; }
|
|
if (!importFile) { showToast(t('toast_select_file'), 'error'); return; }
|
|
|
|
const btn = document.getElementById('importRunBtn');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Importing…';
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', importFile);
|
|
formData.append('entity_id', entityId);
|
|
|
|
try {
|
|
const res = await fetch(`${API_BASE}/api/clients/${activeClient.config.id}/import/file`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showToast(t('toast_import_failed', data.error || res.statusText), 'error');
|
|
} else {
|
|
const e = data.entity;
|
|
showToast(t('toast_import_done', e.label, e.overall_score.toFixed(2), e.overall_label), 'success');
|
|
await loadClient(activeClient.config.id);
|
|
showTab('entities');
|
|
}
|
|
} catch (e) {
|
|
showToast(t('toast_import_failed', e.message), 'error');
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `
|
|
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
|
|
<polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>
|
|
</svg> ${escHtml(t('upload_btn'))}`;
|
|
}
|
|
}
|
|
|
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
function toggleTheme() {
|
|
const isLight = document.body.classList.toggle('light');
|
|
localStorage.setItem('maturityTheme', isLight ? 'light' : 'dark');
|
|
document.getElementById('iconDark').style.display = isLight ? 'none' : '';
|
|
document.getElementById('iconLight').style.display = isLight ? '' : 'none';
|
|
}
|
|
|
|
function applyStoredTheme() {
|
|
if (localStorage.getItem('maturityTheme') === 'light') {
|
|
document.body.classList.add('light');
|
|
document.getElementById('iconDark').style.display = 'none';
|
|
document.getElementById('iconLight').style.display = '';
|
|
}
|
|
}
|
|
|
|
// ── Utilities ─────────────────────────────────────────────────────────────────
|
|
function escHtml(s) {
|
|
return String(s ?? '').replace(/&/g, '&').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 ─────────────────────────────────────────────────────────────────────
|
|
document.getElementById('loginForm').addEventListener('submit', handleLoginSubmit);
|
|
init();
|