Octopus-React-Wp/frontend/src/components/Admin/Bilans.jsx
2025-11-02 21:36:00 +01:00

1189 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { useNavigate } from "react-router-dom";
import { getToken } from "../../auth";
import "../Admin/Bilans.css";
const LOGO_URL =
"https://res.cloudinary.com/dh5qgexjo/image/upload/v1758101960/arinfo_tevrd7.png";
const BUCKETS = ["Performance", "Competence", "Connaissance", "Unassigned"];
const BUCKET_LABEL = {
Performance: "Critères de Performance",
Competence: "Critères de Compétence",
Connaissance: "Critères de Connaissance",
Unassigned: "À classer",
};
const LEVELS = [
{ key: "na", short: "NA", label: "Non acquis" },
{ key: "ec", short: "EC", label: "En cours" },
{ key: "ac", short: "AC", label: "Acquis" },
{ key: "ma", short: "MA", label: "Maîtrisé" },
];
const LEVEL_LABEL = {
na: "Non acquis",
ec: "En cours dacquisition",
ac: "Acquis",
ma: "Maîtrisé",
};
const FALLBACK_MODELS = ["gpt-4o-mini", "gpt-4o", "gpt-4.1", "gpt-3.5-turbo"];
const titles = {
CP1: { h1: "Bilan-CP1", h2: "Réaliser des illustrations" },
CP2: {
h1: "Bilan-CP2",
h2: "Concevoir des interfaces graphiques et des prototypes",
},
CP3: {
h1: "Bilan-CP3",
h2: "Réaliser une animation pour différents supports de diffusion",
},
CP4: { h1: "Bilan-CP4", h2: "Créer des supports de communication" },
CP5: {
h1: "Bilan-CP5",
h2: "Mettre en oeuvre une stratégie webmarketing",
},
CP6: {
h1: "Bilan-CP6",
h2: "Assurer une veille pro et développer les compétences collectives",
},
CP7: { h1: "Bilan-CP7", h2: "Intégrer des pages web" },
CP8: {
h1: "Bilan-CP8",
h2: "Adapter des systèmes de gestion de contenus",
},
CP9: {
h1: "Bilan-CP9",
h2: "Optimiser en continu un site web ou une interface",
},
};
const allDefaults = {
CP1: {
Performance: [
"Les illustrations sont adaptées aux différents supports",
"Les illustrations respectent la charte graphique",
"Le format denregistrement est adapté au support de diffusion",
"Le poids des fichiers est adapté au support de diffusion",
],
Competence: [
"Comprendre la notion daccessibilité des contenus Web aux personnes handicapées",
"Optimiser le poids et adapter la taille des réalisations",
"Réaliser des illustrations vectorielles pour des besoins de haute qualité et flexibilité.",
"Exporter les visuels dans les formats de fichiers adaptés (JPEG, PNG, SVG, PDF, etc.).",
],
Connaissance: [
"Connaissance des différents modes colorimétriques",
"Connaissance des règles de composition photographique",
"Connaissance de la symbolique des formes et des couleurs",
"Connaissance des différents formats denregistrement d'images",
"Connaître les techniques de retouche et de modification dimages.",
"Connaître les règles liées aux droits dauteur et aux licences sur lutilisation dimages.",
],
},
CP2: {
Performance: [
"Les interfaces sont conçues en respectant les éléments définis par la charte graphique.",
"Lensemble des interfaces présente une cohérence visuelle, favorisant une bonne UX.",
"Les éléments sont placés de manière harmonieuse, suivant un système de grille rigoureux.",
"Les interfaces sont conçues pour être adaptables sur différents types d'écrans (responsive).",
"Un prototype fonctionnel est élaboré pour simuler les interactions clés de linterface.",
"Les interfaces sont conçues en tenant compte des principes daccessibilité et dergonomie.",
],
Competence: [
"Maîtriser les outils de conception graphique comme Figma, Adobe XD, ou Sketch.",
"Concevoir des wireframes pour organiser les éléments visuels.",
"Concevoir des interfaces qui facilitent la navigation et lusage pour les utilisateurs.",
"Gérer les choix typographiques et les palettes de couleurs de manière cohérente.",
"Intégrer les retours des utilisateurs pour améliorer les prototypes.",
"Intégrer des éléments interactifs tels que boutons, menus, et transitions.",
],
Connaissance: [
"Comprend les bases des principes de (UX/UI).",
"Connaître les principales normes daccessibilité, notamment le WCAG.",
"Connaître tendances actuelles dans le design dinterfaces graphiques.",
"Connaît les concepts de base du design responsive pour ladaptabilité multi-supports.",
"Comprend les différents éléments dinteraction, comme les CTA, les icônes, et les transitions.",
"Connaître les étapes du prototypage, de la conception à la validation.",
],
},
CP3: {
Performance: [
"Un storyboard est réalisé pour structurer et planifier l'animation avant sa conception.",
"L'animation est correctement adaptée aux différents supports de diffusion.",
"L'animation est livrée dans les délais impartis, tout en respectant les spécifications.",
"Les transitions entre les scènes sont fluides et les effets sont cohérents.",
"L'animation est optimisée pour une diffusion fluide sur différents supports.",
"L'animation respecte les spécifications techniques et créatives du cahier des charges.",
],
Competence: [
"Utiliser un logiciel danimation",
"Concevoir des éléments graphiques adaptés à l'animation.",
"Intégrer et synchroniser différents médias (texte, image, son, vidéo).",
"Gérer les contraintes techniques liées aux différents supports de diffusion.",
"Adapter le contenu de l'animation aux attentes de la cible et au contexte de diffusion.",
"Exporter l'animation dans les formats adaptés à chaque support de diffusion.",
],
Connaissance: [
"Connaître les différents formats et codecs pour les animations (GIF, MP4, etc.).",
"Connaissance des spécificités des sites d'hébergement et de partage vidéo",
"Connaître les outils professionnels d'animation et leurs fonctionnalités principales.",
"S'informé des tendances actuelles en matière d'animation digitale.",
"Connaître les contraintes techniques pour le web (poids, compatibilité, etc.).",
"Connaissance des principes de lanimation.",
],
},
CP4: {
Performance: [
"Les supports créés respectent les normes et les contraintes techniques (fond perdu, dimensions).",
"Les supports sont visuellement attractifs, conformes à la charte graphique et adaptés aux cibles.",
"Les différents supports de communication sont produits et livrés dans les délais impartis.",
"Le message est cohérent et en adéquation avec les objectifs de la communication.",
"Les supports sont déclinés efficacement sur différents formats (print et digital).",
"Les newsletters sont fonctionnelles, avec des liens actifs, et optimisées.",
],
Competence: [
"Maîtriser les logiciels de PAO (Photoshop, Illustrator, InDesign) pour créer des supports visuels.",
"Intégrer correctement un fond perdu dans un document destiné à l'impression.",
"Utiliser des outils comme Mailchimp ou Brevo pour la création de newsletters.",
"Concevoir un calendrier éditorial en fonction des objectifs et des cibles.",
"Concevoir des supports de communication adaptésà plusieurs appareils (desktop, mobile).",
"Créer des contenus qui captent l'attention et engagent l'audience.",
],
Connaissance: [
"Connaître les différents formats de fichiers (PDF, PNG, JPG, etc.) adaptés à chaque support.",
"Connaître les bonnes pratiques pour l'impression (résolution, marges, formats).",
"Comprendre les objectifs de chaque support et adapter le message en conséquence.",
"Comprendre les règles de mise en page (grille, typographie, hiérarchie visuelle).",
"Connaître les plateformes d'emailing et sait gérer les envois de newsletters.",
"Comprendre les principes de base du SEO à intégrer dans les newsletters.",
],
},
CP5: {
Performance: [
"Les objectifs de la stratégie sont spécifiquement définis, mesurables, atteignables, pertinents et temporels",
"Les différents canaux de communication utilisés (SEO, SEA, réseaux sociaux) sont cohérents entre eux.",
"La stratégie est ajustée de façon continue sur la base des KPIs, afin d'optimiser les résultats.",
"La stratégie est mise à jour en réponse aux tendances actuelles et aux retours des clients.",
"Les actions mises en place contribuent à une augmentation mesurable de la visibilité de la marque.",
],
Competence: [
"Utiliser des outils d'analyse tels que Google Analytics, SEMrush, ou Ahrefs.",
"Concevoir et planifier des campagnes de marketing sur divers canaux numériques.",
"Optimiser un site pour le SEO en tenant compte des mots-clés et du contenu.",
"Utiliser efficacement les réseaux sociaux pour promouvoir la stratégie webmarketing.",
],
Connaissance: [
"Connaître les KPIs essentiels à suivre pour mesurer l'efficacité des actions marketing.",
"Comprendre les bases du SEO, notamment le choix des mots-clés et l'optimisation.",
"Comprendre les bases des stratégies de contenu et de la création de contenu engageant.",
"Comprendre l'importance de la segmentation de l'audience pour personnaliser les campagnes.",
"Connaître les règles de base et la réglementation en vigueur (RGPD, respect de la vie privée, etc.).",
],
},
CP6: {
Performance: [
"Le système de veille mis en place permet de suivre lactualité de plusieurs professionnels",
"La veille est mise à jour régulièrement, garantissant une réactivité face aux évolutions du secteur.",
"La veille couvre des sources diversifiées (blogs, forums, articles, réseaux).",
"La charte graphique est respectée sur lensemble des supports de communication",
"Partager efficacement les résultats de la veille avec ses collègues.",
"Les informations collectées sont pertinentes et en lien avec les besoins du projet.",
"Des outils adéquats (RSS, alertes, etc.) sont utilisés pour optimiser la veille.",
],
Competence: [
"Identifier des sources dinformation fiables et pertinentes.",
"Savoir utiliser différents outils de veille selon les objectifs de recherche.",
"Savoir analyser les tendances émergentes et les contextualiser dans le cadre professionnel.",
"Automatiser ses recherches avec des outils dédiés à la veille",
"Savoir collaborer avec ses collègues pour enrichir la veille professionnelle.",
"Savoir rédiger des synthèses ou des rapports concis à partir des résultats de sa veille.",
"Savoir gérer une veille collective en impliquant d'autres membres de l'équipe.",
],
Connaissance: [
"Connaître les principaux outils de veille (agrégateurs, plateformes de contenu, etc).",
"Connaître les bonnes pratiques pour organiser et structurer sa veille professionnelle.",
"Connaissance des outils et techniques",
"Connaître les principales tendances et évolutions dans son secteur d'activité.",
"Comprendre l'importance de la veille pour rester compétitif et innovant.",
"Savoir où trouver des informations fiables et pertinentes pour son domaine.",
"Connaître les principaux réseaux professionnels et sait comment y contribuer.",
],
},
CP7: {
Performance: [
"Les pages web respectent la charte graphique",
"Les pages tiennent compte des standards de lorganisation W3C",
"L'affichage des pages est optimisé pour les navigateurs cibles",
"La charte graphique est respectée sur lensemble des supports de communication",
"Les pages s'adaptent aux périphériques cibles",
"Le code est optimisé pour le référencement naturel",
],
Competence: [
"Utiliser un éditeur de code",
"Utiliser un framework HTML / CSS",
"Utiliser des logiciels de transfert de fichiers",
"Gérer les droits sur les répertoires dun serveur dhébergement",
"Utiliser les techniques pour bloquer ou favoriser lindexation des fichiers",
],
Connaissance: [
"Connaissance des langages HTML et CSS",
"Connaissance des standards du web W3C et des normes d'accessibilité WAI ou RGAA",
"Connaissance des techniques du responsive web design",
"Connaissance du langage JavaScript",
"Connaissance des principes du référencement naturel (SEO)",
],
},
CP8: {
Performance: [
"WordPress est correctement installé et configuré sur le serveur ou lhébergement.",
"Des pages sont créées selon les spécifications du projet avec un contenu approprié.",
"Le thème WP choisi est correctement personnalisé pour répondre aux besoins du client.",
"Les plugins nécessaires sont installés et configurés en fonction des exigences du site.",
"Le site est optimisé pour des performances élevées (vitesse de chargement cache etc.).",
"Des mesures de sécurité basiques (mises à jour protection des accès) sont mises en place.",
],
Competence: [
"Naviguer dans le tableau de bord et utiliser les fonctionnalités principales.",
"Créer et gérer des utilisateurs avec des rôles différents (administrateur éditeur etc.).",
"Rechercher installer et configurer les extensions selon les besoins du site.",
"Créer et organiser des menus de navigation et des widgets dans WP.",
"Personnaliser le CSS et le HTML du site pour modifier lapparence au-delà du thème.",
"Configurer les extensions SEO pour améliorer le référencement du site.",
],
Connaissance: [
"Connaître la structure des thèmes WordPress et comment les installer ou les modifier.",
"Connaître les principales extensions utilisées dans les projets web sous WordPress.",
"Connaître les bonnes pratiques de référencement naturel sur WP.",
"Connaître les principales méthodes de sécurisation dun site WordPress.",
"Comprendre limportance des mises à jour régulières des plugins thèmes et WP.",
"Connaître les principales options dhébergement pour un site WordPress.",
],
},
CP9: {
Performance: [
"Le temps de chargement du site est optimisé, répondant aux normes de performance",
"Le site est testé et fonctionne sur différents navigateurs et appareils (desktop, mobile).",
"Les contenus du site sont régulièrement mis à jour en fonction des besoins de l'audience.",
"Optimisations SEO sont effectuées régulièrement pour améliorer la visibilité du site.",
"Les erreurs techniques (liens cassés, erreurs de serveur) sont rapidement corrigées.",
],
Competence: [
"Savoir utiliser des outils tels que lighthouse Search Console, ou GTMetrix.",
"Maîtriser les techniques d'optimisation (mise en cache, minification des ressources).",
"Configurer les hébergements web.",
"Optimiser les fichiers multimédia et réduire le poids des pages web.",
"Optimisations SEO sont effectuées régulièrement.",
"Identifier et de corriger les vulnérabilités de sécurité sur le site.",
],
Connaissance: [
"Connaître les Critères SEO actuels pour optimiser le référencement d'un site web.",
"Connaître les normes web en termes d'accessibilité, sécurité, et performance.",
"Comprendre les techniques de sécurisation d'un site (HTTPS, cookies...).",
"Connaître les bonnes pratiques en dev web pour maintenir la performance du site.",
"Comprendre l'importance des outils de gestion des versions (Git, Duplicator...)",
"Connaître les principaux outils de monitoring pour surveiller la performance du site.",
],
},
};
const buildItemsFromDefaults = (cpKey) => {
const defs = allDefaults[cpKey] || {};
let idx = 1;
const items = [];
["Performance", "Competence", "Connaissance"].forEach((bucket) => {
(defs[bucket] || []).forEach((label) => {
items.push({
id: `${cpKey}-${bucket}-${idx++}`,
label,
checked: false,
level: "ec",
bucket,
});
});
});
return items;
};
const Bilan = () => {
const [currentCp, setCurrentCp] = useState("CP1");
const [items, setItems] = useState(() => buildItemsFromDefaults("CP1"));
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [newCriterion, setNewCriterion] = useState("");
const [aiNotes, setAiNotes] = useState("");
const [aiTone, setAiTone] = useState("neutre");
const [aiPrepend, setAiPrepend] = useState(false);
const [openaiKey, setOpenaiKey] = useState("");
const [openaiModel, setOpenaiModel] = useState("gpt-4o-mini");
const [observation, setObservation] = useState("");
const [status, setStatus] = useState(null);
const [isAiLoading, setIsAiLoading] = useState(false);
const [dragOverBucket, setDragOverBucket] = useState(null);
const draggingIdRef = useRef(null);
const navigate = useNavigate();
const token = getToken();
useEffect(() => {
if (!token) {
navigate("/admin/login");
}
}, [token, navigate]);
useEffect(() => {
setItems(buildItemsFromDefaults(currentCp));
}, [currentCp]);
const bucketOrder = useMemo(() => {
const base = ["Performance", "Competence", "Connaissance"];
return items.some((it) => it.bucket === "Unassigned")
? ["Unassigned", ...base]
: base;
}, [items]);
const anySelected = useMemo(
() => items.some((item) => item.checked),
[items]
);
const showStatus = useCallback((message, type = "info", withProgress = false) => {
setStatus({
id: Date.now(),
message,
type,
withProgress,
});
}, []);
useEffect(() => {
if (!status || status.withProgress) return;
const timeout = setTimeout(
() => setStatus(null),
status.type === "info" ? 3000 : 5000
);
return () => clearTimeout(timeout);
}, [status]);
const updateItem = useCallback((id, updater) => {
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...updater } : item))
);
}, []);
const handleToggleAll = useCallback((checked) => {
setItems((prev) => prev.map((item) => ({ ...item, checked })));
}, []);
const handleAddCriterion = useCallback(() => {
const label = newCriterion.trim();
if (label.length < 3) return;
setItems((prev) => [
...prev,
{
id: `custom-${Date.now()}`,
label,
checked: false,
level: "ec",
bucket: "Unassigned",
source: "Personnalisé",
},
]);
setNewCriterion("");
}, [newCriterion]);
const handleRemoveCriterion = useCallback((id) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const handleDragStart = useCallback((id) => {
draggingIdRef.current = id;
}, []);
const handleDragEnd = useCallback(() => {
draggingIdRef.current = null;
setDragOverBucket(null);
}, []);
const handleBucketDragOver = useCallback((bucketKey, event) => {
event.preventDefault();
if (dragOverBucket !== bucketKey) {
setDragOverBucket(bucketKey);
}
}, [dragOverBucket]);
const handleBucketDrop = useCallback((bucketKey, event) => {
event.preventDefault();
setDragOverBucket(null);
const id = draggingIdRef.current;
if (!id) return;
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, bucket: bucketKey } : item
)
);
draggingIdRef.current = null;
}, []);
const handleGenerateObservation = useCallback(() => {
const total = items.length;
const selected = items.filter((item) => item.checked);
const counts = {
na: selected.filter((c) => c.level === "na").length,
ec: selected.filter((c) => c.level === "ec").length,
ac: selected.filter((c) => c.level === "ac").length,
ma: selected.filter((c) => c.level === "ma").length,
};
const validated = counts.ac + counts.ma;
const percentage = total ? Math.round((validated / total) * 100) : 0;
const buckets = ["Performance", "Competence", "Connaissance"];
let text = "## BILAN D'ÉVALUATION DES COMPÉTENCES\n";
text += "==================================================\n\n";
if (firstName || lastName) {
text += `Apprenant : ${(firstName + " " + lastName).trim()}\n\n`;
}
text += "### SYNTHÈSE GLOBALE :\n";
text += `- Non acquis : ${counts.na}\n`;
text += `- En cours dacquisition : ${counts.ec}\n`;
text += `- Acquis : ${counts.ac}\n`;
text += `- Maîtrisé : ${counts.ma}\n`;
text += `- Total validés (Acquis + Maîtrisé) : ${validated} / ${total} (${percentage}%)\n\n`;
const bucketStats = buckets
.map((bucket) => {
const all = items.filter((item) => item.bucket === bucket);
if (!all.length) return null;
const ok = all.filter(
(item) =>
item.checked && (item.level === "ac" || item.level === "ma")
).length;
const pct = Math.round((ok / all.length) * 100);
return { label: BUCKET_LABEL[bucket], ok, total: all.length, pct };
})
.filter(Boolean);
if (bucketStats.length) {
text += "### DÉTAIL PAR CATÉGORIE (taux de validation / total) :\n";
bucketStats.forEach((stat) => {
text += `- ${stat.label} : ${stat.pct}% (${stat.ok}/${stat.total})\n`;
});
text += "\n";
}
const validatedItems = selected.filter(
(item) => item.level === "ac" || item.level === "ma"
);
text += "### COMPÉTENCES VALIDÉES :\n";
if (validatedItems.length) {
BUCKETS.forEach((bucket) => {
const subset = validatedItems.filter((item) => item.bucket === bucket);
if (!subset.length) return;
text += `${BUCKET_LABEL[bucket]} :\n`;
subset.forEach((item) => {
text += ` - ${item.label} (${LEVEL_LABEL[item.level]})\n`;
});
});
} else {
text += "Aucune compétence validée pour linstant.\n";
}
text += "\n";
const toImprove = selected.filter(
(item) => item.level === "na" || item.level === "ec"
);
if (toImprove.length) {
text += "### COMPÉTENCES À RENFORCER :\n";
BUCKETS.forEach((bucket) => {
const subset = toImprove.filter((item) => item.bucket === bucket);
if (!subset.length) return;
text += `${BUCKET_LABEL[bucket]} :\n`;
subset.forEach((item) => {
text += ` - ${item.label}${LEVEL_LABEL[item.level]}\n`;
});
});
text += "\n";
}
text += "## RECOMMANDATIONS :\n";
if (counts.ma > counts.ac && counts.ma > counts.ec && counts.ma > counts.na) {
text += "- Proposer des projets plus complexes pour entretenir la motivation.\n";
text += "- Donner plus dautonomie et de responsabilités.\n";
}
if (counts.ac >= counts.ma && counts.ac >= counts.ec) {
text += "- Varier les exercices pour transformer les acquis en maîtrise.\n";
text += "- Introduire progressivement des notions avancées.\n";
}
if (counts.ec > counts.ac && counts.ec > counts.ma) {
text += "- Renforcer la pratique guidée pour consolider les acquis fragiles.\n";
text += "- Planifier des révisions régulières sur les notions encore instables.\n";
}
if (counts.na > 0) {
text += "- Revoir en priorité les notions non acquises avec un accompagnement rapproché.\n";
text += "- Travailler avec des exercices simples et progressifs pour poser les bases.\n";
}
if (validated === 0) {
text += "- Mettre en place un plan de formation renforcé avec objectifs courts et progressifs.\n";
text += "- Prévoir un suivi rapproché et des feedbacks fréquents.\n";
}
text += "\n";
text += "### INFORMATIONS COMPLÉMENTAIRES :\n";
text += `- Date d'évaluation : ${new Date().toLocaleDateString("fr-FR")}\n`;
text += `- Nombre total de critères : ${total}\n`;
text += `- Sélectionnés : ${selected.length} | Validés (Acquis/Maîtrisé) : ${validated} | À renforcer : ${toImprove.length}\n`;
if (aiNotes.trim()) {
text += `\nNOTE FORMATEUR :\n${aiNotes.trim()}\n`;
}
setObservation(text);
showStatus("Observation générée.", "success");
}, [aiNotes, firstName, items, lastName, showStatus]);
const insertAISummaryIntoObservation = useCallback(
(text) => {
setObservation((prev) =>
aiPrepend
? `${text}\n\n${prev || ""}`.trim()
: `${prev || ""}${prev ? "\n\n" : ""}${text}`.trim()
);
},
[aiPrepend]
);
const buildPayloadForAI = useCallback(() => {
const selected = items.filter((item) => item.checked);
const byBucket = (array, bucket) =>
array.filter((item) => item.bucket === bucket).map((item) => item.label);
const acquis = selected.filter((item) => item.level === "ac");
const maitrise = selected.filter((item) => item.level === "ma");
const inProgress = selected.filter((item) => item.level === "ec");
const notAcquired = selected.filter((item) => item.level === "na");
return {
prenom: firstName.trim(),
nom: lastName.trim(),
tone: aiTone,
counts: {
total: items.length,
selected: selected.length,
acquis: acquis.length,
maitrise: maitrise.length,
inProgress: inProgress.length,
notAcquired: notAcquired.length,
},
performance: {
acquis: byBucket(acquis, "Performance"),
maitrise: byBucket(maitrise, "Performance"),
improve: byBucket([...notAcquired, ...inProgress], "Performance"),
},
competence: {
acquis: byBucket(acquis, "Competence"),
maitrise: byBucket(maitrise, "Competence"),
improve: byBucket([...notAcquired, ...inProgress], "Competence"),
},
connaissance: {
acquis: byBucket(acquis, "Connaissance"),
maitrise: byBucket(maitrise, "Connaissance"),
improve: byBucket([...notAcquired, ...inProgress], "Connaissance"),
},
};
}, [aiTone, firstName, items, lastName]);
const generateAISummaryOpenAI = useCallback(async () => {
if (!openaiKey.trim()) {
showStatus("Renseigne ta clé OpenAI (champ sk-...).", "error");
return;
}
const payload = buildPayloadForAI();
if (!payload.counts.selected) {
showStatus("Sélectionne quelques critères avant d'utiliser l'IA.", "error");
return;
}
const observationTexte = observation.trim();
const notesFormateur = aiNotes.trim();
setIsAiLoading(true);
showStatus("Génération du résumé via OpenAI…", "info", true);
const callOpenAI = async (model) => {
const response = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${openaiKey.trim()}`,
},
body: JSON.stringify({
model,
temperature: 0.6,
messages: [
{
role: "system",
content: `
Tu es formateur en développement web.
À partir des données (critères et niveaux), du bilan automatique affiché et des notes éventuelles, rédige une synthèse finale denviron 8 à 10 phrases.
Contraintes : valoriser les réussites, évoquer les difficultés, reformuler les notes formateur, varier le vocabulaire et les connecteurs, adopter un ton professionnel bienveillant.
`,
},
{
role: "user",
content: `Données JSON : ${JSON.stringify(payload)}
Observation affichée :
${observationTexte}
Notes formateur :
${notesFormateur}`,
},
],
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}${text}`);
}
return response.json();
};
const tried = new Set();
const attemptOrder = [
openaiModel,
...FALLBACK_MODELS.filter((model) => model !== openaiModel),
];
try {
let content = "";
let usedModel = openaiModel;
for (const model of attemptOrder) {
if (tried.has(model)) continue;
tried.add(model);
try {
const data = await callOpenAI(model);
content =
data?.choices?.[0]?.message?.content ||
data?.choices?.[0]?.delta?.content ||
"";
usedModel = model;
if (content) break;
} catch (err) {
if (tried.size === attemptOrder.length) {
throw err;
}
}
}
if (!content) {
throw new Error("Réponse vide du modèle.");
}
insertAISummaryIntoObservation(
`## RESUME FORMATEUR\n----------------------------\n${content.trim()}`
);
showStatus(`Résumé OpenAI inséré (modèle : ${usedModel}).`, "success");
} catch (error) {
showStatus(`Erreur OpenAI : ${error.message || error}`, "error");
} finally {
setStatus((prev) => (prev ? { ...prev, withProgress: false } : prev));
setIsAiLoading(false);
}
}, [
aiNotes,
buildPayloadForAI,
insertAISummaryIntoObservation,
observation,
openaiKey,
openaiModel,
showStatus,
]);
const downloadPDF = useCallback(async () => {
const textRaw = observation.trim();
if (!textRaw) return;
try {
showStatus("Préparation du PDF…", "info", true);
const pdfDoc = await PDFDocument.create();
const A4 = { w: 595.28, h: 841.89 };
let curr = pdfDoc.addPage([A4.w, A4.h]);
const font = await pdfDoc.embedFont(StandardFonts.TimesRoman);
const boldFont = await pdfDoc.embedFont(StandardFonts.TimesRomanBold);
const fontSize = 11;
const margin = 40;
const maxWidth = A4.w - margin * 2;
let y = A4.h - 130;
const logoBytes = await fetch(LOGO_URL).then((res) => res.arrayBuffer());
const logo = await pdfDoc.embedPng(logoBytes);
const logoDims = logo.scale(0.15);
const drawHeaderFooter = (page, pageNumber) => {
page.drawImage(logo, {
x: 40,
y: A4.h - 80,
width: logoDims.width,
height: logoDims.height,
});
const h1Title = titles[currentCp]?.h1 || "";
const title =
"Bilan dévaluation" + (h1Title ? " " + h1Title : "");
const textWidth = font.widthOfTextAtSize(title, 16);
page.drawText(title, {
x: (A4.w - textWidth) / 2,
y: A4.h - 110,
size: 16,
font,
});
page.drawText(`Page ${pageNumber}`, {
x: A4.w - 160,
y: 20,
size: 10,
font,
});
page.drawText(new Date().toLocaleDateString("fr-FR"), {
x: 40,
y: 20,
size: 10,
font,
});
};
const wrap = (line) => {
const words = line.split(/\s+/);
const out = [];
let acc = "";
for (const word of words) {
const test = acc ? `${acc} ${word}` : word;
const fontToUse = acc.includes("**") ? boldFont : font;
if (
fontToUse.widthOfTextAtSize(test.replace(/\*\*/g, ""), fontSize) >
maxWidth
) {
if (acc) out.push(acc);
acc = word;
} else {
acc = test;
}
}
if (acc) out.push(acc);
return out;
};
const parseMarkdown = (text) => {
const lines = text.split("\n");
const parsed = [];
for (const line of lines) {
if (/^###\s+/.test(line)) {
parsed.push({ type: "h3", text: line.replace(/^###\s+/, "") });
} else if (/^##\s+/.test(line)) {
parsed.push({ type: "h2", text: line.replace(/^##\s+/, "") });
} else if (/^#\s+/.test(line)) {
parsed.push({ type: "h1", text: line.replace(/^#\s+/, "") });
} else if (line.trim() === "") {
parsed.push({ type: "br" });
} else {
parsed.push({ type: "p", text: line });
}
}
return parsed;
};
const parsed = parseMarkdown(textRaw);
let pageNumber = 1;
drawHeaderFooter(curr, pageNumber);
y -= 40;
for (const block of parsed) {
if (y < margin + fontSize * 2) {
curr = pdfDoc.addPage([A4.w, A4.h]);
pageNumber++;
drawHeaderFooter(curr, pageNumber);
y = A4.h - 120;
y -= 40;
}
if (block.type === "h1") {
curr.drawText(block.text, {
x: margin,
y,
size: 18,
font: boldFont,
});
y -= 28;
continue;
}
if (block.type === "h2") {
curr.drawText(block.text, {
x: margin,
y,
size: 15,
font: boldFont,
});
y -= 24;
continue;
}
if (block.type === "h3") {
curr.drawText(block.text, {
x: margin,
y,
size: 13,
font: boldFont,
});
y -= 20;
continue;
}
if (block.type === "br") {
y -= 12;
continue;
}
const wrappedLines = wrap(block.text);
for (const line of wrappedLines) {
if (y < margin + fontSize * 2) {
curr = pdfDoc.addPage([A4.w, A4.h]);
pageNumber++;
drawHeaderFooter(curr, pageNumber);
y = A4.h - 120;
y -= 40;
}
let offsetX = margin;
let buffer = "";
let inBold = false;
for (let i = 0; i < line.length; i += 1) {
if (line[i] === "*" && line[i + 1] === "*") {
if (buffer) {
const activeFont = inBold ? boldFont : font;
curr.drawText(buffer, {
x: offsetX,
y,
size: fontSize,
font: activeFont,
});
offsetX += activeFont.widthOfTextAtSize(buffer, fontSize);
buffer = "";
}
inBold = !inBold;
i += 1;
} else {
buffer += line[i];
}
}
if (buffer) {
const activeFont = inBold ? boldFont : font;
curr.drawText(buffer, {
x: offsetX,
y,
size: fontSize,
font: activeFont,
});
}
y -= 16;
}
}
const bytes = await pdfDoc.save();
const blob = new Blob([bytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const prenom = firstName.trim() || "Prenom";
const nom = lastName.trim() || "Nom";
const titre = titles[currentCp]?.h1 || "Bilan";
const date = new Date().toISOString().slice(0, 10);
const fileName = `${nom}-${prenom}_${date}_${titre}.pdf`;
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
showStatus("PDF exporté.", "success");
} catch (error) {
showStatus(`Erreur PDF : ${error.message || error}`, "error");
} finally {
setStatus((prev) => (prev ? { ...prev, withProgress: false } : prev));
}
}, [currentCp, firstName, lastName, observation, showStatus]);
const currentTitle = titles[currentCp] || { h1: "", h2: "" };
return (
<div className="bilan-wrapper">
<img src={LOGO_URL} alt="Logo watermark" className="logo-bg" />
<div className="bilan-container">
<div className="header">
<button
type="button"
className="back-btn"
onClick={() => navigate("/admin/dashboard")}
>
Retour au dashboard
</button>
<div className="controls controls-select">
<label htmlFor="cpSelect">
<strong>Choisir le CP :</strong>
</label>
<select
id="cpSelect"
value={currentCp}
onChange={(event) => setCurrentCp(event.target.value)}
>
{Object.keys(allDefaults).map((cp) => (
<option key={cp} value={cp}>
{cp}
</option>
))}
</select>
</div>
<div>
<h1>{currentTitle.h1}</h1>
<h2>{currentTitle.h2}</h2>
</div>
</div>
<div className="controls identity">
<input
id="prenom"
placeholder="Prénom"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
/>
<input
id="nom"
placeholder="Nom"
value={lastName}
onChange={(event) => setLastName(event.target.value)}
/>
</div>
<div className="controls actions">
<button type="button" onClick={() => handleToggleAll(true)}>
Tout sélectionner
</button>
<button type="button" onClick={() => handleToggleAll(false)}>
Tout désélectionner
</button>
<input
id="newCriterion"
placeholder="Ajouter un critère…"
value={newCriterion}
onChange={(event) => setNewCriterion(event.target.value)}
/>
<button type="button" onClick={handleAddCriterion}>
Ajouter
</button>
</div>
<div className="competences-grid">
{bucketOrder.map((bucket) => {
const itemsInBucket = items.filter((item) => item.bucket === bucket);
return (
<div
key={bucket}
className={`competence-card bucket ${
dragOverBucket === bucket ? "dragover" : ""
}`}
>
<h4>
{BUCKET_LABEL[bucket]}{" "}
<span className="badge">({itemsInBucket.length})</span>
</h4>
<div
className="bucket-list"
onDragOver={(event) => handleBucketDragOver(bucket, event)}
onDragLeave={() =>
dragOverBucket === bucket && setDragOverBucket(null)
}
onDrop={(event) => handleBucketDrop(bucket, event)}
>
{itemsInBucket.map((item) => (
<div
key={item.id}
className="competence-item"
draggable
onDragStart={() => handleDragStart(item.id)}
onDragEnd={handleDragEnd}
>
<input
type="checkbox"
className="competence-checkbox sel"
checked={item.checked}
onChange={(event) =>
updateItem(item.id, { checked: event.target.checked })
}
/>
<label className="competence-label">
<div>{item.label}</div>
</label>
<div
className="level-group"
role="radiogroup"
aria-label="Niveau"
>
<span className="level-title">Niveau :</span>
{LEVELS.map((level) => (
<span className="level-pill" key={level.key}>
<input
type="radio"
id={`lvl-${item.id}-${level.key}`}
name={`lvl-${item.id}`}
value={level.key}
checked={item.level === level.key}
onChange={() =>
updateItem(item.id, { level: level.key })
}
/>
<label htmlFor={`lvl-${item.id}-${level.key}`}>
<span className="short">{level.short}</span>
<span className="long">{level.label}</span>
</label>
</span>
))}
</div>
<button
type="button"
className="remove-field-btn"
title="Supprimer"
onClick={() => handleRemoveCriterion(item.id)}
>
×
</button>
</div>
))}
</div>
</div>
);
})}
</div>
<div className="ai">
<strong>Résumé</strong>
<div className="row">
<div>
<label htmlFor="aiNotes">Notes formateur</label>
<textarea
id="aiNotes"
value={aiNotes}
onChange={(event) => setAiNotes(event.target.value)}
/>
</div>
<div>
<label htmlFor="aiTone">Tonalité</label>
<select
id="aiTone"
value={aiTone}
onChange={(event) => setAiTone(event.target.value)}
>
<option value="neutre">Neutre</option>
<option value="valorisant">Valorisant</option>
<option value="exigeant">Exigeant</option>
</select>
<label className="checkbox-inline">
<input
id="aiPrepend"
type="checkbox"
checked={aiPrepend}
onChange={(event) => setAiPrepend(event.target.checked)}
/>{" "}
Insérer en tête
</label>
</div>
</div>
<div className="row">
<div>
<label htmlFor="openaiKey">Clé API OpenAI</label>
<input
id="openaiKey"
type="password"
placeholder="sk-..."
value={openaiKey}
onChange={(event) => setOpenaiKey(event.target.value)}
/>
</div>
<div>
<label htmlFor="openaiModel">Modèle</label>
<select
id="openaiModel"
value={openaiModel}
onChange={(event) => setOpenaiModel(event.target.value)}
>
{FALLBACK_MODELS.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
</div>
<div className="controls ai-actions">
<button type="button" onClick={handleGenerateObservation}>
Résumé...
</button>
<button
type="button"
onClick={generateAISummaryOpenAI}
disabled={isAiLoading}
>
Générer l'observation
</button>
{isAiLoading && (
<div className="ai-loader" aria-live="polite">
<span className="spinner" />
<span>Génération en cours</span>
</div>
)}
{anySelected && (
<button type="button" onClick={downloadPDF}>
Exporter PDF
</button>
)}
</div>
</div>
{status && (
<div className={`status ${status.type}`}>
{status.withProgress ? (
<>
<div>{status.message}</div>
<div className="progress">
<div className="progress-bar" />
</div>
</>
) : (
status.message
)}
</div>
)}
<textarea
id="observation"
className="observation-textarea"
placeholder="Observation générée ici…"
value={observation}
onChange={(event) => setObservation(event.target.value)}
/>
</div>
</div>
);
};
export default Bilan;