Compare commits
6 Commits
affa042c05
...
090cbc19a2
| Author | SHA1 | Date | |
|---|---|---|---|
| 090cbc19a2 | |||
| 794ae69fa0 | |||
| 88b075fb88 | |||
| b5f2eac842 | |||
| a42957ad41 | |||
| 421ae4c2bf |
@@ -498,12 +498,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 2 — EXERCICE 1 ============ -->
|
||||
<!-- ============ SLIDE 2 — EXERCICE 1 : INTRO ============ -->
|
||||
<div class="slide" id="slide-2">
|
||||
<div class="scroll-area">
|
||||
<div class="deco-circle" style="width:280px;height:280px;background:var(--amber-light);top:-40px;right:-40px;opacity:0.5;"></div>
|
||||
<div class="section-tag">Exercice 1 / 4 — 5 min</div>
|
||||
<div class="slide-title">🔎 Recherche par nom de produit</div>
|
||||
<p class="slide-intro">La recherche par nom est le chemin le plus direct dans BioCID : elle donne accès à la <strong>fiche complète</strong> d'un produit déjà identifié — son <strong>type</strong>, sa <strong>substance active</strong>, son <strong>fabricant</strong> et ses <strong>conditions d'utilisation</strong>.</p>
|
||||
<div class="info-card">
|
||||
<span>🎯</span>
|
||||
<div>Pour chaque question, saisissez votre réponse dans le champ prévu. Vos réponses sont <strong>enregistrées automatiquement</strong> et reprises dans le PDF final.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 3 — EXERCICE 1 : QUESTIONS ============ -->
|
||||
<div class="slide" id="slide-3">
|
||||
<div class="scroll-area">
|
||||
<div class="slide-title">Recherche par nom de produit</div>
|
||||
<p class="slide-intro">Trouvez un produit spécifique et relevez ses caractéristiques principales.</p>
|
||||
<ul class="questions-list">
|
||||
<li class="q-item">
|
||||
<div class="q-head"><span class="q-num">1</span><span>Recherchez un produit connu (ex. « Goliath », « Phobi », « Soricide »). Notez son <strong>type de produit</strong>, sa <strong>substance active</strong> et son <strong>fabricant</strong>.</span></div>
|
||||
@@ -521,8 +531,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 3 — EXERCICE 2 ============ -->
|
||||
<div class="slide" id="slide-3">
|
||||
<!-- ============ SLIDE 4 — EXERCICE 2 ============ -->
|
||||
<div class="slide" id="slide-4">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag">Exercice 2 / 4 — 5 min</div>
|
||||
<div class="slide-title">Recherche par type de produit</div>
|
||||
@@ -540,8 +550,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 4 — EXERCICE 3 ============ -->
|
||||
<div class="slide" id="slide-4">
|
||||
<!-- ============ SLIDE 5 — EXERCICE 3 ============ -->
|
||||
<div class="slide" id="slide-5">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag">Exercice 3 / 4 — 5 min</div>
|
||||
<div class="slide-title">Recherche par substance active</div>
|
||||
@@ -559,8 +569,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 5 — EXERCICE 4 (CAS DE SYNTHÈSE) ============ -->
|
||||
<div class="slide" id="slide-5">
|
||||
<!-- ============ SLIDE 6 — EXERCICE 4 (CAS DE SYNTHÈSE) ============ -->
|
||||
<div class="slide" id="slide-6">
|
||||
<div class="scroll-area">
|
||||
<div class="section-tag" style="background:var(--coral-light);color:var(--coral);">Exercice 4 / 4 — Cas de synthèse</div>
|
||||
<div class="slide-title">Mise en situation</div>
|
||||
@@ -585,8 +595,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ SLIDE 6 — ASTUCES / FIN ============ -->
|
||||
<div class="slide" id="slide-6">
|
||||
<!-- ============ SLIDE 7 — ASTUCES / FIN ============ -->
|
||||
<div class="slide" id="slide-7">
|
||||
<div class="scroll-area">
|
||||
<div class="deco-circle" style="width:320px;height:320px;background:var(--amber-light);bottom:-80px;right:-60px;opacity:0.5;"></div>
|
||||
<div style="text-align:center;">
|
||||
@@ -613,15 +623,16 @@
|
||||
<script>
|
||||
let currentSlide = 0;
|
||||
let history = [];
|
||||
const totalSlides = 6;
|
||||
const totalSlides = 7;
|
||||
|
||||
// Configuration des boutons du footer par slide
|
||||
const slideConfig = {
|
||||
1: { next: 2, nextLabel: "Commencer l'exercice 1 →" },
|
||||
2: { hint: 'ex1', next: 3, nextLabel: 'Exercice suivant →' },
|
||||
3: { hint: 'ex2', next: 4, nextLabel: 'Exercice suivant →' },
|
||||
4: { hint: 'ex3', next: 5, nextLabel: 'Cas de synthèse →' },
|
||||
5: { next: 6, nextLabel: "Terminer l'exercice →" }
|
||||
2: { next: 3, nextLabel: 'Aux questions →' },
|
||||
3: { hint: 'ex1', next: 4, nextLabel: 'Exercice suivant →' },
|
||||
4: { hint: 'ex2', next: 5, nextLabel: 'Exercice suivant →' },
|
||||
5: { hint: 'ex3', next: 6, nextLabel: 'Cas de synthèse →' },
|
||||
6: { next: 7, nextLabel: "Terminer l'exercice →" }
|
||||
};
|
||||
|
||||
const hints = {
|
||||
@@ -655,7 +666,7 @@ function goTo(n) {
|
||||
const sa = next.querySelector('.scroll-area');
|
||||
if (sa) sa.scrollTop = 0;
|
||||
updateUI();
|
||||
if (n === 6) setTimeout(launchConfetti, 200);
|
||||
if (n === 7) setTimeout(launchConfetti, 200);
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
@@ -859,6 +870,116 @@ function exporterPDF() {
|
||||
setTimeout(() => w.print(), 300);
|
||||
}
|
||||
|
||||
/* ======================== SAUVEGARDE AUTOMATIQUE ======================== */
|
||||
/* Les réponses sont enregistrées dans le navigateur au fil de la saisie, puis
|
||||
proposées à la restauration au rechargement. En file://, localStorage peut être
|
||||
indisponible (Safari) : dans ce cas la page fonctionne normalement, sans filet. */
|
||||
const CLE_SAUVEGARDE = 'certibiocide:biocid:v1';
|
||||
|
||||
let reponsesModifiees = false;
|
||||
function marquerModifie() { reponsesModifiees = true; sauvegarder(); }
|
||||
|
||||
document.addEventListener('input', marquerModifie, true);
|
||||
document.addEventListener('change', marquerModifie, true);
|
||||
|
||||
// Avertit avant tout rechargement ou départ. Le texte de la boîte est imposé
|
||||
// par le navigateur, il n'est pas personnalisable.
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!reponsesModifiees) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
|
||||
function etatCourant() {
|
||||
const champs = {};
|
||||
document.querySelectorAll('input[id], textarea[id]').forEach(el => {
|
||||
if (el.value) champs[el.id] = el.value;
|
||||
});
|
||||
return { savedAt: new Date().toISOString(), slide: currentSlide, champs };
|
||||
}
|
||||
|
||||
let minuteurSauvegarde = null;
|
||||
function sauvegarder() {
|
||||
clearTimeout(minuteurSauvegarde);
|
||||
minuteurSauvegarde = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(CLE_SAUVEGARDE, JSON.stringify(etatCourant()));
|
||||
} catch (e) { /* navigation privée ou quota : on continue sans sauvegarde */ }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function lireSauvegarde() {
|
||||
try { return JSON.parse(localStorage.getItem(CLE_SAUVEGARDE)); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function effacerSauvegarde() {
|
||||
try { localStorage.removeItem(CLE_SAUVEGARDE); } catch (e) {}
|
||||
}
|
||||
|
||||
function appliquerSauvegarde(data) {
|
||||
Object.entries(data.champs || {}).forEach(([id, v]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = v;
|
||||
});
|
||||
reponsesModifiees = true;
|
||||
if (typeof data.slide === 'number' && data.slide > 0) goTo(data.slide);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
/* Bandeau de restauration : on ne restaure jamais sans demander, pour qu'un
|
||||
nouveau groupe n'hérite pas des réponses du précédent sur un poste partagé. */
|
||||
function injecterStylesBandeau() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.restore-bar {
|
||||
position: fixed; top: 84px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 900; max-width: min(680px, calc(100vw - 32px));
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
background: var(--white); border: 1.5px solid var(--amber);
|
||||
border-radius: 14px; padding: 14px 18px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12); font-size: 13.5px;
|
||||
}
|
||||
.restore-bar .restore-text { flex: 1; min-width: 220px; line-height: 1.5; }
|
||||
.restore-bar .restore-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.restore-bar button {
|
||||
font-family: 'Syne', sans-serif; font-weight: 700; font-size: 12.5px;
|
||||
padding: 8px 16px; border-radius: 20px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: transparent; color: var(--text-muted);
|
||||
}
|
||||
.restore-bar button:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.restore-bar button.primary {
|
||||
background: var(--amber); border-color: var(--amber); color: #fff;
|
||||
}
|
||||
.restore-bar button.primary:hover { background: var(--amber-dark); border-color: var(--amber-dark); color: #fff; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function proposerRestauration() {
|
||||
const data = lireSauvegarde();
|
||||
if (!data || !data.champs || Object.keys(data.champs).length === 0) return;
|
||||
|
||||
const quand = new Date(data.savedAt);
|
||||
const libelle = isNaN(quand.getTime()) ? '' :
|
||||
` du ${quand.toLocaleDateString('fr-FR')} à ${quand.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
injecterStylesBandeau();
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'restore-bar';
|
||||
bar.innerHTML = `
|
||||
<span class="restore-text">💾 Des réponses enregistrées${libelle} ont été retrouvées sur ce poste.</span>
|
||||
<span class="restore-actions">
|
||||
<button type="button" class="primary" id="btnRestaurer">Restaurer</button>
|
||||
<button type="button" id="btnRepartir">Repartir de zéro</button>
|
||||
</span>`;
|
||||
document.body.appendChild(bar);
|
||||
|
||||
document.getElementById('btnRestaurer').onclick = () => { appliquerSauvegarde(data); bar.remove(); };
|
||||
document.getElementById('btnRepartir').onclick = () => { effacerSauvegarde(); bar.remove(); };
|
||||
}
|
||||
|
||||
proposerRestauration();
|
||||
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -962,7 +962,7 @@ span.hotspot { display: inline; padding: 0 2px; -webkit-box-decoration-break: cl
|
||||
</table>
|
||||
<div class="actions-row">
|
||||
<button class="btn btn-amber" onclick="exporterPDF()">⬇ Télécharger mes réponses (PDF)</button>
|
||||
<a href="correction-fds.html" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
<a href="corriger-exercice.html?exo=fds" class="btn btn-outline">✔ Corriger un rendu</a>
|
||||
<button class="btn btn-outline" onclick="restartAll()">↺ Recommencer</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1113,6 +1113,7 @@ function onEtiClick(e) {
|
||||
// Exercice terminé : plus d'interaction (les corrections sont dans la correction générale)
|
||||
if (etiPos >= etiOrder.length) return;
|
||||
|
||||
marquerModifie();
|
||||
const target = etiZones[etiOrder[etiPos]];
|
||||
if (clicked === target.zone) {
|
||||
hs.classList.remove('hs-wrong');
|
||||
@@ -1356,7 +1357,7 @@ document.addEventListener('pointerup', e => {
|
||||
if (!ftDrag) return;
|
||||
const target = document.elementFromPoint(e.clientX, e.clientY);
|
||||
const answerDot = target && target.closest('.match-dot[data-side="right"]');
|
||||
if (answerDot) ftConnections[ftDrag.qid] = answerDot.dataset.answer;
|
||||
if (answerDot) { marquerModifie(); ftConnections[ftDrag.qid] = answerDot.dataset.answer; }
|
||||
document.querySelectorAll('.match-dot.active').forEach(d => d.classList.remove('active'));
|
||||
ftDrag = null;
|
||||
refreshFtState();
|
||||
@@ -1407,6 +1408,7 @@ function initFdsDnd() {
|
||||
r.className = 'result-message'; r.innerHTML = '';
|
||||
};
|
||||
const fill = (slot, v, l) => {
|
||||
marquerModifie();
|
||||
slot.dataset.value = v; slot.textContent = l; slot.classList.add('filled');
|
||||
resetResults(); updateFdsGate();
|
||||
};
|
||||
@@ -1820,6 +1822,23 @@ function launchConfetti() {
|
||||
rafId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
/* ======================== PROTECTION CONTRE LA PERTE DES RÉPONSES ======================== */
|
||||
/* Les réponses ne sont pas enregistrées : on avertit avant tout rechargement ou départ.
|
||||
Le texte de la boîte est imposé par le navigateur, il n'est pas personnalisable. */
|
||||
let reponsesModifiees = false;
|
||||
function marquerModifie() { reponsesModifiees = true; }
|
||||
|
||||
// Les champs texte déclenchent input/change ; les parties FDS (glisser-déposer),
|
||||
// FT (liaisons) et Étiquette (zones cliquables) appellent marquerModifie() directement.
|
||||
document.addEventListener('input', marquerModifie, true);
|
||||
document.addEventListener('change', marquerModifie, true);
|
||||
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!reponsesModifiees) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
generateQuizzes();
|
||||
updateUI();
|
||||
|
||||
@@ -508,6 +508,19 @@
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.info-card {
|
||||
background: var(--amber-light);
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
max-width: 620px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
color: var(--amber-dark);
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.steps-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -707,7 +720,7 @@
|
||||
<div id="idError" style="color:var(--coral);font-size:13px;margin-bottom:0.5rem;display:none;">Merci de renseigner au moins le nom du groupe.</div>
|
||||
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap;">
|
||||
<button class="btn btn-teal" onclick="confirmIdentite()">Continuer →</button>
|
||||
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
|
||||
<a href="corriger-exercice.html?exo=tp" class="btn btn-outline btn-teal">Corriger</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -716,21 +729,26 @@
|
||||
<div class="slide" id="slide-1">
|
||||
<div class="deco-circle" style="width:400px;height:400px;background:var(--amber-light);top:-100px;right:-80px;opacity:0.6;"></div>
|
||||
<div class="deco-circle" style="width:200px;height:200px;background:var(--amber-light);bottom:60px;left:-60px;opacity:0.5;"></div>
|
||||
<div style="text-align:center; z-index:1;">
|
||||
<div class="hero-label">Formation</div>
|
||||
<div class="hero-title">CERTIBIOCIDE</div>
|
||||
<div style="font-family:'Syne',sans-serif;font-size:clamp(14px,2.5vw,22px);font-weight:600;color:var(--amber-mid);letter-spacing:0.04em;margin-bottom:1.5rem;">TRAVAUX PRATIQUES</div>
|
||||
<p class="hero-sub">Votre mission, si vous l'acceptez : maîtriser l'identification, le traitement et la sécurité face aux nuisibles.</p>
|
||||
<button class="btn btn-teal" onclick="goTo(2)">Démarrer la mission →</button>
|
||||
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Avant de commencer</div>
|
||||
<div class="slide-title">Certibiocide — Travaux pratiques</div>
|
||||
<p class="slide-intro">Votre mission, si vous l'acceptez : maîtriser l'identification, le traitement et la sécurité face aux nuisibles.</p>
|
||||
<div class="info-card">
|
||||
<span>🎯</span>
|
||||
<div>3 travaux pratiques vous attendent : définir un <strong>protocole d'intervention</strong>, trouver votre <strong>produit sur BioCID</strong>, puis réaliser l'<strong>analyse de risques</strong>.</div>
|
||||
</div>
|
||||
<button class="btn btn-teal" style="margin-top:1.5rem;" onclick="goTo(2)">Démarrer la mission →</button>
|
||||
</div>
|
||||
|
||||
<!-- ======================== SLIDE 2 — CHOIX DU CAS ======================== -->
|
||||
<div class="slide" id="slide-2">
|
||||
<div class="deco-circle" style="width:300px;height:300px;background:var(--amber-light);top:-60px;left:-60px;opacity:0.4;"></div>
|
||||
<div class="section-tag" style="background:var(--amber-light);color:var(--amber-dark);">Étape 1 / 3</div>
|
||||
<div class="slide-title">Choisissez votre mission</div>
|
||||
<div class="slide-title">🎯 Choisissez votre mission</div>
|
||||
<p class="slide-intro">Chaque groupe choisit un cas pratique. Vous travaillerez avec ce scénario tout au long des 3 exercices.</p>
|
||||
<div class="info-card" style="margin-bottom:1.25rem;">
|
||||
<span>💡</span>
|
||||
<div>Le cas retenu conditionne <strong>tous les exercices suivants</strong> et apparaît dans le PDF final. Choisissez celui qui se rapproche le plus de votre pratique.</div>
|
||||
</div>
|
||||
<div class="case-grid">
|
||||
<div class="case-card" onclick="selectCase('punaises','🛏️','Hôtel — Punaises de lit','Infestation importante dans un hôtel')">
|
||||
<div class="case-icon">🛏️</div>
|
||||
@@ -1160,7 +1178,7 @@
|
||||
<div class="submit-zone">
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
|
||||
<button class="btn btn-teal" onclick="exporterPDF()">⬇ Télécharger en PDF</button>
|
||||
<a href="correction-tp-nuisibles.html" class="btn btn-outline btn-teal">Corriger</a>
|
||||
<a href="corriger-exercice.html?exo=tp" class="btn btn-outline btn-teal">Corriger</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-top:0.75rem;">
|
||||
@@ -1317,6 +1335,7 @@ function updateUI() {
|
||||
}
|
||||
|
||||
function selectCase(key, icon, title, desc) {
|
||||
marquerModifie();
|
||||
selectedCase.key = key;
|
||||
selectedCase.icon = icon;
|
||||
selectedCase.title = title;
|
||||
@@ -1620,6 +1639,156 @@ function escapeHtml(str) {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ======================== SAUVEGARDE AUTOMATIQUE ======================== */
|
||||
/* Les réponses sont enregistrées dans le navigateur au fil de la saisie, puis
|
||||
proposées à la restauration au rechargement. En file://, localStorage peut être
|
||||
indisponible (Safari) : dans ce cas la page fonctionne normalement, sans filet. */
|
||||
const CLE_SAUVEGARDE = 'certibiocide:tp-nuisibles:v1';
|
||||
|
||||
let reponsesModifiees = false;
|
||||
function marquerModifie() { reponsesModifiees = true; sauvegarder(); }
|
||||
|
||||
document.addEventListener('input', marquerModifie, true);
|
||||
document.addEventListener('change', marquerModifie, true);
|
||||
|
||||
// Avertit avant tout rechargement ou départ. Le texte de la boîte est imposé
|
||||
// par le navigateur, il n'est pas personnalisable.
|
||||
window.addEventListener('beforeunload', e => {
|
||||
if (!reponsesModifiees) return;
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
|
||||
function etatCourant() {
|
||||
const champs = {};
|
||||
document.querySelectorAll('input[id], textarea[id]').forEach(el => {
|
||||
if (el.value) champs[el.id] = el.value;
|
||||
});
|
||||
// Les cellules du tableau TP3 n'ont pas d'id : on les repère par leur position.
|
||||
const risques = Array.from(document.querySelectorAll('#riskTableBody .risk-row'))
|
||||
.map(row => Array.from(row.querySelectorAll('.risk-cell')).map(c => c.value));
|
||||
|
||||
return {
|
||||
savedAt: new Date().toISOString(),
|
||||
slide: currentSlide,
|
||||
champs,
|
||||
risques,
|
||||
cas: selectedCase.key || '',
|
||||
formateur: stagiaire.formateur || '',
|
||||
nom: stagiaire.nom || '',
|
||||
date: stagiaire.date || '',
|
||||
};
|
||||
}
|
||||
|
||||
let minuteurSauvegarde = null;
|
||||
function sauvegarder() {
|
||||
clearTimeout(minuteurSauvegarde);
|
||||
minuteurSauvegarde = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(CLE_SAUVEGARDE, JSON.stringify(etatCourant()));
|
||||
} catch (e) { /* navigation privée ou quota : on continue sans sauvegarde */ }
|
||||
}, 400);
|
||||
}
|
||||
|
||||
function lireSauvegarde() {
|
||||
try { return JSON.parse(localStorage.getItem(CLE_SAUVEGARDE)); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
function effacerSauvegarde() {
|
||||
try { localStorage.removeItem(CLE_SAUVEGARDE); } catch (e) {}
|
||||
}
|
||||
|
||||
function appliquerSauvegarde(data) {
|
||||
Object.entries(data.champs || {}).forEach(([id, v]) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = v;
|
||||
});
|
||||
|
||||
// Recrée autant de lignes de risques que nécessaire avant de les remplir
|
||||
const risques = data.risques || [];
|
||||
const tbody = document.getElementById('riskTableBody');
|
||||
while (tbody.querySelectorAll('.risk-row').length < risques.length) addRiskRow();
|
||||
tbody.querySelectorAll('.risk-row').forEach((row, i) => {
|
||||
const valeurs = risques[i] || [];
|
||||
row.querySelectorAll('.risk-cell').forEach((cell, j) => { cell.value = valeurs[j] || ''; });
|
||||
});
|
||||
|
||||
// Le cas pratique et le formateur passent par leurs boutons, qui remettent
|
||||
// aussi l'interface (carte sélectionnée, bouton de confirmation) dans le bon état.
|
||||
if (data.cas) {
|
||||
const carte = Array.from(document.querySelectorAll('.case-card'))
|
||||
.find(c => (c.getAttribute('onclick') || '').includes("'" + data.cas + "'"));
|
||||
if (carte) carte.click();
|
||||
}
|
||||
if (data.formateur) {
|
||||
const btn = document.getElementById('btn' + data.formateur);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
stagiaire.nom = data.nom || stagiaire.nom;
|
||||
stagiaire.date = data.date || stagiaire.date;
|
||||
|
||||
reponsesModifiees = true;
|
||||
if (typeof data.slide === 'number' && data.slide > 0) goTo(data.slide);
|
||||
updateUI();
|
||||
}
|
||||
|
||||
/* Bandeau de restauration : on ne restaure jamais sans demander, pour qu'un
|
||||
nouveau groupe n'hérite pas des réponses du précédent sur un poste partagé. */
|
||||
function injecterStylesBandeau() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.restore-bar {
|
||||
position: fixed; top: 84px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 900; max-width: min(680px, calc(100vw - 32px));
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
background: var(--white); border: 1.5px solid var(--amber);
|
||||
border-radius: 14px; padding: 14px 18px;
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12); font-size: 13.5px;
|
||||
}
|
||||
.restore-bar .restore-text { flex: 1; min-width: 220px; line-height: 1.5; }
|
||||
.restore-bar .restore-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.restore-bar button {
|
||||
font-family: 'Syne', sans-serif; font-weight: 700; font-size: 12.5px;
|
||||
padding: 8px 16px; border-radius: 20px; cursor: pointer;
|
||||
border: 1px solid var(--border); background: transparent; color: var(--text-muted);
|
||||
}
|
||||
.restore-bar button:hover { border-color: var(--amber); color: var(--amber); }
|
||||
.restore-bar button.primary {
|
||||
background: var(--amber); border-color: var(--amber); color: #fff;
|
||||
}
|
||||
.restore-bar button.primary:hover { background: var(--amber-dark); border-color: var(--amber-dark); color: #fff; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function proposerRestauration() {
|
||||
const data = lireSauvegarde();
|
||||
if (!data) return;
|
||||
const aDesReponses = (data.champs && Object.keys(data.champs).length > 0)
|
||||
|| (data.risques || []).some(r => r.some(v => v));
|
||||
if (!aDesReponses) return;
|
||||
|
||||
const quand = new Date(data.savedAt);
|
||||
const libelle = isNaN(quand.getTime()) ? '' :
|
||||
` du ${quand.toLocaleDateString('fr-FR')} à ${quand.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`;
|
||||
|
||||
injecterStylesBandeau();
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'restore-bar';
|
||||
bar.innerHTML = `
|
||||
<span class="restore-text">💾 Des réponses enregistrées${libelle} ont été retrouvées sur ce poste.</span>
|
||||
<span class="restore-actions">
|
||||
<button type="button" class="primary" id="btnRestaurer">Restaurer</button>
|
||||
<button type="button" id="btnRepartir">Repartir de zéro</button>
|
||||
</span>`;
|
||||
document.body.appendChild(bar);
|
||||
|
||||
document.getElementById('btnRestaurer').onclick = () => { appliquerSauvegarde(data); bar.remove(); };
|
||||
document.getElementById('btnRepartir').onclick = () => { effacerSauvegarde(); bar.remove(); };
|
||||
}
|
||||
|
||||
proposerRestauration();
|
||||
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1,525 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction BioCID — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL DE THEMES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
|
||||
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
|
||||
<h1>Plateforme BioCID — Lecture du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis l'exercice BioCID</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> questions renseignées</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
// Les 4 exercices, avec les intitulés exacts exportés (data-question) comme clés.
|
||||
const THEMES = [
|
||||
{
|
||||
title: "Exercice 1 — Recherche par nom",
|
||||
icon: "🔎", color: "amber",
|
||||
items: [
|
||||
"Ex1 — Produit : type, substance active, fabricant",
|
||||
"Ex1 — Date d'autorisation",
|
||||
"Ex1 — Conditions d'utilisation",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 2 — Recherche par type",
|
||||
icon: "🗂️", color: "blue",
|
||||
items: [
|
||||
"Ex2 — Nombre de produits dans la catégorie",
|
||||
"Ex2 — Comparaison entre catégories",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 3 — Recherche par substance",
|
||||
icon: "🧪", color: "teal",
|
||||
items: [
|
||||
"Ex3 — 3 produits contenant la substance",
|
||||
"Ex3 — Formulations et concentrations comparées",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Exercice 4 — Cas de synthèse",
|
||||
icon: "📞", color: "coral",
|
||||
items: [
|
||||
"Ex4 — 3 produits applicables",
|
||||
"Ex4 — Fiche de chaque produit",
|
||||
"Ex4 — Argumentaire (+ bonus FDS)",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
|
||||
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(sans réponse\)/i.test(t);
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Retire les en-têtes/pieds d'impression du navigateur (date, titre, about:blank) sans toucher au corps
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/\s+\d{1,3}\/\d{1,3}\s*$/, " ");
|
||||
clean = clean.replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Réponses aux exercices"]),
|
||||
};
|
||||
|
||||
// Sépare "Groupe N" du nom des participants
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
const startIdx = clean.search(/Réponses aux exercices/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
|
||||
|
||||
const answers = {};
|
||||
for (let i = 0; i < ALL_SECTIONS.length; i++) {
|
||||
const label = ALL_SECTIONS[i];
|
||||
const nextLabel = ALL_SECTIONS[i + 1];
|
||||
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
|
||||
}
|
||||
|
||||
return { meta, answers };
|
||||
}
|
||||
|
||||
let currentThemeData = null;
|
||||
let currentTheme = 0;
|
||||
|
||||
function renderThemePanel() {
|
||||
const theme = THEMES[currentTheme];
|
||||
const data = currentThemeData;
|
||||
const colors = COLOR_MAP[theme.color];
|
||||
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
|
||||
<div class="theme-title">${theme.title}</div>
|
||||
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${theme.items.map(label => {
|
||||
const value = data.answers[label] || "";
|
||||
const empty = isEmpty(value);
|
||||
const displayLabel = label.replace(/^Ex\d+\s*[—-]\s*/, "");
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(displayLabel)}</div>
|
||||
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentTheme === 0;
|
||||
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= THEMES.length) return;
|
||||
currentTheme = idx;
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentThemeData = data;
|
||||
currentTheme = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
|
||||
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
|
||||
|
||||
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderThemePanel();
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export de l'exercice BioCID.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
|
||||
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
|
||||
(function autoLoadFromHub() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auto") !== "1") return;
|
||||
const dataUrl = localStorage.getItem("correctionFile");
|
||||
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
|
||||
localStorage.removeItem("correctionFile");
|
||||
localStorage.removeItem("correctionFileName");
|
||||
localStorage.removeItem("correctionExo");
|
||||
if (!dataUrl) return;
|
||||
try {
|
||||
const bin = atob(dataUrl.split(",").pop());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
handleFile(new File([bytes], name, { type: "application/pdf" }));
|
||||
} catch (e) { console.error(e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,622 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction FDS, FT & Étiquettes — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* SCORE GLOBAL */
|
||||
.score-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.score-ring {
|
||||
width: 84px; height: 84px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
font-family: 'Syne', sans-serif;
|
||||
}
|
||||
.score-ring .score-pct { font-size: 22px; font-weight: 800; line-height: 1; }
|
||||
.score-ring .score-lbl { font-size: 9px; letter-spacing: 0.06em; opacity: 0.9; }
|
||||
.score-detail .score-main { font-family: 'Syne', sans-serif; font-size: 18px; font-weight: 800; }
|
||||
.score-detail .score-sub { font-size: 13px; color: var(--text-muted); margin-top: 3px; }
|
||||
.score-parts { margin-left: auto; display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.score-pill {
|
||||
font-size: 12px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* CARROUSEL DE PARTIES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 5px; }
|
||||
.qcard .qgroup { font-size: 13px; }
|
||||
.qcard .qgroup.ok { color: var(--teal); }
|
||||
.qcard .qgroup.ko { color: var(--coral); }
|
||||
.qcard .qgroup.miss { color: var(--text-muted); font-style: italic; }
|
||||
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction de TD</div>
|
||||
<h1>FDS, FT & Étiquettes — Correction du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du groupe ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TD FDS, FT & Étiquettes</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="score-card" id="scoreCard"></div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentPart - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentPart + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
/* ==== Clé de correction (identique à l'exercice) ==== */
|
||||
const fdsQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
|
||||
{ id: 2, text: "Quels sont les dangers du produit ?", answer: "2" },
|
||||
{ id: 3, text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
|
||||
{ id: 4, text: "Quels gestes adopter en premiers secours ?", answer: "4" },
|
||||
{ id: 5, text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
|
||||
{ id: 6, text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
|
||||
{ id: 7, text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
|
||||
{ id: 8, text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
|
||||
{ id: 9, text: "Quel est le pH de ce produit ?", answer: "9" },
|
||||
{ id: 10, text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
|
||||
{ id: 11, text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
|
||||
{ id: 12, text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
|
||||
{ id: 13, text: "Comment éliminer le produit ?", answer: "13" },
|
||||
{ id: 14, text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
|
||||
{ id: 15, text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
|
||||
{ id: 16, text: "Où trouver le mode d'emploi du produit ?", answer: "absent" }
|
||||
];
|
||||
const ftQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
|
||||
{ id: 2, text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
|
||||
{ id: 3, text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
|
||||
{ id: 4, text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
|
||||
{ id: 5, text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" }
|
||||
];
|
||||
const etiquetteQuestions = [
|
||||
{ id: 1, text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
|
||||
{ id: 2, text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
|
||||
{ id: 3, text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
|
||||
{ id: 4, text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
|
||||
{ id: 5, text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
|
||||
{ id: 6, text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
|
||||
{ id: 7, text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
|
||||
{ id: 8, text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
|
||||
{ id: 9, text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
|
||||
{ id: 10, text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" }
|
||||
];
|
||||
|
||||
const PARTS = [
|
||||
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
|
||||
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
|
||||
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
|
||||
];
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
// Extrait la réponse du groupe pour un marqueur [Px-Qn]
|
||||
function extractSelected(fullText, marker, nextMarker) {
|
||||
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
|
||||
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
/* ==== Correction ==== */
|
||||
function normFdsToken(sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return null;
|
||||
if (/^absent$/i.test(sel.trim())) return "absent";
|
||||
const m = /(\d+)/.exec(sel);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function scoreFds(q, sel) {
|
||||
const tok = normFdsToken(sel);
|
||||
if (tok === null) return { state: "miss" };
|
||||
return { state: q.answer.split(",").includes(tok) ? "ok" : "ko" };
|
||||
}
|
||||
function scoreText(q, sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return { state: "miss" };
|
||||
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
|
||||
return { state: norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko" };
|
||||
}
|
||||
function expectedLabel(part, q) {
|
||||
if (part.kind === "fds") {
|
||||
const parts = q.answer.split(",");
|
||||
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
|
||||
return parts[0] === "absent" ? "Absent" : "Rubrique " + parts[0];
|
||||
}
|
||||
return q.answer;
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Nettoie les en-têtes/pieds d'impression du navigateur sans toucher au corps du document
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/FDS, FT (&|&) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Score global"]),
|
||||
};
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
// Liste ordonnée de tous les marqueurs pour borner chaque extraction
|
||||
const markers = [];
|
||||
PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
|
||||
|
||||
const selected = {};
|
||||
let mIdx = 0;
|
||||
PARTS.forEach(p => {
|
||||
p.questions.forEach((q, i) => {
|
||||
const marker = "[" + p.prefix + "-Q" + (i + 1) + "]";
|
||||
const next = markers[mIdx + 1];
|
||||
selected[marker] = extractSelected(clean, marker, next);
|
||||
mIdx++;
|
||||
});
|
||||
});
|
||||
|
||||
return { meta, selected };
|
||||
}
|
||||
|
||||
let currentData = null;
|
||||
let currentPart = 0;
|
||||
|
||||
function partResults(part) {
|
||||
return part.questions.map((q, i) => {
|
||||
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
|
||||
const sel = (currentData.selected[marker] || "").trim();
|
||||
const res = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
|
||||
return { q, i, sel, state: res.state };
|
||||
});
|
||||
}
|
||||
|
||||
function renderPart() {
|
||||
const part = PARTS[currentPart];
|
||||
const colors = COLOR_MAP[part.color];
|
||||
const results = partResults(part);
|
||||
const ok = results.filter(r => r.state === "ok").length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${part.icon}</div>
|
||||
<div class="theme-title">${part.title}</div>
|
||||
<div class="theme-count">${ok}/${results.length} corrects</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${results.map(r => {
|
||||
const badge = r.state === "ok" ? "ok" : (r.state === "ko" ? "ko" : "miss");
|
||||
const badgeIcon = r.state === "ok" ? "✓" : (r.state === "ko" ? "✗" : "?");
|
||||
const groupTxt = r.state === "miss" ? "Sans réponse" : escapeHtml(r.sel);
|
||||
const showExpected = r.state !== "ok";
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${badge}">${badgeIcon}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">Q${r.i + 1}. ${escapeHtml(r.q.text)}</div>
|
||||
<div class="qgroup ${badge}">Réponse du groupe : ${groupTxt}</div>
|
||||
${showExpected ? `<div class="qexpected">Attendu : ${escapeHtml(expectedLabel(part, r.q))}</div>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentPart === 0;
|
||||
document.getElementById("nextBtn").disabled = currentPart === PARTS.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentPart));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= PARTS.length) return;
|
||||
currentPart = idx;
|
||||
renderPart();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentData = data;
|
||||
currentPart = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) : ""}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
// Score global + par partie
|
||||
let totalOk = 0, totalNb = 0;
|
||||
const pillHtml = PARTS.map(p => {
|
||||
const results = partResults(p);
|
||||
const ok = results.filter(r => r.state === "ok").length;
|
||||
totalOk += ok; totalNb += results.length;
|
||||
return `<div class="score-pill">${p.icon} ${ok}/${results.length}</div>`;
|
||||
}).join("");
|
||||
|
||||
const pct = totalNb ? Math.round((totalOk / totalNb) * 100) : 0;
|
||||
const ringColor = pct >= 80 ? "var(--teal-mid)" : (pct >= 60 ? "var(--amber)" : "var(--coral-mid)");
|
||||
const verdict = pct >= 80 ? "Très bon rendu" : (pct >= 60 ? "Rendu correct" : "À revoir en priorité");
|
||||
|
||||
document.getElementById("scoreCard").innerHTML = `
|
||||
<div class="score-ring" style="background:${ringColor};">
|
||||
<div class="score-pct">${pct}%</div>
|
||||
<div class="score-lbl">RÉUSSITE</div>
|
||||
</div>
|
||||
<div class="score-detail">
|
||||
<div class="score-main">${totalOk} / ${totalNb} bonnes réponses</div>
|
||||
<div class="score-sub">${verdict}</div>
|
||||
</div>
|
||||
<div class="score-parts">${pillHtml}</div>
|
||||
`;
|
||||
|
||||
document.getElementById("dots").innerHTML = PARTS.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderPart();
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TD FDS, FT & Étiquettes.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
|
||||
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
|
||||
(function autoLoadFromHub() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auto") !== "1") return;
|
||||
const dataUrl = localStorage.getItem("correctionFile");
|
||||
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
|
||||
localStorage.removeItem("correctionFile");
|
||||
localStorage.removeItem("correctionFileName");
|
||||
localStorage.removeItem("correctionExo");
|
||||
if (!dataUrl) return;
|
||||
try {
|
||||
const bin = atob(dataUrl.split(",").pop());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
handleFile(new File([bytes], name, { type: "application/pdf" }));
|
||||
} catch (e) { console.error(e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,668 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Correction TP Nuisibles — Certibiocide</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=DM+Sans:ital,wght@0,400;0,500;1,400&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="../header-style.css"/>
|
||||
<style>
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.syne { font-family: 'Syne', sans-serif; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
h1 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
/* DROPZONE */
|
||||
#dropzone {
|
||||
background: #fff;
|
||||
border: 2px dashed rgba(186,117,23,0.35);
|
||||
border-radius: 16px;
|
||||
padding: 56px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
#dropzone.drag { border-color: var(--amber); background: var(--amber-light); }
|
||||
#dropzone .icon { font-size: 40px; margin-bottom: 12px; }
|
||||
#dropzone p.title { font-family: 'Syne', sans-serif; font-weight: 700; font-size: 16px; margin-bottom: 6px; }
|
||||
#dropzone p.sub { color: var(--text-muted); font-size: 13.5px; margin-bottom: 20px; }
|
||||
#filePicker { display: none; }
|
||||
.btn-pick {
|
||||
font-family: 'Syne', sans-serif;
|
||||
display: inline-block;
|
||||
background: var(--amber);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
padding: 10px 26px;
|
||||
border-radius: 30px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
#status { margin-top: 16px; font-size: 13px; }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* RESULT */
|
||||
#result { display: none; }
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
.id-card { display: none; }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.id-card .label {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: var(--amber-dark);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.id-card .value { font-size: 14.5px; margin-top: 2px; }
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--teal); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL DE THEMES */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 420px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.miss { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .qtext { flex: 1; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; }
|
||||
.qcard .qanswer.empty { color: var(--coral); font-style: italic; }
|
||||
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* TABLEAU ANALYSE DE RISQUES */
|
||||
.risk-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.risk-table th {
|
||||
font-family: 'Syne', sans-serif;
|
||||
text-align: left;
|
||||
font-size: 11.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--amber-dark);
|
||||
background: var(--amber-light);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.risk-table th:first-child { border-radius: 8px 0 0 8px; }
|
||||
.risk-table th:last-child { border-radius: 0 8px 8px 0; }
|
||||
.risk-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.risk-table tr:last-child td { border-bottom: none; }
|
||||
.meta-line { font-size: 12.5px; color: var(--text-muted); margin-top: 20px; }
|
||||
.meta-line strong { color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">Gauthier Chombart & Nathan Chauwin</div>
|
||||
<nav>
|
||||
<a href="../index.html">Accueil</a>
|
||||
<a href="../index.html#formations">Exercices</a>
|
||||
<a href="../index.html#documentation">Documentation</a>
|
||||
<a href="../index.html#rapports">Rapports</a>
|
||||
<a href="../index.html#about">À propos</a>
|
||||
<a href="../index.html#contact">Contact</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction de TP</div>
|
||||
<h1>TP Nuisibles — Lecture du rendu</h1>
|
||||
|
||||
<div id="dropzone">
|
||||
<div class="icon">📄</div>
|
||||
<p class="title">Déposez le PDF du stagiaire ici</p>
|
||||
<p class="sub">ou cliquez pour sélectionner le fichier exporté depuis le TP</p>
|
||||
<label class="btn-pick">Choisir un fichier
|
||||
<input type="file" id="filePicker" accept="application/pdf">
|
||||
</label>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="id-card" id="idCard"></div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> questions répondues</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentTheme - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentTheme + 1)">→</button>
|
||||
</div>
|
||||
<div id="themePanel"></div>
|
||||
</div>
|
||||
|
||||
<h2 class="syne" style="font-size:16px;font-weight:800;margin-bottom:12px;color:var(--amber-dark);">Analyse de risques</h2>
|
||||
<div class="table-wrap" id="tp3Meta"></div>
|
||||
<div class="table-wrap" id="tp3Table" style="display:none;"></div>
|
||||
<p class="meta-line" id="suiviLine"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
// Thèmes regroupant les sections du TP, avec icône et couleur
|
||||
const THEMES = [
|
||||
{
|
||||
title: "Premier contact & diagnostic",
|
||||
icon: "🗣️", color: "purple",
|
||||
items: [
|
||||
"Questions posées au client lors du premier contact",
|
||||
"Conseils donnés en premier lieu",
|
||||
"Actions de prévention menées sur le site",
|
||||
"Éléments observés / preuve de présence du nuisible",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Solutions envisagées",
|
||||
icon: "🧰", color: "blue",
|
||||
items: [
|
||||
"Solutions alternatives non-biocides",
|
||||
"Solutions biocides mises en place",
|
||||
"Type de produit (TP) et mode de traitement",
|
||||
"Suivi mis en place et durée",
|
||||
"Gestion des déchets",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Produit & traçabilité",
|
||||
icon: "🧪", color: "teal",
|
||||
items: [
|
||||
"FDS trouvée (éléments principaux)",
|
||||
"Nom du produit et fabricant/fournisseur",
|
||||
"Numéro AMM",
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "Sécurité & santé",
|
||||
icon: "🛡️", color: "coral",
|
||||
items: [
|
||||
"Risques pour la santé",
|
||||
"EPI à porter",
|
||||
"Risques environnement / dispersion accidentelle",
|
||||
"DL50 substance(s) active(s)",
|
||||
"Gestes de premiers secours",
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const ALL_SECTIONS = THEMES.flatMap(t => t.items);
|
||||
|
||||
const COLOR_MAP = {
|
||||
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(sans réponse\)$/i.test(t);
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
function parsePdfText(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/[ \t]+\n/g, "\n").replace(/\s+/g, " ").trim();
|
||||
// Retire les répétitions d'en-tête de page (ex: "17/06/2026 15:42 Résultats TP — ... about:blank 1/2")
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4}\s+\d{1,2}:\d{2}\s+Résultats TP[^]*?about:blank\s*\d\/\d/gi, " ");
|
||||
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", ["Cas pratique"]),
|
||||
cas: captureBetween(clean, "Cas pratique", ["Réponses TP1"]),
|
||||
};
|
||||
|
||||
// Sépare "Groupe N" du nom des participants (ex: "Groupe 1 Romain Kevin Valere Greg P")
|
||||
const groupMatch = /^Groupe\s*(\d+)\s*(.*)$/i.exec(meta.nom.trim());
|
||||
if (groupMatch) {
|
||||
meta.groupe = "Groupe " + groupMatch[1];
|
||||
meta.participants = groupMatch[2].trim();
|
||||
} else {
|
||||
meta.groupe = "";
|
||||
meta.participants = meta.nom.trim();
|
||||
}
|
||||
|
||||
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
|
||||
const endIdx = clean.search(/TP3\s*[—-]\s*Analyse de risques/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
|
||||
|
||||
const answers = {};
|
||||
for (let i = 0; i < ALL_SECTIONS.length; i++) {
|
||||
const label = ALL_SECTIONS[i];
|
||||
const nextLabel = ALL_SECTIONS[i + 1];
|
||||
answers[label] = captureBetween(block, label, nextLabel ? [nextLabel] : []);
|
||||
}
|
||||
|
||||
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
|
||||
const tp3 = {
|
||||
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
|
||||
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
|
||||
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
|
||||
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
|
||||
};
|
||||
|
||||
// Tableau de risques : on découpe en s'appuyant sur les intitulés d'étapes connus du TP
|
||||
const KNOWN_STEPS = [
|
||||
"remplir la poudreuse",
|
||||
"sécurisation du site",
|
||||
"application traitements",
|
||||
"rangement du matériel. purger la perche",
|
||||
"retrait du nid",
|
||||
"gestion des déchets. élimination par des filiaires spé.",
|
||||
];
|
||||
let riskRows = [];
|
||||
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
|
||||
if (tableMatch) {
|
||||
let raw = tableMatch[1].trim();
|
||||
if (raw) {
|
||||
// Repère les positions de chaque étape connue dans le texte aplati
|
||||
const positions = [];
|
||||
KNOWN_STEPS.forEach(step => {
|
||||
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
|
||||
if (idx >= 0) positions.push({ step, idx });
|
||||
});
|
||||
positions.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
if (positions.length > 0) {
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const from = positions[i].idx + positions[i].step.length;
|
||||
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
|
||||
let rest = raw.slice(from, to).trim();
|
||||
// rest contient "risque... mesure..." -> on coupe sur les mots clés de mesures (gants/masques, barrieres, etc.)
|
||||
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
|
||||
const mm = measureRx.exec(rest);
|
||||
let risque = rest, mesure = "";
|
||||
if (mm) {
|
||||
risque = rest.slice(0, mm.index).trim();
|
||||
mesure = rest.slice(mm.index).trim();
|
||||
}
|
||||
risque = risque.replace(/^[\s\-–.]+/, "").trim();
|
||||
riskRows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
|
||||
}
|
||||
} else {
|
||||
riskRows.push({ etape: raw, risque: "", mesure: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
|
||||
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
|
||||
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
|
||||
|
||||
return {
|
||||
meta, answers, tp3, riskRows,
|
||||
suivi: {
|
||||
realise: mReal ? mReal[1].trim() : "",
|
||||
verifie: mVerif ? mVerif[1].trim() : "",
|
||||
maj: mMaj ? mMaj[1].trim() : "",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let currentThemeData = null;
|
||||
let currentTheme = 0;
|
||||
|
||||
function renderThemePanel() {
|
||||
const theme = THEMES[currentTheme];
|
||||
const data = currentThemeData;
|
||||
const colors = COLOR_MAP[theme.color];
|
||||
const themeAnswered = theme.items.filter(s => !isEmpty(data.answers[s])).length;
|
||||
|
||||
document.getElementById("themePanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${theme.icon}</div>
|
||||
<div class="theme-title">${theme.title}</div>
|
||||
<div class="theme-count">${themeAnswered}/${theme.items.length}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${theme.items.map(label => {
|
||||
const value = data.answers[label] || "";
|
||||
const empty = isEmpty(value);
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${empty ? 'miss' : 'ok'}">${empty ? '!' : '✓'}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(label)}</div>
|
||||
<div class="qanswer ${empty ? 'empty' : ''}">${empty ? 'Sans réponse' : escapeHtml(value)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentTheme === 0;
|
||||
document.getElementById("nextBtn").disabled = currentTheme === THEMES.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentTheme));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (idx < 0 || idx >= THEMES.length) return;
|
||||
currentTheme = idx;
|
||||
renderThemePanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentThemeData = data;
|
||||
currentTheme = 0;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
if (data.meta.participants) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(data.meta.participants)}</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("idCard").innerHTML = `
|
||||
<div><div class="label">Groupe / Stagiaire</div><div class="value">${escapeHtml(data.meta.nom) || "—"}</div></div>
|
||||
<div><div class="label">Date de session</div><div class="value">${escapeHtml(data.meta.date_session) || "—"}</div></div>
|
||||
<div><div class="label">Formateur</div><div class="value">${escapeHtml(data.meta.formateur) || "—"}</div></div>
|
||||
<div><div class="label">Cas pratique</div><div class="value">${escapeHtml(data.meta.cas) || "—"}</div></div>
|
||||
`;
|
||||
|
||||
const answeredCount = ALL_SECTIONS.filter(s => !isEmpty(data.answers[s])).length;
|
||||
document.getElementById("progressFill").style.width = (answeredCount / ALL_SECTIONS.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${answeredCount} / ${ALL_SECTIONS.length}`;
|
||||
|
||||
document.getElementById("dots").innerHTML = THEMES.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? ' active' : ''}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
renderThemePanel();
|
||||
|
||||
document.getElementById("tp3Meta").innerHTML = `
|
||||
<p style="margin-bottom:6px;"><strong>Intitulé :</strong> ${escapeHtml(data.tp3.intitule) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p style="margin-bottom:6px;"><strong>Description :</strong> ${escapeHtml(data.tp3.description) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p style="margin-bottom:6px;"><strong>Lieu :</strong> ${escapeHtml(data.tp3.lieu) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
<p><strong>Début prévu le :</strong> ${escapeHtml(data.tp3.debut) || '<em style="color:var(--coral)">—</em>'}</p>
|
||||
`;
|
||||
|
||||
const tp3TableEl = document.getElementById("tp3Table");
|
||||
if (data.riskRows && data.riskRows.length > 0) {
|
||||
tp3TableEl.style.display = "block";
|
||||
tp3TableEl.innerHTML = `
|
||||
<table class="risk-table">
|
||||
<thead><tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr></thead>
|
||||
<tbody>
|
||||
${data.riskRows.map(r => `
|
||||
<tr>
|
||||
<td>${escapeHtml(r.etape) || "—"}</td>
|
||||
<td>${escapeHtml(r.risque) || "—"}</td>
|
||||
<td>${escapeHtml(r.mesure) || "—"}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
} else {
|
||||
tp3TableEl.style.display = "none";
|
||||
}
|
||||
|
||||
document.getElementById("suiviLine").innerHTML =
|
||||
`Réalisée par : <strong>${escapeHtml(data.suivi.realise) || "—"}</strong> — Vérifiée par : <strong>${escapeHtml(data.suivi.verifie) || "—"}</strong> — Mise à jour par : <strong>${escapeHtml(data.suivi.maj) || "—"}</strong>`;
|
||||
|
||||
document.getElementById("dropzone").style.display = "none";
|
||||
document.getElementById("result").style.display = "block";
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
if (!file) return;
|
||||
const statusEl = document.getElementById("status");
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
const data = parsePdfText(fullText);
|
||||
renderResult(data);
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export du TP.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("dropzone").style.display = "block";
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("status").textContent = "";
|
||||
document.getElementById("filePicker").value = "";
|
||||
document.getElementById("tp3Table").style.display = "none";
|
||||
}
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
dropzone.addEventListener("dragover", e => { e.preventDefault(); dropzone.classList.add("drag"); });
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("drag"));
|
||||
dropzone.addEventListener("drop", e => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("drag");
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
document.getElementById("filePicker").addEventListener("change", e => handleFile(e.target.files?.[0]));
|
||||
|
||||
// Chargement automatique depuis la page « Corriger un exercice » (?auto=1)
|
||||
(function autoLoadFromHub() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("auto") !== "1") return;
|
||||
const dataUrl = localStorage.getItem("correctionFile");
|
||||
const name = localStorage.getItem("correctionFileName") || "rendu.pdf";
|
||||
localStorage.removeItem("correctionFile");
|
||||
localStorage.removeItem("correctionFileName");
|
||||
localStorage.removeItem("correctionExo");
|
||||
if (!dataUrl) return;
|
||||
try {
|
||||
const bin = atob(dataUrl.split(",").pop());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
handleFile(new File([bytes], name, { type: "application/pdf" }));
|
||||
} catch (e) { console.error(e); }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -10,7 +10,7 @@
|
||||
body {
|
||||
padding: calc(36px + 70px) 20px 80px;
|
||||
}
|
||||
.wrap { max-width: 820px; margin: 0 auto; }
|
||||
.wrap { max-width: 860px; margin: 0 auto; }
|
||||
.eyebrow {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 12px;
|
||||
@@ -142,6 +142,183 @@ h1 {
|
||||
#status.hint { color: var(--text-muted); }
|
||||
#status.loading { color: var(--amber); }
|
||||
#status.error { color: var(--coral); }
|
||||
|
||||
/* ======================= RÉSULTAT ======================= */
|
||||
#result { display: none; }
|
||||
#setup.hidden { display: none; }
|
||||
|
||||
.toprow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#fileName { font-size: 12.5px; color: var(--text-muted); }
|
||||
.btn-reset {
|
||||
font-family: 'Syne', sans-serif;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
padding: 6px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-reset:hover { border-color: var(--amber); color: var(--amber); }
|
||||
|
||||
/* BANDEAU PARTICIPANTS */
|
||||
#participantsBanner {
|
||||
background: linear-gradient(135deg, var(--amber) 0%, #D8923A 100%);
|
||||
border-radius: 16px;
|
||||
padding: 20px 26px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#participantsBanner .pb-icon {
|
||||
width: 48px; height: 48px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
#participantsBanner .pb-group {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
#participantsBanner .pb-names {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
#participantsBanner .pb-meta {
|
||||
margin-left: auto;
|
||||
font-size: 12.5px;
|
||||
text-align: right;
|
||||
opacity: 0.92;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.progress-row { display: flex; align-items: center; gap: 10px; margin-bottom: 30px; font-size: 13px; color: var(--text-muted); }
|
||||
.progress-track { flex: 1; height: 7px; background: rgba(0,0,0,0.08); border-radius: 6px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--amber); transition: width 0.4s ease; }
|
||||
.progress-row strong { font-family: 'Syne', sans-serif; color: var(--text); }
|
||||
|
||||
/* CARROUSEL */
|
||||
.carousel-wrap { margin-bottom: 36px; }
|
||||
.carousel-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.nav-btn {
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover { background: var(--amber-light); border-color: var(--amber); }
|
||||
.nav-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.nav-btn:disabled:hover { background: #fff; border-color: var(--border); }
|
||||
.dots { display: flex; gap: 7px; }
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; background: rgba(0,0,0,0.15); cursor: pointer; transition: background 0.15s, transform 0.15s; }
|
||||
.dot.active { background: var(--amber); transform: scale(1.3); }
|
||||
|
||||
.theme-panel {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 26px 28px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.theme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.theme-icon {
|
||||
width: 44px; height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.theme-title { font-family: 'Syne', sans-serif; font-weight: 800; font-size: 17px; }
|
||||
.theme-count { font-size: 12.5px; color: var(--text-muted); margin-left: auto; white-space: nowrap; }
|
||||
|
||||
.cards { display: flex; flex-direction: column; gap: 10px; }
|
||||
.qcard {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 18px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
.qcard:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.06); }
|
||||
.qcard .badge {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.qcard .badge.ok { background: var(--teal-light); color: var(--teal); }
|
||||
.qcard .badge.ko { background: var(--coral-light); color: var(--coral); }
|
||||
.qcard .badge.miss { background: rgba(0,0,0,0.06); color: var(--text-muted); }
|
||||
.qcard .qtext { flex: 1; min-width: 0; }
|
||||
.qcard .qlabel { font-weight: 600; font-size: 13.5px; margin-bottom: 4px; }
|
||||
.qcard .qanswer { font-size: 13.5px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.qcard .qanswer.empty { color: var(--text-muted); font-style: italic; }
|
||||
.qcard .qexpected { font-size: 12.5px; color: var(--text-muted); margin-top: 4px; }
|
||||
.qcard .qexpected strong { color: var(--teal); }
|
||||
|
||||
/* ANNEXES (TP3 …) */
|
||||
.annex {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 24px 28px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.annex h2 {
|
||||
font-family: 'Syne', sans-serif;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.annex .kv { font-size: 13.5px; margin-bottom: 6px; }
|
||||
.annex .kv span { color: var(--text-muted); }
|
||||
.table-scroll { overflow-x: auto; margin-top: 14px; }
|
||||
.annex table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 520px; }
|
||||
.annex th, .annex td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
.annex th { background: var(--amber-light); font-family: 'Syne', sans-serif; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -158,9 +335,10 @@ h1 {
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<div id="setup">
|
||||
<div class="eyebrow">Certibiocide — Correction des rendus</div>
|
||||
<h1>Corriger un exercice</h1>
|
||||
<p class="lead">Sélectionnez l'exercice concerné, puis déposez le fichier PDF exporté par le groupe. La lecture du rendu s'ouvre automatiquement.</p>
|
||||
<p class="lead">Sélectionnez l'exercice concerné, puis déposez le fichier PDF exporté par le groupe. Les réponses s'affichent directement ici, exercice par exercice.</p>
|
||||
|
||||
<p class="step-label"><span class="step-num">1</span> Choisissez l'exercice à corriger</p>
|
||||
<div class="exo-grid" id="exoGrid">
|
||||
@@ -211,21 +389,541 @@ h1 {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result">
|
||||
<div id="participantsBanner" style="display:none;"></div>
|
||||
<div class="eyebrow">Certibiocide — Correction d'exercice</div>
|
||||
<h1 id="resultTitle"></h1>
|
||||
|
||||
<div class="toprow">
|
||||
<span id="fileName"></span>
|
||||
<button class="btn-reset" onclick="resetAll()">↺ Charger un autre PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="progress-row">
|
||||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||||
<span><strong id="progressCount"></strong> <span id="progressLabel"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-wrap">
|
||||
<div class="carousel-nav">
|
||||
<button class="nav-btn" id="prevBtn" onclick="goTo(currentGroup - 1)">←</button>
|
||||
<div class="dots" id="dots"></div>
|
||||
<button class="nav-btn" id="nextBtn" onclick="goTo(currentGroup + 1)">→</button>
|
||||
</div>
|
||||
<div id="groupPanel"></div>
|
||||
</div>
|
||||
|
||||
<div id="annexes"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 Gauthier Chombart & Nathan Chauwin<br>Tous droits réservés — Formations Certibiocide & Techniques — Formateurs indépendants</p>
|
||||
<p style="margin-top: 10px; opacity: 0.6;">Tous droits réservés — Gauthier Chombart & Nathan Chauwin — Formateurs indépendants</p>
|
||||
</footer>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
// Chaque exercice pointe vers sa page de correction dédiée (parseur spécifique).
|
||||
const CORRECTORS = {
|
||||
biocid: "correction-biocid.html",
|
||||
fds: "correction-fds.html",
|
||||
tp: "correction-tp-nuisibles.html",
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js";
|
||||
|
||||
/* ======================= OUTILS COMMUNS ======================= */
|
||||
|
||||
const COLOR_MAP = {
|
||||
amber: { bg: "var(--amber-light)", fg: "var(--amber-dark)" },
|
||||
blue: { bg: "var(--blue-light)", fg: "var(--blue)" },
|
||||
teal: { bg: "var(--teal-light)", fg: "var(--teal)" },
|
||||
coral: { bg: "var(--coral-light)", fg: "var(--coral)" },
|
||||
purple: { bg: "var(--purple-light)", fg: "var(--purple)" },
|
||||
};
|
||||
|
||||
function escapeRx(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
||||
function escapeHtml(s) {
|
||||
if (!s) return "";
|
||||
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||
}
|
||||
function isEmpty(v) {
|
||||
if (!v) return true;
|
||||
const t = v.trim();
|
||||
return t === "" || /^\(?sans réponse\)?$/i.test(t) || t === "—";
|
||||
}
|
||||
|
||||
function captureBetween(text, label, nextLabels) {
|
||||
const startRx = new RegExp(escapeRx(label) + "\\s*:?\\s*", "i");
|
||||
const m = startRx.exec(text);
|
||||
if (!m) return "";
|
||||
const from = m.index + m[0].length;
|
||||
let to = text.length;
|
||||
for (const nl of nextLabels || []) {
|
||||
const endRx = new RegExp(escapeRx(nl), "i");
|
||||
const m2 = endRx.exec(text.slice(from));
|
||||
if (m2 && from + m2.index < to) to = from + m2.index;
|
||||
}
|
||||
return text.slice(from, to).trim();
|
||||
}
|
||||
|
||||
// Aplatit le texte et retire les en-têtes/pieds d'impression du navigateur.
|
||||
function flatten(raw) {
|
||||
let clean = raw.replace(/\r/g, "").replace(/\s+/g, " ").trim();
|
||||
clean = clean.replace(/\d{1,2}\/\d{1,2}\/\d{4},?\s+\d{1,2}:\d{2}/g, " ");
|
||||
clean = clean.replace(/Page\s*\d{1,3}\s*sur\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank\s*\d{1,3}\s*\/\s*\d{1,3}/gi, " ");
|
||||
clean = clean.replace(/about:blank/gi, " ");
|
||||
clean = clean.replace(/file:\/\/\S+/gi, " ");
|
||||
return clean.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
// Sépare "Groupe 3 — Alice Bob" en { groupe, participants }
|
||||
function splitGroupe(nom) {
|
||||
const t = (nom || "").trim();
|
||||
const m = /^Groupe\s*(\d+)\s*[—\-:]*\s*(.*)$/i.exec(t);
|
||||
if (m) return { groupe: "Groupe " + m[1], participants: m[2].trim() };
|
||||
return { groupe: "", participants: t };
|
||||
}
|
||||
|
||||
function readMeta(clean, lastKey) {
|
||||
const meta = {
|
||||
nom: captureBetween(clean, "Groupe / Stagiaire", ["Date de session"]),
|
||||
date_session: captureBetween(clean, "Date de session", ["Formateur"]),
|
||||
formateur: captureBetween(clean, "Formateur", [lastKey]),
|
||||
};
|
||||
Object.assign(meta, splitGroupe(meta.nom));
|
||||
return meta;
|
||||
}
|
||||
|
||||
/* ======================= EXERCICE 1 — BIOCID ======================= */
|
||||
|
||||
const BIOCID_GROUPS = [
|
||||
{ title: "Exercice 1 — Recherche par nom", icon: "🔎", color: "amber", items: [
|
||||
"Ex1 — Produit : type, substance active, fabricant",
|
||||
"Ex1 — Date d'autorisation",
|
||||
"Ex1 — Conditions d'utilisation",
|
||||
]},
|
||||
{ title: "Exercice 2 — Recherche par type", icon: "🗂️", color: "blue", items: [
|
||||
"Ex2 — Nombre de produits dans la catégorie",
|
||||
"Ex2 — Comparaison entre catégories",
|
||||
]},
|
||||
{ title: "Exercice 3 — Recherche par substance", icon: "🧪", color: "teal", items: [
|
||||
"Ex3 — 3 produits contenant la substance",
|
||||
"Ex3 — Formulations et concentrations comparées",
|
||||
]},
|
||||
{ title: "Exercice 4 — Cas de synthèse", icon: "📞", color: "coral", items: [
|
||||
"Ex4 — 3 produits applicables",
|
||||
"Ex4 — Fiche de chaque produit",
|
||||
"Ex4 — Argumentaire (+ bonus FDS)",
|
||||
]},
|
||||
];
|
||||
|
||||
function parseBiocid(raw) {
|
||||
let clean = flatten(raw);
|
||||
clean = clean.replace(/Exercices BioCID\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = readMeta(clean, "Réponses aux exercices");
|
||||
const startIdx = clean.search(/Réponses aux exercices/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx) : clean;
|
||||
|
||||
const all = BIOCID_GROUPS.flatMap(g => g.items);
|
||||
const answers = {};
|
||||
all.forEach((label, i) => {
|
||||
answers[label] = captureBetween(block, label, all[i + 1] ? [all[i + 1]] : []);
|
||||
});
|
||||
|
||||
const groups = BIOCID_GROUPS.map(g => ({
|
||||
title: g.title, icon: g.icon, color: g.color,
|
||||
items: g.items.map(label => {
|
||||
const value = answers[label] || "";
|
||||
return {
|
||||
label: label.replace(/^Ex\d+\s*[—-]\s*/, ""),
|
||||
answer: value,
|
||||
state: isEmpty(value) ? "miss" : "ok",
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return { title: "Plateforme BioCID — Lecture du rendu", meta, groups, scored: false };
|
||||
}
|
||||
|
||||
/* ======================= EXERCICE 2 — FDS / FT / ÉTIQUETTE ======================= */
|
||||
|
||||
const fdsQuestions = [
|
||||
{ text: "Quel est le nom du produit et qui est le fabricant ?", answer: "1" },
|
||||
{ text: "Quels sont les dangers du produit ?", answer: "2" },
|
||||
{ text: "Quelles sont les molécules actives et leurs concentrations ?", answer: "3" },
|
||||
{ text: "Quels gestes adopter en premiers secours ?", answer: "4" },
|
||||
{ text: "En cas d'incendie, quels sont les moyens d'extinction conseillés ?", answer: "5" },
|
||||
{ text: "Que faire en cas de dispersion accidentelle ?", answer: "6" },
|
||||
{ text: "Dans quelles conditions stocker mon produit ?", answer: "7" },
|
||||
{ text: "Quels équipements de protection individuelle (EPI) dois-je porter ?", answer: "8" },
|
||||
{ text: "Quel est le pH de ce produit ?", answer: "9" },
|
||||
{ text: "Le produit est-il stable dans des conditions normales ?", answer: "10" },
|
||||
{ text: "Quels sont les effets potentiels sur la santé ?", answer: "11" },
|
||||
{ text: "Quel est le degré de toxicité pour les poissons ? Potentiel de bioaccumulation ?", answer: "12" },
|
||||
{ text: "Comment éliminer le produit ?", answer: "13" },
|
||||
{ text: "Est-ce que mon produit est classé dangereux pour le transport ?", answer: "14" },
|
||||
{ text: "En cas d'ingestion du produit, que faire ?", answer: "4,11" },
|
||||
{ text: "Où trouver le mode d'emploi du produit ?", answer: "absent" },
|
||||
];
|
||||
const ftQuestions = [
|
||||
{ text: "Quel est le nom du produit et qui est le fabricant ?", answer: "Description" },
|
||||
{ text: "Sur quelles matières ce produit est-il compatible ?", answer: "Description" },
|
||||
{ text: "Quel est (sont) le(s) type(s) de ce produit ?", answer: "Propriétés biocides" },
|
||||
{ text: "Quels sont les types de conditionnements disponibles ?", answer: "Conditionnements" },
|
||||
{ text: "Où retrouver les différents modes d'utilisation de ce produit ?", answer: "Utilisations" },
|
||||
];
|
||||
const etiquetteQuestions = [
|
||||
{ text: "Quel est le nom du produit et le nom de l'entreprise ?", answer: "Identité" },
|
||||
{ text: "Quels pictogrammes de danger sont affichés ?", answer: "Pictogrammes" },
|
||||
{ text: "Quels sont les mots d'avertissement (« Attention » ou « Danger ») ?", answer: "Mots d'avertissement" },
|
||||
{ text: "Quelles sont les mentions de danger (H-statements) identifiées ?", answer: "Mentions de danger" },
|
||||
{ text: "Quels équipements de protection individuelle (EPI) sont recommandés ?", answer: "Conseils de prudence" },
|
||||
{ text: "Quels premiers secours sont indiqués ?", answer: "Conseils de prudence" },
|
||||
{ text: "Comment éliminer l'emballage après utilisation ?", answer: "Conseils de prudence" },
|
||||
{ text: "Quel est le contenu net du produit ?", answer: "Contenu net" },
|
||||
{ text: "Y a-t-il un numéro d'appel d'urgence mentionné ?", answer: "Numéro d'urgence" },
|
||||
{ text: "Quelles consignes de stockage sont affichées ?", answer: "Conseils de prudence" },
|
||||
];
|
||||
|
||||
const FDS_PARTS = [
|
||||
{ prefix: "P1", title: "Partie 1 — FDS", icon: "📄", color: "amber", questions: fdsQuestions, kind: "fds" },
|
||||
{ prefix: "P2", title: "Partie 2 — Fiche Technique", icon: "📋", color: "coral", questions: ftQuestions, kind: "text" },
|
||||
{ prefix: "P3", title: "Partie 3 — Étiquette", icon: "🏷️", color: "teal", questions: etiquetteQuestions, kind: "text" },
|
||||
];
|
||||
|
||||
// Récupère « Réponse du groupe : X — Attendu : … » pour un marqueur [Px-Qn]
|
||||
function extractSelected(fullText, marker, nextMarker) {
|
||||
const seg = captureBetween(fullText, marker, nextMarker ? [nextMarker] : []);
|
||||
const m = /Réponse du groupe\s*:?\s*([\s\S]*?)\s*[—–-]*\s*Attendu\s*:/i.exec(seg);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
function normFdsToken(sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return null;
|
||||
if (/^absent$/i.test(sel.trim())) return "absent";
|
||||
const m = /(\d+)/.exec(sel);
|
||||
return m ? m[1] : "";
|
||||
}
|
||||
function scoreFds(q, sel) {
|
||||
const tok = normFdsToken(sel);
|
||||
if (tok === null) return "miss";
|
||||
return q.answer.split(",").includes(tok) ? "ok" : "ko";
|
||||
}
|
||||
function scoreText(q, sel) {
|
||||
if (!sel || /non répondu/i.test(sel)) return "miss";
|
||||
const norm = /^absent$/i.test(sel.trim()) ? "absent" : sel.trim();
|
||||
return norm.toLowerCase() === q.answer.toLowerCase() ? "ok" : "ko";
|
||||
}
|
||||
function fdsExpectedLabel(part, q) {
|
||||
if (part.kind !== "fds") return q.answer;
|
||||
const parts = q.answer.split(",");
|
||||
if (parts.length > 1) return "Rubrique " + parts.join(" ou ");
|
||||
return parts[0] === "absent" ? "Absent de la FDS" : "Rubrique " + parts[0];
|
||||
}
|
||||
|
||||
function parseFds(raw) {
|
||||
let clean = flatten(raw);
|
||||
clean = clean.replace(/FDS, FT (&|&) Étiquettes\s*[—–-]\s*Réponses\s*\([^)]*\)/gi, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = readMeta(clean, "Score global");
|
||||
|
||||
const markers = [];
|
||||
FDS_PARTS.forEach(p => p.questions.forEach((q, i) => markers.push("[" + p.prefix + "-Q" + (i + 1) + "]")));
|
||||
|
||||
let mIdx = 0;
|
||||
const groups = FDS_PARTS.map(part => ({
|
||||
title: part.title, icon: part.icon, color: part.color,
|
||||
items: part.questions.map((q, i) => {
|
||||
const marker = "[" + part.prefix + "-Q" + (i + 1) + "]";
|
||||
const sel = extractSelected(clean, marker, markers[mIdx + 1]);
|
||||
mIdx++;
|
||||
const state = part.kind === "fds" ? scoreFds(q, sel) : scoreText(q, sel);
|
||||
return {
|
||||
label: q.text,
|
||||
answer: sel,
|
||||
state,
|
||||
expected: fdsExpectedLabel(part, q),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
return { title: "FDS, FT & Étiquettes — Correction du rendu", meta, groups, scored: true };
|
||||
}
|
||||
|
||||
/* ======================= EXERCICE 3 — TP NUISIBLES ======================= */
|
||||
|
||||
const TP_GROUPS = [
|
||||
{ title: "Premier contact & diagnostic", icon: "🗣️", color: "purple", items: [
|
||||
"Questions posées au client lors du premier contact",
|
||||
"Conseils donnés en premier lieu",
|
||||
"Actions de prévention menées sur le site",
|
||||
"Éléments observés / preuve de présence du nuisible",
|
||||
]},
|
||||
{ title: "Solutions envisagées", icon: "🧰", color: "blue", items: [
|
||||
"Solutions alternatives non-biocides",
|
||||
"Solutions biocides mises en place",
|
||||
"Type de produit (TP) et mode de traitement",
|
||||
"Suivi mis en place et durée",
|
||||
"Gestion des déchets",
|
||||
]},
|
||||
{ title: "Produit & traçabilité", icon: "🧪", color: "teal", items: [
|
||||
"FDS trouvée (éléments principaux)",
|
||||
"Nom du produit et fabricant/fournisseur",
|
||||
"Numéro AMM",
|
||||
]},
|
||||
{ title: "Sécurité & santé", icon: "🛡️", color: "coral", items: [
|
||||
"Risques pour la santé",
|
||||
"EPI à porter",
|
||||
"Risques environnement / dispersion accidentelle",
|
||||
"DL50 substance(s) active(s)",
|
||||
"Gestes de premiers secours",
|
||||
]},
|
||||
];
|
||||
|
||||
const TP_KNOWN_STEPS = [
|
||||
"remplir la poudreuse",
|
||||
"sécurisation du site",
|
||||
"application traitements",
|
||||
"rangement du matériel. purger la perche",
|
||||
"retrait du nid",
|
||||
"gestion des déchets. élimination par des filiaires spé.",
|
||||
];
|
||||
|
||||
function parseTpRiskTable(tp3Zone) {
|
||||
const rows = [];
|
||||
const tableMatch = /Étapes\s*Risques\s*Mesures de prévention([\s\S]*?)(Réalisée par|$)/i.exec(tp3Zone);
|
||||
if (!tableMatch) return rows;
|
||||
const raw = tableMatch[1].trim();
|
||||
if (!raw) return rows;
|
||||
|
||||
const positions = [];
|
||||
TP_KNOWN_STEPS.forEach(step => {
|
||||
const idx = raw.toLowerCase().indexOf(step.toLowerCase());
|
||||
if (idx >= 0) positions.push({ step, idx });
|
||||
});
|
||||
positions.sort((a, b) => a.idx - b.idx);
|
||||
|
||||
if (positions.length === 0) {
|
||||
rows.push({ etape: raw, risque: "—", mesure: "—" });
|
||||
return rows;
|
||||
}
|
||||
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const from = positions[i].idx + positions[i].step.length;
|
||||
const to = i + 1 < positions.length ? positions[i + 1].idx : raw.length;
|
||||
const rest = raw.slice(from, to).trim();
|
||||
const measureRx = /(gants\s*\/\s*masques\s*\/\s*lunettes[^.]*|barrieres\.?\s*epi)/i;
|
||||
const mm = measureRx.exec(rest);
|
||||
let risque = rest, mesure = "";
|
||||
if (mm) {
|
||||
risque = rest.slice(0, mm.index).trim();
|
||||
mesure = rest.slice(mm.index).trim();
|
||||
}
|
||||
risque = risque.replace(/^[\s\-–.]+/, "").trim();
|
||||
rows.push({ etape: positions[i].step, risque: risque || "—", mesure: mesure || "—" });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parseTp(raw) {
|
||||
let clean = flatten(raw);
|
||||
clean = clean.replace(/Résultats TP\s*[—–-]\s*[^[]{0,80}?(?=Groupe \/ Stagiaire)/gi, " ").replace(/\s+/g, " ").trim();
|
||||
|
||||
const meta = readMeta(clean, "Cas pratique");
|
||||
meta.cas = captureBetween(clean, "Cas pratique", ["Réponses TP1"]);
|
||||
|
||||
const startIdx = clean.search(/Réponses TP1\s*&?\s*TP2/i);
|
||||
const endIdx = clean.search(/TP3\s*[—–-]\s*Analyse de risques/i);
|
||||
const block = startIdx >= 0 ? clean.slice(startIdx, endIdx >= 0 ? endIdx : clean.length) : clean;
|
||||
|
||||
const all = TP_GROUPS.flatMap(g => g.items);
|
||||
const answers = {};
|
||||
all.forEach((label, i) => {
|
||||
answers[label] = captureBetween(block, label, all[i + 1] ? [all[i + 1]] : []);
|
||||
});
|
||||
|
||||
const groups = TP_GROUPS.map(g => ({
|
||||
title: g.title, icon: g.icon, color: g.color,
|
||||
items: g.items.map(label => ({
|
||||
label,
|
||||
answer: answers[label] || "",
|
||||
state: isEmpty(answers[label]) ? "miss" : "ok",
|
||||
})),
|
||||
}));
|
||||
|
||||
const tp3Zone = endIdx >= 0 ? clean.slice(endIdx) : "";
|
||||
const tp3 = {
|
||||
intitule: captureBetween(tp3Zone, "Intitulé", ["Description"]),
|
||||
description: captureBetween(tp3Zone, "Description", ["Lieu"]),
|
||||
lieu: captureBetween(tp3Zone, "Lieu", ["Début prévu le"]),
|
||||
debut: captureBetween(tp3Zone, "Début prévu le", ["Étapes"]),
|
||||
};
|
||||
const riskRows = parseTpRiskTable(tp3Zone);
|
||||
|
||||
const mReal = clean.match(/Réalisée par\s*:\s*([^—\n]*)/i);
|
||||
const mVerif = clean.match(/Vérifiée par\s*:\s*([^—\n]*)/i);
|
||||
const mMaj = clean.match(/Mise à jour par\s*:\s*([^—\n]*)/i);
|
||||
|
||||
const annexes = [];
|
||||
if (tp3.intitule || riskRows.length) {
|
||||
annexes.push({
|
||||
title: "TP3 — Analyse de risques",
|
||||
html: `
|
||||
<div class="kv"><span>Intitulé :</span> ${escapeHtml(tp3.intitule) || "—"}</div>
|
||||
<div class="kv"><span>Description :</span> ${escapeHtml(tp3.description) || "—"}</div>
|
||||
<div class="kv"><span>Lieu :</span> ${escapeHtml(tp3.lieu) || "—"}</div>
|
||||
<div class="kv"><span>Début prévu le :</span> ${escapeHtml(tp3.debut) || "—"}</div>
|
||||
${riskRows.length ? `
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<tr><th>Étapes</th><th>Risques</th><th>Mesures de prévention</th></tr>
|
||||
${riskRows.map(r => `<tr><td>${escapeHtml(r.etape)}</td><td>${escapeHtml(r.risque)}</td><td>${escapeHtml(r.mesure)}</td></tr>`).join("")}
|
||||
</table>
|
||||
</div>` : ""}
|
||||
<div class="kv" style="margin-top:14px;">
|
||||
<span>Réalisée par :</span> ${escapeHtml(mReal ? mReal[1].trim() : "") || "—"} ·
|
||||
<span>Vérifiée par :</span> ${escapeHtml(mVerif ? mVerif[1].trim() : "") || "—"} ·
|
||||
<span>Mise à jour par :</span> ${escapeHtml(mMaj ? mMaj[1].trim() : "") || "—"}
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
return { title: "TP Nuisibles — Lecture du rendu", meta, groups, scored: false, annexes };
|
||||
}
|
||||
|
||||
/* ======================= REGISTRE DES EXERCICES ======================= */
|
||||
|
||||
const EXERCISES = {
|
||||
biocid: {
|
||||
parse: parseBiocid,
|
||||
// Signature : en-tête propre au PDF exporté par Exo_BioCID.html
|
||||
signature: /Exercices\s+Pratiques\s+BioCID/i,
|
||||
sentinel: /Réponses aux exercices|Ex1\s*[—–-]/i,
|
||||
name: "Plate-forme BioCID",
|
||||
},
|
||||
fds: {
|
||||
parse: parseFds,
|
||||
signature: /TD\s*[—–-]\s*FDS,\s*FT/i,
|
||||
sentinel: /\[P1-Q1\]|Partie 1\s*[—–-]\s*FDS/i,
|
||||
name: "FDS, FT & Étiquettes",
|
||||
},
|
||||
tp: {
|
||||
parse: parseTp,
|
||||
signature: /TP\s+Nuisibles\s*[—–-]\s*Certibiocide/i,
|
||||
sentinel: /Réponses TP1|TP3\s*[—–-]\s*Analyse de risques/i,
|
||||
name: "TP Nuisibles",
|
||||
},
|
||||
};
|
||||
|
||||
// Identifie l'exercice réellement contenu dans le PDF (signature d'abord, sentinelle en secours).
|
||||
function detectExercise(text) {
|
||||
for (const [key, cfg] of Object.entries(EXERCISES)) {
|
||||
if (cfg.signature.test(text)) return key;
|
||||
}
|
||||
for (const [key, cfg] of Object.entries(EXERCISES)) {
|
||||
if (cfg.sentinel.test(text)) return key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ======================= RENDU ======================= */
|
||||
|
||||
let currentData = null;
|
||||
let currentGroup = 0;
|
||||
|
||||
function renderGroupPanel() {
|
||||
const group = currentData.groups[currentGroup];
|
||||
const colors = COLOR_MAP[group.color] || COLOR_MAP.amber;
|
||||
const good = group.items.filter(it => it.state === "ok").length;
|
||||
const countLabel = currentData.scored
|
||||
? `${good}/${group.items.length} corrects`
|
||||
: `${good}/${group.items.length} renseignées`;
|
||||
|
||||
document.getElementById("groupPanel").innerHTML = `
|
||||
<div class="theme-panel">
|
||||
<div class="theme-head">
|
||||
<div class="theme-icon" style="background:${colors.bg};color:${colors.fg};">${group.icon}</div>
|
||||
<div class="theme-title">${escapeHtml(group.title)}</div>
|
||||
<div class="theme-count">${countLabel}</div>
|
||||
</div>
|
||||
<div class="cards">
|
||||
${group.items.map(it => {
|
||||
const mark = it.state === "ok" ? "✓" : (it.state === "ko" ? "✗" : "!");
|
||||
const empty = it.state === "miss";
|
||||
return `
|
||||
<div class="qcard">
|
||||
<div class="badge ${it.state}">${mark}</div>
|
||||
<div class="qtext">
|
||||
<div class="qlabel">${escapeHtml(it.label)}</div>
|
||||
<div class="qanswer ${empty ? "empty" : ""}">${empty ? "Sans réponse" : escapeHtml(it.answer)}</div>
|
||||
${it.expected && it.state !== "ok"
|
||||
? `<div class="qexpected">Attendu : <strong>${escapeHtml(it.expected)}</strong></div>`
|
||||
: ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join("")}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.getElementById("prevBtn").disabled = currentGroup === 0;
|
||||
document.getElementById("nextBtn").disabled = currentGroup === currentData.groups.length - 1;
|
||||
document.querySelectorAll(".dot").forEach((d, i) => d.classList.toggle("active", i === currentGroup));
|
||||
}
|
||||
|
||||
function goTo(idx) {
|
||||
if (!currentData || idx < 0 || idx >= currentData.groups.length) return;
|
||||
currentGroup = idx;
|
||||
renderGroupPanel();
|
||||
}
|
||||
|
||||
function renderResult(data) {
|
||||
currentData = data;
|
||||
currentGroup = 0;
|
||||
|
||||
document.getElementById("resultTitle").textContent = data.title;
|
||||
|
||||
const banner = document.getElementById("participantsBanner");
|
||||
const noms = data.meta.participants || data.meta.nom;
|
||||
if (noms) {
|
||||
banner.style.display = "flex";
|
||||
banner.innerHTML = `
|
||||
<div class="pb-icon">👥</div>
|
||||
<div>
|
||||
<div class="pb-group">${escapeHtml(data.meta.groupe) || "Participants"}</div>
|
||||
<div class="pb-names">${escapeHtml(noms)}</div>
|
||||
</div>
|
||||
<div class="pb-meta">
|
||||
${data.meta.date_session ? "Session : " + escapeHtml(data.meta.date_session) + "<br>" : ""}
|
||||
${data.meta.formateur ? "Formateur : " + escapeHtml(data.meta.formateur) + "<br>" : ""}
|
||||
${data.meta.cas ? "Cas : " + escapeHtml(data.meta.cas) : ""}
|
||||
</div>`;
|
||||
} else {
|
||||
banner.style.display = "none";
|
||||
}
|
||||
|
||||
const allItems = data.groups.flatMap(g => g.items);
|
||||
const good = allItems.filter(it => it.state === "ok").length;
|
||||
document.getElementById("progressFill").style.width = (good / allItems.length * 100) + "%";
|
||||
document.getElementById("progressCount").textContent = `${good} / ${allItems.length}`;
|
||||
document.getElementById("progressLabel").textContent = data.scored ? "réponses correctes" : "questions renseignées";
|
||||
|
||||
document.getElementById("dots").innerHTML = data.groups.map((_, i) =>
|
||||
`<div class="dot${i === 0 ? " active" : ""}" onclick="goTo(${i})"></div>`
|
||||
).join("");
|
||||
|
||||
document.getElementById("annexes").innerHTML = (data.annexes || []).map(a =>
|
||||
`<div class="annex"><h2>${escapeHtml(a.title)}</h2>${a.html}</div>`
|
||||
).join("");
|
||||
|
||||
renderGroupPanel();
|
||||
|
||||
document.getElementById("setup").classList.add("hidden");
|
||||
document.getElementById("result").style.display = "block";
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
/* ======================= CHARGEMENT DU PDF ======================= */
|
||||
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const status = document.getElementById("status");
|
||||
const statusEl = document.getElementById("status");
|
||||
const filePicker = document.getElementById("filePicker");
|
||||
|
||||
function selectedExo() {
|
||||
@@ -234,23 +932,91 @@ function selectedExo() {
|
||||
}
|
||||
|
||||
function refreshDropzone() {
|
||||
const exo = selectedExo();
|
||||
if (exo) {
|
||||
if (selectedExo()) {
|
||||
dropzone.classList.remove("disabled");
|
||||
status.className = "hint";
|
||||
status.textContent = "Prêt à recevoir le PDF.";
|
||||
statusEl.className = "hint";
|
||||
statusEl.textContent = "Prêt à recevoir le PDF.";
|
||||
} else {
|
||||
dropzone.classList.add("disabled");
|
||||
status.className = "hint";
|
||||
status.textContent = "Sélectionnez d'abord un exercice ci-dessus.";
|
||||
statusEl.className = "hint";
|
||||
statusEl.textContent = "Sélectionnez d'abord un exercice ci-dessus.";
|
||||
}
|
||||
}
|
||||
|
||||
async function readPdfText(file) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
let fullText = "";
|
||||
for (let p = 1; p <= pdf.numPages; p++) {
|
||||
const page = await pdf.getPage(p);
|
||||
const content = await page.getTextContent();
|
||||
fullText += content.items.map(it => it.str).join(" ") + "\n";
|
||||
}
|
||||
return fullText;
|
||||
}
|
||||
|
||||
async function handleFile(file) {
|
||||
const exo = selectedExo();
|
||||
if (!exo) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Choisissez un exercice avant de déposer un fichier.";
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
if (file.type !== "application/pdf" && !/\.pdf$/i.test(file.name)) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Le fichier doit être un PDF.";
|
||||
return;
|
||||
}
|
||||
|
||||
statusEl.className = "loading";
|
||||
statusEl.textContent = "Lecture du PDF en cours…";
|
||||
|
||||
try {
|
||||
const text = await readPdfText(file);
|
||||
const config = EXERCISES[exo];
|
||||
|
||||
const detected = detectExercise(text);
|
||||
|
||||
if (detected && detected !== exo) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = `Fichier incompatible : ce PDF est un rendu « ${EXERCISES[detected].name} », or vous avez sélectionné « ${config.name} ». Changez d'exercice ou déposez le bon fichier.`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = `Ce PDF ne ressemble pas à un rendu « ${config.name} ». Vérifiez l'exercice sélectionné.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = config.parse(text);
|
||||
document.getElementById("fileName").textContent = file.name;
|
||||
renderResult(data);
|
||||
statusEl.className = "hint";
|
||||
statusEl.textContent = "";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusEl.className = "error";
|
||||
statusEl.textContent = "Impossible de lire ce PDF. Vérifiez qu'il s'agit bien d'un export d'exercice.";
|
||||
}
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
currentData = null;
|
||||
document.getElementById("result").style.display = "none";
|
||||
document.getElementById("setup").classList.remove("hidden");
|
||||
document.getElementById("participantsBanner").style.display = "none";
|
||||
document.getElementById("annexes").innerHTML = "";
|
||||
filePicker.value = "";
|
||||
refreshDropzone();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
// Préselection via ?exo=biocid|fds|tp
|
||||
(function preselect() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const exo = params.get("exo");
|
||||
if (exo && CORRECTORS[exo]) {
|
||||
const exo = new URLSearchParams(location.search).get("exo");
|
||||
if (exo && EXERCISES[exo]) {
|
||||
const input = document.getElementById("exo-" + exo);
|
||||
if (input) input.checked = true;
|
||||
}
|
||||
@@ -261,44 +1027,6 @@ document.querySelectorAll('input[name="exo"]').forEach(r =>
|
||||
);
|
||||
refreshDropzone();
|
||||
|
||||
function handleFile(file) {
|
||||
const exo = selectedExo();
|
||||
if (!exo) {
|
||||
status.className = "error";
|
||||
status.textContent = "Choisissez un exercice avant de déposer un fichier.";
|
||||
return;
|
||||
}
|
||||
if (!file) return;
|
||||
if (file.type !== "application/pdf" && !/\.pdf$/i.test(file.name)) {
|
||||
status.className = "error";
|
||||
status.textContent = "Le fichier doit être un PDF.";
|
||||
return;
|
||||
}
|
||||
|
||||
status.className = "loading";
|
||||
status.textContent = "Ouverture du correcteur…";
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
try {
|
||||
// On transmet le PDF à la page de correction dédiée via localStorage
|
||||
localStorage.setItem("correctionFile", reader.result);
|
||||
localStorage.setItem("correctionFileName", file.name);
|
||||
localStorage.setItem("correctionExo", exo);
|
||||
window.location.href = CORRECTORS[exo] + "?auto=1";
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
status.className = "error";
|
||||
status.textContent = "Fichier trop volumineux pour être transmis. Ouvrez la page de correction dédiée.";
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
status.className = "error";
|
||||
status.textContent = "Impossible de lire le fichier.";
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
dropzone.addEventListener("dragover", e => {
|
||||
if (dropzone.classList.contains("disabled")) return;
|
||||
e.preventDefault();
|
||||
|
||||
Reference in New Issue
Block a user