diff --git a/.DS_Store b/.DS_Store index 714bdfd..a45131f 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/frontend/src/components/Admin/Bilans.css b/frontend/src/components/Admin/Bilans.css deleted file mode 100644 index 639aee5..0000000 --- a/frontend/src/components/Admin/Bilans.css +++ /dev/null @@ -1,610 +0,0 @@ -.bilan-wrapper { - min-height: 100vh; - padding: 2.5rem 1.5rem; - background: radial-gradient(circle at top, #1f2847, #0b1425 65%); - display: flex; - justify-content: center; - align-items: flex-start; - position: relative; - overflow: hidden; -} - -.logo-bg { - position: absolute; - width: 80%; - max-width: 900px; - opacity: 0.07; - inset: 0; - margin: auto; - pointer-events: none; - filter: grayscale(1); -} - -.bilan-container { - position: relative; - width: 100%; - max-width: 1100px; - background: rgba(255, 255, 255, 0.08); - border-radius: 20px; - padding: 30px; - color: #fff; - box-shadow: 0 25px 50px rgba(0, 0, 0, 0.25); - border: 1px solid rgba(255, 255, 255, 0.25); - backdrop-filter: blur(20px) saturate(180%); - -webkit-backdrop-filter: blur(20px) saturate(180%); - z-index: 1; -} - -.header { - text-align: center; - margin-bottom: 20px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; -} - -.back-btn { - align-self: flex-start; - border: none; - border-radius: 999px; - padding: 8px 18px; - font-weight: 600; - cursor: pointer; - color: #fff; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.25); - backdrop-filter: blur(6px); - transition: all 0.25s ease; -} - -.back-btn:hover { - transform: translateY(-2px); - background: rgba(255, 255, 255, 0.25); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); -} - -.header h1 { - margin: 0; - font-size: 30px; - font-weight: 700; - background: linear-gradient(135deg, #93c5fd, #c084fc); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.header h2 { - margin: 6px 0 0; - color: rgba(255, 255, 255, 0.85); - font-weight: 500; -} - -.controls { - background: rgba(255, 255, 255, 0.08); - border-radius: 14px; - padding: 14px; - margin: 16px 0; - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px; - justify-content: center; - border: 1px solid rgba(255, 255, 255, 0.2); - backdrop-filter: blur(12px); -} - -.controls input, -.controls select { - padding: 10px 14px; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.12); - color: #fff; - min-width: 180px; -} - -.controls input::placeholder { - color: rgba(255, 255, 255, 0.6); -} - -.bilan-container button, -.ai-actions button { - border: none; - border-radius: 999px; - padding: 10px 20px; - font-weight: 600; - cursor: pointer; - color: #fff; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.25); - backdrop-filter: blur(6px); - transition: all 0.25s ease; -} - -.bilan-container button:hover, -.ai-actions button:hover { - transform: translateY(-2px) scale(1.03); - background: rgba(255, 255, 255, 0.25); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25); -} - -.bilan-container button:disabled, -.ai-actions button:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; - box-shadow: none; -} - -.competences-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 16px; -} - -.competence-card { - background: rgba(255, 255, 255, 0.08); - border-radius: 14px; - padding: 14px; - border: 1px solid rgba(255, 255, 255, 0.2); - backdrop-filter: blur(10px); - min-height: 150px; -} - -.competence-card h4 { - display: flex; - justify-content: space-between; - margin: 0 0 12px; - font-size: 1rem; -} - -.badge { - background: rgba(255, 255, 255, 0.15); - border-radius: 999px; - padding: 2px 10px; - font-size: 0.8rem; -} - -.bucket-list { - display: flex; - flex-direction: column; - gap: 16px; - min-height: 80px; -} - -.bucket.dragover { - border-color: rgba(147, 197, 253, 0.8); - background: rgba(147, 197, 253, 0.1); -} - -.competence-item { - border-top: 1px solid rgba(255, 255, 255, 0.1); - padding: 12px; - border-radius: 12px; - cursor: grab; - position: relative; - background: rgba(255, 255, 255, 0.05); - transition: all 0.25s ease; -} - -.competence-item:hover { - background: rgba(255, 255, 255, 0.12); - transform: translateY(-2px); - box-shadow: 0 6px 14px rgba(0, 0, 0, 0.15); -} - -.competence-item.dragging { - opacity: 0.65; - transform: scale(0.98); - background: rgba(147, 197, 253, 0.15); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); -} - -.competence-item .competence-label { - font-weight: 600; - margin-bottom: 8px; -} - -.remove-field-btn { - background: #ef4444; - color: #fff; - border: 0; - border-radius: 50%; - width: 24px; - height: 24px; - font-size: 14px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - position: absolute; - top: 10px; - right: 10px; - opacity: 0; - transform: scale(0.8); - transition: all 0.25s ease; -} - -.competence-item:hover .remove-field-btn { - opacity: 1; - transform: scale(1); -} - -.competence-checkbox, -#aiPrepend { - appearance: none; - -webkit-appearance: none; - width: 20px; - height: 20px; - border-radius: 6px; - border: 1px solid rgba(255, 255, 255, 0.3); - background: rgba(255, 255, 255, 0.08); - cursor: pointer; - position: relative; - transition: all 0.25s ease; -} - -.competence-checkbox:hover, -#aiPrepend:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.5); -} - -.competence-checkbox:checked, -#aiPrepend:checked { - background: linear-gradient(135deg, #4ade80, #22c55e); - border-color: rgba(34, 197, 94, 0.6); - box-shadow: 0 0 10px rgba(34, 197, 94, 0.6); -} - -.competence-checkbox:checked::after, -#aiPrepend:checked::after { - content: "✔"; - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -52%); - font-size: 13px; - font-weight: 700; - color: #fff; -} - -.bilan-container select { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - padding: 10px 40px 10px 14px; - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.08); - color: #fff; - cursor: pointer; - transition: all 0.25s ease; - min-width: 200px; - background-image: url("data:image/svg+xml;charset=UTF-8,%3Csvg width='20' height='20' viewBox='0 0 20 20' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 7L10 12L15 7' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 12px center; - background-size: 16px; -} - -.bilan-container select:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.4); -} - -.bilan-container select:focus { - outline: none; - border-color: rgba(147, 197, 253, 0.9); - box-shadow: 0 0 10px rgba(147, 197, 253, 0.6); - background: rgba(255, 255, 255, 0.18); -} - -.bilan-container select option { - background: #1f2937; -} - -#openaiKey { - min-width: 260px; - background: rgba(255, 255, 255, 0.08) - url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' stroke='white' stroke-width='2' viewBox='0 0 24 24'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' d='M15 7a2 2 0 11-4 0 2 2 0 014 0zm-4 0v10a2 2 0 002 2h4a2 2 0 002-2V7h-8z'/%3E%3C/svg%3E") - no-repeat 12px center; - background-size: 16px; -} - -#openaiKey::placeholder { - color: rgba(255, 255, 255, 0.55); - font-style: italic; -} - -.bilan-container textarea { - width: 97%; - min-height: 120px; - padding: 14px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.08); - color: #fff; - resize: vertical; - transition: all 0.25s ease; - box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.15); -} - -.bilan-container textarea::placeholder { - color: rgba(255, 255, 255, 0.55); - font-style: italic; -} - -.bilan-container textarea:focus { - outline: none; - border-color: rgba(147, 197, 253, 0.8); - background: rgba(255, 255, 255, 0.12); - box-shadow: 0 0 12px rgba(147, 197, 253, 0.6), - inset 0 3px 8px rgba(0, 0, 0, 0.2); -} - -#observation, -.observation-textarea { - min-height: 240px; - font-size: 15px; - line-height: 1.5; - margin-top: 20px; - width: 100%; -} - -.level-group { - margin-top: 15px; - display: flex; - flex-wrap: wrap; - gap: 10px; - align-items: center; -} - -.level-title { - font-size: 0.9rem; - color: rgba(255, 255, 255, 0.75); - margin-right: 0.5rem; -} - -.level-pill { - position: relative; - overflow: hidden; - border-radius: 999px; - transition: all 0.3s ease; -} - -.level-pill input { - position: absolute; - opacity: 0; - pointer-events: none; -} - -.level-pill label { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 6px 10px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.08); - color: #fff; - font-size: 12px; - cursor: pointer; - user-select: none; - transition: all 0.3s ease; - max-width: 36px; - overflow: hidden; -} - -.level-pill label .short, -.level-pill label .long { - transition: opacity 0.25s ease, transform 0.25s ease; -} - -.level-pill label .long { - opacity: 0; - white-space: nowrap; - transform: translateX(10px); -} - -.level-pill label:hover { - max-width: 160px; - padding: 6px 16px; - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.4); -} - -.level-pill label:hover .long { - opacity: 1; - transform: translateX(0); -} - -.level-pill label:hover .short { - opacity: 0; - transform: translateX(-10px); -} - -.level-pill input:checked + label { - max-width: 160px; - padding: 6px 16px; - border-color: rgba(147, 197, 253, 0.9); - background: rgba(147, 197, 253, 0.25); - box-shadow: 0 0 8px rgba(147, 197, 253, 0.4); - font-weight: 600; -} - -.level-pill input:checked + label .long { - opacity: 1; - transform: translateX(0); -} - -.level-pill input:checked + label .short { - opacity: 0; - transform: translateX(-10px); -} - -.ai { - margin-top: 24px; - background: rgba(255, 255, 255, 0.08); - border-radius: 18px; - padding: 20px; - border: 1px solid rgba(255, 255, 255, 0.25); - color: #fff; - display: flex; - flex-direction: column; - gap: 18px; - box-shadow: 0 12px 28px rgba(0, 0, 0, 0.25); - backdrop-filter: blur(14px) saturate(160%); -} - -.ai strong { - font-size: 24px; - font-weight: 600; - text-align: center; - margin-bottom: 6px; - background: linear-gradient(135deg, #93c5fd, #ffffff); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} - -.ai .row { - display: grid; - grid-template-columns: 2fr 1fr; - gap: 32px; -} - -.ai label { - margin-top: 10px; - font-size: 14px; - font-weight: 500; - color: rgba(255, 255, 255, 0.85); -} - -.ai textarea, -.ai input, -.ai select { - width: 100%; -} - -.ai textarea::placeholder, -.ai input::placeholder { - color: rgba(255, 255, 255, 0.55); -} - -.ai textarea:focus, -.ai input:focus, -.ai select:focus { - outline: none; - border-color: rgba(147, 197, 253, 0.9); - box-shadow: 0 0 10px rgba(147, 197, 253, 0.6); -} - -.ai-actions, -.ai .controls { - display: flex; - flex-wrap: wrap; - gap: 12px; - justify-content: center; - margin-top: 10px; -} - -.ai-loader { - display: inline-flex; - align-items: center; - gap: 8px; - padding: 8px 14px; - border-radius: 999px; - background: rgba(255, 255, 255, 0.15); - border: 1px solid rgba(255, 255, 255, 0.2); - color: #fff; - font-size: 0.9rem; - font-weight: 500; -} - -.spinner { - width: 16px; - height: 16px; - border-radius: 50%; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top-color: #93c5fd; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -.status { - display: none; - margin: 15px auto; - max-width: 800px; - padding: 14px 18px; - border-radius: 12px; - font-weight: 500; - font-size: 15px; - text-align: center; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.08); - color: #fff; - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.2); -} - -.status.info { - border-left: 6px solid #3b82f6; -} - -.status.success { - border-left: 6px solid #16a34a; -} - -.status.error { - border-left: 6px solid #dc2626; -} - -.status .progress { - height: 4px; - background: rgba(255, 255, 255, 0.25); - border-radius: 999px; - overflow: hidden; - margin-top: 10px; -} - -.status .progress-bar { - width: 100%; - height: 100%; - background: linear-gradient(90deg, #93c5fd, #1d4ed8); - animation: progress-stripes 1.2s linear infinite; -} - -@keyframes progress-stripes { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } -} - -@media (max-width: 768px) { - .bilan-wrapper { - padding: 1.5rem 1rem; - } - - .bilan-container { - padding: 1.5rem; - } - - .controls, - .ai .row { - flex-direction: column; - grid-template-columns: 1fr; - } - - .competences-grid { - grid-template-columns: 1fr; - } -} diff --git a/frontend/src/components/Admin/Bilans.jsx b/frontend/src/components/Admin/Bilans.jsx deleted file mode 100644 index 52a01b3..0000000 --- a/frontend/src/components/Admin/Bilans.jsx +++ /dev/null @@ -1,1188 +0,0 @@ -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 ( -
- Logo watermark -
-
- -
- - -
-
-

{currentTitle.h1}

-

{currentTitle.h2}

-
-
- -
- setFirstName(event.target.value)} - /> - setLastName(event.target.value)} - /> -
- -
- - - setNewCriterion(event.target.value)} - /> - -
- -
- {bucketOrder.map((bucket) => { - const itemsInBucket = items.filter((item) => item.bucket === bucket); - return ( -
-

- {BUCKET_LABEL[bucket]}{" "} - ({itemsInBucket.length}) -

-
handleBucketDragOver(bucket, event)} - onDragLeave={() => - dragOverBucket === bucket && setDragOverBucket(null) - } - onDrop={(event) => handleBucketDrop(bucket, event)} - > - {itemsInBucket.map((item) => ( -
handleDragStart(item.id)} - onDragEnd={handleDragEnd} - > - - updateItem(item.id, { checked: event.target.checked }) - } - /> - -
- Niveau : - {LEVELS.map((level) => ( - - - updateItem(item.id, { level: level.key }) - } - /> - - - ))} -
- -
- ))} -
-
- ); - })} -
- -
- Résumé -
-
- -