ScoDoc-PE/app/templates/scolar/students_groups_auto_assignment.j2

860 lines
23 KiB
Plaintext
Raw Normal View History

2023-07-22 22:40:09 +02:00
<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="https://cdnjs.cloudflare.com/ajax/libs/xlsx-populate/1.21.0/xlsx-populate.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,
2023-07-24 21:24:44 +02:00
places: savedData.parcours?.[group.id].places || 28,
2023-07-22 22:40:09 +02:00
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 => {
2023-07-24 21:24:44 +02:00
saved = savedData.etudiants?.['id' + etudiant.id] || {};
2023-07-22 22:40:09 +02:00
etudiants['id' + etudiant.id] = {
2023-07-24 21:24:44 +02:00
actif: saved.actif == false ? false : true,
2023-07-22 22:40:09 +02:00
nom: etudiant.nom + " " + etudiant.prenom,
criteres: saved.criteres || {},
voeux: saved.voeux || {}
};
2023-07-24 21:24:44 +02:00
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;
})
2023-07-22 22:40:09 +02:00
})
/* 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);
2023-07-24 21:34:35 +02:00
saveFile("Donnees groupes - " + formsemestre, workbook);
2023-07-22 22:40:09 +02:00
});
}
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;
})
2023-07-24 21:34:35 +02:00
saveFile("Resultats groupes - " + formsemestre, workbook);
2023-07-22 22:40:09 +02:00
});
}
</script>