<style> .wait { position: fixed; width: 50px; height: 10px; background: #424242; top: calc(50% - 50px); left: 50%; margin-left: -25px; animation: wait 0.6s ease-out alternate infinite; } @keyframes wait { 100% { transform: translateY(-30px) rotate(360deg) } } main h2 { border-bottom: 4px solid #0069d9; grid-column: 1 / -1; margin-bottom: 2px; } main { font-family: Verdana, Geneva, Tahoma, sans-serif; margin-right: 16px; padding: 8px; border-radius: 12px; background: #717171; display: flex; flex-wrap: wrap; gap: 8px; } section { background: #dbccb8; padding: 8px; border-radius: 8px; } .moitemoite { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } @media screen and (max-width: 1000px) { .moitemoite { grid-template-columns: 1fr; } } .notes { opacity: 0.4; pointer-events: none; } .parcours, .notes { display: inline-grid; grid-template-columns: auto auto; align-items: center; grid-row-gap: 4px; grid-column-gap: 16px; margin-bottom: 16px; background: #FFF; border: 1px solid #0069d9; border-radius: 8px; padding: 8px 16px; } h3, .resultats small { grid-column: 1 / -1; margin: 4px 0; } .parcours form { display: inline; } .parcours input { padding: 4px 8px; border: 1px solid #aaa; border-radius: 4px; width: 40px; } .dropZone { border: 2px dashed #0069d9; padding: 8px 16px; text-align: center; } .notes select { padding: 4px 8px; } .donnees, .resultats { display: inline-grid; } .donnees { background: #aaa; padding: 4px; border-radius: 4px; } .donnees>* { border-radius: 4px; border: 1px solid #aaa; } .donnees>*, .resultats>*:not(h2, button) { background: #fff; outline: 1px solid #aaa; padding: 4px 8px; } .entete { background: #0069d9 !important; color: #FFF; } .criteres { text-align: right; } .criteres:not(.criteres+.criteres), .voeux:not(.voeux+.voeux), .entete+.entete { margin-left: 8px; } small { display: block; margin-bottom: 4px; padding: 2px 4px; background: #fff; border-radius: 4px; font-style: italic; } button { background: rgb(134, 179, 0); color: #FFF; font-size: 16px; padding: 16px 32px; border: none; border-radius: 4px; box-shadow: 0 2px 2px rgba(0, 0, 0, 0.26); cursor: pointer; } button:hover { color: #ddd; } button:active { transform: translateY(2px); box-shadow: initial; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 4px; } button.autre { background: #0c9; } .resultats button { background: #90c; margin-bottom: 4px; } b { color: #000; } .moitemoite input:invalid, [contenteditable]:empty { animation: focus .5s alternate infinite; } @keyframes focus { 0% { outline: 1px solid rgba(204, 0, 153, 0) } 100% { outline: 1px solid rgba(204, 0, 153, 1) } } [data-actif] { cursor: pointer; } [data-actif=false] { background: #ccc; } [data-highlight=true] { outline: 2px solid #90c !important; border-radius: 4px; z-index: 1; } </style> <main class="moitemoite"> <div class="wait"></div> <section> <h2>Données</h2> <div class="parcours"></div> <div class="notes"></div> <div style="display:flex; gap: 8px;"> <button onclick="exportData()">Exporter les données</button> <!--<form class="dropZone"> <div><b>Importer les données</b></div> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#0099cc" stroke-width="2" stroke-linecap="round"><path pathlength="100" d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg> Déposez une fichier ou <br> <label><input type="file" accept="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"></label> </form>--> </div> <small style="margin-top: 4px">💡 Vous pouvez ensuite copier / coller, dans les cases blanches ci-dessous, les "notes" et "voeux", directement depuis le tableur.</small> <small>💡 Les voeux doivent être une série d'entiers en partant de 1 - exemple : 3, 1 et 2.</small> <small>💡 Cliquez sur un étudiant pour qu'il ne fasse pas parti du tri.</small> <div class="donnees"></div> </section> <section> <h2>Résultats</h2> <div class=grid> <button class="actionTri" data-tri="notes">Trier uniquement par <b>notes</b></button> <button class="actionTri" data-tri="voeux">Trier par <b>voeux</b> puis par notes</button> </div> <small> 💡 Vous pouvez exporter les résultats pour les utiliser dans l'éditeur de groupes/partitions. </small> <div class="resultats"></div> </section> </main> <script src="{{scu.STATIC_DIR}}/libjs/xlsx-populate-1.21.0.min.js"></script> <script> /************************/ /* A générer par Scodoc */ /************************/ let choixNotes = [ "RCUE1", "RCUE2", "RCUE3", "RCUE4", "RCUE5" ] let formsemestre; let parcours = {}; let nbParcours; /*let parcours = { 1448: { nom: "Communication", places: 28, // Modifiable par l'utilisateur etudiants: [] // Résultat du tri }, 1449: { nom: "Création numérique", places: 28, // Modifiable par l'utilisateur etudiants: [] // Résultat du tri }, 1450: { nom: "Développement Web", places: 28, // Modifiable par l'utilisateur etudiants: [] // Résultat du tri } }*/ let etudiants = {}; /*let etudiants = { 123: { actif: true, nom: "Jean Bono", criteres: { 1448: 10, // Modifiable par l'utilisateur 1449: 12, // Modifiable par l'utilisateur 1450: 8 // Modifiable par l'utilisateur }, voeux: { 1448: 1, // Modifiable par l'utilisateur 1449: 3, // Modifiable par l'utilisateur 1450: 2 // Modifiable par l'utilisateur } }, 124: { actif: true, nom: "Etudiant 2", criteres: { 1448: 11, // Modifiable par l'utilisateur 1449: 12.5, // Modifiable par l'utilisateur 1450: 8 // Modifiable par l'utilisateur }, voeux: { 1448: 2, // Modifiable par l'utilisateur 1449: 1, // Modifiable par l'utilisateur 1450: 3 // Modifiable par l'utilisateur } }, 125: { actif: true, nom: "Etudiant 3", criteres: { 1448: 10, // Modifiable par l'utilisateur 1449: 12.5, // Modifiable par l'utilisateur 1450: 15 // Modifiable par l'utilisateur }, voeux: { 1448: 3, // Modifiable par l'utilisateur 1449: 2, // Modifiable par l'utilisateur 1450: 1 // Modifiable par l'utilisateur } } }*/ /************************/ /* Chargement des datas */ /************************/ go(); async function go() { document.querySelector('.wait').style.display = ""; let params = (new URL(document.location)).searchParams; formsemestre = params.get('formsemestre_id'); let parcoursRaw = await fetchData("/ScoDoc/{{formsemestre.departement.acronym}}/api/formsemestre/" + formsemestre + "/partitions"); let etudiantsRaw = await fetchData("/ScoDoc/{{formsemestre.departement.acronym}}/api/formsemestre/" + formsemestre + "/etudiants"); //let decisionsRaw = await fetchData("/ScoDoc/{{formsemestre.departement.acronym}}/api/formsemestre/" + formsemestre + "/decisions_jury"); let savedData = await getFromScodoc(); etudiantsRaw.sort((a, b) => { return (a.nom + a.prenom).localeCompare(b.nom + b.prenom) }); processParcours(parcoursRaw, savedData); processNotes(); processEtudiants(etudiantsRaw, savedData); processEvents(); document.querySelector("body").classList.add("loaded"); document.querySelector('.wait').style.display = "none"; } function fetchData(request) { return fetch(request) .then(r => { return r.json() }) .then(data => { return data; }).catch(error => { document.querySelector("main").innerHTML = "<h2>Une erreur s'est produite lors du transfert des données.</h2>"; throw 'Fin du script - données invalides'; }) } /************************/ /* Génération du front */ /************************/ function processParcours(parcoursRaw, savedData) { Object.values(parcoursRaw).forEach(partition => { if (partition.partition_name == "Parcours") { Object.values(partition.groups).forEach(group => { parcours[group.id] = { nom: group.group_name, places: savedData.parcours?.[group.id].places || 28, etudiants: [] // Résultat du tri } }) } }) nbParcours = Object.keys(parcours).length; /* Zones parcours */ let output = "<h3>Places</h3>"; Object.entries(parcours).forEach(([idParcours, dataParcours]) => { output += ` <div data-idparcours=${idParcours}>${dataParcours.nom}</div> <div><form onsubmit="return false;"><input type=number required min=0 data-idparcours="${idParcours}" value="${dataParcours.places}"></form> places</div> `; }) document.querySelector(".parcours").innerHTML = output; } function processNotes() { /* Zones critère de notes */ let output = "<h3>Appliquer les notes</h3>"; Object.entries(parcours).forEach(([idParcours, dataParcours]) => { output += ` <div data-idparcours=${idParcours}>${dataParcours.nom}</div> <div> <select data-idparcours=${idParcours}> ${( () => { let output = "<option>-</option>"; choixNotes.forEach(notes => { output += `<option value="${notes}">${notes}</option>`; }) return output; } )()} </select> </div> `; }) document.querySelector(".notes").innerHTML = output; } function processEtudiants(etudiantsRaw, savedData) { /*id123: { actif: true, nom: "Jean Bono", criteres: { 1448: 10, // Modifiable par l'utilisateur 1449: 12, // Modifiable par l'utilisateur 1450: 8 // Modifiable par l'utilisateur }, voeux: { 1448: 1, // Modifiable par l'utilisateur 1449: 3, // Modifiable par l'utilisateur 1450: 2 // Modifiable par l'utilisateur } },*/ etudiantsRaw.forEach(etudiant => { saved = savedData.etudiants?.['id' + etudiant.id] || {}; etudiants['id' + etudiant.id] = { actif: saved.actif == false ? false : true, nom: etudiant.nom + " " + etudiant.prenom, criteres: saved.criteres || {}, voeux: saved.voeux || {} }; if (Object.keys(saved).length == 0) Object.keys(parcours).forEach((idParcours, index) => { etudiants['id' + etudiant.id].criteres[idParcours] = 10; etudiants['id' + etudiant.id].voeux[idParcours] = index + 1; }) }) /* Zone étudiants */ let output = ` <div class=entete>Etudiants</div> <div class=entete style="grid-column: span ${nbParcours}">Notes</div> <div class=entete style="grid-column: span ${nbParcours}">Voeux</div>`; Object.entries(etudiants).forEach(([id, etudiant]) => { output += ` <div data-actif=${etudiant.actif}>${etudiant.nom}</div> ${display(id, etudiant, "criteres", true)} ${display(id, etudiant, "voeux", true)} `; }) document.querySelector(".donnees").style.gridTemplateColumns = `repeat(${1 + nbParcours * 2}, auto)`; document.querySelector(".donnees").innerHTML = output; } /**********************************/ /* Affichage des critères / voeux */ /**********************************/ function display(id, datas, key, editable) { let output = ""; Object.entries(datas[key]).forEach(([idParcours, data]) => { output += `<div class=${key} data-id=${id} data-idParcours=${idParcours} contenteditable=${editable}>${data}</div>`; }) return output; } /**********************************/ /* Gestion des événements */ /**********************************/ function processEvents() { document.querySelectorAll(".parcours input").forEach(e => e.addEventListener("change", changeNbParcours)); document.querySelectorAll(".notes select").forEach(e => e.addEventListener("change", changeNotes)); document.querySelectorAll(".actionTri").forEach(e => e.addEventListener("click", processData)); document.querySelectorAll(".donnees>.criteres, .donnees>.voeux").forEach(e => e.addEventListener("paste", pasteData)); document.querySelectorAll("[data-actif]").forEach(e => e.addEventListener("mousedown", event => { event.preventDefault() })); document.querySelectorAll("[data-actif]").forEach(e => e.addEventListener("click", changeActif)); document.querySelectorAll("[contenteditable]").forEach(e => e.addEventListener("keypress", changeListe)); document.querySelectorAll("[contenteditable]").forEach(e => e.addEventListener("keyup", saveLocal)); document.querySelectorAll("[contenteditable]").forEach(e => e.addEventListener("keyup", saveToScodoc)); document.querySelectorAll("[data-idparcours]").forEach(e => e.addEventListener("mouseover", setHighlight)); document.querySelectorAll("[data-idparcours]").forEach(e => e.addEventListener("mouseleave", resetHighlight)); } /***************************************/ /* Traitement des entrées utilisateurs */ /***************************************/ function changeNbParcours() { let idParcours = this.dataset.idparcours; let places = this.value; parcours[idParcours].places = places; saveToScodoc(); } function changeNotes() { let idParcours = this.dataset.idparcours; let notes = this.value; /******* SCODOC - récupérer et modifier les valeurs des notes ******/ } function changeActif() { let statut = this.dataset.actif; if (statut == "true") { statut = false; } else { statut = true; } this.dataset.actif = statut; etudiants[this.nextElementSibling.dataset.id].actif = statut; saveToScodoc(); } function changeListe(event) { let type = this.className; /* Vérification des touches */ if (type == "criteres") { if (!/[0-9.]/.test(event.key)) { event.preventDefault(); } } else { if (!/[0-9]/.test(event.key)) { event.preventDefault(); } } if (event.key == "." && /[.]/.test(this.innerText)) { event.preventDefault(); } } function saveLocal(event) { /* Enregistement des données */ let type = this.className; let parcours = this.dataset.idparcours; let id = this.dataset.id; etudiants[id][type][parcours] = parseFloat(this.innerText); } /*function importList(semestre) { console.log(parcours); /******* SCODOC - enregistrer la répartition des étudiants dans le parcours ******/ /*document.querySelector(".resultats>h2").innerHTML += `<div>✔️ Groupes importés dans le ${semestre}</div>`; }*/ function setHighlight() { document.querySelectorAll(`[data-idparcours="${this.dataset.idparcours}"]`).forEach(e => { e.dataset.highlight = true; }) } function resetHighlight() { document.querySelectorAll(`[data-highlight=true]`).forEach(e => { e.dataset.highlight = false; }) } function saveToScodoc() { let data = { parcours: parcours, etudiants: etudiants }; fetch( "/ScoDoc/{{formsemestre.departement.acronym}}/api/formsemestre/" + formsemestre + "/save_groups_auto_assignment", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(data) } ); } async function getFromScodoc() { let dataRaw = await fetch("/ScoDoc/{{formsemestre.departement.acronym}}/api/formsemestre/" + formsemestre + "/get_groups_auto_assignment"); let data = await dataRaw.text(); if (data == "") { return {}; } return JSON.parse(data); } /***************/ /* Algo de tri */ /***************/ function processData() { let typeTri = this.dataset.tri; // Vérifications nombre de place let placesTotales = 0; Object.values(parcours).forEach(p => { placesTotales += p.places }) if (placesTotales < Object.keys(etudiants).length) { document.querySelector(".resultats").innerHTML = "<h2>⚠️ Le nombre de places est inférieur au nombre d'étudiants ⚠️</h2>"; return; } // Vérification champs non remplis if (typeTri == "notes") { var recherche = ".moitemoite input:invalid"; } else { var recherche = ".moitemoite input:invalid, [contenteditable]:empty"; } if (document.querySelector(recherche)) { document.querySelector(".resultats").innerHTML = "<h2>⚠️ Veuillez remplir tous les champs ⚠️</h2>"; return; } // Remise à 0 des résultats Object.values(parcours).map(p => p.etudiants = []); // Liste des étudiants à traiter let etudiantsID = Object.keys(etudiants); /* Pour chaque niveau de voeux */ for (let numVoeux = 1; numVoeux < nbParcours + 1; numVoeux++) { /* Ce qu'il restera à traiter au round suivant */ let aTraiter = []; etudiantsID.forEach(id => { if (etudiants[id].actif == false) return; if (typeTri == "notes") { // Récupère le parcours en fonction des notes et du round var groupe = Object.entries(etudiants[id].criteres).sort(([, a], [, b]) => b - a)[numVoeux - 1][0]; } else { // Trouve le parcours qui correspond au numéro du voeux var groupe = Object.keys(etudiants[id].voeux).find(key => etudiants[id].voeux[key] == numVoeux); } if (!groupe) { sco_message("Il semblerait que les voeux de " + etudiants[id].nom + " ne soit pas consécutifs en partant de 1."); } if (parcours[groupe].places == 0) { aTraiter.push(id); } else if (parcours[groupe].etudiants.length < parcours[groupe].places) { // Le groupe n'est pas plein, on y ajoute l'étudiant parcours[groupe].etudiants.push(id); } else { aTraiter.push(groupePlein(groupe, id)); } }) etudiantsID = aTraiter; } showResults(); } function groupePlein(groupe, id) { // Trie des étudiants dans le parcours par rapport au critère parcours[groupe].etudiants = parcours[groupe].etudiants.sort((a, b) => { return etudiants[a].criteres[groupe] - etudiants[b].criteres[groupe]; }) // Si les deux étudiants ont le même niveau : aléatoire let diff = etudiants[parcours[groupe].etudiants[0]].criteres[groupe] - etudiants[id].criteres[groupe]; if (diff == 0) { if (Math.random() < 0.5) { diff = 1; } else { diff = -1; } } // On ajoute dans le parcours le meilleur et on met à traiter l'autre if (diff < 0) { parcours[groupe].etudiants.push(id); return parcours[groupe].etudiants.shift(); } else { return id; } } /***************************/ /* Affichage des résultats */ /***************************/ function showResults() { let output = ` <button class="autre" onclick="exportResults()">Exporter les résultats</button>`; /*` <h2> Importer les résultats dans Scodoc<br> <button onclick='importList("S1")'>S1</button> <button onclick='importList("S2")'>S2</button> <button onclick='importList("S3")'>S3</button> <button onclick='importList("S4")'>S4</button> <button onclick='importList("S5")'>S5</button> <button onclick='importList("S6")'>S6</button> </h2> `*/; Object.values(parcours).forEach(p => { output += `<h2>${p.nom} - ${p.etudiants.length}</h2> <div class=entete>Etudiants</div> <div class=entete style="grid-column: span ${nbParcours}">Notes</div> <div class=entete style="grid-column: span ${nbParcours}">Voeux</div>`; p.etudiants.forEach(id => { output += `<div>${etudiants[id].nom}</div> ${display(id, etudiants[id], "criteres", false)} ${display(id, etudiants[id], "voeux", false)} `; }) }) document.querySelector(".resultats").style.gridTemplateColumns = `repeat(${1 + nbParcours * 2}, auto)`; document.querySelector(".resultats").innerHTML = output; } /*****************************/ /* Copier / coller des datas */ /*****************************/ function pasteData(event) { event.preventDefault(); let data = event.clipboardData.getData('Text'); let lignes = data.split(/\r\n|\r|\n/g); currentTarget = this; // Calcul du décalage dans la grille let offset = -1; do { offset++; currentTarget = currentTarget.previousElementSibling; } while (currentTarget.className != "") // Collage des données currentTarget = this; lignes.forEach(ligne => { let zone = ligne.split(/\t/g); for (let i = 0; i < zone.length; i++) { if (!currentTarget) return; currentTarget.innerText = zone[i]; currentTarget = currentTarget.nextElementSibling; if (currentTarget?.className == "") { for (let j = 0; j <= offset; j++) { currentTarget = currentTarget.nextElementSibling; } return; } } }) updateStudentsDatas(); } function updateStudentsDatas() { document.querySelectorAll(".criteres, .voeux").forEach(e => { etudiants[e.dataset.id][e.className][e.dataset.idparcours] = parseFloat(e.innerText); }) saveStudentsData(); } function saveStudentsData() { /******* SCODOC - enregistrer les données étudiantes ******/ console.log(etudiants); } /***************/ /* Export xlsx */ /***************/ function saveFile(name, workbook) { workbook.outputAsync() .then(function (blob) { var url = window.URL.createObjectURL(blob); var a = document.createElement("a"); document.body.appendChild(a); a.href = url; a.download = name + ".xlsx"; a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); }); } function changeChar(char, nb) { return String.fromCharCode(char.charCodeAt(0) + nb); } async function exportData() { XlsxPopulate.fromBlankAsync() .then(workbook => { const sheet = workbook.sheet(0); sheet.name("Données"); sheet.cell("A1").value("id"); sheet.cell("B1").value("Actif"); sheet.cell("C1").value("Nom"); Object.values(parcours).forEach((p, index) => { sheet.cell(changeChar("D", index) + "1").value("Note " + p.nom); sheet.cell(changeChar("D", index + nbParcours) + "1").value("Voeu " + p.nom); sheet.cell(changeChar("D", index + nbParcours) + "2").value(p.places); }) Object.entries(etudiants).forEach(([id, etudiant], index) => { sheet.cell("A" + (index + 3)).value(id.slice(2)); sheet.cell("B" + (index + 3)).value(etudiant.actif); sheet.cell("C" + (index + 3)).value(etudiant.nom); let offset = 0; Object.values(etudiant.criteres).forEach(critere => { sheet.cell(changeChar("D", offset) + (index + 3)).value(critere); offset++; }) Object.values(etudiant.voeux).forEach(voeux => { sheet.cell(changeChar("D", offset) + (index + 3)).value(voeux); offset++; }) }) sheet.column("A").width(5); sheet.column("B").width(5); sheet.column("C").width(20); sheet.column("D").width(20); sheet.column("E").width(20); sheet.column("F").width(20); sheet.column("G").width(20); sheet.column("H").width(20); sheet.column("I").width(20); sheet.column("J").width(20); sheet.column("K").width(20); sheet.column("L").width(20); saveFile("Donnees groupes - " + formsemestre, workbook); }); } async function exportResults() { XlsxPopulate.fromBlankAsync() .then(workbook => { const sheet = workbook.sheet(0); sheet.name("Résultats"); let colonne = 1; Object.values(parcours).forEach((dataParcours, index) => { let ligne = 1; sheet.row(ligne++).cell(colonne).value(dataParcours.nom).style("bold", true); sheet.row(ligne).cell(colonne).value("id").style("bold", true); sheet.row(ligne++).cell(colonne + 1).value("Nom").style("bold", true).column().width(30); dataParcours.etudiants.forEach(etudiant => { sheet.row(ligne).cell(colonne).value(etudiant.slice(2)); sheet.row(ligne++).cell(colonne + 1).value(etudiants[etudiant].nom); }) colonne += 3; }) saveFile("Resultats groupes - " + formsemestre, workbook); }); } </script>