refactor: decouple Bilan styles and migrate assets to optimized build output

This commit is contained in:
sebvtl728 2026-06-21 18:48:10 +02:00
parent bbe7f2ccee
commit b8c3647224
5 changed files with 285 additions and 88 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bilans de compétences</title>
<script type="module" crossorigin src="/assets/index-CPsAkw2K.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-hUIr4MzD.css">
<script type="module" crossorigin src="/assets/index-C9dFjS6F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CMDmPM3c.css">
</head>
<body>
<div id="root"></div>

View File

@ -1023,6 +1023,83 @@
to { opacity: 1; transform: translateY(0); }
}
/* ── Q&A recommandations ── */
.ai-questions-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-questions-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.ai-questions-header label {
margin: 0;
font-size: 12.5px;
font-weight: 600;
color: var(--text-slate-600);
}
.btn-ask-ai {
flex-shrink: 0;
height: 30px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid #a5b4fc;
background: #eef2ff;
color: #4338ca;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: var(--transition);
white-space: nowrap;
}
.btn-ask-ai:hover:not(:disabled) {
background: #e0e7ff;
border-color: #818cf8;
}
.btn-ask-ai:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ai-questions-box {
background: #f0f4ff;
border: 1px solid #c7d2fe;
border-radius: var(--radius-md);
padding: 10px 14px;
font-size: 13px;
color: #334155;
white-space: pre-line;
line-height: 1.6;
}
.ai-answers-textarea {
width: 100%;
min-height: 80px;
padding: 10px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--border-slate-300);
background: #ffffff;
font-size: 13.5px;
font-family: var(--font-sans);
outline: none;
transition: var(--transition);
box-sizing: border-box;
resize: vertical;
}
.ai-answers-textarea:focus {
border-color: #818cf8;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
@media (max-width: 768px) {
.app-topbar {
height: auto;

View File

@ -320,6 +320,9 @@ const Bilan = () => {
const [aiTone, setAiTone] = useState("neutre");
const [aiPrepend, setAiPrepend] = useState(false);
const [mistralModel, setMistralModel] = useState("mistral-small-latest");
const [aiQuestions, setAiQuestions] = useState("");
const [aiAnswers, setAiAnswers] = useState("");
const [isQuestionsLoading, setIsQuestionsLoading] = useState(false);
const [observation, setObservation] = useState("");
const [toasts, setToasts] = useState([]);
const [isAiLoading, setIsAiLoading] = useState(false);
@ -563,6 +566,82 @@ const Bilan = () => {
};
}, [currentCp, firstName, items, lastName]);
const buildBucketText = useCallback((payload) => {
const fmt = (label, arr) =>
arr.length ? `${label} :\n${arr.map((c) => `- ${c}`).join("\n")}` : "";
return [
fmt("Performance", payload.performance),
fmt("Compétence", payload.competence),
fmt("Connaissance", payload.connaissance),
]
.filter(Boolean)
.join("\n\n");
}, []);
const generateAIQuestions = useCallback(async () => {
if (!MISTRAL_API_KEY) {
showStatus("Clé Mistral introuvable.", "error");
return;
}
const payload = buildPayloadForAI();
if (!payload.validatedCount) {
showStatus("Aucune compétence validée à analyser.", "error");
return;
}
setIsQuestionsLoading(true);
showStatus("Génération des questions…", "info", true);
const userMessage = [
`CP évalué : ${payload.cp}${payload.cpTitle}`,
payload.apprenant ? `Apprenant : ${payload.apprenant}` : "",
"",
"Compétences validées :",
buildBucketText(payload),
aiNotes.trim() ? `\nNotes du formateur : ${aiNotes.trim()}` : "",
]
.filter(Boolean)
.join("\n");
try {
const response = await fetch("https://api.mistral.ai/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${MISTRAL_API_KEY}`,
},
body: JSON.stringify({
model: mistralModel,
temperature: 0.5,
messages: [
{
role: "system",
content: `Tu es ingénieur pédagogique expert du Titre Professionnel CDUI (niveau 6). À partir des compétences validées et des notes du formateur, pose exactement 3 questions ciblées et courtes pour affiner les recommandations pédagogiques du bilan ECF.
Les questions doivent aider à cerner : les difficultés rencontrées, les points forts à valoriser, et les axes de progression prioritaires.
Format : 3 questions numérotées (1. 2. 3.), directes, sans introduction ni commentaire.`,
},
{ role: "user", content: userMessage },
],
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`HTTP ${response.status}${text}`);
}
const data = await response.json();
const questions = data?.choices?.[0]?.message?.content?.trim() || "";
if (!questions) throw new Error("Réponse vide.");
setAiQuestions(questions);
setAiAnswers("");
showStatus("Questions générées — répondez ci-dessous puis lancez la synthèse.", "success");
} catch (error) {
showStatus(`Erreur : ${error.message || error}`, "error");
} finally {
dismissProgressToasts();
setIsQuestionsLoading(false);
}
}, [aiNotes, buildBucketText, buildPayloadForAI, dismissProgressToasts, mistralModel, showStatus]);
const generateAISummaryMistral = useCallback(async () => {
if (!MISTRAL_API_KEY) {
showStatus("Clé Mistral introuvable. Vérifie ton fichier .env.local (VITE_MISTRAL_API_KEY).", "error");
@ -576,22 +655,22 @@ const Bilan = () => {
}
const notesFormateur = aiNotes.trim();
const questionsCtx = aiQuestions.trim();
const answersCtx = aiAnswers.trim();
setIsAiLoading(true);
showStatus("Génération de la synthèse ECF via Mistral…", "info", true);
const formatBucket = (label, items) =>
items.length ? `${label} :\n${items.map((c) => `- ${c}`).join("\n")}` : "";
const userMessage = [
`CP évalué : ${payload.cp}${payload.cpTitle}`,
payload.apprenant ? `Apprenant : ${payload.apprenant}` : "",
"",
"Compétences validées (Acquis / Maîtrisé) :",
formatBucket("Performance", payload.performance),
formatBucket("Compétence", payload.competence),
formatBucket("Connaissance", payload.connaissance),
buildBucketText(payload),
notesFormateur ? `\nNotes du formateur : ${notesFormateur}` : "",
questionsCtx && answersCtx
? `\nQuestions posées au formateur :\n${questionsCtx}\n\nRéponses du formateur :\n${answersCtx}`
: "",
]
.filter(Boolean)
.join("\n");
@ -609,13 +688,14 @@ const Bilan = () => {
messages: [
{
role: "system",
content: `Tu es ingénieur pédagogique expert du Titre Professionnel CDUI (Concepteur Développeur d'Interfaces Utilisateur, niveau 6). Tu rédiges des synthèses pour les bilans ECF (Évaluation en Cours de Formation), documents officiels du Ministère du Travail.
content: `Tu es ingénieur pédagogique expert du Titre Professionnel CDUI (Concepteur Développeur d'Interfaces Utilisateur, niveau 6). Tu rédiges des bilans ECF (Évaluation en Cours de Formation), documents officiels du Ministère du Travail.
À partir des compétences validées (Acquis et Maîtrisé) organisées par Performance, Compétence et Connaissance, rédige une synthèse ECF en 4 à 5 lignes maximum.
- Réponds aux attendus officiels du CP évalué.
- Formule un jugement pédagogique global : ne liste pas les critères un à un.
- Sois synthétique et précis, sans fioritures ni répétitions.
- Adopte un registre professionnel et factuel, adapté à un document officiel.`,
À partir des compétences validées, des notes du formateur et si disponibles des réponses du formateur à tes questions, produis un texte structuré en deux blocs distincts :
1. **Synthèse** (3 à 4 lignes) : jugement pédagogique global sur le CP évalué, factuel et professionnel. Ne liste pas les critères un à un.
2. **Recommandations** (2 à 3 lignes) : axes de progression prioritaires, ancrés dans les réponses du formateur si elles sont présentes.
Adopte un registre adapté à un document officiel. Sois synthétique et précis.`,
},
{
role: "user",
@ -673,7 +753,10 @@ const Bilan = () => {
setIsAiLoading(false);
}
}, [
aiAnswers,
aiNotes,
aiQuestions,
buildBucketText,
buildPayloadForAI,
dismissProgressToasts,
insertAISummaryIntoObservation,
@ -1106,6 +1189,31 @@ const Bilan = () => {
/>
</div>
<div className="input-group ai-questions-group">
<div className="ai-questions-header">
<label>Recommandations questions IA</label>
<button
type="button"
className="btn-ask-ai"
onClick={generateAIQuestions}
disabled={isQuestionsLoading || isAiLoading}
>
{isQuestionsLoading ? "…" : "💬 Générer les questions"}
</button>
</div>
{aiQuestions && (
<>
<div className="ai-questions-box">{aiQuestions}</div>
<textarea
className="ai-answers-textarea"
placeholder="Vos réponses aux questions ci-dessus…"
value={aiAnswers}
onChange={(event) => setAiAnswers(event.target.value)}
/>
</>
)}
</div>
<div className="row-options">
<div className="input-group">
<label htmlFor="aiTone">Tonalité de l'IA</label>