Baseline: EN/FR toggle + question topic translations

Working state before improvements branch:
- Language toggle (EN/FR) for ADEO
- 59 question topics translated to French
- About tab translated and toggle visible on home screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Phil Dore 2026-04-29 16:26:29 +01:00
parent faf2e3be66
commit 8d0a618b68
5 changed files with 738 additions and 151 deletions

View file

@ -43,5 +43,169 @@
"3": "Systematic, consistent, and well-evidenced capability",
"4": "Best-in-class — automated, measured, and continuously improved"
}
},
"question_topics": [
"Across all on/off content, how the unique brand promise is identifiable",
"As part of the same campaign, how the brand identity and message is preserved across all media and content",
"How messages and assets are adapted to the specificities of each channel and format",
"How assets comply with platform-specific guidelines and requirements",
"How assets are designed for efficiency and platform specifications (responsive, vertical)",
"How similar/complementary assets are used across channels for continuous narration",
"How content consistency is maintained across channels",
"The ability to produce high-quality content across various formats (print, digital, video, audio)",
"The strategic approach to paid and organic content, resource allocation, and optimization",
"How the brand promise has been designed to answer what the customer need",
"How the audiences and persona have been chosen in the strategy according to their availability and how precise they are",
"How detailed the needs are, for each persona",
"How the chosen media channels effectively match the actual online habits and platform preferences of the target persona",
"How the specific content formats used are appropriate and engaging for the target personae on each particular channel",
"How well content is tailored to individual users based on their specific data (e.g., name, past purchases, browsing history) to create a more unique and relevant experience beyond broad persona targeting",
"If the brand's official definition of its target customer (i.e., the persona) is treated as a living document, regularly updated with new performance data, customer feedback, and market trends",
"This examines how often (frequency) and how thoroughly (depth) the brand measures its health and market perception. Depth refers to the specific metrics tracked, such as awareness, sentiment, purchase intent, and competitive positioning.",
"Whether there is a consistent and formalized process for testing creative concepts before a full launch (pre-test) and for measuring their impact and effectiveness after a campaign has run (post-test).",
"The ability to go beyond standard metrics (like clicks) to understand why certain creative assets succeed. It involves analysing specific elements like visuals, copy, and calls-to-action to derive actionable insights for future content",
"The proficiency in using brand lift studies to measure the direct impact of advertising campaigns on key brand metrics, such as ad recall, brand awareness, and purchase intent, and the ability to apply these insights effectively",
"The tracking of metrics that measure how deeply an audience interacts with content. It focuses on indicators of genuine interest, such as video view-through rates (VTR), article scroll depth, or time-on-page, rather than just surface-level actions.",
"The process for tracking and managing the resources invested in content creation. It covers both the efficiency of time spent (e.g., man-hours per asset) and the direct financial expenses (e.g., shooting, design, licensing costs)",
"The tracking of production efficiency, including time-to-market, cost per asset, and resource utilisation",
"The use of automated tools (like Creative X) that systematically check creative assets to ensure they comply with brand guidelines, legal disclaimers, and platform-specific ad policies before publication.",
"The company's Digital Asset Management (DAM) system, which is the central library for all creative assets. It evaluates both its availability (how easily and widely it's accessed by teams and partners) and its functional depth (its range of features beyond simple storage, like advanced search, rights management, and software integrations)",
"The tool used to manage and distribute product data to e-commerce channels (like Google Shopping or Facebook Ads). It evaluates its availability to all relevant teams and its functional depth—the ability to customise, optimise, and analyse product feeds for various platforms.",
"This assesses the company's Dynamic Creative Optimization (DCO) technology, which automates the creation of personalised ads. It evaluates its availability (who can access and operate the platform) and its functional depth—the sophistication of its features for creating, testing, and personalising ads in real-time based on data.",
"The platform used for Creative Intelligence, which analyses creative elements to provide data-driven insights on performance. It evaluates the tool's availability (who can access its dashboards and reports) and its functional depth—its ability to move beyond reporting to offer predictive analytics and actionable recommendations for future creative",
"The platforms used for team collaboration on marketing projects (e.g., project management, creative review). It evaluates their availability (are they standardised and accessible to all internal teams and external partners?) and their functional depth—the advanced features that streamline workflows, such as integrated proofing and approval cycles.",
"The company's centralised platform or preferred marketplace for sourcing and licensing creative assets (e.g., stock photography, illustrations, video). It evaluates how this system streamlines the purchasing process, manages budgets, and tracks usage rights.",
"The comprehensive set of technologies and tools used for content production across all formats and channels",
"The availability and proficiency with creative software and tools for content creation and editing",
"The use of automation tools to streamline production workflows and reduce manual processes",
"The use of standardised templates for creating off-line marketing assets (e.g., print ads, in-store posters, brochures). It evaluates the extent to which a system of templates is used to maintain brand consistency and streamline the creation of localised or campaign-specific materials.",
"The system of standardised templates for creating online assets (e.g., social media posts, display ads, email newsletters). It evaluates how this system ensures brand consistency and supports the rapid, scalable production of content for digital channels.",
"The technical capability to automatically generate multiple variations of a creative asset by dynamically inserting personalised elements. This includes tailoring content based on user data, location, browsing behaviour, or a product feed.",
"The use of technology to automatically publish or send final creative assets to various marketing channels. This includes pushing content directly from a central library to ad platforms, social media schedulers, or a Content Management System (CMS) without manual uploads.",
"How seamlessly the validation (approval), production (creation/personalisation), and distribution (publishing) stages are connected through automation. It measures the degree to which assets flow through this entire lifecycle with minimal manual intervention",
"The level of automation in production workflows from concept to final delivery",
"The use of templates and standardisation to ensure consistency and efficiency in production",
"The ability to process multiple assets or content pieces simultaneously for efficiency",
"The use of automated systems to ensure quality control and compliance in production",
"How generative AI tools are integrated into the content creation process. It examines its use across different stages, from brainstorming and copywriting to image generation, as well as the existence of formal guidelines governing its ethical and on-brand application",
"The company's commitment to experimenting with new content types (emerging formats) like interactive AR, virtual world experiences, or new social video styles. It evaluates the volume (frequency and scale of tests) and depth (the rigor of analysis) of this experimentation to find future opportunities.",
"How Creative Test & Learn is an ongoing, systematic process embedded in the marketing workflow. It evaluates if there is a regular cadence for testing creative hypotheses, gathering insights, and applying those learnings to consistently optimise future assets",
"The capability to identify, evaluate, and adopt emerging technologies for production and creative processes",
"The systematic approach to experimenting with new creative concepts and production methods",
"The management of innovation projects from ideation to implementation",
"The strategic planning for future production and creative capabilities",
"The existence, quality, and accessibility of documentation designed to guide marketing and creative execution. It covers foundational brand standards, tactical checklists for processes, and practical guides (playbooks) for specific tasks.",
"How effectively the organisation leverages its network of external partners, including agencies, tech vendors, and freelancers. It evaluates whether these relationships are purely transactional or are treated as a strategic ecosystem to drive innovation, share insights, and create greater value.",
"The effectiveness and consistency of the recurring practices (rituals) that facilitate collaboration between central (corporate) teams and individual Business Units (BUs). It evaluates whether these processes are well-defined and purposeful, ensuring alignment between global strategy and local market execution",
"Whether these teams work in integrated workflows with shared goals or operate in isolated departmental silos.",
"How formally the RACI (Responsible, Accountable, Consulted, Informed) framework is used to define roles and responsibilities for key operational tasks. It evaluates whether these role assignments are clearly documented, up-to-date, and consistently followed to avoid confusion and ensure clear ownership.",
"The team's collective capabilities, evaluating the presence and strength of both strategic skills (e.g., long-term planning, data analysis, brand strategy) and operational skills (e.g., platform expertise, content creation, campaign management). It aims to identify any critical talent or skill gaps.",
"The organisational structure and role definitions for production and creative teams",
"The programs and processes for developing production and creative skills within the organisation",
"The processes and tools for collaboration within and across production and creative teams",
"The capability to manage and adapt to changes in processes, technologies, and organizational structure"
],
"translations": {
"fr": {
"description": "Évaluation de la maturité du contenu",
"entity_label": "Marchés",
"entity_label_singular": "Marché",
"scoring": {
"labels": {
"1": "Débutant",
"2": "Intermédiaire",
"3": "Maîtrisé",
"4": "Expert"
}
},
"pillars": [
"OMNICANAL",
"CENTRICITÉ CLIENT",
"MESURE",
"CAPACITÉS TECHNOLOGIQUES",
"AUTOMATISATION & INDUSTRIALISATION",
"INNOVATION",
"ORGANISATION"
],
"about": {
"summary": "Un audit de 59 questions réparties sur 7 piliers qui évalue le niveau de sophistication de chaque Business Unit dans la production et la gestion du contenu marketing — indiquant à ADEO exactement où se situe chaque marque et où concentrer les investissements.",
"pillar_descriptions": {
"OMNICANAL": "Le contenu fonctionne-t-il de manière cohérente sur TV, réseaux sociaux, print, digital, etc. ?",
"CENTRICITÉ CLIENT": "Le contenu est-il adapté à des types de clients et des comportements spécifiques ?",
"MESURE": "Les performances du contenu sont-elles correctement suivies ?",
"CAPACITÉS TECHNOLOGIQUES": "Quels outils et plateformes sont utilisés (ex. DAM, DCO, Figma) ?",
"AUTOMATISATION & INDUSTRIALISATION": "Dans quelle mesure le workflow de production de contenu est-il automatisé ?",
"INNOVATION": "L'IA est-elle utilisée, de nouveaux formats testés et des expériences menées ?",
"ORGANISATION": "L'équipe, les processus et le partage des connaissances sont-ils bien structurés ?"
},
"scoring_descriptions": {
"1": "Rien de systématique — les choses se produisent de manière ad hoc ou pas du tout",
"2": "Des capacités existent, mais elles sont incohérentes ou non documentées",
"3": "Capacité systématique, cohérente et bien documentée",
"4": "Meilleure pratique — automatisée, mesurée et continuellement améliorée"
}
},
"question_topics": [
"Comment la promesse de marque unique est identifiable dans tout le contenu on/off",
"Dans le cadre d'une même campagne, comment l'identité et le message de marque sont préservés sur tous les médias et contenus",
"Comment les messages et les assets sont adaptés aux spécificités de chaque canal et format",
"Comment les assets respectent les directives et exigences spécifiques à chaque plateforme",
"Comment les assets sont conçus pour l'efficacité et les spécifications des plateformes (responsive, vertical)",
"Comment des assets similaires/complémentaires sont utilisés sur différents canaux pour une narration continue",
"Comment la cohérence du contenu est maintenue d'un canal à l'autre",
"La capacité à produire du contenu de haute qualité dans différents formats (print, digital, vidéo, audio)",
"L'approche stratégique du contenu payant et organique, l'allocation des ressources et l'optimisation",
"Comment la promesse de marque a été conçue pour répondre aux besoins des clients",
"Comment les audiences et les personas ont été choisis dans la stratégie selon leur disponibilité et leur précision",
"Le niveau de détail des besoins pour chaque persona",
"Comment les canaux médias choisis correspondent aux habitudes en ligne et aux préférences de plateforme du persona cible",
"Comment les formats de contenu utilisés sont appropriés et engageants pour les personas cibles sur chaque canal",
"Dans quelle mesure le contenu est personnalisé pour chaque utilisateur selon ses données spécifiques (ex. nom, achats passés, historique de navigation) au-delà du ciblage par persona",
"Si la définition officielle du client cible (le persona) est traitée comme un document vivant, régulièrement mis à jour avec de nouvelles données de performance, retours clients et tendances du marché",
"Fréquence et profondeur avec lesquelles la marque mesure sa santé et sa perception sur le marché, incluant les indicateurs tels que la notoriété, le sentiment, l'intention d'achat et le positionnement concurrentiel.",
"L'existence d'un processus cohérent et formalisé pour tester les concepts créatifs avant un lancement complet (pré-test) et mesurer leur impact et leur efficacité après une campagne (post-test).",
"La capacité à aller au-delà des indicateurs standards (comme les clics) pour comprendre pourquoi certains assets créatifs réussissent, en analysant des éléments spécifiques comme les visuels, les textes et les appels à l'action.",
"La maîtrise des études de brand lift pour mesurer l'impact direct des campagnes publicitaires sur des indicateurs clés de marque, tels que le rappel publicitaire, la notoriété et l'intention d'achat.",
"Le suivi des indicateurs mesurant la profondeur d'interaction du public avec le contenu, en se concentrant sur des indicateurs d'intérêt réel tels que les taux de visionnage vidéo (VTR), la profondeur de défilement ou le temps passé sur la page.",
"Le processus de suivi et de gestion des ressources investies dans la création de contenu, couvrant l'efficacité du temps passé (ex. heures/asset) et les dépenses financières directes (ex. shooting, design, licences).",
"Le suivi de l'efficacité de la production, incluant le délai de mise sur le marché, le coût par asset et l'utilisation des ressources.",
"L'utilisation d'outils automatisés (comme Creative X) qui vérifient systématiquement les assets créatifs pour s'assurer de leur conformité aux chartes de marque, mentions légales et politiques publicitaires des plateformes avant publication.",
"Le système de gestion des assets digitaux (DAM) de l'entreprise, bibliothèque centrale de tous les assets créatifs. Évalue sa disponibilité (accessibilité pour les équipes et partenaires) et sa profondeur fonctionnelle (recherche avancée, gestion des droits, intégrations logicielles).",
"L'outil de gestion et de distribution des données produits vers les canaux e-commerce (ex. Google Shopping, Facebook Ads). Évalue sa disponibilité pour toutes les équipes concernées et sa profondeur fonctionnelle — capacité à personnaliser, optimiser et analyser les flux produits.",
"Évalue la technologie de Dynamic Creative Optimization (DCO) de l'entreprise, qui automatise la création de publicités personnalisées. Mesure sa disponibilité (qui peut accéder à la plateforme) et sa profondeur fonctionnelle — sophistication des fonctionnalités de création, test et personnalisation en temps réel.",
"La plateforme de Creative Intelligence qui analyse les éléments créatifs pour fournir des informations basées sur les données. Évalue sa disponibilité (accès aux tableaux de bord) et sa profondeur fonctionnelle — capacité à aller au-delà du reporting vers l'analyse prédictive et les recommandations actionnables.",
"Les plateformes de collaboration d'équipe sur les projets marketing (ex. gestion de projet, révision créative). Évalue leur disponibilité (standardisation et accessibilité pour toutes les équipes et partenaires) et leur profondeur fonctionnelle — fonctionnalités avancées comme les cycles de validation et d'approbation intégrés.",
"La plateforme centralisée ou la marketplace préférée de l'entreprise pour sourcer et licencier des assets créatifs (ex. photothèques, illustrations, vidéos). Évalue comment ce système optimise le processus d'achat, gère les budgets et suit les droits d'utilisation.",
"L'ensemble des technologies et outils utilisés pour la production de contenu dans tous les formats et canaux",
"La disponibilité et la maîtrise des logiciels et outils créatifs pour la création et l'édition de contenu",
"L'utilisation d'outils d'automatisation pour optimiser les workflows de production et réduire les processus manuels",
"L'utilisation de modèles standardisés pour créer des assets marketing hors ligne (ex. publicités print, affiches en magasin, brochures). Évalue dans quelle mesure un système de modèles est utilisé pour maintenir la cohérence de marque et simplifier la création de supports localisés.",
"Le système de modèles standardisés pour créer des assets en ligne (ex. posts réseaux sociaux, display ads, newsletters). Évalue comment ce système garantit la cohérence de marque et soutient la production rapide et évolutive de contenu pour les canaux digitaux.",
"La capacité technique à générer automatiquement plusieurs variations d'un asset créatif en insérant dynamiquement des éléments personnalisés, selon les données utilisateur, la localisation, le comportement de navigation ou un flux produit.",
"L'utilisation de la technologie pour publier ou envoyer automatiquement les assets créatifs finaux vers divers canaux marketing, depuis une bibliothèque centrale vers les plateformes publicitaires, les planificateurs de réseaux sociaux ou un CMS, sans upload manuel.",
"La fluidité avec laquelle les étapes de validation (approbation), production (création/personnalisation) et distribution (publication) sont connectées par l'automatisation. Mesure le degré auquel les assets traversent ce cycle de vie avec un minimum d'intervention manuelle.",
"Le niveau d'automatisation des workflows de production, du concept à la livraison finale",
"L'utilisation de modèles et de standardisation pour garantir la cohérence et l'efficacité de la production",
"La capacité à traiter simultanément plusieurs assets ou contenus pour gagner en efficacité",
"L'utilisation de systèmes automatisés pour assurer le contrôle qualité et la conformité en production",
"Comment les outils d'IA générative sont intégrés dans le processus de création de contenu, depuis le brainstorming et la rédaction jusqu'à la génération d'images, ainsi que l'existence de directives formelles régissant leur application éthique et conforme à la marque.",
"L'engagement de l'entreprise à expérimenter de nouveaux types de contenu (formats émergents) comme la RA interactive, les expériences en monde virtuel ou les nouveaux formats vidéo sociaux. Évalue le volume (fréquence et échelle des tests) et la profondeur (rigueur de l'analyse) de cette expérimentation.",
"Dans quelle mesure le Test & Learn créatif est un processus systématique intégré au workflow marketing, avec une cadence régulière pour tester des hypothèses créatives, collecter des insights et les appliquer pour optimiser les futurs assets.",
"La capacité à identifier, évaluer et adopter les technologies émergentes pour les processus de production et de création",
"L'approche systématique pour expérimenter de nouveaux concepts créatifs et méthodes de production",
"La gestion des projets d'innovation, de l'idéation à la mise en œuvre",
"La planification stratégique des capacités futures de production et de création",
"L'existence, la qualité et l'accessibilité de la documentation destinée à guider l'exécution marketing et créative, couvrant les standards de marque fondamentaux, les checklists tactiques et les guides pratiques (playbooks) pour des tâches spécifiques.",
"L'efficacité avec laquelle l'organisation exploite son réseau de partenaires externes (agences, fournisseurs technologiques, freelances). Évalue si ces relations sont purement transactionnelles ou traitées comme un écosystème stratégique pour stimuler l'innovation et créer de la valeur.",
"L'efficacité et la régularité des pratiques récurrentes (rituels) qui facilitent la collaboration entre les équipes centrales et les Business Units. Évalue si ces processus sont bien définis et purposeful, assurant l'alignement entre la stratégie globale et l'exécution locale.",
"Si ces équipes travaillent dans des workflows intégrés avec des objectifs partagés ou fonctionnent en silos départementaux isolés.",
"Dans quelle mesure le cadre RACI (Responsable, Approbateur, Consulté, Informé) est formellement utilisé pour définir les rôles et responsabilités des tâches opérationnelles clés. Évalue si ces assignations sont clairement documentées, à jour et systématiquement respectées.",
"Les capacités collectives de l'équipe, évaluant la présence et la force des compétences stratégiques (ex. planification long terme, analyse de données, stratégie de marque) et opérationnelles (ex. expertise plateforme, création de contenu, gestion de campagnes). Vise à identifier les lacunes de compétences critiques.",
"La structure organisationnelle et la définition des rôles pour les équipes de production et création",
"Les programmes et processus de développement des compétences de production et création au sein de l'organisation",
"Les processus et outils de collaboration au sein et entre les équipes de production et de création",
"La capacité à gérer et à s'adapter aux changements de processus, de technologies et de structure organisationnelle"
]
}
}
}

262
i18n/ui.js Normal file
View file

@ -0,0 +1,262 @@
'use strict';
// UI string catalogue — EN + FR
// Loaded as a plain <script> before script.js. Access via window.UI_STRINGS or the t() helper.
window.UI_STRINGS = {
en: {
header_title: 'Maturity Tool',
home_tab_clients: 'Clients',
home_tab_about: 'About this tool',
home_new_client: 'New Client',
home_new_client_sub:'Set up a new maturity assessment client',
home_open_arrow: 'Open →',
home_generated: d => `Generated ${d}`,
home_last_updated: d => `Last updated ${d}`,
home_explore: (n, lbl) => `Click below to explore the maturity scores, pillar breakdowns, and deliverables for all ${n} ${lbl}.`,
home_open_btn: (n, lbl) => `Open ${n} ${lbl}`,
summary_avg: 'Avg Score',
summary_highest: 'Highest',
summary_lowest: 'Lowest',
sort_by: 'Sort by:',
sort_overall: 'Overall Score',
group_by_brand: 'Group by brand',
card_questions: n => `${n} question${n !== 1 ? 's' : ''}`,
card_view_detail: 'View detail →',
card_issues: n => `${n} issue${n !== 1 ? 's' : ''}`,
back_to: lbl => `Back to ${lbl}`,
export_label: 'Export',
deliverables_label: 'Deliverables',
btn_summary_pdf: 'Summary PDF',
btn_scores_xlsx: 'Scores XLSX',
data_quality: 'Data quality',
pillar_breakdown: 'Pillar Breakdown',
field_score: 'Score',
field_rationale: 'Rationale',
field_gaps: 'Gaps Identified',
field_refs: 'References',
score_hint: 'Click a score to update it',
score_updated: (from, to) => `✓ Score updated ${from}${to}`,
compare_select: lbl => `Select ${lbl} to Compare`,
compare_btn: 'Compare Selected',
compare_clear: 'Clear',
compare_overall: 'OVERALL',
compare_legend_high:'Highest in row',
compare_legend_low: 'Lowest in row',
heatmap_title: 'Pillar Heatmap',
heatmap_pillar: 'Pillar',
heatmap_avg: 'Avg',
heatmap_overall: 'Overall',
sync_title: 'Sync from Box',
sync_desc: 'Re-run the data converter against the current source files in Box.',
sync_scope: 'Scope',
sync_all: 'All entities',
sync_btn_label: 'Sync from Box',
upload_title: 'Upload File',
upload_entity: 'Entity to update',
upload_placeholder: '-- Select entity --',
upload_file: 'File (CSV or XLSX)',
upload_drop: 'Drop a CSV or XLSX here, or click to browse',
upload_btn: 'Import',
upload_note: 'Updates scores only — preserves entity name & metadata',
export_all: 'Export all',
tab_compare: 'Compare',
tab_heatmap: 'Heatmap',
tab_update: 'Update Data',
about_what_is: 'What is this tool?',
about_maturity_levels:'Maturity Levels',
about_how_to: 'How to use this tool',
about_the_pillars: n => `The ${n} Pillars`,
about_assessment: 'About the Assessment',
about_scoring_scale:'Scoring Scale',
about_level: n => `Level ${n}`,
how_to_items: [
['Markets tab', 'Browse all markets sorted by overall score. Click any card to drill into pillar scores and individual question rationale.'],
['Heatmap tab', 'See all markets and pillars in one grid — instantly spot which pillars are strong or weak across the board.'],
['Compare tab', 'Select 2 or more markets to put them side by side. Green = highest score in that pillar, red = lowest.'],
['Radar chart', "Each market detail view shows a spider chart of its 7 pillar scores — useful for understanding the shape of a market's maturity."],
['⚠ Flag badge', 'An amber warning on a card means that market has incomplete data (missing scores, rationale, or gap analysis). Click the card to see detail.'],
['Update Data tab', 'Re-sync data from Box source files or upload a new CSV/XLSX directly.'],
],
overview_generated: d => `Data generated ${d} · Click any row to view details`,
overview_view: lbl => `View ${lbl}`,
overview_compare: lbl => `Compare ${lbl}`,
flag_missing_score: n => `${n} question${n !== 1 ? 's' : ''} missing score`,
flag_missing_rationale: n => `${n} question${n !== 1 ? 's' : ''} missing rationale`,
flag_missing_gaps: n => `${n} questions missing gap analysis`,
flag_stale: days => `Data last synced ${days} days ago`,
toast_load_failed: 'Failed to load data',
toast_connect_failed: 'Could not connect to server',
toast_client_failed: 'Failed to load client data',
toast_overview_failed: 'Failed to load overview',
toast_select_two: 'Select at least 2 to compare',
toast_score_saved: (q, score, lbl) => `Q${q} score saved: ${score} (${lbl})`,
toast_save_failed: 'Save failed — changes kept in memory only',
toast_sync_complete: 'Sync complete — reloading…',
toast_sync_failed: 'Sync failed',
toast_select_entity: 'Select an entity first',
toast_select_file: 'Select a file to upload',
toast_import_done: (lbl, score, lvl) => `Imported ${lbl}: ${score} (${lvl})`,
toast_import_failed: msg => `Import failed: ${msg}`,
toast_pdf_failed: msg => `PDF failed: ${msg}`,
toast_pdf_downloaded: 'PDF downloaded',
toast_xlsx_downloaded: 'XLSX downloaded',
toast_csv_downloaded: 'CSV downloaded',
wiz_client_required: 'Client name is required',
wiz_id_failed: 'Could not derive a client ID from that name',
wiz_pillar_required: 'Add at least one pillar',
wiz_create_failed: msg => `Failed to create: ${msg}`,
wiz_created: name => `"${name}" created! Set up data in the Update tab.`,
pillar_short: {
'OMNICHANNEL': 'OMNI',
'CLIENT CENTRICITY': 'CLIENT',
'MEASUREMENT': 'MEASURE',
'TECH CAPABILITIES': 'TECH',
'AUTOMATION & INDUSTRIALIZATION': 'AUTO',
'INNOVATION': 'INNOV',
'ORGANISATION': 'ORG',
},
},
fr: {
header_title: 'Outil de Maturité',
home_tab_clients: 'Clients',
home_tab_about: "À propos de l'outil",
home_new_client: 'Nouveau client',
home_new_client_sub:"Configurer un nouveau client d'évaluation",
home_open_arrow: 'Ouvrir →',
home_generated: d => `Généré le ${d}`,
home_last_updated: d => `Dernière mise à jour : ${d}`,
home_explore: (n, lbl) => `Cliquez ci-dessous pour explorer les scores de maturité, les analyses par pilier et les livrables pour les ${n} ${lbl}.`,
home_open_btn: (n, lbl) => `Ouvrir ${n} ${lbl}`,
summary_avg: 'Score moyen',
summary_highest: 'Le plus haut',
summary_lowest: 'Le plus bas',
sort_by: 'Trier par :',
sort_overall: 'Score global',
group_by_brand: 'Grouper par marque',
card_questions: n => `${n} question${n !== 1 ? 's' : ''}`,
card_view_detail: 'Voir le détail →',
card_issues: n => `${n} problème${n !== 1 ? 's' : ''}`,
back_to: lbl => `Retour aux ${lbl}`,
export_label: 'Exporter',
deliverables_label: 'Livrables',
btn_summary_pdf: 'Résumé PDF',
btn_scores_xlsx: 'Scores XLSX',
data_quality: 'Qualité des données',
pillar_breakdown: 'Analyse par pilier',
field_score: 'Score',
field_rationale: 'Justification',
field_gaps: 'Lacunes identifiées',
field_refs: 'Références',
score_hint: 'Cliquez sur un score pour le mettre à jour',
score_updated: (from, to) => `✓ Score mis à jour ${from}${to}`,
compare_select: lbl => `Sélectionner des ${lbl} à comparer`,
compare_btn: 'Comparer la sélection',
compare_clear: 'Effacer',
compare_overall: 'GLOBAL',
compare_legend_high:'Le plus élevé de la ligne',
compare_legend_low: 'Le plus bas de la ligne',
heatmap_title: 'Carte thermique par pilier',
heatmap_pillar: 'Pilier',
heatmap_avg: 'Moy.',
heatmap_overall: 'Global',
sync_title: 'Synchroniser depuis Box',
sync_desc: 'Relancer le convertisseur de données sur les fichiers source actuels dans Box.',
sync_scope: 'Périmètre',
sync_all: 'Toutes les entités',
sync_btn_label: 'Synchroniser depuis Box',
upload_title: 'Importer un fichier',
upload_entity: 'Entité à mettre à jour',
upload_placeholder: '-- Sélectionner une entité --',
upload_file: 'Fichier (CSV ou XLSX)',
upload_drop: 'Déposez un CSV ou XLSX ici, ou cliquez pour parcourir',
upload_btn: 'Importer',
upload_note: 'Met à jour les scores uniquement — préserve le nom et les métadonnées',
export_all: 'Tout exporter',
tab_compare: 'Comparer',
tab_heatmap: 'Carte thermique',
tab_update: 'Mise à jour',
about_what_is: "Qu'est-ce que cet outil ?",
about_maturity_levels:'Niveaux de maturité',
about_how_to: 'Comment utiliser cet outil',
about_the_pillars: n => `Les ${n} piliers`,
about_assessment: "À propos de l'évaluation",
about_scoring_scale:"Échelle de notation",
about_level: n => `Niveau ${n}`,
how_to_items: [
['Onglet Marchés', 'Parcourez tous les marchés classés par score global. Cliquez sur une carte pour explorer les scores par pilier et la justification de chaque question.'],
['Onglet Carte thermique', 'Visualisez tous les marchés et piliers dans une grille — repérez instantanément les piliers forts ou faibles.'],
['Onglet Comparer', 'Sélectionnez 2 marchés ou plus pour les comparer côte à côte. Vert = score le plus élevé, rouge = le plus bas.'],
['Graphique radar', "La vue détaillée de chaque marché affiche un graphique araignée de ses 7 scores par pilier."],
["⚠ Indicateur d'alerte", "Un avertissement orange sur une carte signifie que ce marché a des données incomplètes. Cliquez pour voir les détails."],
['Onglet Mise à jour', 'Resynchronisez les données depuis Box ou importez un nouveau CSV/XLSX directement.'],
],
overview_generated: d => `Données générées le ${d} · Cliquez sur une ligne pour voir les détails`,
overview_view: lbl => `Voir les ${lbl}`,
overview_compare: lbl => `Comparer les ${lbl}`,
flag_missing_score: n => `${n} question${n !== 1 ? 's' : ''} sans score`,
flag_missing_rationale: n => `${n} question${n !== 1 ? 's' : ''} sans justification`,
flag_missing_gaps: n => `${n} questions sans analyse des lacunes`,
flag_stale: days => `Données synchronisées il y a ${days} jours`,
toast_load_failed: 'Échec du chargement des données',
toast_connect_failed: 'Impossible de se connecter au serveur',
toast_client_failed: 'Échec du chargement des données client',
toast_overview_failed: "Échec du chargement de l'aperçu",
toast_select_two: 'Sélectionnez au moins 2 éléments à comparer',
toast_score_saved: (q, score, lbl) => `Score Q${q} enregistré : ${score} (${lbl})`,
toast_save_failed: 'Enregistrement échoué — modifications conservées en mémoire',
toast_sync_complete: 'Synchronisation terminée — rechargement…',
toast_sync_failed: 'Synchronisation échouée',
toast_select_entity: "Sélectionnez d'abord une entité",
toast_select_file: 'Sélectionnez un fichier à importer',
toast_import_done: (lbl, score, lvl) => `Importé ${lbl} : ${score} (${lvl})`,
toast_import_failed: msg => `Importation échouée : ${msg}`,
toast_pdf_failed: msg => `Erreur PDF : ${msg}`,
toast_pdf_downloaded: 'PDF téléchargé',
toast_xlsx_downloaded: 'XLSX téléchargé',
toast_csv_downloaded: 'CSV téléchargé',
wiz_client_required: 'Le nom du client est requis',
wiz_id_failed: 'Impossible de dériver un identifiant client de ce nom',
wiz_pillar_required: 'Ajoutez au moins un pilier',
wiz_create_failed: msg => `Échec de la création : ${msg}`,
wiz_created: name => `« ${name} » créé ! Configurez les données dans l'onglet Mise à jour.`,
pillar_short: {
'OMNICANAL': 'OMNI',
'CENTRICITÉ CLIENT': 'CLIENT',
'MESURE': 'MESURE',
'CAPACITÉS TECHNOLOGIQUES': 'TECH',
'AUTOMATISATION & INDUSTRIALISATION': 'AUTO',
'INNOVATION': 'INNOV',
'ORGANISATION': 'ORG',
},
},
};

View file

@ -456,6 +456,9 @@
</div>
<div style="display:flex;align-items:center;gap:8px;">
<span id="userBadge" style="font-size:11px;color:var(--text-muted);display:none;"></span>
<button id="langToggle" onclick="toggleLang()" onmouseenter="this.style.borderColor='var(--accent)';this.style.color='var(--accent)';" onmouseleave="this.style.borderColor='var(--border)';this.style.color='var(--text-muted)';" style="display:none;background:none;border:1px solid var(--border);border-radius:8px;padding:5px 11px;font-size:12px;font-weight:700;letter-spacing:0.06em;color:var(--text-muted);cursor:pointer;transition:all 0.2s;" title="Switch language / Changer de langue">
<span id="langToggleLabel">FR</span>
</button>
<button class="theme-toggle" onclick="toggleTheme()" id="themeToggle" title="Toggle theme">
<svg id="iconDark" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
<svg id="iconLight" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
@ -502,7 +505,7 @@
<div id="cardControls" style="display:none;margin-bottom:14px;align-items:center;gap:10px;flex-wrap:wrap;"></div>
<!-- Export row (all entities) -->
<div id="exportRow" style="display:none;margin-bottom:16px;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-right:4px;">Export all</span>
<span id="exportAllLabel" style="font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.07em;color:var(--text-muted);margin-right:4px;">Export all</span>
<button class="btn-ghost" onclick="exportCsv()" title="Download flat CSV of all entities">
<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>
CSV
@ -535,12 +538,12 @@
<!-- Sync from Box -->
<div class="panel fade-up">
<p class="section-header">Sync from Box</p>
<p style="font-size:13px;color:var(--text-sub);margin:0 0 14px;line-height:1.65;">
<p class="section-header" id="syncTitle">Sync from Box</p>
<p id="syncDesc" style="font-size:13px;color:var(--text-sub);margin:0 0 14px;line-height:1.65;">
Re-run the data converter against the current source files in Box.
</p>
<div style="margin-bottom:14px;">
<label class="field-label" for="syncEntitySel">Scope</label>
<label class="field-label" id="syncScopeLabel" for="syncEntitySel">Scope</label>
<select id="syncEntitySel" class="field-select">
<option value="">All entities</option>
</select>
@ -554,15 +557,15 @@
<!-- Upload File -->
<div class="panel fade-up" style="animation-delay:40ms;">
<p class="section-header">Upload File</p>
<p class="section-header" id="uploadTitle">Upload File</p>
<div style="margin-bottom:14px;">
<label class="field-label" for="importEntitySel">Entity to update</label>
<label class="field-label" id="uploadEntityLabel" for="importEntitySel">Entity to update</label>
<select id="importEntitySel" class="field-select">
<option value="">-- Select entity --</option>
</select>
</div>
<div style="margin-bottom:18px;">
<label class="field-label">File (CSV or XLSX)</label>
<label class="field-label" id="uploadFileLabel">File (CSV or XLSX)</label>
<div class="drop-zone" id="dropZone"
onclick="document.getElementById('importFileInput').click()"
ondragover="event.preventDefault();this.classList.add('drag-over')"
@ -578,7 +581,7 @@
<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>
Import
</button>
<span style="font-size:11px;color:var(--text-muted);">Updates scores only — preserves entity name &amp; metadata</span>
<span id="uploadNote" style="font-size:11px;color:var(--text-muted);">Updates scores only — preserves entity name &amp; metadata</span>
</div>
</div>
@ -630,6 +633,7 @@
<!-- ══ TOAST ═════════════════════════════════════════════════════════════════ -->
<div id="toast"></div>
<script src="i18n/ui.js"></script>
<script src="script.js?v=2"></script>
</body>
</html>

440
script.js
View file

@ -32,7 +32,134 @@ const PILLAR_SHORT = {
};
function pillarShort(name) {
return PILLAR_SHORT[name] || name.slice(0, 6).toUpperCase();
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 ────────────────────────────────────────────────────────
@ -42,20 +169,20 @@ function getEntityFlags(entity) {
const zeroScore = qs.filter(q => !q.score || q.score === 0);
if (zeroScore.length > 0)
flags.push({ icon: '✕', label: `${zeroScore.length} question${zeroScore.length > 1 ? 's' : ''} missing score` });
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: `${noRationale.length} question${noRationale.length > 1 ? 's' : ''} missing rationale` });
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: `${noGaps.length} questions missing gap analysis` });
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: `Data last synced ${days} days ago` });
flags.push({ icon: '⏱', label: t('flag_stale', days) });
}
return flags;
@ -82,10 +209,15 @@ async function init() {
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 = 'Could not connect to server';
showToast('Failed to load data', 'error');
document.getElementById('headerSub').textContent = t('toast_connect_failed');
showToast(t('toast_load_failed'), 'error');
}
}
@ -176,13 +308,13 @@ function renderAbout() {
const scoreColors = { 1:'#C62828', 2:'#E65100', 3:'#2E7D32', 4:'#1B5E20' };
const levelRows = scoring ? Object.entries(scoring.labels).map(([lvl, name]) => {
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(name)}</span>
<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);">Level ${escHtml(lvl)}</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>`;
@ -206,7 +338,7 @@ function renderAbout() {
<!-- Summary -->
<div class="panel fade-up" style="margin-bottom:16px;">
<p class="section-header">What is this tool?</p>
<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>
@ -222,21 +354,14 @@ function renderAbout() {
<!-- Scoring scale -->
<div class="about-card fade-up" style="animation-delay:40ms;">
<p class="section-header" style="margin-top:0;">Maturity Levels</p>
<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;">How to use this tool</p>
${[
['Markets tab', 'Browse all markets sorted by overall score. Click any card to drill into pillar scores and individual question rationale.'],
['Heatmap tab', 'See all markets and pillars in one grid — instantly spot which pillars are strong or weak across the board.'],
['Compare tab', 'Select 2 or more markets to put them side by side. Green = highest score in that pillar, red = lowest.'],
['Radar chart', 'Each market detail view shows a spider chart of its 7 pillar scores — useful for understanding the shape of a market\'s maturity.'],
['⚠ Flag badge', 'An amber warning on a card means that market has incomplete data (missing scores, rationale, or gap analysis). Click the card to see detail.'],
['Update Data tab', 'Re-sync data from Box source files or upload a new CSV/XLSX directly.'],
].map(([title, desc]) => `
<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>
@ -245,7 +370,7 @@ function renderAbout() {
<!-- Pillars -->
<div class="about-card fade-up" style="animation-delay:120ms;grid-column:1/-1;">
<p class="section-header" style="margin-top:0;">The ${(client && client.pillars || []).length} Pillars</p>
<p class="section-header" style="margin-top:0;">${escHtml(t('about_the_pillars', (client && client.pillars || []).length))}</p>
${pillarRows}
</div>
@ -259,7 +384,7 @@ function renderHome() {
document.getElementById('tabBar').style.display = 'none';
document.getElementById('homeScreen').style.display = '';
document.getElementById('clientView').style.display = 'none';
document.getElementById('headerTitle').textContent = 'Maturity Tool';
document.getElementById('headerTitle').textContent = t('header_title');
document.getElementById('headerSub').textContent = `${allClients.length} client${allClients.length !== 1 ? 's' : ''}`;
if (allClients.length === 1) {
@ -277,20 +402,20 @@ function renderHome() {
</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;">
Click below to explore the maturity scores, pillar breakdowns, and deliverables for all ${c.entity_count} ${escHtml(c.entity_label || 'markets')}.
${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;">
Open ${c.entity_count} ${escHtml(c.entity_label || 'Markets')}
${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;">Last updated ${escHtml(c.generated)}</p>
<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;">New Client</p>
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-align:center;line-height:1.4;">Set up a new maturity assessment client</p>
<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) => `
@ -307,8 +432,8 @@ function renderHome() {
<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);">Generated ${escHtml(c.generated)}</span>
<span style="font-size:12px;font-weight:600;color:${c.accent_color};">Open </span>
<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('') + `
@ -316,8 +441,8 @@ function renderHome() {
<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;">New Client</p>
<p style="font-size:12px;color:var(--text-muted);margin:6px 0 0;text-align:center;line-height:1.4;">Set up a new maturity assessment client</p>
<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>`;
}
}
@ -361,7 +486,7 @@ async function renderSingleClientHome(id) {
</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(e.overall_label)}</span>
<span class="badge ${lvl}" style="width:88px;text-align:center;flex-shrink:0;">${escHtml(scoreLabelDisplay(e.overall_level) || e.overall_label)}</span>
</div>`;
}).join('');
@ -389,15 +514,15 @@ async function renderSingleClientHome(id) {
</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">Avg Score</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;">Highest</div>
<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;">Lowest</div>
<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>
@ -407,21 +532,21 @@ async function renderSingleClientHome(id) {
<!-- Market score list -->
<div class="panel fade-up" style="margin-bottom:20px;animation-delay:60ms;">
<p class="section-header">${escHtml(config.entity_label || 'Entities')} Overview</p>
<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;">Data generated ${escHtml(data.generated)} · Click any row to view details</p>
<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;">
View ${escHtml(config.entity_label || 'Markets')}
${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;">
Compare ${escHtml(config.entity_label || 'Markets')}
${escHtml(t('overview_compare', cfg('entity_label') || config.entity_label || 'Markets'))}
</button>
</div>
@ -437,54 +562,72 @@ async function renderSingleClientHome(id) {
});
} catch (e) {
showToast('Failed to load overview', 'error');
showToast(t('toast_overview_failed'), 'error');
}
}
function renderAboutSection(config) {
const about = config.about;
const scoring = config.scoring;
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 => {
const desc = (about.pillar_descriptions || {})[pname] || '';
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(pname)}</p>
<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 desc = (about.scoring_descriptions || {})[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(scoring.labels[n])}</span>
<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">About the Assessment</p>
${about.summary ? `<p style="font-size:13px;color:var(--text-sub);margin:0 0 20px;line-height:1.7;">${escHtml(about.summary)}</p>` : ''}
<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;">
${about.question_count ? `${about.question_count} Questions across ` : ''}${config.pillars.length} Pillars
${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;">Scoring Scale</p>
<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();
}
@ -707,10 +850,10 @@ function wizardSaveStep() {
}
function wizardValidate() {
if (wizStep === 0 && !wizData.name) { showToast('Client name is required', 'error'); return false; }
if (wizStep === 0 && !wizData.id) { showToast('Could not derive a client ID from that name', 'error'); return false; }
if (wizStep === 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('Add at least one pillar', 'error'); return false;
showToast(t('wiz_pillar_required'), 'error'); return false;
}
return true;
}
@ -741,13 +884,13 @@ async function createClient() {
const data = await res.json();
if (!res.ok) throw new Error(data.error || res.statusText);
closeWizard();
showToast(`"${cfg.name}" created! Set up data in the Update tab.`, 'success');
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('Failed to create: ' + e.message, 'error');
showToast(t('wiz_create_failed', e.message), 'error');
btn.disabled = false; btn.textContent = '✓ Create Client';
}
}
@ -764,6 +907,9 @@ async function loadClient(id) {
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);
@ -772,7 +918,7 @@ async function loadClient(id) {
renderCompareSelector();
document.getElementById('exportRow').style.display = 'flex';
} catch (e) {
showToast('Failed to load client data', 'error');
showToast(t('toast_client_failed'), 'error');
}
}
@ -793,8 +939,11 @@ function enterClientView(config) {
document.getElementById('clientView').style.display = '';
// Update tab label to match entity type
const entityLabel = config.entity_label || 'Entities';
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');
}
@ -806,7 +955,7 @@ function showTab(name) {
document.getElementById(`tab-${t}`).classList.toggle('active', t === name);
});
activeTab = name;
if (name === 'update') populateImportEntitySelect();
if (name === 'update') { populateImportEntitySelect(); updateUpdateTabText(); }
if (name === 'heatmap') renderHeatmap();
}
@ -817,7 +966,7 @@ function renderSummaryBar() {
const avg = (scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(2);
const best = entities[0];
const worst = entities[entities.length - 1];
const entityLabel = activeClient.config.entity_label || 'Entities';
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;">
@ -827,15 +976,15 @@ function renderSummaryBar() {
</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">Avg Score</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;">Highest</div>
<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;">Lowest</div>
<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>
@ -853,18 +1002,18 @@ function renderCardControls() {
if (!controls) return;
const pillars = activeClient.config.pillars;
const pillarOpts = pillars.map(p =>
`<option value="${escHtml(p)}"${sortPillar === p ? ' selected' : ''}>${escHtml(p)}</option>`
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);">Sort by:</span>
<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' : ''}>Overall Score</option>
<option value="overall"${sortPillar === 'overall' ? ' selected' : ''}>${escHtml(t('sort_overall'))}</option>
${pillarOpts}
</select>
<button class="ctrl-btn${groupByGroup ? ' active' : ''}" id="groupBtn" onclick="onGroupToggle()">
Group by brand
${escHtml(t('group_by_brand'))}
</button>
`;
@ -968,11 +1117,12 @@ function buildEntityCard(entity, i) {
const flags = getEntityFlags(entity);
const flagTip = flags.map(f => f.label).join(' · ');
const pillarBars = pillars.map(pname => {
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 short = pillarShort(pname);
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>
@ -991,14 +1141,14 @@ function buildEntityCard(entity, i) {
<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(entity.overall_label)}</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);">${entity.pillars.reduce((s, p) => s + p.questions.length, 0)} questions</span>
${flags.length ? `<span class="entity-flag-badge" title="${escHtml(flagTip)}">${flags.length} issue${flags.length > 1 ? 's' : ''}</span>` : ''}
<span style="font-size:12px;color:var(--accent);font-weight:600;white-space:nowrap;">View detail </span>
<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>`;
}
@ -1064,14 +1214,15 @@ function radarSvg(entity, config) {
// 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';
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
const labelR = r + 22;
const lx = cx + labelR * Math.cos(angle);
const ly = cy + labelR * Math.sin(angle);
let anchor = 'middle';
if (lx < cx - 8) anchor = 'end';
else if (lx > cx + 8) anchor = 'start';
const short = pillarShort(pname);
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('');
@ -1095,11 +1246,13 @@ function openEntityDetail(entity) {
const lvlCls = scoreClass(entity.overall_level);
const bgCls = scoreBgClass(entity.overall_level);
const pillarAccordions = pillars.map(pname => {
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 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) => {
@ -1107,7 +1260,7 @@ function openEntityDetail(entity) {
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(q.topic)}</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('');
@ -1116,7 +1269,7 @@ function openEntityDetail(entity) {
<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(pname)}</span>
<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>
@ -1124,7 +1277,7 @@ function openEntityDetail(entity) {
<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(p.label)}</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>
@ -1136,16 +1289,16 @@ function openEntityDetail(entity) {
// 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);">Deliverables</span>
<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>
Summary PDF
${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>
Scores XLSX
${escHtml(t('btn_scores_xlsx'))}
</button>` : ''}
<span style="width:1px;height:20px;background:var(--border);margin:0 4px;flex-shrink:0;"></span>
` : '';
@ -1154,11 +1307,11 @@ function openEntityDetail(entity) {
<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>
Back to ${escHtml(activeClient.config.entity_label || 'entities')}
${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);">Export</span>
<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
@ -1178,7 +1331,7 @@ function openEntityDetail(entity) {
${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(entity.overall_label)}</span>
<span class="badge ${lvlCls}" style="font-size:10px;margin-top:5px;">${escHtml(scoreLabelDisplay(entity.overall_level) || entity.overall_label)}</span>
</div>
</div>
</div>
@ -1188,12 +1341,12 @@ function openEntityDetail(entity) {
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;">Data quality</span>
<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">Pillar Breakdown</p>
<p class="section-header">${escHtml(t('pillar_breakdown'))}</p>
<div id="pillarAccordion">${pillarAccordions}</div>
`;
@ -1225,20 +1378,21 @@ function openQuestionModal(entity, pillar, question) {
<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 = question.topic;
document.getElementById('qModalTitle').textContent = getQuestionTopic(question);
document.getElementById('qModalMeta').textContent = `Q${question.q_num} · ${pillar.name} · ${entity.label}`;
const fields = [
{ label: 'Rationale', value: question.rationale },
{ label: 'Gaps Identified', value: question.gaps || '—' },
{ label: 'References', value: question.refs || '—' },
{ 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 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;
@ -1246,18 +1400,18 @@ function openQuestionModal(entity, pillar, question) {
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(scoring.labels[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(scoring.labels[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">Score</span>
<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">Click a score to update it</p>
<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 => {
@ -1318,7 +1472,7 @@ async function updateScore(entityId, pillarName, qNum, newScore) {
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);">✓ Score updated ${oldScore}${newScore}</span>`;
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) {
@ -1337,9 +1491,9 @@ async function updateScore(entityId, pillarName, qNum, newScore) {
body: JSON.stringify(activeClient.data),
});
if (!res.ok) throw new Error(await res.text());
showToast(`Q${qNum} score saved: ${newScore} (${question.label})`, 'success');
showToast(t('toast_score_saved', qNum, newScore, scoreLabelDisplay(question.level) || question.label), 'success');
} catch (e) {
showToast('Save failed — changes kept in memory only', 'error');
showToast(t('toast_save_failed'), 'error');
}
}
@ -1357,7 +1511,7 @@ function renderCompareSelector() {
const entityLabel = activeClient.config.entity_label || 'Entities';
document.getElementById('compareSelector').innerHTML = `
<p class="section-header">Select ${escHtml(entityLabel)} to Compare</p>
<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);
@ -1369,15 +1523,15 @@ function renderCompareSelector() {
<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(e.overall_label)}</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()">Compare Selected</button>
<button class="btn-ghost" onclick="clearCompare()">Clear</button>
<button class="btn-primary" onclick="renderCompareTable()">${escHtml(t('compare_btn'))}</button>
<button class="btn-ghost" onclick="clearCompare()">${escHtml(t('compare_clear'))}</button>
</div>
`;
}
@ -1394,7 +1548,7 @@ function clearCompare() {
function renderCompareTable() {
if (selectedForCompare.size < 2) {
showToast('Select at least 2 to compare', 'error');
showToast(t('toast_select_two'), 'error');
return;
}
@ -1447,7 +1601,7 @@ function renderCompareTable() {
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);">OVERALL</td>
<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;">
@ -1456,12 +1610,12 @@ function renderCompareTable() {
}).join('')}
</tr>`;
const pillarRows = pillars.map(pname => {
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(pname, vals);
return buildRow(pillarDisplayName(pi), vals);
}).join('');
wrap.innerHTML = `
@ -1473,8 +1627,8 @@ function renderCompareTable() {
</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>Highest in row</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>Lowest in row</span>
<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>
`;
}
@ -1500,6 +1654,7 @@ function renderHeatmap() {
// 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>`;
@ -1519,7 +1674,7 @@ function renderHeatmap() {
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(pname)}</td>
<td style="font-weight:600;color:var(--text);min-width:180px;font-size:12px;padding:10px 14px;">${escHtml(displayName)}</td>
${cells}
${rowAvgCell}
</tr>`;
@ -1539,20 +1694,20 @@ function renderHeatmap() {
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;">Pillar Heatmap</p>
<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;">Pillar</th>
<th style="text-align:left;min-width:180px;">${escHtml(t('heatmap_pillar'))}</th>
${headerCells}
<th style="text-align:center;color:var(--text-muted);">Avg</th>
<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;">Overall</td>
<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>
@ -1562,7 +1717,8 @@ function renderHeatmap() {
<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));
return `<span><span class="heatmap-cell ${bgCls}" style="font-size:10px;padding:2px 8px;min-width:0;">${n}</span> ${escHtml(scoring.labels[n])}</span>`;
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>
`;
@ -1589,7 +1745,7 @@ async function downloadDeliverable(entityId, type) {
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
showToast(`${label} downloaded`);
showToast(type === 'pdf' ? t('toast_pdf_downloaded') : t('toast_xlsx_downloaded'));
} catch (e) {
showToast(`${label} unavailable: ${e.message}`, 'error');
} finally {
@ -1633,7 +1789,7 @@ function exportCsv() {
r.map(v => `"${String(v ?? '').replace(/"/g, '""')}"`).join(',')
).join('\r\n');
triggerDownload(`${config.id}_maturity_all_entities.csv`, 'text/csv;charset=utf-8;', csv);
showToast('CSV downloaded');
showToast(t('toast_csv_downloaded'));
}
// XLSX — multi-sheet workbook
@ -1726,7 +1882,7 @@ function exportXlsx(entityId) {
: `${config.id}_Maturity_All_Entities.xlsx`;
XLSX.writeFile(wb, filename);
showToast('XLSX downloaded');
showToast(t('toast_xlsx_downloaded'));
}
// PDF — server-generated via Python/ReportLab
@ -1746,9 +1902,9 @@ async function downloadPdf(entityId) {
a.download = `${entityId}_Maturity_Report.pdf`;
a.click();
URL.revokeObjectURL(url);
showToast('PDF downloaded');
showToast(t('toast_pdf_downloaded'));
} catch (e) {
showToast('PDF failed: ' + e.message, 'error');
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; }
}
@ -1772,9 +1928,9 @@ function populateImportEntitySelect() {
`<option value="${escHtml(e.id)}">${escHtml(e.label)}</option>`
).join('');
document.getElementById('importEntitySel').innerHTML =
'<option value="">-- Select entity --</option>' + options;
`<option value="">${escHtml(t('upload_placeholder'))}</option>` + options;
document.getElementById('syncEntitySel').innerHTML =
'<option value="">All entities</option>' + options;
`<option value="">${escHtml(t('sync_all'))}</option>` + options;
}
function onFileSelected(file) {
@ -1815,10 +1971,10 @@ async function runBoxSync() {
const data = await res.json();
if (!res.ok) {
log.textContent = `Error:\n${data.detail || data.error}`;
showToast('Sync failed', 'error');
showToast(t('toast_sync_failed'), 'error');
} else {
log.textContent = (data.log || 'Done.').trim();
showToast('Sync complete — reloading…', 'success');
showToast(t('toast_sync_complete'), 'success');
setTimeout(async () => {
await loadClient(activeClient.config.id);
showTab('entities');
@ -1826,7 +1982,7 @@ async function runBoxSync() {
}
} catch (e) {
log.textContent = 'Network error: ' + e.message;
showToast('Sync failed: ' + e.message, 'error');
showToast(t('toast_sync_failed') + ': ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.innerHTML = `
@ -1839,8 +1995,8 @@ async function runBoxSync() {
async function runFileImport() {
const entityId = document.getElementById('importEntitySel').value;
if (!entityId) { showToast('Select an entity first', 'error'); return; }
if (!importFile) { showToast('Select a file to upload', 'error'); return; }
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;
@ -1857,22 +2013,22 @@ async function runFileImport() {
});
const data = await res.json();
if (!res.ok) {
showToast('Import failed: ' + (data.error || res.statusText), 'error');
showToast(t('toast_import_failed', data.error || res.statusText), 'error');
} else {
const e = data.entity;
showToast(`Imported ${escHtml(e.label)}: ${e.overall_score.toFixed(2)} (${e.overall_label})`, 'success');
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('Import failed: ' + e.message, 'error');
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> Import`;
</svg> ${escHtml(t('upload_btn'))}`;
}
}

View file

@ -77,7 +77,8 @@ app.get('/api/clients', authenticate, (_req, res) => {
generated: dat.generated,
pillars: cfg.pillars || [],
scoring: cfg.scoring || {},
about: cfg.about || null,
about: cfg.about || null,
has_translations: !!(cfg.translations && Object.keys(cfg.translations).length > 0),
};
});
res.json(clients);