ScoDoc/app/static/js/assiduites.js
2024-03-15 16:08:41 +01:00

911 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
*
* Ensemble des fonctions liées à la gestion des assiduités
* Créé par : HARTMANN Matthias (Iziram)
*
*/
/**
* <== OUTILS ==>
*/
/**
* Ajout d'une fonction `capitalize` sur tous les strings
* "alice".capitalize() -> "Alice"
*/
Object.defineProperty(String.prototype, "capitalize", {
value: function () {
return this.charAt(0).toUpperCase() + this.slice(1).toLowerCase();
},
enumerable: false,
});
/**
* Fait une requête GET de façon asynchrone
* @param {String} path adresse distante
* @param {CallableFunction} success fonction à effectuer en cas de succès
* @param {CallableFunction} errors fonction à effectuer en cas d'échec
*/
async function async_get(path, success, errors) {
const response = fetch(path);
response
.then((response) => {
if (response.ok) {
response.json().then((data) => {
success(data, "success");
});
} else {
throw new Error("Network response was not ok.");
}
})
.catch((error) => {
console.error(error);
if (errors) errors(error);
});
return response;
}
/**
* Fait une requête POST de façon asynchrone
* @param {String} path adresse distante
* @param {object} data données à envoyer (objet js)
* @param {CallableFunction} success fonction à effectuer en cas de succès
* @param {CallableFunction} errors fonction à effectuer en cas d'échec
*/
async function async_post(path, data, success, errors) {
console.log("async_post " + path);
let response;
try {
response = await fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (response.ok) {
const responseData = await response.json();
success(responseData);
} else {
throw new Error("Network response was not ok.");
}
} catch (error) {
console.error(error);
if (errors) errors(error);
}
return response;
}
/**
* Récupère les étudiants en fonction des groupes sélectionnés
* @param {Array} groupIds - Les identifiants des groupes pour lesquels récupérer les étudiants.
* @returns {Promise<Object>} Un objet contenant les étudiants, indexés par leur identifiant.
*/
async function recupEtuds(groupIds) {
const etuds = new Map();
if (groupIds == null || groupIds.length == 0) return etuds;
// Créer un tableau de promesses pour chaque requête GET asynchrone.
let requests = groupIds.map((groupId) =>
fetch(`../../api/group/${groupId}/etudiants`)
.then((response) => {
if (!response.ok) {
throw new Error("Network response was not ok");
}
return response.json();
})
.then((data) => {
data.forEach((etud) => {
etuds.set(etud.id, etud);
});
})
.catch((error) =>
console.error(
"There has been a problem with your fetch operation:",
error
)
)
);
// Attendre que toutes les promesses dans le tableau `requests` soient résolues.
await Promise.all(requests);
return etuds;
}
/**
* Récupère l'assiduité des étudiants pour une date donnée
* @param {Map} etuds
* @param {Date} date
*/
async function recupAssiduites(etuds, date) {
const etudIds = [...etuds.keys()].join(",");
const date_debut = date.add(-1, "days").format("YYYY-MM-DDTHH:mm");
const date_fin = date.add(2, "days").format("YYYY-MM-DDTHH:mm");
url =
`../../api/assiduites/group/query?date_debut=${date_debut}` +
`&date_fin=${date_fin}&etudids=${etudIds}&with_justifs`;
await fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res.json();
})
.then((data) => {
Object.keys(data).forEach((etudid) => {
const etud = etuds.get(Number(etudid));
const assiduites = data[etudid];
etud.assiduites = assiduites;
});
})
.catch((error) =>
console.error(
"There has been a problem with your fetch operation:",
error
)
);
}
/**
* Génération ligne étudiante
*/
function creerLigneEtudiant(etud, index) {
let currentAssiduite = {
etat: "",
type: "creation",
assiduite_id: -1,
date_debut: null,
date_fin: null,
};
function recupConflitsAssiduites(assiduites) {
const period = getPeriodAsDate();
return assiduites.filter((assi) => {
const interval = {
deb: new Date(Date.removeUTC(assi.date_debut)),
fin: new Date(Date.removeUTC(assi.date_fin)),
};
return (
period.deb.isBefore(interval.fin) && period.fin.isAfter(interval.deb)
);
});
}
const conflits = readOnly ? [] : recupConflitsAssiduites(etud.assiduites);
if (conflits.length > 0) {
currentAssiduite = conflits[0];
const conflitsPeriode = {
deb: new Date(Date.removeUTC(currentAssiduite.date_debut)),
fin: new Date(Date.removeUTC(currentAssiduite.date_fin)),
};
const period = getPeriodAsDate();
currentAssiduite.type =
period.deb.isSame(conflitsPeriode.deb) &&
period.fin.isSame(conflitsPeriode.fin)
? "edition"
: "conflit";
}
const ligneEtud = document.createElement("div");
ligneEtud.classList.add("etud_row");
if (Object.keys(etudsDefDem).includes(etud.id)) {
ligneEtud.classList.add(etudsDefDem[etud.id] == "D" ? "dem" : "def");
}
ligneEtud.id = `etud_row_${etud.id}`;
if (currentAssiduite.type === "conflit" && !readOnly)
ligneEtud.classList.add("conflit");
// div index avec l'index
const indexDiv = document.createElement("div");
indexDiv.classList.add("index");
indexDiv.textContent = index;
ligneEtud.appendChild(indexDiv);
// div name_field
const nameField = document.createElement("div");
nameField.classList.add("name_field");
if ($("#pdp").is(":checked")) {
const pdp = document.createElement("img");
pdp.src = `../../api/etudiant/etudid/${etud.id}/photo?size=small`;
pdp.alt = `${etud.nom} ${etud.prenom}`;
pdp.classList.add("pdp");
nameField.appendChild(pdp);
}
const nameSet = document.createElement("a");
nameSet.classList.add("name_set");
nameSet.href = `bilan_etud?etudid=${etud.id}`;
const nom = document.createElement("h4");
nom.classList.add("nom");
nom.textContent = etud.nom;
const prenom = document.createElement("h5");
prenom.classList.add("prenom");
prenom.textContent = etud.prenom;
nameSet.appendChild(nom);
nameSet.appendChild(prenom);
nameField.appendChild(nameSet);
ligneEtud.appendChild(nameField);
// div assiduites_bar
const assiduitesBar = document.createElement("div");
assiduitesBar.classList.add("assiduites_bar");
const prevDateAssi = document.createElement("div");
prevDateAssi.id = "prevDateAssi";
function recupDerniereAssiduite(assiduites) {
const period = {
deb: $("#date").datepicker("getDate").add(-1, "days"),
fin: $("#date").datepicker("getDate"),
};
const lastAssiduite = assiduites
.filter((assi) => {
const interval = {
deb: new Date(Date.removeUTC(assi.date_debut)),
fin: new Date(Date.removeUTC(assi.date_fin)),
};
return (
period.deb.isBefore(interval.fin) && period.fin.isAfter(interval.deb)
);
})
.sort((a, b) => {
return (
new Date(Date.removeUTC(b.date_debut)) -
new Date(Date.removeUTC(a.date_debut))
);
})
.pop();
return lastAssiduite ?? null;
}
const lastAssiduite = recupDerniereAssiduite(etud.assiduites);
prevDateAssi.classList.add(lastAssiduite?.etat.toLowerCase() ?? "vide");
setupAssiduiteBubble(prevDateAssi, lastAssiduite);
assiduitesBar.appendChild(prevDateAssi);
// div minitimeline
assiduitesBar.appendChild(createMiniTimeline(etud.assiduites));
ligneEtud.appendChild(assiduitesBar);
// fieldset btns_field single
const btnsField = document.createElement("fieldset");
btnsField.classList.add("btns_field", "single");
btnsField.setAttribute("etudid", etud.id);
btnsField.setAttribute("type", currentAssiduite.type);
btnsField.setAttribute("assiduite_id", currentAssiduite.assiduite_id);
// Création des boutons d'assiduités
if (readOnly) {
} else if (currentAssiduite.type != "conflit") {
["present", "retard", "absent"].forEach((abs) => {
const btn = document.createElement("input");
btn.type = "checkbox";
btn.value = abs;
btn.name = `btn_assiduites_${index}`;
btn.id = `rbtn_${abs}`;
btn.classList.add("rbtn", abs);
btn.title = abs;
btn.checked = abs === currentAssiduite?.etat.toLowerCase();
// Une seule checkbox à la fois
btn.addEventListener("click", () => {
Array.from(btn.parentElement.children).forEach((chbox) => {
if (chbox.checked && chbox.value !== btn.value) {
chbox.checked = false;
}
});
});
// Action au clic
btn.addEventListener("click", (e) => {
actionAssiduite(
etud,
btn.value,
currentAssiduite.type,
currentAssiduite.type == "edition" ? currentAssiduite : null
);
e.preventDefault();
});
btnsField.appendChild(btn);
});
} else {
const btn = document.createElement("input");
btn.type = "checkbox";
btn.value = "conflit";
btn.name = `btn_assiduites_${index}`;
btn.id = `rbtn_conflit`;
btn.classList.add("rbtn", "conflit");
btn.title = "conflit";
// TODO : Ouvrir solveur
const solveur = new ConflitResolver(etud.assiduites, getPeriodAsDate(), {
deb: $("#date").datepicker("getDate"),
fin: $("#date").datepicker("getDate").add(1, "days"),
});
const update = () => {
MiseAJourLigneEtud(etud);
};
solveur.callbacks = {
delete: update,
edit: update,
split: update,
};
btn.addEventListener("click", () => {
solveur.open();
btn.checked = false;
});
btnsField.appendChild(btn);
}
ligneEtud.appendChild(btnsField);
return ligneEtud;
}
/**
* Génération de toutes les lignes étudiantes
*/
async function creerTousLesEtudiants(etuds) {
const etudsDiv = document.querySelector(".etud_holder");
etudsDiv.innerHTML = "";
const moduleImplId = readOnly ? null : $("#moduleimpl_select").val();
const inscriptions = await getInscriptionModule(moduleImplId);
[...etuds.values()]
.sort((a, b) => {
return a.sort_key > b.sort_key ? 1 : -1;
})
.filter((etud) => {
return inscriptions == null || inscriptions.includes(etud.id);
})
.forEach((etud, index) => {
etudsDiv.appendChild(creerLigneEtudiant(etud, index + 1));
});
}
/**
* Récupère une version lisible du moduleimpl
* @param {Object} assiduite
* @returns {String}
*/
async function getModuleImpl(assiduite) {
if (assiduite == null) return "Pas de module";
const id = assiduite.moduleimpl_id;
if (id == null || id == undefined) {
if (
assiduite.hasOwnProperty("external_data") &&
assiduite.external_data != null &&
assiduite.external_data.hasOwnProperty("module")
) {
return assiduite.external_data.module == "Autre"
? "Autre module (pas dans la liste)"
: assiduite.external_data.module;
} else {
return "Pas de module";
}
}
if (id in moduleimpls) {
return moduleimpls[id];
}
const url_api = `../../api/moduleimpl/${id}`;
return await fetch(url_api)
.then((res) => {
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res.json();
})
.then((data) => {
moduleimpls[id] = `${data.module.code} ${data.module.abbrev}`;
return moduleimpls[id];
})
.catch((_) => {
moduleimpls[id] = "Pas de module";
return moduleimpls[id];
});
}
/**
* Renvoie le moduleimpl_id de l'assiduité
* ou l'external_data.module si le moduleimpl_id n'est pas défini
* "" si aucun module n'est défini
* @param {Object} assiduite
* @returns {String}
*/
function getModuleImplId(assiduite) {
const id = assiduite.moduleimpl_id;
if (id == null || id == undefined) {
if (
assiduite.hasOwnProperty("external_data") &&
assiduite.external_data != null &&
assiduite.external_data.hasOwnProperty("module")
) {
return assiduite.external_data.module.toLowerCase();
} else {
return "";
}
} else {
return id + "";
}
}
/**
* Récupère les etudid de tous les étudiants inscrits au module
* @param {String} moduleimpl_id
* @returns {Array}
*/
async function getInscriptionModule(moduleimpl_id) {
if ([null, "", "autre"].includes(moduleimpl_id)) return null;
if (!inscriptionsModules.has(moduleimpl_id)) {
const path = `../../api/moduleimpl/${moduleimpl_id}/inscriptions`;
await fetch(path)
.then((res) => {
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res.json();
})
.then((data) => {
inscriptionsModules.set(
moduleimpl_id,
data.map((i) => i.etudid)
);
})
.catch((_) => {
inscriptionsModules.set(moduleimpl_id, []);
});
}
return inscriptionsModules.get(moduleimpl_id);
}
async function MiseAJourLigneEtud(etud) {
//Récupérer ses assiduités
function RecupAssiduitesEtudiant(etudid) {
const date = $("#date").datepicker("getDate");
const date_debut = date.add(-1, "days").format("YYYY-MM-DDTHH:mm");
const date_fin = date.add(2, "days").format("YYYY-MM-DDTHH:mm");
url =
`../../api/assiduites/${etudid}/query?date_debut=${date_debut}` +
`&date_fin=${date_fin}&with_justifs`;
return fetch(url)
.then((res) => {
if (!res.ok) {
throw new Error("Network response was not ok");
}
return res.json();
})
.then((data) => {
etud.assiduites = data;
})
.catch((error) => {
console.error(
"There has been a problem with your fetch operation:",
error
);
});
}
await RecupAssiduitesEtudiant(etud.id);
const etudRow = document.getElementById(`etud_row_${etud.id}`);
if (etudRow == null) return;
const ligneEtud = creerLigneEtudiant(
etud,
document.querySelector(`#etud_row_${etud.id}`).querySelector(".index")
.textContent
);
etudRow.replaceWith(ligneEtud);
}
async function actionAssiduite(etud, etat, type, assiduite = null) {
const modimpl_id = $("#moduleimpl_select").val();
if (
assiduite &&
assiduite.etat.toLowerCase() === etat &&
assiduite.moduleimpl_id == modimpl_id
)
type = "suppression";
const { deb, fin } = getPeriodAsDate();
let assiduiteObjet = assiduite ?? {
date_debut: deb,
date_fin: fin,
etudid: etud.id,
};
assiduiteObjet.etat = etat;
assiduiteObjet.moduleimpl_id = modimpl_id;
if (type === "creation") {
await async_post(
`../../api/assiduite/${etud.id}/create`,
[assiduiteObjet],
(data) => {
if (data.success.length > 0) {
MiseAJourLigneEtud(etud);
envoiToastEtudiant(etat, etud);
} else {
console.error(data.errors["0"].message);
erreurModuleImpl(data.errors["0"].message);
}
},
(error) => {
console.error("Erreur lors de la création de l'assiduité", error);
}
);
} else if (type === "edition") {
await async_post(
`../../api/assiduite/${assiduite.assiduite_id}/edit`,
{
etat: assiduiteObjet.etat,
moduleimpl_id: assiduiteObjet.moduleimpl_id,
},
(data) => {
MiseAJourLigneEtud(etud);
envoiToastEtudiant(etat, etud);
},
(error) => {
console.error("Erreur lors de la modification de l'assiduité", error);
}
);
} else if (type === "suppression") {
await async_post(
`../../api/assiduite/delete`,
[assiduite.assiduite_id],
(data) => {
if (data.success.length > 0) {
MiseAJourLigneEtud(etud);
envoiToastEtudiant("remove", etud);
} else {
console.error(data.errors["0"].message);
erreurModuleImpl(data.errors["0"].message);
}
},
(error) => {
console.error("Erreur lors de la suppression de l'assiduité", error);
}
);
}
}
function erreurModuleImpl(message) {
if (message == "Module non renseigné") {
const HTML = `
<p>Attention, le module doit obligatoirement être renseigné.</p>
<p>Cela vient de la configuration du semestre ou plus largement du département.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
const content = document.createElement("div");
content.innerHTML = HTML;
openAlertModal("Sélection du module", content);
}
if (message == "L'étudiant n'est pas inscrit au module") {
const HTML = `
<p>Attention, l'étudiant n'est pas inscrit à ce module.</p>
<p>Si c'est une erreur, veuillez voir avec le ou les responsables de votre scodoc.</p>
`;
const content = document.createElement("div");
content.innerHTML = HTML;
openAlertModal("Sélection du module", content);
}
}
function mettreToutLeMonde(etat, el = null) {
const lignesEtuds = [...document.querySelectorAll("fieldset.btns_field")];
const { deb, fin } = getPeriodAsDate();
const assiduiteObjet = {
date_debut: deb,
date_fin: fin,
etat: etat,
moduleimpl_id: $("#moduleimpl_select").val(),
};
if (el != null) el.checked = false;
// Suppression des assiduités
if (etat == "vide") {
const assiduites_id = lignesEtuds
.filter((e) => e.getAttribute("type") == "edition")
.map((e) => Number(e.getAttribute("assiduite_id")));
afficheLoader();
async_post(
`../../api/assiduite/delete`,
assiduites_id,
async (data) => {
retirerLoader();
if (data.errors.length == 0) {
await recupAssiduites(etuds, $("#date").datepicker("getDate"));
creerTousLesEtudiants(etuds);
} else {
console.error(data.errors);
}
envoiToastTous("remove", assiduites_id.length);
},
(error) => {
console.error("Erreur lors de la suppression de l'assiduité", error);
}
);
return;
}
// Création / édition des assiduités
const assiduitesACreer = lignesEtuds
.filter((e) => e.getAttribute("type") == "creation")
.map((e) => Number(e.getAttribute("etudid")));
const assiduitesAEditer = lignesEtuds
.filter((e) => e.getAttribute("type") == "edition")
.map((e) => Number(e.getAttribute("assiduite_id")));
// création
const promiseCreate = async_post(
`../../api/assiduites/create`,
assiduitesACreer.map((etudid) => {
return { ...assiduiteObjet, etudid };
}),
async (data) => {
if (data.errors.length > 0) {
console.error(data.errors);
}
},
(error) => {
console.error("Erreur lors de la création de l'assiduité", error);
}
);
const promiseEdit = async_post(
`../../api/assiduites/edit`,
assiduitesAEditer.map((assiduite_id) => {
return { ...assiduiteObjet, assiduite_id };
}),
async (data) => {
if (data.errors.length > 0) {
console.error(data.errors);
}
},
(error) => {
console.error("Erreur lors de l'édition de l'assiduité", error);
}
);
// Affiche un loader
afficheLoader();
Promise.all([promiseCreate, promiseEdit]).then(async () => {
retirerLoader();
await recupAssiduites(etuds, $("#date").datepicker("getDate"));
creerTousLesEtudiants(etuds);
envoiToastTous(etat, assiduitesACreer.length + assiduitesAEditer.length);
});
}
function afficheLoader() {
const loaderDiv = document.createElement("div");
loaderDiv.id = "loader";
const span = document.createElement("span");
span.textContent = "Chargement en cours";
loaderDiv.appendChild(span);
const loader = document.createElement("div");
loader.classList.add("loader");
loaderDiv.appendChild(loader);
document.body.appendChild(loaderDiv);
}
function retirerLoader() {
document.getElementById("loader").remove();
}
function envoiToastEtudiant(etat, etud) {
let etatAffiche;
switch (etat.toUpperCase()) {
case "PRESENT":
etatAffiche = "%etud% a été noté(e) <u><strong>présent(e)</strong></u>";
break;
case "RETARD":
etatAffiche = "%etud% a été noté(e) <u><strong>en retard</strong></u>";
break;
case "ABSENT":
etatAffiche = "%etud% a été noté(e) <u><strong>absent(e)</strong></u>";
break;
case "REMOVE":
etatAffiche = "L'assiduité de %etud% a été retirée.";
}
const nom_prenom = `${etud.nom.toUpperCase()} ${etud.prenom.capitalize()}`;
const span = document.createElement("span");
span.innerHTML = etatAffiche.replace("%etud%", nom_prenom);
pushToast(generateToast(span, getToastColorFromEtat(etat.toUpperCase()), 5));
}
function envoiToastTous(etat, count) {
const span = document.createElement("span");
let etatAffiche = etat;
switch (etat) {
case "remove":
if (count > 0) {
span.innerHTML = `${count} assiduités ont été supprimées.`;
} else {
span.innerHTML = `Aucune assiduité n'a été supprimée.`;
}
break;
case "retard":
etatAffiche = "En retard";
default:
if (count > 0) {
span.innerHTML = `${count} étudiants ont été mis <u><strong>${etatAffiche
.capitalize()
.trim()}</strong></u>`;
} else {
span.innerHTML = `Aucun étudiant n'a été mis <u><strong>${etatAffiche
.capitalize()
.trim()}</strong></u>`;
}
break;
}
pushToast(generateToast(span, getToastColorFromEtat(etat.toUpperCase()), 5));
}
function estJourTravail(jour, nonWorkdays) {
const d = Intl.DateTimeFormat("fr-FR", {
timeZone: SCO_TIMEZONE,
weekday: "short",
})
.format(jour)
.replace(".", "");
return !nonWorkdays.includes(d);
}
function retourJourTravail(date) {
const jourMiliSecondes = 86400000; // 24 * 3600 * 1000 | H * s * ms
let jour = date;
let compte = 0;
while (!estJourTravail(jour, nonWorkDays) && compte++ < 7) {
jour = new Date(jour - jourMiliSecondes);
}
return jour;
}
function dateCouranteEstTravaillee() {
const date = $("#date").datepicker("getDate");
if (!estJourTravail(date, nonWorkDays)) {
const nouvelleDate = retourJourTravail(date);
$("#date").datepicker("setDate", nouvelleDate);
const att = document.createTextNode(
`Le jour sélectionné (${Date.toFRA(
date.format("YYYY-MM-DD")
)}) n'est pas un jour travaillé.`
);
const div = document.createElement("div");
div.appendChild(att);
div.appendChild(document.createElement("br"));
div.appendChild(
document.createTextNode(
`Le dernier jour travaillé disponible a été sélectionné : ${Date.toFRA(
nouvelleDate.format("YYYY-MM-DD")
)}.`
)
);
openAlertModal("Attention", div, "", "#eec660");
return false;
}
return true;
}
/**
* Ajout de la visualisation des assiduités de la mini timeline
* @param {HTMLElement} el l'élément survollé
* @param {Assiduité} assiduite l'assiduité représentée par l'élément
*/
function setupAssiduiteBubble(el, assiduite) {
function formatDateModal(dateStr) {
const date = new Date(Date.removeUTC(dateStr));
return date.format("DD/MM/Y HH:mm");
}
if (!assiduite) return;
const bubble = document.createElement("div");
bubble.className = "assiduite-bubble";
bubble.classList.add(assiduite.etat.toLowerCase());
// Ajout d'un lien pour plus d'informations
const infos = document.createElement("a");
infos.className = "assiduite-infos";
infos.textContent = ``;
infos.title = "Cliquez pour plus d'informations";
infos.target = "_blank";
infos.href = `tableau_assiduite_actions?type=assiduite&action=details&obj_id=${assiduite.assiduite_id}`;
bubble.appendChild(infos);
const idDiv = document.createElement("div");
idDiv.className = "assiduite-id";
getModuleImpl(assiduite).then((modImpl) => {
idDiv.textContent = `${modImpl}`;
});
bubble.appendChild(idDiv);
const periodDivDeb = document.createElement("div");
periodDivDeb.className = "assiduite-period";
periodDivDeb.textContent = `${formatDateModal(assiduite.date_debut)}`;
bubble.appendChild(periodDivDeb);
const periodDivFin = document.createElement("div");
periodDivFin.className = "assiduite-period";
periodDivFin.textContent = `${formatDateModal(assiduite.date_fin)}`;
bubble.appendChild(periodDivFin);
const stateDiv = document.createElement("div");
stateDiv.className = "assiduite-state";
stateDiv.textContent = `État: ${assiduite.etat.capitalize()}`;
bubble.appendChild(stateDiv);
const motifDiv = document.createElement("div");
stateDiv.className = "assiduite-why";
const motif = ["", null, undefined].includes(assiduite.desc)
? "Pas de motif"
: assiduite.desc.capitalize();
stateDiv.textContent = `Motif: ${motif}`;
bubble.appendChild(motifDiv);
const userIdDiv = document.createElement("div");
userIdDiv.className = "assiduite-user_id";
userIdDiv.textContent = `saisie le ${formatDateModal(
assiduite.entry_date,
" à "
)}`;
if (assiduite.user_id != null) {
userIdDiv.textContent += `\npar ${assiduite.user_nom_complet}`;
}
bubble.appendChild(userIdDiv);
el.appendChild(bubble);
}