PageSourceSearch

https://boussole.up.railway.app/backoffice/playground-candidate-fl…?v=1b043de98383f93686e3814b69bbbb0272dad8d2

js boussole.up.railway.app collected 2026-10-03 12:12:04 UTC 35,637 bytes, 765 lines download raw bytes

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.