1// ============================================================= 2// PLAYGROUND â FLOW CANDIDAT (étapes 7 à 10 recruteur) 3// ============================================================= 4// 5// Module extrait de playground.js le 2026-07-22 (1ère extraction hors 6// candidate-detail.js). Regroupe les étapes du tour de formation 7// recruteur qui manipulent un candidat sélectionné (fiche, commentaires, 8// pipeline). Ces 4 étapes partagent le même pattern : sélecteur candidat 9// en haut + contenu spécifique dessous. 10// 11// Contenu : 12// 1. renderCandidateSelector(currentId) â helper : boutons de 13// sélection horizontaux réutilisés par les étapes 8-10. 14// 2. renderRecruteurCandidateDetail() â étape 8 : fiche candidat en 15// mode formation (score, réponses, docs). 16// 3. renderRecruteurComment() â étape 9 : ajout/modif/suppr de 17// commentaires en local (sessionStorage playgroundState). 18// 4. renderRecruteurPipeline() â étape 10 : stepper des 5 étapes + 19// actions Retenir/Refuser en local. 20// 21// Toutes les mutations passent par playgroundState (sessionStorage) â 22// aucun appel réseau, univers parallèle strict pour la formation. 23// 24// Dépendances externes (globales JS déjà chargées) : 25// - escapeHtml, helpIcon (core.js, helpTexts.js) 26// - playgroundState, persistPlaygroundState (playground.js) 27// - getSelectedCandidate, wireCandidateSelector (playground.js) 28// - PIPELINE_STAGE_NAMES, stepCircleStyle (playground.js) 29// - initPipelineForCandidate, pgNowFormatted (playground.js) 30 31/** 32 * Renvoie le HTML du sélecteur de candidat (boutons horizontaux avec 33 * nom + score). Réutilisé en haut des étapes 8, 9, 10. 34 * 35 * @param {string} currentId - ID du candidat actuellement sélectionné 36 */ 37function renderCandidateSelector(currentId) { 38 const candidates = playgroundState.data.candidates; 39 return ` 40 <div style="display: flex; gap: 0.4rem; flex-wrap: wrap; margin-bottom: 1rem; padding-bottom: 0.6rem; border-bottom: 1px solid var(--color-border);"> 41 <span style="font-size: 0.8rem; color: var(--color-text-light); align-self: center; margin-right: 0.3rem;">Candidat·e :</span> 42 ${candidates.map((c) => { 43 const isActive = c.id === currentId; 44 // Active = bouton anthracite plein (style btn-primary compact), 45 // sinon = btn-outline btn-small standard de l'app 46 const cls = isActive 47 ? "pg-candidate-btn pg-candidate-btn-active btn btn-small" 48 : "pg-candidate-btn btn btn-outline btn-small"; 49 return ` 50 <button class="${cls}" data-cid="${escapeHtml(c.id)}"> 51 ${escapeHtml(c.firstName)} ${escapeHtml(c.lastName)} 52 <span style="opacity: 0.75; margin-left: 0.3rem;">${c.score}%</span> 53 </button> 54 `; 55 }).join("")} 56 </div> 57 `; 58} 59 60/** 61 * Câble les clics du sélecteur de candidat. Appelle la fonction de 62 * re-render passée en argument pour rafraîchir l'étape courante. 63 */ 64function wireCandidateSelector(rerenderFn) { 65 document.querySelectorAll(".pg-candidate-btn").forEach((btn) => { 66 btn.addEventListener("click", () => { 67 playgroundState.selectedCandidateId = btn.dataset.cid; 68 persistPlaygroundState(); 69 rerenderFn(); 70 }); 71 }); 72} 73 74/** 75 * Initialise la structure pipelineSteps pour un candidat si elle n'existe 76 * pas encore. Au démarrage, l'étape 1 est en "pending". 77 */ 78function initPipelineForCandidate(candidateId) { 79 if (!playgroundState.data.pipelineSteps[candidateId]) { 80 playgroundState.data.pipelineSteps[candidateId] = [ 81 { step: 1, status: "pending", rejectionComment: null, validatedAt: null }, 82 ]; 83 } 84} 85 86/** 87 * Helper d'horodatage pour les commentaires et pipeline (format français). 88 */ 89function pgNowFormatted() { 90 const d = new Date(); 91 return d.toLocaleString("fr-FR", { 92 day: "2-digit", month: "2-digit", year: "numeric", 93 hour: "2-digit", minute: "2-digit", 94 }); 95} 96 97// ============================================================= 98// ÃTAPE 8 â FICHE CANDIDAT (lecture détaillée) 99// ============================================================= 100 101/** 102 * Affiche la fiche détaillée d'un candidat : identité, documents, 103 * détail du score (compétence à niveau à pondération), réponses au 104 * formulaire. C'est l'étape de "lecture" â pas d'action ici, juste 105 * comprendre le profil. 106 */ 107function renderRecruteurCandidateDetail() { 108 const candidate = getSelectedCandidate(); 109 if (!candidate) { 110 document.getElementById("pg-content").innerHTML = ` 111 <div class="playground-step"> 112 <p>Aucune candidature reçue. Reviens à l'étape 6 pour générer les profils.</p> 113 </div>`; 114 return; 115 } 116 117 // --- Détail du score (table pédagogique) --- 118 const skills = playgroundState.data.skills; 119 let totalPoints = 0; 120 let maxPoints = 0; 121 const levelLabels = ["", "Débutant", "Intermédiaire", "Avancé"]; 122 const scoreRows = skills.map((skill) => { 123 const r = candidate.ratings.find((x) => x.skillId === skill.id); 124 const level = r?.level || 0; 125 const sub = level * skill.priorityWeight; 126 const max = 3 * skill.priorityWeight; 127 totalPoints += sub; 128 maxPoints += max; 129 return ` 130 <tr> 131 <td>${escapeHtml(skill.name)}</td> 132 <td>${level} <span style="color: var(--color-text-light);">â ${levelLabels[level] || "â"}</span></td> 133 <td style="text-align: center;">${skill.priorityWeight}</td> 134 <td style="text-align: right; font-weight: 600;">${sub}</td> 135 </tr>`; 136 }).join(""); 137 138 // --- Réponses aux questions --- 139 const questions = playgroundState.data.questions; 140 const answersHtml = questions.map((q) => { 141 const ans = candidate.answers[q.id]; 142 let displayed; 143 if (Array.isArray(ans)) { 144 displayed = ans.length ? ans.join(", ") : "(aucune option cochée)"; 145 } else if (ans !== undefined && ans !== null && ans !== "") { 146 displayed = String(ans); 147 } else { 148 displayed = "(non renseigné)"; 149 } 150 return ` 151 <div style="margin-bottom: 0.7rem;"> 152 <div style="font-size: 0.85rem; color: var(--color-text-light); font-weight: 500; margin-bottom: 0.2rem;">${escapeHtml(q.label)}</div> 153 <div style="white-space: pre-wrap; padding: 0.4rem 0.6rem; background: var(--color-bg); border-radius: 4px; font-size: 0.9rem;">${escapeHtml(displayed)}</div> 154 </div>`; 155 }).join(""); 156 157 // --- Couleur du score (sémantique, alignée sur les tokens L214) --- 158 const scoreColor = candidate.score >= 80 ? "var(--color-success)" 159 : candidate.score >= 60 ? "#827717" // ocre, pas de token 160 : candidate.score >= 40 ? "var(--color-warning)" 161 : "var(--color-error)"; 162 163 document.getElementById("pg-content").innerHTML = ` 164 <div class="playground-step"> 165 <h2 style="margin-top: 0;">Ãtape ${playgroundState.step + 1} â La fiche candidat</h2> 166 167 <div class="playground-card playground-card-info"> 168 <p style="margin: 0;"> 169 <strong>ð¡ Voilà tout ce que tu sais</strong> sur ce·tte candidat·e. Le score 170 est le résultat d'un calcul transparent que tu peux décortiquer ligne par 171 ligne. <strong>Toujours lire la lettre de motivation</strong> avant de te 172 faire un avis : un score moyen avec une lettre exceptionnelle peut être 173 le meilleur match. 174 </p> 175 </div> 176 177 <div class="playground-card" style="background: #f5f0fa; border-left: 3px solid #6b4c9a;"> 178 <h3 style="margin: 0 0 0.3rem 0; font-size: 1rem;">ð³ï¸ Le vote équipe (-3 à +3)</h3> 179 <p style="margin: 0 0 0.4rem 0; font-size: 0.9rem;"> 180 Sur chaque fiche, tu peux poser un vote allant de <strong>-3 (rejet ferme)</strong> 181 à <strong>+3 (coup de cÅur)</strong>. Tous les membres du projet voient les votes 182 des autres avec leur nom. C'est un signal collectif pour aider à trancher, 183 surtout sur les profils ambigus. 184 </p> 185 <p style="margin: 0; font-size: 0.85rem; color: var(--color-text-light);"> 186 Le vote est <strong>modifiable à tout moment</strong>. Tu peux aussi le retirer. 187 </p> 188 <p style="margin: 0.5rem 0 0 0; font-size: 0.85rem; padding: 0.4rem 0.6rem; background: #fff; border-radius: 4px;"> 189 ð <strong>Sur la vraie fiche</strong>, le vote et les commentaires sont 190 regroupés dans une <strong>barre latérale à droite</strong> qui reste 191 visible quand tu scrolles â tu peux lire le profil et voter ou commenter 192 en parallèle, sans devoir naviguer. 193 </p> 194 </div> 195 196 <!-- Nouveautés Lucile #9 + Damien #10 + EPIC-1 : encart pédagogique --> 197 <div class="playground-card" style="background: #e8f5e9; border-left: 3px solid var(--color-success);"> 198 <h3 style="margin: 0 0 0.3rem 0; font-size: 1rem;">⨠Quelques infos en plus sur la vraie fiche</h3> 199 <ul style="margin: 0; padding-left: 1.2rem; font-size: 0.9rem;"> 200 <li style="margin-bottom: 0.3rem;"> 201 <strong>Années d'expérience</strong> auto-déclarées par le candidat (champ 202 optionnel sur le formulaire public). 203 </li> 204 <li style="margin-bottom: 0.3rem;"> 205 <strong>Type de contrat recherché</strong> par le candidat. Si tu as 206 précisé un type de contrat pour ton projet et qu'il diffère de ce que 207 recherche le candidat, un badge <strong>â ï¸ Inadéquation avec le poste</strong> 208 apparaît côté recruteur (invisible côté candidat). 209 </li> 210 <li style="margin-bottom: 0;"> 211 <strong>Type d'étape pipeline</strong> : chaque étape de ton pipeline 212 peut être typée (entretien / mail / appel / exercice / autre) et affiche 213 un badge visuel pour t'aider à structurer ton process. 214 </li> 215 </ul> 216 </div> 217 218 <div class="playground-card" style="background: #fff8e1; border-left: 3px solid var(--color-warning);"> 219 <h3 style="margin: 0 0 0.3rem 0; font-size: 1rem;">ð¤ Détection contenu IA</h3> 220 <p style="margin: 0; font-size: 0.9rem;"> 221 Boussole peut analyser si une candidature semble écrite par une IA. 222 L'analyse est <strong>prioritaire sur le CV et la lettre de motivation</strong> 223 (où les candidats utilisent le plus ChatGPT) plutôt que sur les réponses courtes. 224 Indicateur statistique seulement â <strong>jamais un motif de refus à lui seul</strong>. 225 </p> 226 </div> 227 228 ${renderCandidateSelector(candidate.id)} 229 230 <!-- En-tête du candidat --> 231 <div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;"> 232 <h3 style="margin: 0; font-size: 1.3rem;">${escapeHtml(candidate.firstName)} ${escapeHtml(candidate.lastName)}</h3> 233 ${candidate.isVolunteer ? '<span style="background: var(--color-accent-bg); color: var(--color-accent); font-size: 0.75rem; padding: 0.2rem 0.5rem; border-radius: 8px;">ð Bénévole</span>' : ''}
234 <span style="margin-left: auto; padding: 0.3rem 0.8rem; background: ${scoreColor}; color: #fff; border-radius: 8px; font-weight: 700; font-size: 1.05rem;"> 235 ${candidate.score}% 236 </span> 237 </div> 238 239 <!-- Bloc Identité --> 240 <div class="detail-section"> 241 <h4 style="margin-top: 0;">Identité</h4> 242 <div style="display: grid; grid-template-columns: max-content 1fr; gap: 0.3rem 1rem; font-size: 0.9rem;"> 243 <strong>Email :</strong> <span>${escapeHtml(candidate.email)}</span> 244 <strong>Téléphone :</strong> <span>${escapeHtml(candidate.phone || "â")}</span> 245 <strong>Localisation :</strong> <span>${escapeHtml(candidate.location)}</span> 246 <strong>LinkedIn :</strong> <span>${candidate.linkedinUrl ? escapeHtml(candidate.linkedinUrl) : "<em style='color: var(--color-text-light);'>non renseigné</em>"}</span> 247 <strong>Reçu le :</strong> <span>${escapeHtml(candidate.receivedAt)}</span> 248 </div> 249 </div> 250 251 <!-- Bloc Documents --> 252 <div class="detail-section"> 253 <h4 style="margin-top: 0;">Documents</h4> 254 <div style="display: flex; gap: 0.6rem; flex-wrap: wrap;"> 255 <button class="pg-fake-doc btn btn-outline btn-small" style="text-align: left;"> 256 ð cv-${escapeHtml(candidate.firstName.toLowerCase())}-${escapeHtml(candidate.lastName.toLowerCase())}.pdf 257 </button> 258 <button class="pg-fake-doc btn btn-outline btn-small" style="text-align: left;"> 259 ð lettre-${escapeHtml(candidate.firstName.toLowerCase())}-${escapeHtml(candidate.lastName.toLowerCase())}.pdf 260 </button> 261 </div> 262 </div> 263 264 <!-- Bloc Détail du score --> 265 <div class="detail-section"> 266 <h4 style="margin-top: 0;">Détail du score</h4> 267 <p style="font-size: 0.85rem; color: var(--color-text-light); margin: 0.3rem 0 0.5rem 0;"> 268 Score = somme(niveau à pondération) / somme(3 à pondération) à 100. 269 </p> 270 <table class="candidates-table"> 271 <thead> 272 <tr> 273 <th>Compétence</th> 274 <th>Niveau auto-déclaré</th> 275 <th style="text-align: center;">Pondération</th> 276 <th style="text-align: right;">Sous-score</th> 277 </tr> 278 </thead> 279 <tbody>${scoreRows}</tbody> 280 <tfoot> 281 <tr style="border-top: 2px solid var(--color-border);"> 282 <td colspan="3" style="text-align: right; font-weight: 600; padding-top: 0.5rem;">Total</td> 283 <td style="text-align: right; font-weight: 700; padding-top: 0.5rem;"> 284 ${totalPoints} / ${maxPoints} = <span style="color: ${scoreColor};">${candidate.score}%</span> 285 </td> 286 </tr> 287 </tfoot> 288 </table> 289 </div> 290 291 <!-- Bloc Réponses au formulaire --> 292 <div class="detail-section"> 293 <h4 style="margin-top: 0;">Réponses au formulaire</h4> 294 ${answersHtml || '<p style="color: var(--color-text-light); font-size: 0.9rem;"><em>Aucune question définie.</em></p>'} 295 </div> 296 297 <p style="font-size: 0.85rem; color: var(--color-text-light);"> 298 Quand tu as bien examiné la fiche, clique <strong>"Suivant"</strong> pour 299 ajouter des commentaires partageables avec tes collègues évaluateur·rice·s. 300 </p> 301 </div> 302 `; 303 304 // Câblages 305 wireCandidateSelector(renderRecruteurCandidateDetail); 306 307 document.querySelectorAll(".pg-fake-doc").forEach((btn) => { 308 btn.addEventListener("click", () => { 309 alert("ð Aperçu de document non disponible en mode formation.\n\nDans la vraie app, ce clic ouvrirait une prévisualisation du PDF dans une fenêtre."); 310 }); 311 }); 312} 313 314// ============================================================= 315// ÃTAPE 9 â COMMENTAIRES COLLABORATIFS 316// ============================================================= 317 318/** 319 * Affiche un récap court de la fiche du candidat sélectionné, puis la 320 * section commentaires : liste + formulaire d'ajout. Le user peut 321 * modifier/supprimer ses propres commentaires uniquement. 322 * 323 * Auteur des nouveaux commentaires = currentUser.name (récupéré depuis 324 * l'app principale). Si non disponible, fallback "Toi". 325 */ 326function renderRecruteurComment() { 327 const candidate = getSelectedCandidate(); 328 if (!candidate) { 329 document.getElementById("pg-content").innerHTML = `<div class="playground-step"><p>Aucun candidat à afficher.</p></div>`; 330 return; 331 } 332 333 // Initialiser le tableau de commentaires si besoin 334 if (!playgroundState.data.comments[candidate.id]) { 335 playgroundState.data.comments[candidate.id] = []; 336 } 337 const comments = playgroundState.data.comments[candidate.id]; 338 const userName = (typeof currentUser !== "undefined" && currentUser?.name) || "Toi"; 339 340 // --- HTML des commentaires existants --- 341 let commentsHtml; 342 if (comments.length === 0) { 343 commentsHtml = `<p style="font-size: 0.9rem; color: var(--color-text-light); padding: 1rem; text-align: center; background: var(--color-bg); border-radius: var(--radius);">Aucun commentaire pour l'instant. Sois le premier à en laisser un.</p>`; 344 } else { 345 commentsHtml = comments.map((c) => { 346 const isMine = c.author === userName; 347 return ` 348 <div data-comment-id="${escapeHtml(c.id)}" style="border-left: 3px solid var(--color-primary); padding: 0.5rem 0.7rem; margin-bottom: 0.5rem; background: var(--color-white);"> 349 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.3rem;"> 350 <strong style="font-size: 0.85rem;">${escapeHtml(c.author)}</strong>
351 <span style="font-size: 0.75rem; color: var(--color-text-light);">${escapeHtml(c.createdAt)}${c.editedAt ? ' (modifié)' : ''}</span> 352 </div> 353 <div class="pg-comment-content" style="white-space: pre-wrap; font-size: 0.9rem;">${escapeHtml(c.content)}</div> 354 ${isMine ? ` 355 <div style="margin-top: 0.4rem; display: flex; gap: 0.3rem;"> 356 <button class="btn btn-small btn-outline pg-edit-comment" data-cid="${escapeHtml(c.id)}">Modifier</button> 357 <button class="btn btn-small btn-danger pg-del-comment" data-cid="${escapeHtml(c.id)}">Supprimer</button> 358 </div> 359 ` : ""} 360 </div>`; 361 }).join(""); 362 } 363 364 document.getElementById("pg-content").innerHTML = ` 365 <div class="playground-step"> 366 <h2 style="margin-top: 0;">Ãtape ${playgroundState.step + 1} â Commenter et collaborer</h2> 367 368 <div class="playground-card playground-card-info"> 369 <p style="margin: 0;"> 370 <strong>ð¡ Les commentaires sont visibles par tous les évaluateur·rice·s</strong> 371 invité·e·s sur la session. C'est l'endroit pour partager une intuition, 372 signaler un point à creuser en entretien, ou trancher entre deux profils 373 proches. Tu peux modifier ou supprimer tes propres commentaires, mais pas 374 ceux des autres. 375 </p> 376 <p style="margin: 0.5rem 0 0 0; font-size: 0.85rem; padding: 0.4rem 0.6rem; background: #fff; border-radius: 4px;"> 377 ð <strong>Sur la vraie fiche</strong>, cette zone est dans la <strong>sidebar 378 à droite</strong> (juste au-dessus du vote pondéré). Tu peux donc lire le 379 profil et commenter en parallèle, sans naviguer. Ici en mode formation, 380 on l'affiche au centre pour la pédagogie. 381 </p> 382 </div> 383 384 ${renderCandidateSelector(candidate.id)} 385 386 <!-- Récap court du candidat --> 387 <div style="display: flex; align-items: center; gap: 0.7rem; padding: 0.5rem 0.8rem; background: var(--color-bg); border-radius: var(--radius); margin-bottom: 1rem;"> 388 <strong>${escapeHtml(candidate.firstName)} ${escapeHtml(candidate.lastName)}</strong> 389 <span style="color: var(--color-text-light); font-size: 0.85rem;">â ${escapeHtml(candidate.location)}</span> 390 <span style="margin-left: auto; font-weight: 700;">${candidate.score}%</span> 391 </div> 392 393 <!-- Commentaires --> 394 <div class="detail-section"> 395 <h4 style="margin-top: 0;">Commentaires (${comments.length})</h4> 396 <div id="pg-comments-list">${commentsHtml}</div> 397 </div> 398 399 <!-- Formulaire d'ajout --> 400 <div class="detail-section"> 401 <h4 style="margin-top: 0;">Ajouter un commentaire</h4> 402 <textarea id="pg-new-comment" rows="3" placeholder="Ton commentaire (visible par les autres évaluateurs)..." style="width: 100%; font-family: inherit;"></textarea> 403 <div style="margin-top: 0.5rem; display: flex; justify-content: space-between; align-items: center;"> 404 <span style="font-size: 0.8rem; color: var(--color-text-light);">Signé : ${escapeHtml(userName)}</span> 405 <button class="btn btn-small" id="pg-publish-comment" style="background: var(--color-primary); color: #fff;">Publier</button> 406 </div> 407 </div> 408 </div> 409 `; 410 411 // --- Câblages --- 412 wireCandidateSelector(renderRecruteurComment); 413 414 // Publier un nouveau commentaire 415 document.getElementById("pg-publish-comment").addEventListener("click", () => { 416 const textarea = document.getElementById("pg-new-comment"); 417 const content = textarea.value.trim(); 418 if (!content) { 419 alert("Le commentaire ne peut pas être vide."); 420 return; 421 } 422 comments.push({ 423 id: makeLocalId("cm"), 424 author: userName, 425 content, 426 createdAt: pgNowFormatted(), 427 }); 428 persistPlaygroundState(); 429 renderRecruteurComment(); 430 }); 431 432 // Modifier un commentaire (replacement de l'affichage par un textarea inline) 433 document.querySelectorAll(".pg-edit-comment").forEach((btn) => { 434 btn.addEventListener("click", () => { 435 const cid = btn.dataset.cid; 436 const cmt = comments.find((x) => x.id === cid); 437 if (!cmt) return; 438 const newContent = prompt("Modifier le commentaire :", cmt.content); 439 if (newContent === null) return; // user a annulé 440 const trimmed = newContent.trim(); 441 if (!trimmed) { 442 alert("Le commentaire ne peut pas être vide."); 443 return; 444 } 445 cmt.content = trimmed; 446 cmt.editedAt = pgNowFormatted(); 447 persistPlaygroundState(); 448 renderRecruteurComment(); 449 }); 450 }); 451 452 // Supprimer un commentaire 453 document.querySelectorAll(".pg-del-comment").forEach((btn) => { 454 btn.addEventListener("click", () => { 455 if (!confirm("Supprimer ce commentaire ?")) return; 456 const cid = btn.dataset.cid; 457 const idx = comments.findIndex((x) => x.id === cid); 458 if (idx >= 0) { 459 comments.splice(idx, 1); 460 persistPlaygroundState(); 461 renderRecruteurComment(); 462 } 463 }); 464 }); 465} 466 467// ============================================================= 468// ÃTAPE 10 â PIPELINE (faire avancer / refuser) 469// ============================================================= 470 471// Aligné sur DEFAULT_STAGES côté backend (pipelineHelper.js) â pipeline 472// par défaut L214 à 5 étapes. Si tu modifies ici, modifie aussi le 473// backend pour garder la cohérence du tour de formation. 474const PIPELINE_STAGE_NAMES = [ 475 "", // index 0 inutilisé 476 "Présélection sur dossier", 477 "Entretien visio équipe", 478 "Entretien IRL équipe", 479 "Entretien RH", 480 "Entretien Direction", 481]; 482 483/**
484 * Affiche le pipeline du candidat sélectionné : 4 cercles connectés 485 * matérialisant chaque étape avec sa couleur (gris pending, vert 486 * retained, rouge rejected). Actions sur l'étape courante : 487 * - "Retenir pour l'étape suivante" â status = retained, crée 488 * l'étape suivante en pending 489 * - "Refuser" â demande un motif puis status = rejected 490 * 491 * En bas : récap de tous les candidats (mini-kanban) pour visualiser 492 * l'effet des décisions sur l'ensemble. 493 */ 494function renderRecruteurPipeline() { 495 const candidate = getSelectedCandidate(); 496 if (!candidate) { 497 document.getElementById("pg-content").innerHTML = `<div class="playground-step"><p>Aucun candidat à afficher.</p></div>`; 498 return; 499 } 500 501 initPipelineForCandidate(candidate.id); 502 const pipelineSteps = playgroundState.data.pipelineSteps[candidate.id]; 503 504 // L'étape courante = la dernière du tableau 505 const currentStep = pipelineSteps[pipelineSteps.length - 1]; 506 507 // --- Stepper visuel : 4 cercles avec leur état --- 508 function stepCircleStyle(stepNum) { 509 const data = pipelineSteps.find((s) => s.step === stepNum); 510 if (!data) { 511 // Ãtape pas encore atteinte 512 return { bg: "#e0e0e0", color: "#888", label: "" }; 513 } 514 if (data.status === "retained") return { bg: "var(--color-success)", color: "#fff", label: "â" }; 515 if (data.status === "rejected") return { bg: "var(--color-error)", color: "#fff", label: "â" }; 516 return { bg: "var(--color-warning-bg)", color: "var(--color-warning)", label: "â¦" }; // pending 517 } 518 519 // 5 étapes alignées sur DEFAULT_STAGES backend (cf. PIPELINE_STAGE_NAMES 520 // au-dessus). La taille des cercles est légèrement réduite par rapport 521 // à la version 4 étapes pour rester compact horizontalement. 522 const totalSteps = PIPELINE_STAGE_NAMES.length - 1; 523 let stepperHtml = '<div style="display: flex; align-items: center; justify-content: space-between; margin: 1rem 0;">'; 524 for (let i = 1; i <= totalSteps; i++) { 525 const s = stepCircleStyle(i); 526 const isCurrent = currentStep.step === i; 527 stepperHtml += ` 528 <div style="display: flex; flex-direction: column; align-items: center; flex: 1;"> 529 <div style="width: 2.5rem; height: 2.5rem; border-radius: 50%; 530 background: ${s.bg}; color: ${s.color}; 531 display: flex; align-items: center; justify-content: center; 532 font-weight: 700; font-size: 1rem; 533 box-shadow: ${isCurrent ? '0 0 0 3px rgba(248, 155, 18, 0.35)' : 'none'};"> 534 ${s.label || i} 535 </div> 536 <div style="font-size: 0.75rem; margin-top: 0.3rem; text-align: center; color: var(--color-text);"> 537 ${PIPELINE_STAGE_NAMES[i]} 538 </div> 539 </div> 540 ${i < totalSteps ? '<div style="flex: 0.3; height: 2px; background: var(--color-border); align-self: flex-start; margin-top: 1.2rem;"></div>' : ''} 541 `; 542 } 543 stepperHtml += '</div>'; 544 545 // --- Bloc d'action sur l'étape courante --- 546 let actionsHtml = ""; 547 if (currentStep.status === "pending") { 548 const isLastStep = currentStep.step === totalSteps; 549 actionsHtml = ` 550 <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;"> 551 <button class="btn btn-small" id="pg-retain" 552 style="background: var(--color-success); color: #fff;"> 553 â ${isLastStep ? "Embaucher" : "Retenir pour l'étape suivante"} 554 </button> 555 <button class="btn btn-small btn-danger" id="pg-reject"> 556 â Refuser à l'étape ${currentStep.step} 557 </button> 558 </div> 559 `; 560 } else { 561 // Ãtape terminée (retained ou rejected) â possibilité de revenir en arrière 562 const label = currentStep.status === "retained" ? "Ãtape validée" : "Candidature refusée"; 563 const isRetained = currentStep.status === "retained"; 564 const colorVar = isRetained ? "var(--color-success)" : "var(--color-error)"; 565 const bgVar = isRetained ? "var(--color-success-bg)" : "var(--color-error-bg)"; 566 actionsHtml = ` 567 <div style="padding: 0.6rem; background: ${bgVar}; border-left: 3px solid ${colorVar}; border-radius: 4px;"> 568 <strong style="color: ${colorVar};">${label}</strong> à l'étape ${currentStep.step} â ${PIPELINE_STAGE_NAMES[currentStep.step]} 569 ${currentStep.rejectionComment ? `<div style="margin-top: 0.3rem; font-size: 0.85rem;"><strong>Motif :</strong> ${escapeHtml(currentStep.rejectionComment)}</div>` : ""} 570 ${currentStep.validatedAt ? `<div style="margin-top: 0.2rem; font-size: 0.75rem; color: var(--color-text-light);">${escapeHtml(currentStep.validatedAt)}</div>` : ""} 571 <button class="btn btn-small btn-outline" id="pg-undo-step" style="margin-top: 0.5rem;">
572 â¶ Revenir en arrière (annuler la décision) 573 </button> 574 </div> 575 `; 576 } 577 578 // --- Récap kanban-light de tous les candidats --- 579 const allCandidates = playgroundState.data.candidates; 580 const recapRows = allCandidates.map((c) => { 581 initPipelineForCandidate(c.id); 582 const steps = playgroundState.data.pipelineSteps[c.id]; 583 const last = steps[steps.length - 1]; 584 // Badges réutilisant les classes .badge-* de la charte L214 585 let badge; 586 if (last.status === "retained") { 587 badge = `<span class="badge badge-retained">Retenu·e â étape ${last.step}/4</span>`; 588 } else if (last.status === "rejected") { 589 badge = `<span class="badge badge-rejected">Refusé·e à l'étape ${last.step}</span>`; 590 } else { 591 badge = `<span class="badge badge-pending">En cours â étape ${last.step}/4</span>`; 592 } 593 return ` 594 <tr ${c.id === candidate.id ? 'style="background: var(--color-accent-bg);"' : ''}> 595 <td><strong>${escapeHtml(c.firstName)} ${escapeHtml(c.lastName)}</strong></td> 596 <td>${c.score}%</td> 597 <td>${badge}</td> 598 </tr>`; 599 }).join(""); 600 601 document.getElementById("pg-content").innerHTML = ` 602 <div class="playground-step"> 603 <h2 style="margin-top: 0;">Ãtape ${playgroundState.step + 1} â Faire avancer dans le pipeline</h2> 604 605 ${playgroundState.parcours === "evaluateur" ? ` 606 <div class="playground-card" style="background: #fff8e1; border-left: 3px solid var(--color-warning);"> 607 <p style="margin: 0;"> 608 <strong>ð Décision réservée au recruteur.</strong> En tant qu'évaluateur, 609 tu vois l'avancée du pipeline mais tu ne peux pas <strong>retenir ou refuser</strong> 610 un candidat â c'est le rôle du recruteur en charge du projet. Ton avis pèse 611 via les <strong>commentaires</strong> et le <strong>vote pondéré (-3 à +3)</strong> 612 sur la fiche. 613 </p> 614 <p style="margin: 0.4rem 0 0 0; font-size: 0.85rem; color: var(--color-text-light);"> 615 Dans ce tour de formation, les boutons sont activés pour que tu puisses 616 voir comment ça marche. En vrai, ils ne s'afficheront pas sur ton compte évaluateur. 617 </p> 618 </div> 619 ` : ` 620 <div class="playground-card playground-card-info"> 621 <p style="margin: 0;"> 622 <strong>ð¡ Le pipeline structure ta décision.</strong> à chaque étape, tu 623 retiens (â) ou tu refuses (â). Refuser <strong>exige un motif</strong> â 624 c'est ce motif que tu pourras réutiliser dans l'email automatique de refus 625 à la prochaine étape de ce tour. Tu peux toujours revenir en arrière si 626 tu changes d'avis. 627 </p> 628 <p style="margin: 0.4rem 0 0 0; font-size: 0.85rem;"> 629 <strong>ð¥ Cette décision est réservée au recruteur</strong> en charge du projet 630 â les évaluateurs invités peuvent commenter et voter, mais ne peuvent pas 631 faire avancer eux-mêmes les candidats. 632 </p> 633 <p style="margin: 0.5rem 0 0 0; font-size: 0.85rem; padding: 0.4rem 0.6rem; background: #fff; border-radius: 4px;"> 634 ð <strong>Dans la vraie app, ton pipeline est typable</strong> : à la 635 création du projet tu peux choisir un type pour chaque étape 636 (<em>entretien / mail / appel / exercice / autre</em>) qui affichera 637 un badge visuel sur la fiche pour t'aider à structurer ton process. 638 Pense aussi à l'option <strong>« Lettre de motivation obligatoire »</strong> 639 si ton poste l'exige (RH, communication, etc.). 640 </p> 641 <p style="margin: 0.4rem 0 0 0; font-size: 0.85rem; padding: 0.4rem 0.6rem; background: #fff7e6; border-radius: 4px;"> 642 â° <strong>Notification automatique J+15</strong> : si un candidat reste 643 « en attente » à une étape depuis plus de 15 jours, Boussole te le rappelle 644 dans le digest email quotidien â ça évite d'oublier les profils en stand-by. 645 </p> 646 </div> 647 `} 648 649 ${renderCandidateSelector(candidate.id)} 650 651 <!-- Récap court du candidat sélectionné --> 652 <div style="display: flex; align-items: center; gap: 0.7rem; padding: 0.5rem 0.8rem; background: var(--color-bg); border-radius: var(--radius); margin-bottom: 0.6rem;"> 653 <strong>${escapeHtml(candidate.firstName)} ${escapeHtml(candidate.lastName)}</strong>
654 <span style="color: var(--color-text-light); font-size: 0.85rem;">â ${escapeHtml(candidate.location)}</span> 655 <span style="margin-left: auto; font-weight: 700;">${candidate.score}%</span> 656 </div> 657 658 <!-- Stepper pipeline --> 659 ${stepperHtml} 660 661 <!-- Actions ou statut --> 662 ${actionsHtml} 663 664 <!-- Récap global de tous les candidats --> 665 <div class="detail-section" style="margin-top: 1.5rem;"> 666 <h4 style="margin-top: 0;">Ãtat de toutes les candidatures</h4> 667 <p style="font-size: 0.85rem; color: var(--color-text-light); margin: 0.2rem 0 0.5rem 0;"> 668 Vue d'ensemble (clique un nom pour basculer dessus). 669 </p> 670 <table class="candidates-table"> 671 <thead> 672 <tr> 673 <th>Candidat·e</th> 674 <th>Score</th> 675 <th>Pipeline</th> 676 </tr> 677 </thead> 678 <tbody id="pg-recap-tbody">${recapRows}</tbody> 679 </table> 680 </div> 681 </div> 682 `; 683 684 // --- Câblages --- 685 wireCandidateSelector(renderRecruteurPipeline); 686 687 // Action "Retenir" 688 const retainBtn = document.getElementById("pg-retain"); 689 if (retainBtn) { 690 retainBtn.addEventListener("click", () => { 691 currentStep.status = "retained"; 692 currentStep.validatedAt = pgNowFormatted(); 693 // Si pas la dernière étape, créer la suivante en pending 694 if (currentStep.step < totalSteps) { 695 pipelineSteps.push({ 696 step: currentStep.step + 1, 697 status: "pending", 698 rejectionComment: null, 699 validatedAt: null, 700 }); 701 } 702 persistPlaygroundState(); 703 renderRecruteurPipeline(); 704 }); 705 } 706 707 // Action "Refuser" 708 const rejectBtn = document.getElementById("pg-reject"); 709 if (rejectBtn) { 710 rejectBtn.addEventListener("click", () => { 711 const motif = prompt( 712 "Motif du refus (sera utilisé dans l'email automatique au candidat) :", 713 "" 714 ); 715 if (motif === null) return; // annulé 716 const trimmed = motif.trim(); 717 if (!trimmed) { 718 alert("Le motif est obligatoire pour refuser."); 719 return; 720 } 721 currentStep.status = "rejected"; 722 currentStep.rejectionComment = trimmed; 723 currentStep.validatedAt = pgNowFormatted(); 724 persistPlaygroundState(); 725 renderRecruteurPipeline(); 726 }); 727 } 728 729 // Action "Revenir en arrière" 730 const undoBtn = document.getElementById("pg-undo-step"); 731 if (undoBtn) { 732 undoBtn.addEventListener("click", () => { 733 if (!confirm("Annuler cette décision ? L'étape repassera en attente.")) return; 734 currentStep.status = "pending"; 735 currentStep.rejectionComment = null; 736 currentStep.validatedAt = null; 737 // Si on a créé une étape suivante (cas d'un retained â pending revert), 738 // il faut la supprimer 739 if (pipelineSteps.length > 1 && pipelineSteps[pipelineSteps.length - 1].step > currentStep.step) { 740 // ne devrait pas arriver car currentStep est le dernier 741 } 742 // Cas plus subtil : on a fait Retenir étape 1 puis on revient en arrière 743 // â l'étape 2 a été créée, il faut la retirer 744 if (pipelineSteps.length > 1) { 745 const idx = pipelineSteps.findIndex((s) => s.step === currentStep.step); 746 // si on annule retained sur étape N, il faut retirer N+1 si elle existe 747 if (idx >= 0 && idx < pipelineSteps.length - 1) { 748 pipelineSteps.splice(idx + 1); 749 } 750 } 751 persistPlaygroundState(); 752 renderRecruteurPipeline(); 753 }); 754 } 755 756 // Clic sur une ligne du récap pour switcher de candidat 757 document.querySelectorAll("#pg-recap-tbody tr").forEach((row, idx) => { 758 row.style.cursor = "pointer"; 759 row.addEventListener("click", () => { 760 playgroundState.selectedCandidateId = allCandidates[idx].id; 761 persistPlaygroundState(); 762 renderRecruteurPipeline(); 763 }); 764 }); 765}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.