Lm : a jour

This commit is contained in:
Alexis Burnaz 2025-12-22 16:05:14 +01:00
parent 77bf98e6cd
commit d6f06dfd7b
29 changed files with 721 additions and 2090 deletions

3
ecole/.gitignore vendored
View File

@ -4,6 +4,9 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Classique
package-lock.json
# Logs # Logs
logs/ logs/
*.log *.log

View File

@ -102,6 +102,13 @@ hr.form {
font-size: smaller; font-size: smaller;
} }
.helper-text.error {
font-weight: bold;
text-align: center;
margin-top: 20px;
white-space: pre-line;
}
.mrg { .mrg {
padding: 0 5% !important; padding: 0 5% !important;
} }
@ -445,3 +452,63 @@ a.grille-garanties:hover{
display: none; display: none;
} }
} }
/* Overlay loader */
#loader-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(
rgba(10, 20, 60, 0.2),
rgba(0, 0, 0, 0.4)
);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
font-family: 'Roboto', sans-serif;
opacity: 0;
backdrop-filter: blur(0px);
pointer-events: none;
transition: opacity 0.5s ease, backdrop-filter 0.5s ease;
}
#loader-overlay.active {
opacity: 1;
backdrop-filter: blur(3px);
pointer-events: all;
}
#loader-overlay.hidden {
display: none;
}
.loader-spin-wrap {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.5s ease, transform 0.5s ease;
transition-delay: 0.5s;
}
#loader-overlay.active .loader-spin-wrap {
opacity: 1;
transform: translateY(0);
}
/* Spinner circulaire */
.loader-spin {
width: 50px;
aspect-ratio: 1;
border-radius: 50%;
mask:1;
background:
radial-gradient(farthest-side,darkblue 94%,transparent) top/8px 8px no-repeat,
conic-gradient(transparent 30%,darkblue);
-webkit-mask: radial-gradient(farthest-side,transparent calc(100% - 8px),#000 0);
animation: l13 1s infinite linear;
}
@keyframes l13 {
100% { transform: rotate(1turn); }
}

View File

@ -42,99 +42,25 @@ table.dataTable thead th>div {
left: 0 !important; left: 0 !important;
} }
#historiqueParcours_filter { #historiqueParcours_filter label {
margin-bottom: 20px; display: flex;
align-items: center;
} }
.dataTables_wrapper .dataTables_filter {
position: relative;
text-align: left;
float: left;
padding: 2px;
overflow: visible;
}
/* Cacher complètement le label "Rechercher" de DataTables */
.dataTables_wrapper .dataTables_filter label {
position: relative;
display: inline-block;
margin: 0;
font-size: 0 !important;
line-height: 0 !important;
overflow: visible;
}
.dataTables_wrapper .dataTables_filter label > span:first-child {
display: none !important;
}
/* fond blanc, bordure grise fine, capsule arrondie */
.dataTables_wrapper .dataTables_filter input[type="search"] { .dataTables_wrapper .dataTables_filter input[type="search"] {
background-color: #fff; background-color: transparent;
border: 1px solid #e0e0e0; border: none;
border-radius: 42px; border-bottom: 1px solid #26a69a;
border-radius: 0;
outline: none; outline: none;
height: 42px; height: 3rem;
width: 220px; width: 100%;
font-size: 15px; font-size: 16px;
margin: 0; margin: 0 0 8px 0;
padding: 0 20px 0 52px; padding: 0;
box-sizing: border-box; box-shadow: none;
transition: all 0.2s ease; box-sizing: content-box;
color: #333; transition: box-shadow .3s, border .3s, -webkit-box-shadow .3s;
position: relative;
}
.dataTables_wrapper .dataTables_filter input[type="search"]:focus {
background-color: #fff;
border-color: #1d9bf0;
box-shadow: 0 0 0 2px #1d9bf0;
color: #333;
}
.dataTables_wrapper .dataTables_filter input[type="search"]::placeholder {
color: #71767a;
}
/* Icône de loupe SVG */
.dataTables_wrapper .dataTables_filter label::before {
content: "";
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%);
width: 18px;
height: 18px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2371767a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
pointer-events: none;
z-index: 1;
}
/* Texte "Rechercher" à côté de l'icône */
.dataTables_wrapper .dataTables_filter label::after {
content: "Rechercher";
position: absolute;
left: 52px;
top: 50%;
transform: translateY(-50%);
font-size: 15px;
color: #71767a;
pointer-events: none;
z-index: 1;
white-space: nowrap;
}
/* Cacher le texte "Rechercher" quand on tape, focus, ou si l'input a une valeur */
.dataTables_wrapper .dataTables_filter:focus-within label::after,
.dataTables_wrapper .dataTables_filter label.has-value::after {
opacity: 0;
}
.dataTables_wrapper .dataTables_filter input[type="search"]:focus::placeholder {
color: transparent;
} }
#historiqueParcours_length>label { #historiqueParcours_length>label {
@ -157,7 +83,7 @@ table.dataTable thead th>div {
width: 60px; width: 60px;
} }
/* Style Input recherche par ligne */ /* Style Input search by row */
#historiqueParcours>thead>tr:nth-child(2)>th>input { #historiqueParcours>thead>tr:nth-child(2)>th>input {
font-size: 13px !important; font-size: 13px !important;
padding: 6px !important; padding: 6px !important;
@ -179,7 +105,7 @@ table.dataTable thead .sorting_desc:before {
content: ""; content: "";
} }
/* boutons de navigation */ /* boutons de navigationw */
.dataTables_wrapper .dataTables_paginate .paginate_button { .dataTables_wrapper .dataTables_paginate .paginate_button {
background-color: white !important; background-color: white !important;
border: darkblue solid 1.5px !important; border: darkblue solid 1.5px !important;
@ -219,31 +145,10 @@ td.nc-value {
} }
#divToggleSearch { #divToggleSearch {
width: 300px;
grid-column: 2; grid-column: 2;
grid-row: 3; grid-row: 3;
justify-self: center; justify-self: center;
white-space: nowrap;
}
#toggleSearch {
white-space: nowrap;
width: auto;
min-width: fit-content;
padding: 0 24px;
color: white !important;
background-color: darkblue !important;
border: none !important;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(16, 0, 75, 0.2), 0 4px 8px rgba(16, 0, 75, 0.1);
transition: background-color 0.3s;
}
#toggleSearch:hover {
background-color: #26a69a !important;
}
#toggleSearch:active {
background-color: gray !important;
} }
#divExtractAll { #divExtractAll {
@ -301,14 +206,3 @@ td.nc-value {
#checkRegionAdmin label { #checkRegionAdmin label {
display: inline-block; display: inline-block;
} }
#historiqueParcours tr.shown > td { background: #fffdf5; }
.parcours-details { font-size: 0.95rem; }
/* Style pour les boutons d'export désactivés */
#divBtnFilter button:disabled {
opacity: 0.5 !important;
cursor: not-allowed !important;
pointer-events: none !important;
}

View File

@ -0,0 +1,66 @@
/* Overlay loader (css pris du site https://css-loaders.com/)*/
#loader-overlay {
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: linear-gradient(
rgba(10, 20, 60, 0.2),
rgba(0, 0, 0, 0.4)
);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
font-family: 'Roboto', sans-serif;
opacity: 0;
backdrop-filter: blur(0px);
pointer-events: none;
transition: opacity 0.5s ease, backdrop-filter 0.5s ease;
}
#loader-overlay.active {
opacity: 1;
backdrop-filter: blur(3px);
pointer-events: all;
}
#loader-overlay.hidden {
display: none;
}
/* Spinner wrapper (fade/slide) */
.loader-spin-wrap {
opacity: 0;
transform: translateY(10px);
transition: opacity 0.5s ease, transform 0.5s ease;
transition-delay: 0.5s; /* apparaît après 0.5s */
}
#loader-overlay.active .loader-spin-wrap {
opacity: 1;
transform: translateY(0);
}
/* Spinner circulaire */
.loader-spin {
width: 50px;
aspect-ratio: 1;
border-radius: 50%;
mask:1;
background:
radial-gradient(farthest-side,darkblue 94%,transparent) top/8px 8px no-repeat,
conic-gradient(transparent 30%,darkblue);
-webkit-mask: radial-gradient(farthest-side,transparent calc(100% - 8px),#000 0);
animation: l13 1s infinite linear;
}
@keyframes l13 {
100% { transform: rotate(1turn); }
}
/* Erreur */
#error-message {
display: none;
color: red;
font-weight: bold;
text-align: center;
margin-top: 20px;
white-space: pre-line;
}

View File

@ -161,234 +161,3 @@ async function loadContrat(idContrat) {
console.error("Erreur lors de la récupération des informations contrat :", error); console.error("Erreur lors de la récupération des informations contrat :", error);
} }
} }
// ========== Fonctions utilitaires génériques ==========
/**
* Formatage des dates (ISO vers format français)
* @param {string} iso - Date au format ISO
* @param {boolean} withTime - Inclure l'heure (défaut: true)
* @returns {string} Date formatée (dd/mm/yyyy ou dd/mm/yyyy hh:mm)
*/
function fmtDate(iso, withTime = true) {
// Si la valeur est null, undefined, ou vide
if (!iso || (typeof iso === 'string' && iso.trim() === "")) return "NC";
// Convertir en string si ce n'est pas déjà le cas
const dateStr = String(iso).trim();
// Vérifier les valeurs invalides connues
if (dateStr === "00/00/0000" || dateStr === "00/00" || dateStr === "null" || dateStr === "undefined") {
return "NC";
}
let d;
// Si c'est déjà au format jj/mm/aaaa (format français)
if (dateStr.includes("/") && dateStr.split("/").length === 3) {
const parts = dateStr.split("/");
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const year = parseInt(parts[2], 10);
// Vérifier si les valeurs sont valides
if (isNaN(day) || isNaN(month) || isNaN(year)) {
return "NC";
}
// Si le jour ou le mois est 00, considérer comme invalide
if (day === 0 || month === 0) {
return "NC";
}
// Si le mois est invalide
if (month < 1 || month > 12) {
return "NC";
}
// Si l'année est 0000, afficher juste jj/mm (sans l'année)
if (year === 0) {
return `${String(day).padStart(2, "0")}/${String(month).padStart(2, "0")}`;
}
// Si l'année est valide, créer une vraie date et valider
const monthIndex = month - 1; // Les mois commencent à 0
d = new Date(year, monthIndex, day);
// Vérifier si la date est valide (ex: 31/02/2000 serait invalide)
if (d.getDate() !== day || d.getMonth() !== monthIndex || d.getFullYear() !== year) {
return "NC";
}
// Formater la date
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
if (!withTime) return `${dd}/${mm}/${yyyy}`;
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
// Si c'est au format jj/mm (pour date d'échéance)
else if (dateStr.includes("/") && dateStr.split("/").length === 2) {
const parts = dateStr.split("/");
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
// Pour l'échéance, on retourne juste jj/mm
if (isNaN(day) || isNaN(month) || day === 0 || month === 0 || month > 12) {
return "NC";
}
return `${String(day).padStart(2, "0")}/${String(month).padStart(2, "0")}`;
}
// Sinon, essayer de parser comme date ISO
else {
d = new Date(dateStr);
if (isNaN(d.getTime())) return "NC";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
if (!withTime) return `${dd}/${mm}/${yyyy}`;
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
}
/**
* Crée un élément key-value pour l'affichage de détails
* @param {string} label - Libellé
* @param {*} value - Valeur (booléen converti en Oui/Non)
* @returns {string} HTML formaté
*/
function kv(label, value) {
const v = (value === true) ? "Oui" : (value === false) ? "Non" : (value ?? "NC");
return `<div style="display:flex; gap:8px; margin:2px 0;">
<div style="min-width:220px;"><strong>${label}</strong> :</div>
<div>${v}</div>
</div>`;
}
/**
* Crée une grille à 2 colonnes pour l'affichage de détails
* @param {string} innerLeft - Contenu colonne gauche
* @param {string} innerRight - Contenu colonne droite
* @returns {string} HTML formaté
*/
function gridWrap2cols(innerLeft, innerRight) {
return `<div style="display:grid;grid-template-columns:repeat(2,minmax(280px,1fr));gap:14px;">${innerLeft}${innerRight}</div>`;
}
/**
* Fonction debounce pour limiter la fréquence d'exécution d'une fonction
* Utile pour les recherches en temps réel et éviter les appels API excessifs
* @param {Function} fn - Fonction à débouncer
* @param {number} delay - Délai en millisecondes (défaut: 300ms)
* @returns {Function} Fonction débouncée avec méthode cancel()
*/
function debounce(fn, delay = 300) {
let t;
function wrapped(...args) {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
}
wrapped.cancel = () => clearTimeout(t);
return wrapped;
}
/**
* Décode un token JWT et retourne le payload
* @param {string} token - Token JWT à décoder
* @returns {Object|null} Payload décodé ou null en cas d'erreur
*/
function parseJwt(token) {
try {
const base64Url = token.split(".")[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map(function (c) {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
})
.join("")
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error("Erreur lors du décodage du token:", error);
return null;
}
}
/**
* Affiche un message d'erreur dans l'élément avec l'ID "error"
* @param {string} message - Message d'erreur à afficher
*/
function displayError(message) {
const errorElement = document.getElementById("error");
if (errorElement) {
errorElement.textContent = message;
errorElement.style.display = "block";
}
}
/**
* Crée un message formaté avec différents types (info, warn, error, dev)
* @param {string} type - Type de message : 'info', 'warn', 'error', 'dev'
* @param {string} title - Titre du message
* @param {string} description - Description du message (peut contenir du HTML)
* @returns {string} HTML du message formaté
*/
function createMessageBox(type, title, description) {
// Protection contre les paramètres invalides
if (!type || typeof type !== 'string') type = 'info';
if (!title || typeof title !== 'string') title = 'Message';
if (!description || typeof description !== 'string') description = '';
const configs = {
info: {
icon: 'fa-info-circle',
bgColor: '#e3f2fd',
borderColor: '#2196f3',
textColor: '#1565c0'
},
warn: {
icon: 'fa-exclamation-triangle',
bgColor: '#fff3e0',
borderColor: '#ff9800',
textColor: '#e65100'
},
error: {
icon: 'fa-times-circle',
bgColor: '#ffebee',
borderColor: '#f44336',
textColor: '#c62828'
},
dev: {
icon: 'fa-tools',
bgColor: '#fff3cd',
borderColor: '#ffc107',
textColor: '#856404'
}
};
const config = configs[type] || configs.info;
// Échappement basique pour éviter les problèmes (description peut contenir du HTML valide)
const safeTitle = String(title).replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Description peut contenir du HTML (comme <br>), donc on ne l'échappe pas complètement
// mais on s'assure qu'elle est une string
const safeDescription = String(description);
return `
<div style="padding: 20px; text-align: center; background-color: ${config.bgColor}; border: 1px solid ${config.borderColor}; border-radius: 4px; margin: 10px 0; width: 100%; box-sizing: border-box;">
<p style="color: ${config.textColor}; font-weight: 600; margin: 0 0 10px 0;">
<i class="fas ${config.icon}" style="margin-right: 8px;"></i>
${safeTitle}
</p>
<p style="color: ${config.textColor}; margin: 0; font-size: 0.9rem;">
${safeDescription}
</p>
</div>
`;
}

File diff suppressed because it is too large Load Diff

31
ecole/public/js/loader.js Normal file
View File

@ -0,0 +1,31 @@
document.addEventListener("DOMContentLoaded", () => {
const loader = document.getElementById("loader-overlay"); // déjà présent dans le layout.ejs
const errorMessage = document.getElementById("error-message");
let activateTimeout = null;
//activer le loader et le montrer a l'écran
window.showLoader = function() {
clearTimeout(activateTimeout);
errorMessage.style.display = "none";
loader.classList.remove("hidden");
activateTimeout = setTimeout(() => {
loader.classList.add("active");
}, 500);
};
//enlever le loader et le faire disparaitre
window.hideLoader = function() {
clearTimeout(activateTimeout);
loader.classList.remove("active");
setTimeout(() => loader.classList.add("hidden"), 500);
};
//cas d'erreur
window.showError = function(msg) {
clearTimeout(activateTimeout);
errorMessage.textContent = msg;
errorMessage.style.display = "block";
};
});

View File

@ -79,6 +79,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('step-' + key).classList.add('line') document.getElementById('step-' + key).classList.add('line')
} }
} }
showLoader();
// Charger le formulaire associé // Charger le formulaire associé
fetch(fetchUrl) fetch(fetchUrl)
@ -119,6 +120,7 @@ document.addEventListener('DOMContentLoaded', function() {
inputChanged = true inputChanged = true
}) })
}) })
hideLoader();
}) })
.catch(error => console.error('Error:', error)); .catch(error => console.error('Error:', error));
@ -155,6 +157,9 @@ document.addEventListener('DOMContentLoaded', function() {
const parcours = JSON.parse(sessionStorage.getItem('parcours')); const parcours = JSON.parse(sessionStorage.getItem('parcours'));
var produit = parcours["@expand"].contrat.produit var produit = parcours["@expand"].contrat.produit
const btn = this // bouton "générer projet"
btn.disabled = true; // le desactiver le temps du téléchargement
var fileName var fileName
switch (produit.toLowerCase()) { switch (produit.toLowerCase()) {
case 'fac': case 'fac':
@ -178,6 +183,9 @@ document.addEventListener('DOMContentLoaded', function() {
link.download = fileName; link.download = fileName;
link.click(); link.click();
}) })
.finally(() => {
btn.disabled = false; // réactiver le bouton a la fin du téléchargement
})
.catch(error => console.error('Error downloading file:', error)); .catch(error => console.error('Error downloading file:', error));
}); });
@ -188,6 +196,9 @@ document.addEventListener('DOMContentLoaded', function() {
const parcours = JSON.parse(sessionStorage.getItem('parcours')); const parcours = JSON.parse(sessionStorage.getItem('parcours'));
let produit = parcours["@expand"].contrat.produit let produit = parcours["@expand"].contrat.produit
const btn = this // bouton "générer projet"
btn.disabled = true; // le desactiver le temps du téléchargement
// Envoi de la requête POST au serveur pour générer le projet // Envoi de la requête POST au serveur pour générer le projet
fetch(`/generate/${produit}/projet/${numParcours}`, { fetch(`/generate/${produit}/projet/${numParcours}`, {
method: 'POST', method: 'POST',
@ -217,6 +228,9 @@ document.addEventListener('DOMContentLoaded', function() {
window.URL.revokeObjectURL(url); // Nettoie l'URL objet window.URL.revokeObjectURL(url); // Nettoie l'URL objet
a.remove(); // Supprime l'élément a du document a.remove(); // Supprime l'élément a du document
}) })
.finally(() => {
btn.disabled = false; // réactiver le bouton a la fin du téléchargement
})
.catch(error => console.error('Erreur lors de la génération du projet:', error)); .catch(error => console.error('Erreur lors de la génération du projet:', error));
}); });
@ -227,6 +241,9 @@ document.addEventListener('DOMContentLoaded', function() {
const parcours = JSON.parse(sessionStorage.getItem('parcours')); const parcours = JSON.parse(sessionStorage.getItem('parcours'));
let produit = parcours["@expand"].contrat.produit let produit = parcours["@expand"].contrat.produit
const btn = this // bouton "générer déclinaison tarifaire"
btn.disabled = true; // le desactiver le temps du téléchargement
// Envoi de la requête POST au serveur pour générer le projet // Envoi de la requête POST au serveur pour générer le projet
fetch(`/generate/${produit}/tarif/${numParcours}`, { fetch(`/generate/${produit}/tarif/${numParcours}`, {
method: 'POST', method: 'POST',
@ -257,6 +274,9 @@ document.addEventListener('DOMContentLoaded', function() {
window.URL.revokeObjectURL(url); // Nettoie l'URL objet window.URL.revokeObjectURL(url); // Nettoie l'URL objet
a.remove(); // Supprime l'élément a du document a.remove(); // Supprime l'élément a du document
}) })
.finally(() => {
btn.disabled = false; // réactiver le bouton a la fin du téléchargement
})
.catch(error => console.error('Erreur lors de la génération du projet:', error)); .catch(error => console.error('Erreur lors de la génération du projet:', error));
}); });

View File

@ -0,0 +1,49 @@
document.addEventListener("DOMContentLoaded", () => {
const container = document.querySelector(".container");
async function loadPage(url, push = true) {
showLoader();
try {
const res = await fetch(url, { headers: { "X-Requested-With": "fetch" }});
const html = await res.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const newContent = doc.querySelector(".container").innerHTML;
container.innerHTML = newContent;
if (push) history.pushState({}, "", url);
}
catch (err) {
showError("Impossible de charger la page. Vérifiez votre connexion.");
}
finally {
hideLoader();
}
}
// Intercepter les clics sur les liens internes
document.body.addEventListener("click", (e) => {
const link = e.target.closest("a");
if (link && link.getAttribute("href").startsWith("/")) {
e.preventDefault();
loadPage(link.href);
}
});
// Gérer le bouton retour
window.addEventListener("popstate", () => {
loadPage(location.pathname, false);
});
});

View File

@ -96,7 +96,7 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio
const genreValue = document.getElementById('genreVehicule').value || 'Non défini'; const genreValue = document.getElementById('genreVehicule').value || 'Non défini';
const typeValue = document.getElementById('typeVehicule').value || 'Non défini'; const typeValue = document.getElementById('typeVehicule').value || 'Non défini';
const immatValue = document.getElementById('immatVehicule').value || 'Non défini'; const immatValue = document.getElementById('immatVehicule').value || 'Non défini';
const capitalValue = document.getElementById('capitalVehicule').value || 'Non défini'; const capitalValue = document.getElementById('capitalVeh').value || 'Non défini';
addRowVehicule(marqueValue, genreValue, typeValue, immatValue, capitalValue); addRowVehicule(marqueValue, genreValue, typeValue, immatValue, capitalValue);
}); });
@ -745,7 +745,7 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio
document.getElementById('genreVehicule').value = ''; document.getElementById('genreVehicule').value = '';
document.getElementById('typeVehicule').value = ''; document.getElementById('typeVehicule').value = '';
document.getElementById('immatVehicule').value = ''; document.getElementById('immatVehicule').value = '';
document.getElementById('capitalVehicule').value = ''; document.getElementById('capitalVeh').value = '';
// Ajouter un écouteur d'événements pour supprimer // Ajouter un écouteur d'événements pour supprimer
newRow.querySelector('.delete-btn').addEventListener('click', function() { newRow.querySelector('.delete-btn').addEventListener('click', function() {

View File

@ -132,6 +132,10 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio
element.addEventListener('input', function () { element.addEventListener('input', function () {
affichagePropositions(); affichagePropositions();
}) })
element.addEventListener('change', function () {
affichagePropositions();
})
}) })
document.querySelectorAll('select').forEach((element) => { document.querySelectorAll('select').forEach((element) => {
@ -417,9 +421,9 @@ window.initSubmenuForm = initSubmenuForm;// Module IIFE pour éviter la pollutio
document.getElementById('cotisationDetaillee').checked = true document.getElementById('cotisationDetaillee').checked = true
document.getElementById('cotisationEnsemble').checked = false document.getElementById('cotisationEnsemble').checked = false
} else { } else {
//Par Defaut
document.getElementById('cotisationEnsemble').checked = true document.getElementById('cotisationDetaillee').checked = true
toggleTypeContrat('ensemble') toggleTypeContrat('detaillee')
} }
if (tarif && tarif.montantSinistre !== undefined && tarif.montantSinistre >= 0) { if (tarif && tarif.montantSinistre !== undefined && tarif.montantSinistre >= 0) {

View File

@ -1,125 +1,25 @@
// controllers/historiqueParcoursController.js
const express = require("express"); const express = require("express");
const router = express.Router(); const router = express.Router();
const renderPage = require("../utils/renderHelper"); const renderPage = require("../utils/renderHelper");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const parcoursService = require("../services/parcoursService"); const parcoursService = require("../services/parcoursService");
const clientService = require("../services/clientService");
const { fmtDateFR, xmlEsc, cellXml, rowXml } = require("../services/globalService");
/**
* Construit les filtres et le tri PocketBase à partir des paramètres DataTables
* @param {Object} params - Paramètres de recherche et filtrage
* @param {string[]} params.regions - Liste des régions à filtrer
* @param {Object} params.search - Objet de recherche globale
* @param {Array} params.columns - Colonnes avec leurs filtres individuels
* @param {Array} params.order - Ordre de tri
* @returns {Object} - {filter: string, sort: string}
*/
function buildPocketBaseFilterAndSort({ regions = [], search = { value: "" }, columns = [], order = [] }) {
const parts = [];
/**
* Recherche globale : recherche dans tous les champs pertinents
*/
const q = (search?.value || "").trim();
if (q) {
const esc = q.replace(/"/g, '\\"');
parts.push(`(
numParcours ~ "${esc}"
|| contrat.numSaisine ~ "${esc}"
|| contrat.numContrat ~ "${esc}"
|| contrat.produit ~ "${esc}"
|| contrat.type ~ "${esc}"
|| contrat.intermediaire.nom ~ "${esc}"
|| contrat.intermediaire.numPortefeuille ~ "${esc}"
|| contrat.client.nom ~ "${esc}"
|| contrat.client.numClient ~ "${esc}"
|| dernierUtilisateur.prenom ~ "${esc}"
|| dernierUtilisateur.nom ~ "${esc}"
|| dernierUtilisateur.matricule ~ "${esc}"
|| dernierUtilisateur.region.nom ~ "${esc}"
)`);
}
/**
* Recherche par colonne : filtre spécifique pour chaque colonne
*/
const colFilter = (idx, fieldPaths) => {
const v = (columns[idx]?.search?.value || "").trim();
if (!v) return null;
const esc = v.replace(/"/g, '\\"');
return `(${fieldPaths.map(fp => `${fp} ~ "${esc}"`).join(" || ")})`;
};
const pushIf = (v) => { if (v) parts.push(v); };
// Filtres par colonne (index correspondant à l'ordre des colonnes DataTables)
pushIf(colFilter(0, ["numParcours"]));
pushIf(colFilter(1, ["created"]));
pushIf(colFilter(2, ["dernierUtilisateur.matricule"]));
pushIf(colFilter(3, ["dernierUtilisateur.prenom", "dernierUtilisateur.nom"]));
pushIf(colFilter(4, ["dernierUtilisateur.region.nom"]));
pushIf(colFilter(5, ["contrat.numSaisine"]));
pushIf(colFilter(6, ["contrat.numContrat"]));
pushIf(colFilter(7, ["contrat.produit"]));
pushIf(colFilter(8, ["contrat.type"]));
pushIf(colFilter(9, ["contrat.intermediaire.numPortefeuille"]));
pushIf(colFilter(10, ["contrat.intermediaire.nom"]));
pushIf(colFilter(11, ["contrat.client.numClient"]));
pushIf(colFilter(12, ["contrat.client.nom"]));
const filter = parts.length ? parts.join(" && ") : "";
/**
* Construction du tri PocketBase
* Mapping des index de colonnes DataTables vers les champs PocketBase
* Le préfixe "-" indique un tri décroissant
*/
const sortMap = {
0: "numParcours",
1: "created",
2: "dernierUtilisateur.matricule",
4: "dernierUtilisateur.region.nom",
6: "contrat.numContrat",
7: "contrat.produit",
10: "contrat.intermediaire.nom",
12: "contrat.client.nom"
};
let sort = "-created"; // Tri par défaut : date de création décroissante
if (order && order.length > 0) {
const { column, dir } = order[0];
const field = sortMap[column];
if (field) {
sort = (dir === "desc" ? "-" : "") + field;
}
}
return { filter, sort };
}
/**
* Route GET / : Affichage de la page Historique des parcours
*/
router.get("/", (req, res) => { router.get("/", (req, res) => {
renderPage("historiqueParcours.ejs", res); renderPage("historiqueParcours.ejs", res);
}); });
/** router.get("/read", async (req, res) => {
* /regionUser : requête sur la region de l'user actuel
*/
router.get("/:regionUser", async (req, res) => {
try { try {
const { regionUser } = req.params; const allParcours = await parcoursService.getAllParcours();
const data = await parcoursService.getParcoursByRegionsPage([regionUser], 1, 10, { filter: "", sort: "-created" });
if (data) { if (allParcours) {
res.json({ valid: true, data }); res.json({ valid: true, allParcours });
} else { } else {
res.json({ valid: false }); res.json({ valid: false });
} }
} catch (error) { } catch (error) {
logger.log("error", error); logger.log("error", error);
res.status(500).json({ res.status(500).json({
valid: false, valid: false,
error: "Erreur lors de la récupération des parcours.", error: "Erreur lors de la récupération des parcours.",
@ -127,508 +27,25 @@ router.get("/:regionUser", async (req, res) => {
} }
}); });
/** //controller to get parcours by region
* /datatable : DataTables server-side (gestion de pagination) router.get("/:regionUser", async (req, res) => {
*/
router.post("/datatable", async (req, res) => {
try { try {
const { const { regionUser } = req.params;
draw = 1, const data = await parcoursService.getParcoursByRegion(regionUser);
start = 0,
length = 10,
regions = [],
search = { value: "" },
columns = [],
order = []
} = req.body || {};
const page = Math.floor(start / length) + 1; // nb de page if (data) {
const perPage = Number(length) || 10; //nb d'éléments par page res.json({ valid: true, data });
const { filter, sort } = buildPocketBaseFilterAndSort({ search, columns, order }); // construction du filtrage côté Back
const result = await parcoursService.getParcoursByRegionsPage([], page, perPage, { filter, sort });
/**
* Construction des lignes de données pour DataTables
* Traitement séquentiel pour garantir la récupération des clients
*/
const rows = [];
for (const parcours of result.items) {
try {
const contrat = parcours["@expand"]?.contrat || null;
/**
* Récupération du client avec fallback
* L'expand PocketBase ne fonctionne pas toujours pour contrat.client,
* donc on récupère directement via l'ID si nécessaire
*/
let client = null;
if (contrat) {
// Tentative via expand (si disponible)
client = contrat["@expand"]?.client || null;
// Fallback : récupération directe via l'ID du client
if (!client && contrat.client) {
const clientId = typeof contrat.client === 'string'
? contrat.client
: (contrat.client?.id || contrat.client);
if (clientId) {
try {
client = await clientService.getClient(clientId);
} catch (err) {
// Erreur silencieuse : client non trouvé ou erreur de récupération
client = null;
}
}
}
// Cas où contrat.client est déjà un objet (expand réussi mais pas dans @expand)
if (!client && contrat.client && typeof contrat.client === 'object' && contrat.client.numClient) {
client = contrat.client;
}
}
const lastUser = parcours["@expand"]?.dernierUtilisateur;
const region = lastUser?.["@expand"]?.region;
const produit = contrat ? (contrat.produit || "NC") : "NC";
/**
* Construction de la ligne DataTables
* Ordre des colonnes : Numéro Parcours, Date Création, Matricule, Utilisateur, Région,
* Numéro Saisine, Numéro Contrat, Produit, Type, Portefeuille, Intermédiaire,
* Numéro Client, Nom Client, Bouton Reprendre, Bouton Générer
*/
rows.push([
parcours.numParcours,
fmtDateFR(parcours.created),
lastUser?.matricule || "NC",
lastUser ? `${lastUser.prenom} ${lastUser.nom}`.trim() || "NC" : "NC",
region ? region.nom : "NC",
contrat ? (contrat.numSaisine || "NC") : "NC",
contrat ? (contrat.numContrat || "NC") : "NC",
produit,
contrat ? (contrat.type || "NC") : "NC",
contrat ? (contrat["@expand"]?.intermediaire?.numPortefeuille || "NC") : "NC",
contrat ? (contrat["@expand"]?.intermediaire?.nom || "NC") : "NC",
client ? (client.numClient || "NC") : "NC",
client ? (client.nom || "NC") : "NC",
`<button type="button" id="btnReprendre" class="btn" onclick="window.location.href='${(!contrat || (!contrat.numSaisine && !contrat.numContrat)) ? `/contrat?numParcours=${parcours.numParcours}` : `/navParcours?numParcours=${parcours.numParcours}&submenu=client`}'"><i class="fas fa-arrow-right"></i></button>`,
`<button type="button" id="btnGenerate" class="btn" data-produit="${produit}" data-num-parcours="${parcours.numParcours}" ${( !contrat || contrat.produit == "" || (contrat.fac == "" && contrat.rc == "" && contrat.tppc == "") || contrat.client == "" || contrat.intermediaire == "" ) ? 'disabled' : ''}><i class="fa-solid fa-file-arrow-down"></i></button>`
]);
} catch (err) {
logger.log("error", `Erreur traitement parcours ${parcours?.numParcours || 'inconnu'}:`, err);
// Ligne par défaut en cas d'erreur
rows.push(["NC", "NC", "NC", "NC", "NC", "NC", "NC", "NC", "NC", "NC", "NC", "NC", "NC", "", ""]);
}
}
res.json({
draw: Number(draw),
recordsTotal: result.totalItems,
recordsFiltered: result.totalItems,
data: rows
});
} catch (error) {
logger.log("error", error);
res.status(500).json({ draw: 0, recordsTotal: 0, recordsFiltered: 0, data: [] });
}
});
/**
* EXPORT CSV
* Exporte l'historique des parcours au format CSV
* Supporte l'export complet ou filtré selon les paramètres de la requête
*/
router.post("/export/csv", async (req, res) => {
let aborted = false;
req.on("aborted", () => {
aborted = true;
logger.log("warn", "Client a interrompu la connexion pendant l'export CSV");
});
res.on("finish", () => {
logger.log("info", "Export CSV terminé");
});
try {
const {
regions = [],
search = { value: "" },
columns = [],
order = [],
mode = "filtered",
} = req.body || {};
const effective = (mode === "full")
? { regions: [], search: { value: "" }, columns: [], order }
: { regions, search, columns, order };
const { filter, sort } = buildPocketBaseFilterAndSort(effective);
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="historique_parcours.csv"`);
// BOM UTF-8 pour Excel
res.write("\uFEFF");
const headers = [
"Numéro du Parcours","Date de Création","Matricule","Dernier Utilisateur","Region",
"Numéro Saisine","Numéro Contrat","Produit","Type","Numéro de Portefeuille",
"Nom Intermediaire","Numéro de Client","Nom Client"
];
res.write(headers.join(";") + "\n");
/**
* OPTIMISATION : getFullList pour récupérer tous les parcours en une requête
* + batch client pour récupérer tous les clients manquants en une requête
*/
// Construction du filtre régions (identique à getParcoursByRegionsPage)
let regFilter = "";
if (Array.isArray(effective.regions) && effective.regions.length > 0) {
const ors = effective.regions.map(r => `dernierUtilisateur.region.nom = "${r}"`);
regFilter = `(${ors.join(" || ")})`;
}
const finalFilter = [regFilter, filter].filter(Boolean).join(" && ");
// Format avec espaces comme dans le code original qui fonctionnait
const expandFields = "contrat, contrat.client, contrat.intermediaire, dernierUtilisateur.region";
// Récupération de tous les parcours en une seule requête
let allParcours;
try {
allParcours = await parcoursService.getParcoursFullList({
filter: finalFilter,
sort: sort || "-created",
expand: expandFields,
batch: 500,
});
} catch (err) {
logger.log("error", "Erreur récupération parcours pour export CSV:", err);
if (!res.headersSent) {
return res.status(500).send("Erreur lors de la récupération des données");
}
try { res.end(); } catch {}
return;
}
// Collecte des IDs clients manquants (l'expand contrat.client ne fonctionne pas en SDK 0.7.x)
const missingClientIds = [];
for (const parcours of allParcours) {
const contrat = parcours["@expand"]?.contrat;
if (contrat && contrat.client && !contrat["@expand"]?.client) {
missingClientIds.push(contrat.client);
}
}
// Récupération batch de tous les clients manquants en une seule requête
const clientsMap = await clientService.getClientsBatch(missingClientIds);
// Traitement des parcours
for (const parcours of allParcours) {
if (aborted) break;
const contrat = parcours["@expand"]?.contrat || null;
const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null;
// Client : d'abord depuis l'expand, sinon depuis le batch
let client = contrat ? (contrat["@expand"]?.client || null) : null;
if (!client && contrat && contrat.client) {
client = clientsMap.get(contrat.client) || null;
}
const lastUser = parcours["@expand"]?.dernierUtilisateur;
const region = lastUser?.["@expand"]?.region;
const row = [
parcours.numParcours,
fmtDateFR(parcours.created),
lastUser?.matricule || "NC",
lastUser ? `${lastUser.prenom || ""} ${lastUser.nom || ""}`.trim() || "NC" : "NC",
region ? (region.nom || "NC") : "NC",
contrat ? (contrat.numSaisine || "NC") : "NC",
contrat ? (contrat.numContrat || "NC") : "NC",
contrat ? (contrat.produit || "NC") : "NC",
contrat ? (contrat.type || "NC") : "NC",
intermediaire ? (intermediaire.numPortefeuille || "NC") : "NC",
intermediaire ? (intermediaire.nom || "NC") : "NC",
client ? (client.numClient || "NC") : "NC",
client ? (client.nom || "NC") : "NC",
];
const safe = row.map(v => String(v).replaceAll(";", ",").replace(/\r?\n/g, " "));
try {
res.write(safe.join(";") + "\n");
} catch (werr) {
logger.log("error", werr);
aborted = true;
break;
}
}
if (!aborted) {
res.end();
}
} catch (error) {
logger.log("error", error);
if (!res.headersSent) {
return res.status(500).send("Erreur export CSV");
}
try { res.end(); } catch {}
}
});
// ====== UTILITAIRES XML/XLS ======
/**
* EXPORT XLS (SpreadsheetML 2003)
* Format XLS utilisé car XLSX est trop complexe à générer manuellement.
* Le format XLS est toujours supporté par Excel sans perte de données.
*/
router.post("/export/xls", async (req, res) => {
let aborted = false;
req.on("aborted", () => {
aborted = true;
logger.log("warn", "Client a interrompu la connexion pendant l'export XLS");
});
res.on("finish", () => {
logger.log("info", "Export XLS terminé");
});
try {
const {
regions = [],
search = { value: "" },
columns = [],
order = [],
mode = "filtered"
} = req.body || {};
const effective = (mode === "full")
? { regions: [], search: { value: "" }, columns, order }
: { regions, search, columns, order };
const { filter, sort } = buildPocketBaseFilterAndSort(effective);
const headers = [
"Numéro du Parcours","Date de Création","Matricule","Dernier Utilisateur","Region",
"Numéro Saisine","Numéro Contrat","Produit","Type","Numéro de Portefeuille",
"Nom Intermediaire","Numéro de Client","Nom Client"
];
/**
* OPTIMISATION : getFullList pour récupérer tous les parcours en une requête
* + batch client pour récupérer tous les clients manquants en une requête
*/
// Construction du filtre régions (identique à getParcoursByRegionsPage)
let regFilter = "";
if (Array.isArray(effective.regions) && effective.regions.length > 0) {
const ors = effective.regions.map(r => `dernierUtilisateur.region.nom = "${r}"`);
regFilter = `(${ors.join(" || ")})`;
}
const finalFilter = [regFilter, filter].filter(Boolean).join(" && ");
// Format avec espaces comme dans le code original qui fonctionnait
const expandFields = "contrat, contrat.client, contrat.intermediaire, dernierUtilisateur.region";
// Récupération de tous les parcours en une seule requête
let allParcours;
try {
allParcours = await parcoursService.getParcoursFullList({
filter: finalFilter,
sort: sort || "-created",
expand: expandFields,
batch: 500,
});
} catch (err) {
logger.log("error", "Erreur récupération parcours pour export XLS:", err);
if (!res.headersSent) {
return res.status(500).send("Erreur lors de la récupération des données");
}
try { res.end(); } catch {}
return;
}
// Collecte des IDs clients manquants (l'expand contrat.client ne fonctionne pas en SDK 0.7.x)
const missingClientIds = [];
for (const parcours of allParcours) {
const contrat = parcours["@expand"]?.contrat;
if (contrat && contrat.client && !contrat["@expand"]?.client) {
missingClientIds.push(contrat.client);
}
}
// Récupération batch de tous les clients manquants en une seule requête
const clientsMap = await clientService.getClientsBatch(missingClientIds);
const fileName = (mode === "full")
? "historique_parcours_complet.xls"
: "historique_parcours_filtre.xls";
res.setHeader("Content-Type", "application/vnd.ms-excel; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
// En-tête SpreadsheetML 2003
res.write(
`<?xml version="1.0"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:x="urn:schemas-microsoft-com:office:excel"
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"
xmlns:html="http://www.w3.org/TR/REC-html40">
<Styles>
<Style ss:ID="Default" ss:Name="Normal">
<Alignment ss:Vertical="Center"/>
<Borders/>
<Font ss:FontName="Calibri" ss:Size="11"/>
<Interior/>
<NumberFormat/>
<Protection/>
</Style>
<Style ss:ID="Header">
<Font ss:Bold="1"/>
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
</Style>
</Styles>
<Worksheet ss:Name="Historique">
<Table>
`
);
res.write(
`<Row ss:StyleID="Header">` +
headers.map(h => `<Cell><Data ss:Type="String">${xmlEsc(h)}</Data></Cell>`).join("") +
`</Row>\n`
);
// Traitement des parcours
for (const parcours of allParcours) {
const contrat = parcours["@expand"]?.contrat || null;
const intermediaire = contrat ? (contrat["@expand"]?.intermediaire || null) : null;
// Client : d'abord depuis l'expand, sinon depuis le batch
let client = contrat ? (contrat["@expand"]?.client || null) : null;
if (!client && contrat && contrat.client) {
client = clientsMap.get(contrat.client) || null;
}
const lastUser = parcours["@expand"]?.dernierUtilisateur;
const region = lastUser?.["@expand"]?.region;
const row = [
parcours.numParcours,
fmtDateFR(parcours.created),
lastUser?.matricule || "NC",
lastUser ? `${lastUser.prenom || ""} ${lastUser.nom || ""}`.trim() || "NC" : "NC",
region ? (region.nom || "NC") : "NC",
contrat ? (contrat.numSaisine || "NC") : "NC",
contrat ? (contrat.numContrat || "NC") : "NC",
contrat ? (contrat.produit || "NC") : "NC",
contrat ? (contrat.type || "NC") : "NC",
intermediaire ? (intermediaire.numPortefeuille || "NC") : "NC",
intermediaire ? (intermediaire.nom || "NC") : "NC",
client ? (client.numClient || "NC") : "NC",
client ? (client.nom || "NC") : "NC",
].map(v => String(v).replace(/\r?\n/g, " "));
res.write(rowXml(row) + "\n");
}
// Fermeture du fichier XML SpreadsheetML
res.write(
` </Table>
</Worksheet>
</Workbook>`
);
res.end();
} catch (error) {
logger.log("error", error);
if (!res.headersSent) return res.status(500).send("Erreur export XLS");
try { res.end(); } catch {}
}
});
/**
* Route GET /details/:numParcours
* Récupère les détails complets d'un parcours (parcours + contrat + fiche produit)
* Utilisé pour afficher le panneau de détails dans la datatable
*/
router.get("/details/:numParcours", async (req, res) => {
try {
const { numParcours } = req.params;
const parcours = await parcoursService.getDeepDetailsByNumParcours(numParcours);
if (!parcours) return res.json({ valid: false, error: "Parcours introuvable" });
/**
* Extraction des données pour faciliter l'accès côté frontend
*/
const contrat = parcours?.["@expand"]?.contrat || null;
const produit = (contrat?.produit || "").toUpperCase();
/**
* Récupération de la fiche produit selon le type (TPPC, RC, FAC)
* Si l'expand n'a pas fonctionné, on essaie de récupérer directement via l'ID
*/
let produitRecord = null;
if (produit === "TPPC") {
produitRecord = contrat?.["@expand"]?.tppc || null;
} else if (produit === "RC") {
produitRecord = contrat?.["@expand"]?.rc || null;
// Si l'expand n'a pas fonctionné mais qu'on a l'ID, on récupère directement
if (!produitRecord && contrat?.rc) {
try {
const rcId = typeof contrat.rc === 'string' ? contrat.rc : contrat.rc?.id || contrat.rc;
if (rcId) {
const rcService = require("../services/rcService");
const rcData = await rcService.getRCbyId(rcId);
// fetchInfoByCriteria retourne directement l'item, pas un objet avec items
produitRecord = rcData || null;
}
} catch (e) {
logger.log("info", `Erreur récupération RC directe pour ${numParcours}: ${e.message}`);
}
}
} else if (produit === "FAC") {
produitRecord = contrat?.["@expand"]?.fac || null;
// Si l'expand n'a pas fonctionné mais qu'on a l'ID, on récupère directement
if (!produitRecord && contrat?.fac) {
try {
const facId = typeof contrat.fac === 'string' ? contrat.fac : contrat.fac?.id || contrat.fac;
if (facId) {
const facService = require("../services/facService");
const facData = await facService.getFACbyId(facId);
// fetchInfoByCriteria retourne directement l'item, pas un objet avec items
produitRecord = facData || null;
if (!produitRecord) {
logger.log("warn", `FAC non trouvé pour ID ${facId} (parcours ${numParcours})`);
}
} else { } else {
logger.log("warn", `Pas d'ID FAC dans contrat pour parcours ${numParcours}`); res.json({ valid: data });
}
} catch (e) {
logger.log("warn", `Erreur récupération FAC directe pour ${numParcours}: ${e.message}`);
}
} else if (!produitRecord && !contrat?.fac) {
logger.log("warn", `Contrat FAC sans relation FAC pour parcours ${numParcours}`);
}
} }
} catch (error) {
logger.log("error", error);
return res.json({ res.status(500).json({
valid: true, valid: false,
produit, error: "Erreur lors de la récupération des parcours.",
parcours,
contrat,
produitRecord,
}); });
} catch (e) {
logger.log("error", e);
return res.status(500).json({ valid: false, error: "Erreur recherche détails" });
} }
}); });
module.exports = router; module.exports = router;

View File

@ -1,17 +0,0 @@
The MIT License (MIT)
Copyright (c) 2022, Gani Georgiev
Permission is hereby granted, free of charge, to any person obtaining a copy of this software
and associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or
substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,4 +1,5 @@
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const logger = require('../utils/logger');
module.exports = function (req, res, next) { module.exports = function (req, res, next) {
const token = req.headers['authorization']?.split(' ')[1]; const token = req.headers['authorization']?.split(' ')[1];

View File

@ -14,55 +14,7 @@ async function getClient(id) {
return globalService.fetchInfoByCriteria("client", criteria); return globalService.fetchInfoByCriteria("client", criteria);
} }
/**
* Récupère plusieurs clients en plusieurs requêtes batch (optimisation pour exports)
* Découpe en chunks de 50 IDs pour éviter les filtres trop longs
* SDK 0.7.x : getFullList(collection, batchSize, options)
* @param {string[]} clientIds - Tableau d'IDs de clients
* @returns {Map<string, Object>} - Map des clients par ID
*/
async function getClientsBatch(clientIds) {
const clientMap = new Map();
if (!clientIds || clientIds.length === 0) return clientMap;
// Filtrer les IDs valides et uniques
const uniqueIds = [...new Set(clientIds.filter(id => id && typeof id === 'string'))];
if (uniqueIds.length === 0) return clientMap;
// Découper en chunks de 50 pour éviter les filtres trop longs
const CHUNK_SIZE = 50;
const chunks = [];
for (let i = 0; i < uniqueIds.length; i += CHUNK_SIZE) {
chunks.push(uniqueIds.slice(i, i + CHUNK_SIZE));
}
// Traiter chaque chunk
for (const chunk of chunks) {
try {
// Construire le filtre OR pour ce chunk
const filter = chunk.map(id => `id = "${id}"`).join(" || ");
// SDK 0.7.x : getFullList(collection, batchSize, options)
const clients = await db.records.getFullList("client", 500, {
filter: filter,
});
// Ajouter à la map
clients.forEach(client => {
if (client && client.id) {
clientMap.set(client.id, client);
}
});
} catch (err) {
logger.log("warn", `Erreur récupération clients chunk (${chunk.length} IDs):`, err?.message || String(err));
}
}
return clientMap;
}
module.exports = { module.exports = {
createClient, createClient,
getClient, getClient,
getClientsBatch,
}; };

View File

@ -30,15 +30,7 @@ async function fetchInfoByCriteria(collection, criteria) {
return resultList.items[0]; return resultList.items[0];
} }
} catch (error) { } catch (error) {
/** logger.log("error", error);
* Gestion silencieuse des erreurs d'abort (requêtes interrompues)
* Ces erreurs sont normales lors de requêtes parallèles et ne doivent pas être loggées
*/
if (error?.isAbort || error?.name?.includes("Abort") || error?.status === 0) {
return null;
}
// Autres erreurs loggées en info (pas en error) pour éviter le bruit dans les logs
logger.log("info", `Erreur récupération ${collection}:`, error?.message || error);
} }
return null; return null;
@ -76,71 +68,10 @@ function cleanDoubleSpaces(inputString) {
return inputString.replace(/\s{2,}/g, " "); return inputString.replace(/\s{2,}/g, " ");
} }
/**
* Formate une date ISO en format français (jj/mm/aaaa)
* @param {string|Date} iso - Date au format ISO
* @param {boolean} withTime - Inclure l'heure (défaut: false)
* @returns {string} Date formatée (dd/mm/yyyy ou dd/mm/yyyy hh:mm)
*/
function fmtDateFR(iso, withTime = false) {
if (!iso) return "NC";
const d = new Date(iso);
if (isNaN(d.getTime())) return "NC";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
if (!withTime) {
return `${dd}/${mm}/${yyyy}`;
}
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}/${mm}/${yyyy} ${hh}:${mi}`;
}
/**
* Échappe les caractères spéciaux XML
* @param {string} s - Chaîne à échapper
* @returns {string} Chaîne échappée
*/
function xmlEsc(s) {
return String(s ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Génère une cellule XML pour Excel
* @param {string} v - Valeur de la cellule
* @returns {string} Cellule XML formatée
*/
function cellXml(v) {
return `<Cell><Data ss:Type="String">${xmlEsc(v)}</Data></Cell>`;
}
/**
* Génère une ligne XML pour Excel
* @param {Array<string>} cells - Tableau de valeurs pour les cellules
* @returns {string} Ligne XML formatée
*/
function rowXml(cells) {
return `<Row>${cells.map(cellXml).join("")}</Row>`;
}
module.exports = { module.exports = {
getRecordIdFromFieldValue, getRecordIdFromFieldValue,
fetchInfoByCriteria, fetchInfoByCriteria,
updateRecordFromData, updateRecordFromData,
cleanDoubleSpaces, cleanDoubleSpaces,
customFormatNumber, customFormatNumber,
fmtDateFR,
xmlEsc,
cellXml,
rowXml,
}; };

View File

@ -1,105 +1,58 @@
// services/parcoursService.js
const { db } = require("../db/db-connect"); const { db } = require("../db/db-connect");
const logger = require("../utils/logger"); const logger = require("../utils/logger");
const globalService = require("../services/globalService"); const globalService = require("../services/globalService");
/**
* Récupère un parcours par son numéro (avec expand utiles)
*/
async function getParcoursByNumParcours(numParcours) { async function getParcoursByNumParcours(numParcours) {
const criteria = { const criteria = {filter: `numParcours='${numParcours}'`, expand: `dernierUtilisateur.region, contrat`};
filter: `numParcours='${numParcours}'`,
expand: [
"dernierUtilisateur.region",
"contrat",
"contrat.client",
"contrat.intermediaire"
].join(",")
};
return globalService.fetchInfoByCriteria("parcours", criteria); return globalService.fetchInfoByCriteria("parcours", criteria);
} }
/** // get All parcours saved in DB
* Full list (batch côté PocketBase). | Fetch l'ensemble de la BD via chunk "batch" async function getAllParcours() {
* SDK 0.7.x : getFullList(collection, batchSize, options)
*/
async function getParcoursFullList({ filter, sort, expand, fields, batch = 500 }) {
const options = {
sort: sort || "-created",
};
// Ajouter expand si défini
if (expand) {
options.expand = expand;
}
// Ajouter fields si défini
if (fields) {
options.fields = fields;
}
// Ajouter filter SEULEMENT s'il n'est pas vide (SDK 0.7.x rejette les filtres vides)
if (filter && filter.trim() !== "") {
options.filter = filter;
}
// SDK 0.7.x : getFullList(collection, batchSize, options)
return db.records.getFullList("parcours", batch, options);
}
/**
* Pagination multi-régions + filtres/tri optionnels (server-side DataTables)
* Parcours une seule fois db par requête
* @param {string[]} regions
* @param {number} page
* @param {number} perPage
* @param {{filter?: string, sort?: string}} opts
*/
async function getParcoursByRegionsPage(regions = [], page = 1, perPage = 10, opts = {}) {
try { try {
let regFilter = ""; const criteria = {expand: "dernierUtilisateur, contrat, region"};
if (Array.isArray(regions) && regions.length > 0) { const resultList = await db.records.getList("parcours", 1, 200, criteria);
const ors = regions.map(r => `dernierUtilisateur.region.nom = "${r}"`);
regFilter = `(${ors.join(" || ")})`; return resultList;
} catch (error) {
logger.log('error', error);
return null;
} }
}
const filter = [regFilter, opts.filter].filter(Boolean).join(" && "); // get all parcours filtred on region
async function getParcoursByRegion(regionUser) {
/** try {
* Récupération des parcours avec expands nécessaires // Récupérer les enregistrements de la collection "parcours"
* Note: L'expand de contrat.client ne fonctionne pas toujours, const filter = `dernierUtilisateur.region.nom = "${regionUser}"`;
* d'où la nécessité d'un fallback dans le contrôleur const parcoursRecords = await db.records.getFullList("parcours", 200, {
*/ sort: "-created",
const list = await db.records.getList("parcours", page, perPage, { filter: filter,
sort: opts.sort || "-created", expand: "contrat, dernierUtilisateur.region, contrat.intermediaire",
filter: filter || "",
expand: [
"contrat",
"contrat.client",
"contrat.intermediaire",
"dernierUtilisateur.region"
].join(","),
}); });
return { // Récupérer les relations client pour chaque contrat
page: list.page, for (const record of parcoursRecords) {
perPage: list.perPage, const contrat = record["@expand"].contrat;
totalItems: list.totalItems, if (contrat && contrat.client) {
totalPages: list.totalPages, const clientRecord = await db.records.getOne("client", contrat.client);
items: list.items, record["@expand"].contrat.client = clientRecord;
}; }
}
return parcoursRecords;
} catch (error) { } catch (error) {
logger.log('error', error); logger.log('error', error);
throw error; throw error;
} }
} }
/**
* Création d'un parcours vide
*/
async function createNewEmptyParcours(numParcours) { async function createNewEmptyParcours(numParcours) {
try { try {
const data = { ["numParcours"]: numParcours }; const data = { ["numParcours"]: numParcours };
const record = await db.records.create("parcours", data); const record = await db.records.create("parcours", data);
if (record) { if (record) {
return record.id; return record.id;
} else { } else {
@ -111,13 +64,11 @@ async function createNewEmptyParcours(numParcours) {
} }
} }
/**
* MAJ d'un champ d'un parcours
*/
async function updateFieldValueParcours(id, field, value) { async function updateFieldValueParcours(id, field, value) {
try { try {
const data = { [field]: value }; const data = { [field]: value };
const record = await db.records.update("parcours", id, data); const record = await db.records.update("parcours", id, data);
if (record) { if (record) {
return record.id; return record.id;
} else { } else {
@ -129,143 +80,38 @@ async function updateFieldValueParcours(id, field, value) {
} }
} }
/**
* Génère le prochain numéro de parcours
*/
async function getNewParcoursNumber() { async function getNewParcoursNumber() {
try { try {
const list = await db.records.getList("parcours", 1, 1, { sort: "-numParcours" }); // fetch a paginated records list en utilisant le filtre pour le parcours
const last = list?.items?.[0]; const resultList = await db.records.getFullList("parcours", 99999999, {sort: "-numParcours",});
if (!last?.numParcours) return null;
const numericValue = parseInt(String(last.numParcours).substring(1), 10); if (resultList.length > 0) {
if (Number.isNaN(numericValue)) return null; const lastNumParcours = resultList[0].numParcours;
const next = numericValue + 1; // Extrait les chiffres du numéro de parcours
return "P" + next.toString().padStart(9, "0"); const numericPart = lastNumParcours.substring(1); // Supprime le "P" initial
const numericValue = parseInt(numericPart, 10);
if (!isNaN(numericValue)) {
const newNumericValue = numericValue + 1;
const newNumParcours = "P" + newNumericValue.toString().padStart(9, "0");
return newNumParcours;
}
} else {
return null;
}
} catch (error) { } catch (error) {
logger.log("error", error); logger.log("error", error);
return null; return null;
} }
} }
// --- Section détails profonds (contrat + fiche produit) --- //
/**
* Récupère un parcours (via numParcours) avec les expands utiles pour détails.
*/
async function getParcoursForDetails(numParcours) {
try {
const list = await db.records.getList("parcours", 1, 1, {
filter: `numParcours='${numParcours}'`,
expand: [
"contrat",
"contrat.client",
"contrat.intermediaire",
"dernierUtilisateur.region"
].join(","),
});
return list?.items?.[0] || null;
} catch (e) {
logger.log("error", e);
return null;
}
}
/**
* Mappe un libellé produit vers la collection PocketBase (à ajuster si changement de parcours).
*/
function mapProduitToCollection(produitRaw = "") {
const p = String(produitRaw || "").trim().toUpperCase();
const map = {
"TPPC": "tppc",
"RC": "rc",
"FAC": "fac",
};
return map[p] || null;
}
/**
* Récupère la fiche produit pour un contrat donné.
* On tente d'abord par relation "contrat = contratId" si elle existe,
* sinon fallback par "numContrat = x" si jamais la fiche stocke le numéro.
*/
async function getProduitRecordForContrat(contrat, opts = {}) {
try {
if (!contrat) return null;
const collection = mapProduitToCollection(contrat.produit);
if (!collection) return null;
// Tente via une relation directe "contrat" (champ le plus propre)
try {
const record = await db.records.getFirstListItem(collection, `contrat='${contrat.id}'`, {
});
if (record) return record;
} catch (_) { /* ignore, on tente le fallback */ }
// Fallback
if (contrat.numContrat) {
try {
const record = await db.records.getFirstListItem(collection, `numContrat='${contrat.numContrat}'`, {});
if (record) return record;
} catch (_) { /* ignore */ }
}
return null;
} catch (e) {
logger.log("error", e);
return null;
}
}
/**
* reformatage texte - a virer
*/
function escPB(s = "") {
return String(s).replace(/"/g, '\\"');
}
/**
* Détails complets: parcours + contrat + fiche produit
*/
async function getDeepDetailsByNumParcours(numParcours) {
try {
const filter = `numParcours = "${escPB(numParcours)}"`;
const list = await db.records.getList("parcours", 1, 1, {
filter,
expand: [
"contrat",
"contrat.client",
"contrat.intermediaire",
"dernierUtilisateur.region",
// produit lié
"contrat.tppc",
"contrat.rc",
"contrat.fac",
// sous-relations TPPC
"contrat.tppc.tarif",
"contrat.tppc.projet",
].join(","),
});
return list?.items?.[0] || null;
} catch (e) {
logger.log("error", e);
return null;
}
}
module.exports = { module.exports = {
getNewParcoursNumber, getNewParcoursNumber,
getParcoursByNumParcours, getParcoursByNumParcours,
createNewEmptyParcours, createNewEmptyParcours,
updateFieldValueParcours, updateFieldValueParcours,
getParcoursByRegionsPage, getAllParcours,
getParcoursFullList, getParcoursByRegion,
getParcoursForDetails,
getProduitRecordForContrat,
getDeepDetailsByNumParcours,
mapProduitToCollection,
}; };

View File

@ -1,19 +1,10 @@
const { db } = require('../db/db-connect'); const { db } = require('../db/db-connect');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const globalService = require("../services/globalService");
async function createRc(data) { async function createRc(data) {
return await db.records.create('rc', data); return await db.records.create('rc', data);
} }
async function getRCbyId(id) {
const criteria = {
filter: `id='${id}'`
};
return globalService.fetchInfoByCriteria("rc", criteria);
}
module.exports = { module.exports = {
createRc, createRc
getRCbyId
}; };

View File

@ -1,13 +1,6 @@
const ejs = require('ejs'); const ejs = require('ejs');
const path = require('path'); const path = require('path');
/**
* Rend une page EJS
* @param {string} routePath - Chemin vers le fichier EJS
* @param {Object} res - Objet response Express
* @param {Object} options - Options à passer au template
* @param {boolean} fragment - Si true, envoie uniquement le fragment sans layout
*/
function renderPage(routePath, res, options = {}, fragment = false) { function renderPage(routePath, res, options = {}, fragment = false) {
ejs.renderFile(path.join(process.cwd(), 'views', routePath), options, (err, str) => { ejs.renderFile(path.join(process.cwd(), 'views', routePath), options, (err, str) => {
if (err) { if (err) {

View File

@ -31,6 +31,38 @@
<div id="divToggleSearch"> <div id="divToggleSearch">
<button class="btn" id="toggleSearch" type="button">Activer la recherche par colonne</button> <button class="btn" id="toggleSearch" type="button">Activer la recherche par colonne</button>
</div> </div>
<div id="checkRegionAdmin">
<div class="checkbox-wrapper-1">
<label>
<input type="checkbox" id="zone1" name="zone1" class="filled-in" />
<span class="checkboxRegion" id="cbx-42">ILE DE FRANCE</span>
</label>
</div>
<div class="checkbox-wrapper-2">
<label>
<input type="checkbox" id="zone2" name="zone2" class="filled-in" />
<span class="checkboxRegion" id="cbx-43">SUD OUEST</span>
</label>
</div>
<div class="checkbox-wrapper-3">
<label>
<input type="checkbox" id="zone3" name="zone3" class="filled-in" />
<span class="checkboxRegion" id="cbx-44">SUD EST</span>
</label>
</div>
<div class="checkbox-wrapper-4">
<label>
<input type="checkbox" id="zone4" name="zone4" class="filled-in" />
<span class="checkboxRegion" id="cbx-45">OUEST</span>
</label>
</div>
<div class="checkbox-wrapper-5">
<label>
<input type="checkbox" id="zone5" name="zone5" class="filled-in" />
<span class="checkboxRegion" id="cbx-46">NORD EST</span>
</label>
</div>
</div>
</div> </div>
<div> <div>

View File

@ -25,13 +25,25 @@
<link rel="stylesheet" href="/css/global.css"> <link rel="stylesheet" href="/css/global.css">
<!-- DataTables CSS --> <!-- DataTables CSS -->
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.0.8/css/dataTables.dataTables.css"> <link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.0.8/css/dataTables.dataTables.css">
<!-- Loader CSS : style pour l'affichage du loader -->
<link rel="stylesheet" href="/css/loader.css">
</head> </head>
<body> <body>
<main> <main>
<!-- Loader -->
<div id="loader-overlay" class="hidden">
<div class="loader-spin-wrap">
<div class="loader-spin"></div>
</div>
<div id="error-message" class="helper-text error"></div>
</div>
<!-- Navbar -->
<%- include('partials/navbar') %> <%- include('partials/navbar') %>
<!-- Contenu dynamique -->
<div class="container"> <div class="container">
<%- typeof body !=='undefined' ? body : '' %> <%- typeof body !=='undefined' ? body : '' %>
</div> </div>
@ -46,8 +58,11 @@
<script src="/js/verif-form.js"></script> <script src="/js/verif-form.js"></script>
<!-- Feuille de script pour les donnée static JSON de saisie formulaire --> <!-- Feuille de script pour les donnée static JSON de saisie formulaire -->
<script src="/js/json/json-verif-form.js"></script> <script src="/js/json/json-verif-form.js"></script>
<!-- Feuille de script pour le bon fonctionnement du loader -->
<script src="/js/loader.js"></script>
<!-- Script pour la navigation AJAX -->
<script src="/js/navigation.js"></script>
</main> </main>
</body> </body>
</html> </html>

View File

@ -399,7 +399,7 @@
<td><input type="text" name="genre" id="genreVehicule" placeholder="VP" /></td> <td><input type="text" name="genre" id="genreVehicule" placeholder="VP" /></td>
<td><input type="text" name="type" id="typeVehicule" placeholder="Non défini" /></td> <td><input type="text" name="type" id="typeVehicule" placeholder="Non défini" /></td>
<td><input type="text" name="immat" id="immatVehicule" placeholder="AA-999-AA" /></td> <td><input type="text" name="immat" id="immatVehicule" placeholder="AA-999-AA" /></td>
<td><input type="text" name="capital" id="capitalVehicule" placeholder="10 000 €" /></td> <td><input type="text" name="capital" id="capitalVeh" placeholder="10 000 €" /></td>
<td><button class="btn" type="button" id="btnAddVehicule"><i <td><button class="btn" type="button" id="btnAddVehicule"><i
class="material-icons">add</i></button></td> class="material-icons">add</i></button></td>
</tr> </tr>

View File

@ -173,7 +173,7 @@
</td> </td>
<td> <td>
<input type="number" name="nbVehiculesTarif" id="nbVehiculesTarif" <input type="number" name="nbVehiculesTarif" id="nbVehiculesTarif"
placeholder="Non défini" value="0" /> placeholder="Non défini" min="0" value="0" />
</td> </td>
<td> <td>
<input type="number" name="primeVehTarif" id="primeVehTarif" min="0" placeholder="Non défini" /> <input type="number" name="primeVehTarif" id="primeVehTarif" min="0" placeholder="Non défini" />