1189 lines
44 KiB
JavaScript
1189 lines
44 KiB
JavaScript
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 d’acquisition",
|
||
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 d’enregistrement est adapté au support de diffusion",
|
||
"Le poids des fichiers est adapté au support de diffusion",
|
||
],
|
||
Competence: [
|
||
"Comprendre la notion d’accessibilité 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 d’enregistrement d'images",
|
||
"Connaître les techniques de retouche et de modification d’images.",
|
||
"Connaître les règles liées aux droits d’auteur et aux licences sur l’utilisation d’images.",
|
||
],
|
||
},
|
||
CP2: {
|
||
Performance: [
|
||
"Les interfaces sont conçues en respectant les éléments définis par la charte graphique.",
|
||
"L’ensemble 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 l’interface.",
|
||
"Les interfaces sont conçues en tenant compte des principes d’accessibilité et d’ergonomie.",
|
||
],
|
||
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 l’usage 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 d’accessibilité, notamment le WCAG.",
|
||
"Connaître tendances actuelles dans le design d’interfaces graphiques.",
|
||
"Connaît les concepts de base du design responsive pour l’adaptabilité multi-supports.",
|
||
"Comprend les différents éléments d’interaction, 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 d’animation",
|
||
"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 l’animation.",
|
||
],
|
||
},
|
||
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 l’actualité 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 l’ensemble 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 d’information 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 l’organisation W3C",
|
||
"L'affichage des pages est optimisé pour les navigateurs cibles",
|
||
"La charte graphique est respectée sur l’ensemble 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 d’un serveur d’hébergement",
|
||
"Utiliser les techniques pour bloquer ou favoriser l’indexation 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 l’hé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 l’apparence 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 d’un site WordPress.",
|
||
"Comprendre l’importance des mises à jour régulières des plugins thèmes et WP.",
|
||
"Connaître les principales options d’hé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 d’acquisition : ${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 l’instant.\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 d’autonomie 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 d’environ 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;
|