Compare commits

..

No commits in common. "script-migration-rc-fac" and "main" have entirely different histories.

234 changed files with 1975 additions and 4031 deletions

View File

@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "C:\\Users\\B722DA\\Documents\\etv2\\logs\\.08fe637d6ef44c0231313d33bee64c93b9084214-audit.json",
"files": [
{
"date": 1758200946499,
"name": "C:\\Users\\B722DA\\Documents\\etv2\\logs\\easytransport-18092025.log",
"hash": "1706becaf705914860a3975811363123969a9384d470caf37ee1ba3b6762cbcc"
}
],
"hashType": "sha256"
}

View File

@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "C:\\workspace\\etv2\\logs\\.3d67f34307b811ec6c096daeac0152b0eaa334a7-audit.json",
"files": [
{
"date": 1781187673716,
"name": "C:\\workspace\\etv2\\logs\\easytransport-11062026.log",
"hash": "c19430c072d594d17c7e822d673b22a8a9eb97f9955f27a7543fec107e9788c3"
}
],
"hashType": "sha256"
}

View File

@ -1,6 +0,0 @@
#DB_URL=http://ppsi_nanterre.axa-fr.intraxa:3005/
DB_URL=http://127.0.0.1:8091/
DB_ADMIN=admin@axa.fr
DB_PASSWORD=DTadmin123TT
NODE_ENV=developpement
PORT=8082

View File

@ -1,368 +0,0 @@
/* Historique Parcours */
body>main>div>div.section.center-align {
width: 100% !important;
}
body>main>div>div.section.center-align>div.container {
width: 100% !important;
}
#historiqueParcours_wrapper {
width: 100% !important;
overflow-x: auto !important;
margin-top: 30px;
}
table#historiqueParcours {
width: 150%;
border-collapse: collapse !important;
border: 2px solid darkblue !important;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
#historiqueParcours th,
#historiqueParcours td {
color: black;
text-align: left;
font-size: 11px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
table.dataTable thead th {
padding-right: 18px !important;
position: relative !important;
}
/* Div pour encapsuler le texte dans les cellules d'en-tête */
table.dataTable thead th>div {
position: absolute !important;
top: 0 !important;
left: 0 !important;
}
#historiqueParcours_filter {
margin-bottom: 20px;
}
.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"] {
background-color: #fff;
border: 1px solid #e0e0e0;
border-radius: 42px;
outline: none;
height: 42px;
width: 220px;
font-size: 15px;
margin: 0;
padding: 0 20px 0 52px;
box-sizing: border-box;
transition: all 0.2s ease;
color: #333;
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 {
font-size: 14px;
display: flex !important;
align-items: center !important;
}
.dataTables_length select[name="historiqueParcours_length"] {
display: block !important;
font-size: 14px;
color: #555;
padding: 8px;
margin: 0 0.5em;
border: none;
background: none;
padding: 5px;
font-size: 16px;
outline: none;
width: 60px;
}
/* Style Input recherche par ligne */
#historiqueParcours>thead>tr:nth-child(2)>th>input {
font-size: 13px !important;
padding: 6px !important;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* icone de tri sur les colonnes */
table.dataTable thead .sorting,
table.dataTable thead .sorting_asc,
table.dataTable thead .sorting_desc {
background-position: right center;
background-repeat: no-repeat;
}
/* Ajouter un espacement entre le texte et l'icône */
table.dataTable thead .sorting:before,
table.dataTable thead .sorting_asc:before,
table.dataTable thead .sorting_desc:before {
content: "";
}
/* boutons de navigation */
.dataTables_wrapper .dataTables_paginate .paginate_button {
background-color: white !important;
border: darkblue solid 1.5px !important;
color: black !important;
padding: 6px 12px !important;
margin: 0 2px !important;
cursor: pointer !important;
border-radius: 4px !important;
transition: background-color 0.3s, color 0.3s, border-color 0.3s !important;
}
#historiqueParcours_paginate>span>a.paginate_button.current,
.dataTables_wrapper .dataTables_paginate:hover .paginate_button:hover {
background: none !important;
background-color: darkblue !important;
border-color: white !important;
color: white !important;
}
/* NC value */
td.nc-value {
color: lightgray !important;
}
/* Les bouton pour le filtres et les extraction */
#divBtnFilter {
display: inline-grid;
width: 100%;
grid-template-columns: auto;
column-gap: 3%;
row-gap: 10%;
}
#checkRegionAdmin {
grid-column: 1 / span 3;
justify-self: center;
}
#divToggleSearch {
grid-column: 2;
grid-row: 3;
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 {
grid-column: 1;
grid-row: 1;
justify-self: start;
}
#divExtractFilter {
grid-column: 3;
grid-row: 1;
justify-self: end;
}
#divBtnFilter button {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(16, 0, 75, 0.2),
0 4px 8px rgba(16, 0, 75, 0.1);
}
/* Bouton Reprendre */
#btnReprendre,
#btnGenerate {
border: none;
color: white;
padding: 0px 15px;
font-size: 10px;
cursor: pointer;
border-radius: 8px;
}
#btnReprendre i {
margin: 0;
}
/* checkbox Filter Region Admin a supprimer probablement*/
#checkRegionAdmin {
border: 1px solid #ccc;
padding: 10px;
display: flex;
flex-wrap: wrap;
margin-bottom: 10px;
display: none;
}
[class^="checkbox-wrapper-"] {
margin-right: 20px;
}
#checkRegionAdmin input[type="checkbox"] {
display: none;
visibility: hidden;
}
#checkRegionAdmin label {
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;
}
#historiqueParcours td .col-with-text {
white-space: normal !important;
}
#historiqueParcours_wrapper {
overflow-x: visible !important;
}
table.dataTable {
width: 100% !important;
table-layout: auto !important;
}
.col-with-text {
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.col-with-text .np {
white-space: normal !important;
}
.btn-row-details {
cursor: pointer;
width: 35px;
height: 28px;
background-color: #F44336 !important;
border-radius: 6px;
border: 3px solid darkblue;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 16px;
padding: 0;
line-height: 1;
transition: 0.15s ease-in-out;
}
/* petit effet hover propre pour le bouton détails*/
.btn-row-details:hover {
background-color: #d7372f;
transform: scale(1.05);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,279 +0,0 @@
// Module IIFE pour éviter la pollution de l'espace global
(function () {
// Variables globales du module
let parcours, contrat, client, matricule;
// Initialisation du formulaire et des données
function init() {
const token = localStorage.getItem('jwtToken');
if (token) {
const decoded = jwt_decode(token);
matricule = decoded.userMatricule;
}
// Accéder aux informations stockées du parcours
parcours = JSON.parse(sessionStorage.getItem('parcours'));
contrat = JSON.parse(sessionStorage.getItem('contrat'));
client = contrat?.["@expand"]?.client || null;
console.log("Matricule user actuel:", matricule);
console.log("Initialisation pour formulaire client :", contrat);
// Materialize init Modal
var modals = document.querySelectorAll('.modal');
M.Modal.init(modals);
// Appel des différentes fonctions d'initialisation
setupEventListeners();
populateFormData();
updateSubmitButtonState('clientForm');
}
// Configuration des écouteurs d'événements
function setupEventListeners() {
document.getElementById('clientFormBtn').addEventListener('click', handleSubmitForm);
document.getElementById('creditsafe').addEventListener('click', (event) => handleOpenLink(event, 'creditsafeURL'));
document.getElementById('easyQP').addEventListener('click', (event) => handleOpenLink(event, 'easyQPURL'));
document.getElementById('refNoteFi').addEventListener('click', (event) => handleOpenLink(event, 'refNoteFiURL'));
document.getElementById('cl063-client').addEventListener('click', (event) => handleExtractClient(event));
document.getElementById('modalExtraireClient').addEventListener('click', (event) => handleModalExtract(event));
// Controle de saisie et format sur les champs du formulaire
document.getElementById('noteFinanciereClient').addEventListener('input', function () {
validateField('noteFinanciereClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('numClient').addEventListener('input', function () {
validateField('numClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('nomClient').addEventListener('input', function () {
validateField('nomClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('emailClient').addEventListener('input', function () {
validateField('emailClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('adresseClient').addEventListener('input', function () {
validateField('adresseClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('codePostalClient').addEventListener('input', function () {
validateField('codePostalClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('villeClient').addEventListener('input', function () {
validateField('villeClient', true);
updateSubmitButtonState('clientForm');
});
document.getElementById('modalNumClient').addEventListener('input', function () {
validateField('modalNumClient', true);
updateSubmitButtonState('modalExtraireClient');
});
}
// Peupler le formulaire avec les données
function populateFormData() {
const clientStorage = JSON.parse(sessionStorage.getItem('tmp'));
if (client) {
document.getElementById('nomClient').value = client.nom || '';
document.getElementById('numClient').value = client.numClient || '';
document.getElementById('adresseClient').value = client.adresse || '';
document.getElementById('emailClient').value = client.mail || '';
document.getElementById('codePostalClient').value = client.codePostal || '';
document.getElementById('villeClient').value = client.ville || '';
document.getElementById('noteFinanciereClient').value = client.noteFinanciere || '';
}
if (clientStorage) {
document.getElementById('nomClient').value = clientStorage.nomClient || '';
document.getElementById('numClient').value = clientStorage.numClient || '';
document.getElementById('adresseClient').value = clientStorage.adresseClient || '';
document.getElementById('codePostalClient').value = clientStorage.postalClient || '';
document.getElementById('villeClient').value = clientStorage.villeClient || '';
}
}
// Gérer l'ouverture de liens externes
function handleOpenLink(event, urlType) {
event.preventDefault();
let url = '';
switch (urlType) {
case 'creditsafeURL':
url = 'https://www.creditsafe.fr/csfr?UserName=735265dorothee&Password=UH04EuLocXZMxIRqY19w6A%3d%3d&BackOfficeCountry=FR&origincountry=FR&linkages=Y';
break;
case 'easyQPURL':
if (document.getElementById('numClient').value != "") {
url = `https://qualite-portefeuille-iard-ent.axa-fr.intraxa/client/${document.getElementById('numClient').value}`;
} else {
url = `https://qualite-portefeuille-iard-ent.axa-fr.intraxa/`;
}
break;
case 'refNoteFiURL':
url = 'https://app.powerbi.com/groups/me/apps/7b48f9a2-bd97-4178-bc1a-a5f7ef6e985f/reports/d29a9f83-cafe-4a0f-bc38-0929921e8cd3/ReportSection?ctid=396b38cc-aa65-492b-bb0e-3d94ed25a97b';
break;
}
window.open(url, '_blank');
}
// Gérer l'extraction axaPAC
async function handleExtractClient(event) {
event.preventDefault();
// Affiche le modal
const elem = document.getElementById('modalExtractAxAPAC');
const instance = M.Modal.getInstance(elem);
instance.open();
}
async function handleModalExtract() {
document.getElementById('modalExtraireClient').disabled = true;
const response = await fetch(`/client/extract`, {
method: 'POST',
body: JSON.stringify({
"matricule": matricule,
"numClient": document.getElementById("modalNumClient").value
}),
headers: {
'Content-Type': 'application/json',
},
});
const res = await response.json();
if (res.valid) {
document.getElementById("error-extract").style.display = "none";
document.getElementById('modalExtraireClient').disabled = false;
console.log(res);
// Save sessionStorage for Intermediaire
sessionStorage.setItem('tmp', JSON.stringify(res.data));
// Populate data
document.getElementById("numClient").value = (res.data.numClient) ? res.data.numClient : null;
document.getElementById("nomClient").value = (res.data.nomClient) ? res.data.nomClient : null;
document.getElementById("adresseClient").value = (res.data.adresseClient) ? res.data.adresseClient : null;
document.getElementById("codePostalClient").value = (res.data.postalClient) ? res.data.postalClient : null;
document.getElementById("villeClient").value = (res.data.villeClient) ? res.data.villeClient : null;
validateField('numClient', true);
validateField('nomClient', true);
validateField('adresseClient', true);
validateField('codePostalClient', true);
validateField('villeClient', true);
updateSubmitButtonState('clientForm');
// close le modal
const elem = document.getElementById('modalExtractAxAPAC');
const instance = M.Modal.getInstance(elem);
instance.close();
} else {
document.getElementById("error-extract").style.display = "block";
document.getElementById('modalExtraireClient').disabled = false;
console.log("Problème rencontré lors de l'extraction cl063 AxA PAC :", res.error);
}
}
// Gérer la soumission du formulaire
async function handleSubmitForm(event) {
event.preventDefault();
let idClient = client?.id;
const numClient = document.getElementById('numClient').value;
let responseClient;
if (numClient) {
responseClient = await fetch(`/client/read/${numClient}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
});
}
const dataClient = await responseClient.json();
if (dataClient.valid) {
idClient = dataClient.idClient;
} else {
// Créez le client ici si non trouvé
const responseCreateClient = await fetch(`/client/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
});
const dataCreateClient = await responseCreateClient.json();
if (dataCreateClient.valid) {
idClient = dataCreateClient.client.id;
}
}
// Mettre à jour le contrat avec l'ID du client
if (idClient) {
const response = await fetch(`/client/update/${idClient}`, {
method: 'POST',
body: JSON.stringify({
"nom": document.getElementById('nomClient').value.toUpperCase(),
"numClient": document.getElementById('numClient').value,
"adresse": document.getElementById('adresseClient').value.toUpperCase(),
"mail": document.getElementById('emailClient').value,
"codePostal": document.getElementById('codePostalClient').value,
"ville": document.getElementById('villeClient').value.toUpperCase(),
"noteFinanciere": document.getElementById('noteFinanciereClient').value
}),
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (data.valid) {
const idContrat = contrat?.id;
fetch(`/contrat/update/client/${idContrat}/${idClient}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
})
.then(response => response.json())
.then(data => {
if (data.valid) {
// Update Session storage
const clientStorage = JSON.parse(sessionStorage.getItem('tmp'));
if (clientStorage) {
clientStorage.nomClient = document.getElementById('nomClient').value.toUpperCase();
clientStorage.numClient = document.getElementById('numClient').value;
clientStorage.adresseClient = document.getElementById('adresseClient').value.toUpperCase();
clientStorage.postalClient = document.getElementById('codePostalClient').value;
clientStorage.villeClient = document.getElementById('villeClient').value.toUpperCase();
sessionStorage.setItem('tmp', JSON.stringify(clientStorage))
}
// Redirect vers intermédiaire
window.location.href = `/navParcours?numParcours=${getNumParcoursFromURL()}&submenu=intermediaire`;
} else {
console.log('Echec lors de la mise à jour de la relation id contrat - id client :', data);
}
});
}
}
}
// Exposer init globalement pour y accéder depuis l'extérieur
window.initSubmenuForm = init;
})();

View File

@ -1,422 +0,0 @@
// Événement de vérification d'authentification
document.addEventListener('DOMContentLoaded', function () {
// Vérification de la page sur laquelle nous sommes
if (window.location.pathname !== '/auth') {
// Récupération du token JWT du localStorage
const token = localStorage.getItem('jwtToken');
// Si pas de token, redirige vers la page d'authentification
if (!token) {
window.location.replace('/auth');
} else {
// Si un token existe, vérification de sa validité
fetch('/auth/verifyToken', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token
}
})
.then(res => res.json())
.then(data => {
// Si le token n'est pas valide, redirige vers la page d'authentification
if (!data.valid) {
localStorage.removeItem('jwtToken');
window.location.replace('/auth');
}
})
.catch(error => {
console.error('Error:', error);
});
}
}
});
// Événement du bouton déconnexion
document.addEventListener('DOMContentLoaded', function (event) {
// Empêche le comportement par défaut du formulaire
event.preventDefault();
const logoutBtn = document.getElementById('logoutBtn');
if (window.location.pathname === '/auth') {
logoutBtn.style.display = 'none';
} else {
logoutBtn.addEventListener('click', function () {
// Suppression du token JWT du localStorage et redirection
localStorage.removeItem('jwtToken');
window.location.replace('/auth');
});
}
});
// Événement d'initialisation des Materialize content
document.addEventListener('DOMContentLoaded', function () {
// Init dropdown
var sidenav = document.querySelectorAll('.sidenav');
M.Sidenav.init(sidenav);
// Init Modal
var modals = document.querySelectorAll('.modal');
M.Modal.init(modals);
});
// Événement condition de groupe pour les menus
document.addEventListener('DOMContentLoaded', function () {
const token = localStorage.getItem('jwtToken');
if (token) {
const decoded = jwt_decode(token);
const userAuthGroupe = decoded.userAuthGroupe;
const reportingItem = document.getElementById("reportingSidenavSelect");
const adminItem = document.getElementById("adminSidenavSelect");
// Cache par défaut
reportingItem.style.display = 'none';
adminItem.style.display = 'none';
if (userAuthGroupe === 'MANAGER' || userAuthGroupe === 'ADMIN') {
reportingItem.style.display = 'block';
}
if (userAuthGroupe === 'ADMIN') {
adminItem.style.display = 'block';
}
}
});
// Fonction pour extraire le numéro de parcours de l'URL
function getNumParcoursFromURL() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('numParcours');
}
// Fonction pour extraire le sous-menu de l'URL
function getSubmenuFromURL() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('submenu');
}
// Fonction pour vérifier si une valeur est nulle ou non définie
function isNullOrUndefined(value) {
return value === null || value === undefined;
}
// Fonction pour éviter la duplication du code fetch
async function fetchWithJson(url, method, body = null) {
const options = {
method: method,
headers: { 'Content-Type': 'application/json' },
};
if (body) options.body = JSON.stringify(body);
const response = await fetch(url, options);
if (!response.ok) throw new Error('Réseau ou erreur serveur');
return await response.json();
}
// Fonction de load du parcours en session storage
async function loadParcours(numParcours) {
try {
const data = await fetchWithJson(`/parcours/read/${numParcours}`, 'GET');
if (data.valid) {
sessionStorage.setItem('parcours', JSON.stringify(data.parcours));
} else {
sessionStorage.setItem('parcours', null);
sessionStorage.setItem('admins', JSON.stringify(data.admins));
console.log("Parcours introuvable");
}
} catch (error) {
console.error("Erreur lors de la récupération des informations parcours :", error);
}
}
// Fonction générique de toggle
function toggler(btn, div) {
if (document.getElementById(btn).checked) {
document.getElementById(div).style.display = 'block';
} else {
document.getElementById(div).style.display = 'none';
}
}
// Fonction de load du parcours en session storage
async function loadContrat(idContrat) {
try {
const data = await fetchWithJson(`/contrat/read/id/${idContrat}`, 'GET');
if (data.valid) {
sessionStorage.setItem('contrat', JSON.stringify(data.contrat));
} else {
sessionStorage.setItem('contrat', null);
console.log("Contrat introuvable");
}
} catch (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>
`;
}
/**
* Formate une valeur en euros :
* - espace insécable fine entre milliers,
* - virgule pour les décimales,
* - 0 à 2 décimales max,
* - symbole à la fin.
* Si la valeur est invalide => "NC".
*/
function formatEuro(value, options) {
const opts = Object.assign({ minimumFractionDigits: 0, maximumFractionDigits: 2 }, options || {});
if (value === null || value === undefined || value === '' || value === 'NC') return 'NC';
// Accepte string avec virgule ou espaces
const normalized = (typeof value === 'string')
? value.replace(/\s|\u00A0|\u202F|&nbsp;/g, '').replace(',', '.')
: value;
const num = Number(normalized);
if (!isFinite(num)) return 'NC';
const formatted = new Intl.NumberFormat('fr-FR', {
minimumFractionDigits: opts.minimumFractionDigits,
maximumFractionDigits: opts.maximumFractionDigits
}).format(num);
return formatted + ' €';
}

View File

@ -1,858 +0,0 @@
// public/js/historiqueParcours.js
document.addEventListener("DOMContentLoaded", async function () {
// Récupération du token
const token = localStorage.getItem("jwtToken");
if (!token) {
throw new Error("Aucun token trouvé dans le localStorage.");
}
const userData = parseJwt(token);
if (!userData) {
displayError("Erreur lors de l'extraction des données utilisateur à partir du token.");
return;
}
// Initialiser DataTables en mode server-side (obligé pour pagination)
const table = initServerSideDataTable();
// Variable pour suivre l'état des exports
let isExporting = false;
// Fonction pour désactiver/activer les boutons d'export
function setExportButtonsState(disabled) {
isExporting = disabled;
const buttons = ["#exportCSV", "#exportCSVFilter", "#exportXlxs", "#exportXlxsFilter"];
buttons.forEach(selector => {
const $btn = $(selector);
$btn.prop("disabled", disabled);
if (disabled) {
$btn.css("opacity", "0.5");
$btn.css("cursor", "not-allowed");
} else {
$btn.css("opacity", "1");
$btn.css("cursor", "pointer");
}
});
}
// Exports CSV/XLSX
$("#exportCSV").on("click", function () {
if (isExporting) return;
const dt = $("#historiqueParcours").DataTable();
if (!dt) {
displayError("Impossible d'accéder à la table de données.");
return;
}
const settings = dt.settings()[0];
if (!settings || !settings.aoColumns) {
displayError("Structure de données invalide.");
return;
}
setExportButtonsState(true);
const payload = {
mode: "full", // export total
search: { value: "" },
columns: settings.aoColumns.map((c, i) => ({
data: i,
search: { value: "" }
})),
// on garde l'ordre actuel pour la récuperation
order: (dt.order() || []).map(([col, dir]) => ({ column: col || 0, dir: dir || "asc" }))
};
fetch("/historiqueParcours/export/csv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(resp => {
if (!resp.ok) throw new Error("Export CSV (complet) impossible");
return resp.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "historique_parcours_complet.csv";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
setExportButtonsState(false);
})
.catch((err) => {
displayError("Export CSV (complet) impossible");
setExportButtonsState(false);
});
});
$("#exportCSVFilter").on("click", function () {
if (isExporting) return;
const dt = $("#historiqueParcours").DataTable();
if (!dt) {
displayError("Impossible d'accéder à la table de données.");
return;
}
const settings = dt.settings()[0];
if (!settings || !settings.aoColumns) {
displayError("Structure de données invalide.");
return;
}
setExportButtonsState(true);
const payload = {
mode: "filtered", // export avec les filtres/colonnes/tri actuels
search: { value: dt.search() || "" }, // recherche globale
columns: settings.aoColumns.map((c, i) => ({
data: i,
search: { value: dt.column(i).search() || "" } // filtres par colonne
})),
order: (dt.order() || []).map(([col, dir]) => ({ column: col || 0, dir: dir || "asc" }))
};
fetch("/historiqueParcours/export/csv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(resp => {
if (!resp.ok) throw new Error("Export CSV (filtré) impossible");
return resp.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "historique_parcours_filtre.csv";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
setExportButtonsState(false);
})
.catch((err) => {
displayError("Export CSV (filtré) impossible");
setExportButtonsState(false);
});
});
$("#exportXlxs").on("click", function () {
if (isExporting) return;
const dt = $("#historiqueParcours").DataTable();
if (!dt) {
displayError("Impossible d'accéder à la table de données.");
return;
}
const settings = dt.settings()[0];
if (!settings || !settings.aoColumns) {
displayError("Structure de données invalide.");
return;
}
setExportButtonsState(true);
const payload = {
mode: "full",
search: { value: "" },
columns: settings.aoColumns.map((c, i) => ({ data: i, search: { value: "" } })),
order: (dt.order() || []).map(([col, dir]) => ({ column: col || 0, dir: dir || "asc" }))
};
fetch("/historiqueParcours/export/xls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(resp => { if (!resp.ok) throw new Error(); return resp.blob(); })
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "historique_parcours_complet.xls";
document.body.appendChild(a); a.click();
URL.revokeObjectURL(url); a.remove();
setExportButtonsState(false);
})
.catch((err) => {
displayError("Export XLS (complet) impossible");
setExportButtonsState(false);
});
});
$("#exportXlxsFilter").on("click", function () {
if (isExporting) return;
const dt = $("#historiqueParcours").DataTable();
if (!dt) {
displayError("Impossible d'accéder à la table de données.");
return;
}
const settings = dt.settings()[0];
if (!settings || !settings.aoColumns) {
displayError("Structure de données invalide.");
return;
}
setExportButtonsState(true);
const payload = {
mode: "filtered", // export avec les filtres/colonnes/tri actuels
search: { value: dt.search() || "" }, // recherche globale
columns: settings.aoColumns.map((c, i) => ({
data: i,
search: { value: dt.column(i).search() || "" } // filtres par colonne
})),
order: (dt.order() || []).map(([col, dir]) => ({ column: col || 0, dir: dir || "asc" }))
};
fetch("/historiqueParcours/export/xls", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
.then(resp => {
if (!resp.ok) throw new Error("Export XLS (filtré) impossible");
return resp.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "historique_parcours_filtre.xls";
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
setExportButtonsState(false);
})
.catch((err) => {
displayError("Export XLS (filtré) impossible");
setExportButtonsState(false);
});
});
// Délégation pour la génération de projet
$("#historiqueParcours").on("click", "button#btnGenerate", function () {
const numParcours = $(this).data("num-parcours");
const produit = $(this).data("produit");
generateProject(numParcours, produit);
});
});
/* =========================
* Helpers spécifiques server-side
* ========================= */
// Initialisation DataTables en server-side (recherche globale + par colonnes + tri + pagination)
function initServerSideDataTable() {
let inflightController = null;
const table = $("#historiqueParcours").DataTable({
processing: true,
serverSide: true,
searching: true,
paging: true,
orderCellsTop: true,
fixedHeader: true,
responsive: false,
autoWidth: false,
scrollX: false,
pageLength: 10,
retrieve: true,
order: [[0, "desc"]],
searchDelay: 350,
language: {
search: "Rechercher",
lengthMenu: "Afficher _MENU_ entrées par page",
info: "Affichage de _START_ à _END_ sur _TOTAL_ entrées",
infoEmpty: "Affichage de 0 à 0 sur 0 entrée",
infoFiltered: "(filtré de _MAX_ entrées au total)",
paginate: { first: "Début", previous: "Précédent", next: "Suivant", last: "Fin" },
},
ajax: function (data, callback) {
const body = {
draw: data.draw || 1,
start: data.start || 0,
length: data.length || 10,
order: data.order || [],
columns: data.columns || [],
search: data.search || { value: "" },
};
if (inflightController) inflightController.abort(); // action en cours
inflightController = new AbortController();
const currentController = inflightController;
fetch("/historiqueParcours/datatable", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: currentController.signal,
})
.then(res => {
// Vérifier si la requête a été annulée
if (currentController.signal.aborted || inflightController !== currentController) {
return null;
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(payload => {
// Vérifier si la requête a été annulée
if (currentController.signal.aborted || inflightController !== currentController) {
return;
}
if (!payload || typeof payload !== 'object') {
throw new Error("Réponse invalide du serveur");
}
callback({
draw: payload.draw || 0,
recordsTotal: payload.recordsTotal || 0,
recordsFiltered: payload.recordsFiltered || payload.recordsTotal || 0,
data: Array.isArray(payload.data) ? payload.data : []
});
})
.catch(err => {
// Ignorer silencieusement toutes les erreurs d'abort
if (err && (err.name === "AbortError" || err.name === "DOMException")) {
return;
}
// Vérifier aussi si le signal a été aborted
if (currentController.signal.aborted || inflightController !== currentController) {
return;
}
// Seulement afficher une erreur si ce n'est PAS un abort
displayError("Failed to fetch data. Please try again later.");
callback({ draw: 0, recordsTotal: 0, recordsFiltered: 0, data: [] });
});
},
initComplete: function () {
const api = this.api();
// Recherche globale : debounce y compris ENTER (plus de bypass immédiat)
const $globalInput = $('div.dataTables_filter input[type="search"]');
const $filterLabel = $globalInput.closest('label');
$globalInput.off('.DT'); // nettoie handlers datatables
const debouncedGlobal = debounce((v) => {
api.search(v);
api.ajax.reload();
}, 350);
// Fonction pour gérer l'affichage du texte "Rechercher"
function toggleSearchPlaceholder() {
if ($globalInput.val().trim() !== '') {
$filterLabel.addClass('has-value');
} else {
$filterLabel.removeClass('has-value');
}
}
$globalInput.on('input keyup keydown', function () {
debouncedGlobal(this.value);
toggleSearchPlaceholder();
});
// Vérifier l'état initial
toggleSearchPlaceholder();
// Recherche par colonne avec DEBOUNCE (ENTER inclus)
const debouncedColSearch = debounce((i, val) => {
api.column(i).search(val);
api.ajax.reload();
}, 350);
$("#historiqueParcours thead tr:eq(1) th").each(function (i) {
$("input", this).on("input keyup keydown change", function () {
debouncedColSearch(i, this.value);
});
});
$("#toggleSearch").on("click", function () {
const row = $("#historiqueParcours thead tr:eq(1)");
row.toggle();
if (row.is(":visible")) {
$(this).text("ENLEVER LA RECHERCHE PAR COLONNE");
} else {
$(this).text("ACTIVER LA RECHERCHE PAR COLONNE");
}
});
// Cacher la 2e ligne au départ
$("#historiqueParcours thead tr:eq(1)").hide();
},
// --- Ajout bouton détails en 1re colonne
columnDefs: [
{
targets: 0,
render: function (data, type, row) {
const np = String(data ?? "");
const numPacours = np.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
return `
<span class="col-with-text">
<button class="btn-row-details" title="Détails" aria-label="Afficher les détails">?</button>
<span class="np">${numPacours}</span>
</span>
`;
}
},
{ type: "date-eu", targets: 1 }, // Date de Création (colonne 1)
{
// Appliquer la classe nc-value aux cellules contenant "NC"
targets: "_all",
createdCell: function (td, cellData, rowData, row, col) {
// Exclure la première colonne (bouton détails) et les deux dernières (boutons)
if (col !== 0 && col < rowData.length - 2) {
const cellText = String(cellData || "").trim();
if (cellText === "NC") {
td.classList.add("nc-value");
}
}
}
},
{ responsivePriority: 1, targets: -1 },
{ responsivePriority: 2, targets: -2 }
],
});
// --- Toggle des détails
$('#historiqueParcours tbody').on('click', '.btn-row-details', function (e) {
e.preventDefault();
e.stopPropagation();
const api = $('#historiqueParcours').DataTable();
const $tr = $(this).closest('tr');
// si c'est une ligne enfant (responsive), remonter à la parent
const row = api.row($tr.hasClass('child') ? $tr.prev() : $tr);
if (row.child.isShown()) {
row.child.hide();
// change icône
this.textContent = "?";
this.style.background = "#e74c3c";
return;
}
// récupérer le numParcours depuis la 1re cellule
const raw = row.data()?.[0] ?? "";
const tmp = document.createElement('div');
tmp.innerHTML = String(raw);
const numParcours = tmp.textContent.replace("?", "").trim();
if (!numParcours) return;
row.child('<div style="padding:12px 16px; font-style:italic;">Chargement des détails…</div>').show();
this.textContent = "?";
this.style.background = "#c0392b";
fetch(`/historiqueParcours/details/${encodeURIComponent(numParcours)}`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(payload => {
if (!payload || typeof payload !== 'object') {
throw new Error("Réponse invalide");
}
if (!payload.valid) {
row.child(createMessageBox('error', 'Impossible de charger les détails',
'Une erreur est survenue lors du chargement des détails du parcours.'));
this.textContent = "?";
this.style.background = "#e74c3c";
return;
}
row.child(formatDetailsPanel(payload));
})
.catch(() => {
row.child(createMessageBox('error', 'Erreur de chargement',
'Une erreur réseau est survenue lors du chargement des détails.'));
this.textContent = "?";
this.style.background = "#e74c3c";
});
});
return table;
}
/* =========================
* Fonctions annexes importantes
* ========================= */
async function fetchUserDetails(matriculeUser) {
try {
const response = await fetch(`/user/read/matricule/${matriculeUser}`);
const data = await response.json();
return data.valid ? data : null;
} catch (error) {
displayError(`Erreur lors de la récupération du contrat avec le matricule ${matriculeUser} :`, error);
return null;
}
}
async function generateProject(numParcours, produit) {
try {
if (!numParcours || !produit) {
displayError("Paramètres manquants pour la génération du projet.");
return;
}
const response = await fetch(`/generate/${produit}/projet/${encodeURIComponent(numParcours)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("Erreur réseau ou serveur");
const disposition = response.headers.get("content-disposition");
let filename = "projet.docx";
if (disposition) {
const parts = disposition.split(";");
if (parts.length > 1) {
const filenamePart = parts[1].trim();
if (filenamePart.startsWith("filename=")) {
filename = filenamePart.split("=")[1]?.replace(/"/g, "") || filename;
}
}
}
const blob = await response.blob();
if (!blob || blob.size === 0) {
throw new Error("Fichier vide reçu");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
} catch (error) {
displayError("Erreur lors de la génération du projet : " + (error?.message || "Erreur inconnue"));
}
}
// ---------- Helpers rendu ----------
// Les fonctions kv, fmtDate, gridWrap2cols, debounce, parseJwt et displayError sont dans global.js
//formatDetailsPanel : rend l'HTML pour la récuperation des données par parcours
function formatDetailsPanel(payload) {
const prodKey = (payload.produit || "").toUpperCase();
const prod = payload.produitRecord || null;
const contrat = payload.contrat || null;
let body = "";
if (prodKey === "TPPC") body = sectionTPPC(prod, contrat);
else if (prodKey === "RC") body = sectionRC(prod, contrat);
else if (prodKey === "FAC") body = sectionFAC(prod, contrat);
else body = createMessageBox('warn', 'Produit non renseigné', 'Le type de produit n\'a pas été spécifié pour ce parcours.');
return `
<div class="parcours-details" style="padding:14px 16px; background:#fafafa; border-top:1px solid #e0e0e0;">
<div style="font-weight:600; margin:0 0 8px;">Détails ${prodKey || ""}</div>
${body}
</div>
`;
}
// Retourne le tarif en fonction du type de tarif
function buildTarifBlock({ tarifRef, ht, ttc }) {
if (tarifRef && String(tarifRef).trim() !== '') {
return kv("Tarif de référence", tarifRef);
}
const htStr = (ht !== undefined && ht !== null) ? formatEuro(ht) : "NC";
const ttcStr = (ttc !== undefined && ttc !== null) ? formatEuro(ttc) : "NC";
return kv("Tarif commercial HT / TTC", `${htStr} / ${ttcStr}`);
}
function safeCA(value) {
return (value === null || value === undefined || value === '' || value === 'NC')
? "NC"
: formatEuro(value);
}
// ---------- Sections produit ----------
//sectionTTPC : récupere les détails d'un parcours TPPC pour le mettre en forme (2 colonnes)
// ---------- TPPC ----------
function sectionTPPC(produit, contrat) {
if (!produit) {
if (contrat) {
return createMessageBox(
'info',
'Informations non disponibles',
'Les informations sur ce Parcours TPPC ne sont pas encore disponibles.<br>La fiche TPPC n\'a pas encore été créée pour ce parcours.'
);
}
return createMessageBox(
'error',
'Fiche TPPC introuvable',
'Impossible de récupérer les informations de la fiche TPPC pour ce parcours.'
);
}
const tarif = produit?.["@expand"]?.tarif || null; // tppctarif
const projet = produit?.["@expand"]?.projet || null; // tppcprojet
// 1) CA
const caStr = safeCA(produit.ca);
// 2) Type de cotisation
let typeCotStr = "NC";
if (projet?.typeCot) {
typeCotStr = (projet.typeCot === "revisable") ? "Révisable"
: (projet.typeCot === "forfaitaire") ? "Forfaitaire"
: projet.typeCot;
}
// 3) Activité
const activiteAssuree = produit.actAssuree || "NC";
const nbVehicules = (produit.nbVehic !== undefined && produit.nbVehic !== null) ? String(produit.nbVehic) : "NC";
// 4) Marchandises / Garanties
const garanties = Array.isArray(produit.garanties)
? produit.garanties.join(", ")
: (produit.garanties || "NC");
const extensions = [];
if (produit.marCiternes) extensions.push("Citernes");
if (produit.marDenreesSousTemp) extensions.push("Denrées sous température");
if (produit.marAnimaux) extensions.push("Animaux vivants");
if (produit.marFranchise) extensions.push("Franchise");
const extensionsStr = extensions.length > 0 ? extensions.join(", ") : "Aucune";
// 5) Zones (supprimer pour TPPC)
const zonesStr = "NC";
// 6) Dates (projet)
const dateEffet = projet?.dateEffet ? fmtDate(projet.dateEffet, false) : "NC";
const dateEcheance = projet?.dateEcheance ? fmtDate(projet.dateEcheance, false) : "NC";
const dateFin = projet?.dateFin ? fmtDate(projet.dateFin, false) : "NC";
const datesStr = `Effet: ${dateEffet} / Échéance: ${dateEcheance} / Fin: ${dateFin}`;
// 7) Tarif (ref ou com HT/TTC)
const blocTarif = buildTarifBlock({
tarifRef: tarif?.tarifRef,
ht: (produit.primeHT ?? produit.cotTotalHT ?? null),
ttc: (produit.primeTTC ?? produit.cotTotalTTC ?? null)
});
// Disposition en 2 colonnes
const gauche = [
kv("Chiffre d'affaires", caStr),
kv("Type de cotisation", typeCotStr),
kv("Activité assurée", activiteAssuree),
kv("Nombre de véhicules", nbVehicules),
kv("Garanties", garanties),
kv("Extensions de garanties", extensionsStr),
].join("");
const droite = [
kv("Zones", zonesStr),
kv("Dates", datesStr),
blocTarif,
].join("");
return gridWrap2cols(gauche, droite);
}
//sectionRC : récupere les détails d'un parcours RC pour le mettre en forme (2 colonnes)
// ---------- RC ----------
function sectionRC(produit, contrat) {
if (!produit && contrat) {
return createMessageBox(
'info',
'Informations non disponibles',
'Les informations sur ce Parcours RC ne sont pas encore disponibles.<br>La fiche RC n\'a pas encore été créée pour ce parcours.'
);
}
if (!produit) {
return createMessageBox(
'dev',
'Fonctionnalité en cours de développement',
'L\'affichage des détails pour les parcours RC n\'est pas encore disponible.<br>Cette fonctionnalité sera bientôt implémentée.'
);
}
// 1) CA
const caStr = safeCA(produit.ca);
// 2) Type de cotisation
let typeCotStr = "NC";
if (produit.typeCot) {
typeCotStr = (produit.typeCot === "revisable") ? "Révisable"
: (produit.typeCot === "forfaitaire") ? "Forfaitaire"
: produit.typeCot;
}
// 3) Activités
const activites = [];
if (produit.actVoiturier) activites.push("Voiturier");
if (produit.actLoueur) activites.push("Loueur");
if (produit.actMultimodal) activites.push("Multimodal");
if (produit.actDouane) activites.push("Douane");
if (produit.actLevageur) activites.push("Levageur");
if (produit.actTransitaire) activites.push("Transitaire");
const activiteAssuree = activites.length > 0 ? activites.join(", ") : "NC";
// 4) Marchandises
const garanties = [];
if (produit.marRoulant) garanties.push("Roulant");
if (produit.marEngins) garanties.push("Engins");
if (produit.marPerissable) garanties.push("Périssable");
if (produit.marOrdinaire) garanties.push("Ordinaire");
if (produit.marAnimaux) garanties.push("Animaux");
if (produit.marCiterne)garanties.push("Citerne");
if (produit.marBeton) garanties.push("Béton");
if (produit.marExceptionnels) garanties.push("Exceptionnels");
if (produit.marMobilerUsag) garanties.push("Mobilier usagé");
if (produit.marVrac) garanties.push("Vrac");
if (produit.marRoulantDem) garanties.push("Roulant déménagement");
const garantiesStr = garanties.length > 0 ? garanties.join(", ") : "NC";
// 5) Zones
const zones = [];
if (produit.zone1) zones.push("1");
if (produit.zone2) zones.push("2");
if (produit.zone3) zones.push("3");
if (produit.zone4) zones.push("4");
if (produit.zone5) zones.push("5");
if (produit.zone6) zones.push("6");
const zonesStr = zones.length > 0 ? zones.join(", ") : "NC";
// 6) Dates
const dateEffet = produit.dateEffet ? fmtDate(produit.dateEffet, false) : "NC";
const dateEcheance = produit.dateEcheance ? fmtDate(produit.dateEcheance, false) : "NC";
const dateFin = produit.dateFin ? fmtDate(produit.dateFin, false) : "NC";
const datesStr = `Effet: ${dateEffet} / Échéance: ${dateEcheance} / Fin: ${dateFin}`;
// 7) Tarif
const tarif = produit?.["@expand"]?.tarif || null;
const blocTarif = buildTarifBlock({
tarifRef: tarif?.tarifRef,
ht: produit.cotTotalHT ?? null,
ttc: produit.cotTotalTTC ?? null
});
const gauche = [
kv("Chiffre d'affaires", caStr),
kv("Type de cotisation", typeCotStr),
kv("Activités assurées", activiteAssuree),
kv("Marchandises", garantiesStr),
].join("");
const droite = [
kv("Zones", zonesStr),
kv("Dates", datesStr),
blocTarif,
].join("");
return gridWrap2cols(gauche, droite);
}
//sectionFAC : récupere les détails d'un parcours FAC pour le mettre en forme (2 colonnes)
// ---------- FAC ----------
function sectionFAC(produit, contrat) {
if (!produit && contrat) {
return createMessageBox(
'info',
'Informations non disponibles',
'Les informations sur ce Parcours FAC ne sont pas encore disponibles.<br>La fiche FAC n\'a pas encore été créée pour ce parcours.'
);
}
if (!produit) {
return createMessageBox(
'dev',
'Fonctionnalité en cours de développement',
'L\'affichage des détails pour les parcours FAC n\'est pas encore disponible.<br>Cette fonctionnalité sera bientôt implémentée.'
);
}
// 1) CA
const caStr = safeCA(produit.ca);
// 2) Type de cotisation
const typeCotStr = "NC";
// 3) Activité
const activiteAssuree = produit.actAssure || "NC";
// 4) Garanties (modes de transport déclarés)
const garanties = [];
if (produit.terrestre && produit.terrestre !== "NC" && produit.terrestre !== "") garanties.push("Terrestre");
if (produit.maritime && produit.maritime !== "NC" && produit.maritime !== "") garanties.push("Maritime");
if (produit.aerien && produit.aerien !== "NC" && produit.aerien !== "") garanties.push("Aérien");
if (produit.postal && produit.postal !== "NC" && produit.postal !== "") garanties.push("Postal");
if (produit.fluvial && produit.fluvial !== "NC" && produit.fluvial !== "") garanties.push("Fluvial");
const garantiesStr = garanties.length > 0 ? garanties.join(", ") : "NC";
// 5) Zones
const zones = [];
if (produit.zone1) zones.push("1");
if (produit.zone2) zones.push("2");
if (produit.zone3) zones.push("3");
if (produit.zone4) zones.push("4");
if (produit.zone5) zones.push("5");
if (produit.zone6) zones.push("6");
const zonesStr = zones.length > 0 ? zones.join(", ") : "NC";
// 6) Dates
const dateEffet = produit.dateEffet ? fmtDate(produit.dateEffet, false) : "NC";
const dateEcheance = produit.dateEcheance ? fmtDate(produit.dateEcheance, false) : "NC";
const dateFin = produit.dateFin ? fmtDate(produit.dateFin, false) : "NC";
const datesStr = `Effet: ${dateEffet} / Échéance: ${dateEcheance} / Fin: ${dateFin}`;
// 7) Tarif
const tarif = produit?.["@expand"]?.tarif || null;
const blocTarif = buildTarifBlock({
tarifRef: tarif?.tarifRef,
ht: produit.cotAnnuelleHT ?? null,
ttc: produit.cotAnnuelleTTC ?? null
});
const gauche = [
kv("Chiffre d'affaires", caStr),
kv("Type de cotisation", typeCotStr),
kv("Activité assurée", activiteAssuree),
kv("Garanties", garantiesStr),
].join("");
const droite = [
kv("Zones", zonesStr),
kv("Dates", datesStr),
blocTarif,
].join("");
return gridWrap2cols(gauche, droite);
}

View File

@ -1,765 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Migration PocketBase 0.7.5 Éclatement des parcours RC et FAC en 3 collections
CONTEXTE
--------
Le parcours TPPC a déjà été éclaté en 3 collections :
tppc (principale) / tppcprojet / tppctarif
Les parcours RC et FAC sont encore "à plat" : tout est dans une seule collection
(`rc` = 87 champs, `fac` = 51 champs). Le nouveau code front/back attend désormais :
RC : rc (principale) + projetRC + tarifRC (relations rc.projetRC / rc.tarifRC)
FAC : fac (principale) + facprojet + factarif (relations fac.projet / fac.tarif)
Ce script :
1. CRÉE les 4 collections manquantes : projetRC, tarifRC, facprojet, factarif
(ligne _collections + table SQLite + index `<col>_created_idx`, à l'identique
de la façon dont PocketBase 0.7.5 les crée modèle copié sur tppc*).
2. AJOUTE à `rc` et `fac` les colonnes/relations manquantes attendues par le code.
3. MIGRE toutes les données existantes (TOUS les parcours) en répartissant
les champs vers la bonne collection.
PRINCIPE DE FIABILITÉ : migration 100 % NON DESTRUCTIVE.
- On AJOUTE des colonnes, on ne supprime ni ne renomme jamais une colonne existante.
- On COPIE les données (les anciennes valeurs restent dans `rc`/`fac` en secours).
- Les `id` de `rc`/`fac` ne changent pas les relations `contrat.rc` / `contrat.fac`
restent valides.
- Tout s'exécute dans UNE transaction SQLite (atomique : tout ou rien).
- Idempotent : relançable sans risque (les enregistrements déjà migrés sont ignorés).
- Backup automatique du fichier data.db avant toute écriture.
UTILISATION
-----------
ARRÊTER PocketBase avant (accès exclusif au fichier), puis le redémarrer après.
python3 migrate_split_rc_fac.py --db /chemin/vers/pb_data/data.db # migration
python3 migrate_split_rc_fac.py --db .../data.db --dry-run # simulation
python3 migrate_split_rc_fac.py --db .../data.db --verify-only # vérif seule
python3 migrate_split_rc_fac.py --db .../data.db --yes # sans confirmation
Aucune dépendance externe (uniquement la bibliothèque standard Python 3).
"""
import argparse
import datetime
import json
import os
import random
import shutil
import sqlite3
import sys
ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
# ─────────────────────────────────────────────────────────────────────────────
# SPÉCIFICATION DES SCHÉMAS
# ─────────────────────────────────────────────────────────────────────────────
# Chaque champ = (nom, type) où type ∈ {
# "bool", "number", "text", "json",
# ("select", maxSelect, [valeurs]),
# ("rel", "nomCollectionCible"),
# }
# Type SQLite de la colonne backing (identique à ce que crée PocketBase) :
# bool→Boolean DEFAULT FALSE | number→REAL DEFAULT 0 |
# text/select/rel→TEXT DEFAULT '' | json→JSON DEFAULT NULL
# ── projetRC : niveau "projet" du RC ≈ ancien `rc` à plat (+ activités compl. JSON)
PROJET_RC_FIELDS = [
("assureAdditionnel", "json"),
("designationVehicule", "json"),
("grilleMultimodal", "json"),
("grilleTerrestre", "json"),
("grilleAerien", "json"),
("activitesVoiturier", "json"),
("activitesCommissionnaire", "json"),
("activitesDemenageur", "json"),
("activitesLogistique", "json"),
# Activités (chip) + capital "Nous consulter" possible → text
("actVoiturier", "bool"), ("valueActVoiturier", "text"),
("actLoueur", "bool"), ("valueActLoueur", "text"),
("actMultimodal", "bool"), ("valueActMultimodal", "text"),
("actDouane", "bool"), ("valueActDouane", "text"),
("actDemPar", "bool"), ("valueActDemPar", "text"),
("actDemParDom", "bool"), ("valueActDemParDom", "text"),
("actDemParAdv", "bool"), ("valueActDemParAdv", "text"),
("actDemEntr", "bool"), ("valueActDemEntr", "text"),
("actDemInterne", "bool"), ("valueActDemInterne", "text"),
("actGardeMeuble", "bool"), ("valueActGardeMeuble", "text"),
("actEntDep", "bool"), ("valueActEntDep", "text"),
("actPrestaLog", "bool"), ("valueActPrestaLog", "text"),
("actLevageur", "bool"), ("valueActLevageur", "text"),
("actTransitaire", "bool"), ("valueActTransitaire", "text"),
# Marchandises
("marOrdinaire", "bool"), ("marRoulant", "bool"), ("marEngins", "bool"),
("marRoulantDem", "bool"), ("marMobilerUsag", "bool"), ("marPerissable", "bool"),
("marAnimaux", "bool"), ("marCiterne", "bool"), ("marBeton", "bool"),
("marExceptionnels", "bool"), ("marVrac", "bool"),
# Zones
("zone1", "bool"), ("zone2", "bool"), ("zone3", "bool"),
("zone4", "bool"), ("zone5", "bool"), ("zone6", "bool"),
# Extensions RCC / RCE
("extRCCModifCalArrim", "bool"), ("extRCCFerroutage", "bool"),
("extRCCFraisRecons", "bool"), ("extRCCConfie", "bool"),
("typeExtConfies", "text"), ("extRCCTPPC", "bool"), ("extRCCRegie", "bool"),
("extRCCSansMontageDemontage", "bool"),
("autresRC", "bool"), ("extRCEBraDebra", "bool"), ("extRCEMontageDemontage", "bool"),
# Temporalités
("tempo", "text"), ("dateEffet", "text"), ("dateEcheance", "text"), ("dateFin", "text"),
("pj", "bool"), ("programmeInternationale", "bool"), ("participationResultat", "bool"),
# Cotisations (peuvent valoir "Nous consulter" → text)
("typeCot", "text"), ("ca", "text"), ("cotIrreductible", "text"),
("tauxRCCHT", "text"), ("tauxRCCTTC", "text"), ("tauxRCEHT", "text"), ("tauxRCETTC", "text"),
("tauxTotalHT", "text"), ("tauxTotalTTC", "text"),
("cotRCCHT", "text"), ("cotRCCTTC", "text"), ("cotRCEHT", "text"), ("cotRCETTC", "text"),
("cotPJHT", "text"), ("cotPJTTC", "text"), ("cotTotalHT", "text"), ("cotTotalTTC", "text"),
("cotFraisHT", "text"), ("cotFraisTTC", "text"),
]
# ── tarifRC : niveau "tarif" du RC (chiffrage calculé). Aucune source ancienne.
TARIF_RC_FIELDS = [
("sinistre", "number"),
("pourcentageVoiturier", "number"), ("isSetVoiturier", "bool"),
("pourcentageCommissionnaire", "number"), ("isSetCommissionnaire", "bool"),
("pourcentageDemenageur", "number"), ("isSetDemenageur", "bool"),
("pourcentageLogistique", "number"), ("isSetLogistique", "bool"),
("pourcentageAutocariste", "number"), ("isSetAutocariste", "bool"),
("pourcentageAutres", "number"), ("isSetAutres", "bool"),
("primeRCC_250", "number"), ("primeRCE_250", "number"), ("primePJ_250", "number"),
("primeTotal_250", "number"), ("tauxRCC_250", "number"), ("tauxRCE_250", "number"),
("tauxGlobal_250", "number"),
("primeRCC_400", "number"), ("primeRCE_400", "number"), ("primePJ_400", "number"),
("primeTotal_400", "number"), ("tauxRCC_400", "number"), ("tauxRCE_400", "number"),
("tauxGlobal_400", "number"),
("primeRCC_2000", "number"), ("primeRCE_2000", "number"), ("primePJ_2000", "number"),
("primeTotal_2000", "number"), ("tauxRCC_2000", "number"), ("tauxRCE_2000", "number"),
("tauxGlobal_2000", "number"),
("franchiseChoisie", "text"),
("checkDomImmat", "bool"), ("capitalDomImmat", "number"),
("checkContConf", "bool"), ("capitalContConf", "number"),
("checkDiffInv", "bool"), ("capitalDiffInv", "number"),
("checkStationLavage", "bool"), ("checkGarageInterne", "bool"), ("checkCSE", "bool"),
("checkTPPC", "bool"), ("capitalTPPC", "number"), ("vehiculesTPPC", "number"),
("checkPJ", "bool"),
("tarifcommercial", "number"),
]
# ── Colonnes à AJOUTER à la collection `rc` (principale). zone1-6 existent déjà.
RC_MAIN_ADD = [
("tarifRC", ("rel", "tarifRC")),
("projetRC", ("rel", "projetRC")),
("typeCotisation", "text"),
("chiffreAffaires", "number"),
("nombreVehicules", "number"),
("checkRCE", "bool"),
("checkVoiturier", "bool"), ("capitalVoiturier", "number"),
("checkCommissionnaire", "bool"), ("capitalCommissionnaire", "number"),
("checkDemenageur", "bool"), ("capitalDemenageur", "number"),
("checkLogistique", "bool"), ("capitalLogistique", "number"),
("checkAutocariste", "bool"), ("capitalAutocariste", "number"),
("checkAutres", "bool"), ("capitalAutres", "number"),
("actComplVoiturier", "json"), ("actComplCommissionnaire", "json"),
("actComplDemenageur", "json"), ("actComplLogistique", "json"),
("marchandisesVoiturier", "json"), ("marchandisesCommissionnaire", "json"),
("marchandisesDemenageur", "json"), ("marchandisesLogistique", "json"),
("marchandisesAutocariste", "json"), ("marchandisesAutres", "json"),
("commentaire", "text"),
]
# ── facprojet : niveau "projet" du FAC ≈ champs "projet" de l'ancien `fac`.
FAC_PROJET_FIELDS = [
("assureAdditionnel", "json"),
("valeurAssureeBase", "json"), # multi-select → tableau JSON
("risqueTransport", "json"), # multi-select → tableau JSON
("garOpt", "json"), # multi-select → tableau JSON
("valeurAssuree", "text"), # single-select → string
("typeTPPC", "text"),
("tempo", "text"),
("typeContrat", "text"),
("dateEffet", "text"), ("dateEcheance", "text"), ("dateFin", "text"),
("lieuDepart", "text"), ("lieuArrivee", "text"),
("coassurance", "bool"), ("programmeInternational", "bool"),
("participationResultat", "bool"),
# Capitaux/cotisations : text (le code compare franchiseTransport !== "" et rend brut)
("capitalMax", "text"), ("capitalColis", "text"), ("capitalExped", "text"),
("franchiseTransport", "text"), ("cotAnnuelleTTC", "text"), ("cotComptant", "text"),
]
# ── factarif : niveau "tarif" du FAC (chiffrage calculé). Aucune source ancienne.
FAC_TARIF_FIELDS = [
("fluxAchats", "json"), ("fluxVentes", "json"),
("franchise350", "json"), ("franchise750", "json"), ("sansFranchise", "json"),
("asIf", "json"),
("fluxIntersites", "bool"),
("typePolice", "text"), ("typeRO", "text"), ("conditionnement", "text"),
("oldFranchise", "text"), ("sinistres", "text"), ("nbVehicTPPC", "text"),
("typeFlux", "text"), ("montantGarantir", "text"), ("engagementRG", "text"),
("selectedFranchise", "text"), ("typeMarExpo", "text"), ("commentaire", "text"),
("zone", "text"), ("transport", "text"),
]
# Champs JSON dont la valeur est un OBJET (et non un tableau) → vide = "{}".
# Tous les autres champs JSON sont des tableaux → vide = "[]".
# (Le code de génération fait .includes()/.forEach()/.proposition sans garde : on
# garantit donc toujours une valeur JSON valide, jamais NULL, pour éviter tout crash.)
OBJECT_JSON_FIELDS = {"fluxAchats", "fluxVentes", "franchise350", "franchise750", "sansFranchise"}
def json_empty(fname):
return "{}" if fname in OBJECT_JSON_FIELDS else "[]"
# ── Colonnes à AJOUTER à la collection `fac` (principale).
FAC_MAIN_ADD = [
("projet", ("rel", "facprojet")),
("tarif", ("rel", "factarif")),
("nbVehicExpo", "text"),
("actAssuree", "text"),
("typeRG", "text"),
("multimodal", "text"),
("rg", "text"),
("primeHT", "text"),
("primeMini", "text"),
("zones", "json"),
("tppc", "bool"),
]
# Renommages FAC (ancien champ `fac` → nouveau champ `fac` principal). Copie, non destructif.
FAC_MAIN_RENAME = {
"actAssuree": "actAssure",
"nbVehicExpo": "marExpo",
"typeRG": "typeGarantieRG",
"rg": "risqueGuerre",
"primeHT": "cotAnnuelleHT",
"primeMini": "cotIrred",
}
# Mapping "secours" RC principal ← ancien `rc` (d'après FIELD_MAPPING de rc-data-manager.js)
RC_MAIN_FILL = {
"chiffreAffaires": "ca",
"typeCotisation": "typeCot",
"checkRCE": "autresRC",
"checkVoiturier": "actVoiturier", "capitalVoiturier": "valueActVoiturier",
"checkCommissionnaire": "actMultimodal", "capitalCommissionnaire": "valueActMultimodal",
"checkDemenageur": "actDemEntr", "capitalDemenageur": "valueActDemEntr",
"checkLogistique": "actPrestaLog", "capitalLogistique": "valueActPrestaLog",
}
# ─────────────────────────────────────────────────────────────────────────────
# Outils bas niveau
# ─────────────────────────────────────────────────────────────────────────────
def now_pb():
"""Horodatage au format PocketBase : 'YYYY-MM-DD HH:MM:SS.mmm'."""
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.") + \
f"{datetime.datetime.now().microsecond // 1000:03d}"
def rand_id(n=15):
return "".join(random.choices(ALPHABET, k=n))
def sqlite_coltype(ftype):
if ftype == "bool":
return "Boolean DEFAULT FALSE"
if ftype == "number":
return "REAL DEFAULT 0"
if ftype == "json":
return "JSON DEFAULT NULL"
# text, select, rel
return "TEXT DEFAULT ''"
def pb_field_options(ftype, resolve_collection):
"""Bloc `options` au format PocketBase selon le type."""
if ftype == "bool":
return {}
if ftype == "number":
return {"min": None, "max": None}
if ftype == "json":
return {}
if ftype == "text":
return {"min": None, "max": None, "pattern": ""}
if isinstance(ftype, tuple) and ftype[0] == "select":
return {"maxSelect": ftype[1], "values": ftype[2]}
if isinstance(ftype, tuple) and ftype[0] == "rel":
return {"maxSelect": 1, "collectionId": resolve_collection(ftype[1]),
"cascadeDelete": False}
raise ValueError(f"type inconnu: {ftype}")
def base_ftype(ftype):
"""Type 'racine' pour la colonne SQLite."""
if isinstance(ftype, tuple):
return ftype[0] # 'select' ou 'rel' → TEXT
return ftype
def build_schema_json(fields, resolve_collection):
out = []
for name, ftype in fields:
bt = base_ftype(ftype)
pb_type = {"select": "select", "rel": "relation"}.get(bt, bt)
out.append({
"system": False,
"id": rand_id(8),
"name": name,
"type": pb_type,
"required": False,
"unique": False,
"options": pb_field_options(ftype, resolve_collection),
})
return out
# ─────────────────────────────────────────────────────────────────────────────
# Accès schéma DB
# ─────────────────────────────────────────────────────────────────────────────
def table_columns(cur, table):
cur.execute(f"PRAGMA table_info(`{table}`)")
return [r[1] for r in cur.fetchall()]
def collection_id(cur, name):
cur.execute("SELECT id FROM _collections WHERE name=?", (name,))
r = cur.fetchone()
return r[0] if r else None
def collection_exists(cur, name):
return collection_id(cur, name) is not None
# ─────────────────────────────────────────────────────────────────────────────
# Création de collection (ligne _collections + table + index)
# ─────────────────────────────────────────────────────────────────────────────
def create_collection(cur, name, fields, resolve_collection, log):
if collection_exists(cur, name):
log(f" • collection '{name}' existe déjà → ignorée")
return collection_id(cur, name)
cid = rand_id(15)
# garantir l'unicité de l'id de collection
cur.execute("SELECT id FROM _collections")
existing = {r[0] for r in cur.fetchall()}
while cid in existing:
cid = rand_id(15)
schema = build_schema_json(fields, resolve_collection)
ts = now_pb()
cur.execute(
"INSERT INTO _collections "
"(id, name, system, listRule, viewRule, createRule, updateRule, deleteRule, schema, created, updated) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(cid, name, 0, None, None, None, None, None, json.dumps(schema), ts, ts),
)
cols_sql = ["`id` TEXT PRIMARY KEY",
"`created` TEXT DEFAULT \"\" NOT NULL",
"`updated` TEXT DEFAULT \"\" NOT NULL"]
for fname, ftype in fields:
cols_sql.append(f'"{fname}" {sqlite_coltype(base_ftype(ftype))}')
cur.execute(f"CREATE TABLE `{name}` (" + ", ".join(cols_sql) + ")")
cur.execute(f"CREATE INDEX `{name}_created_idx` ON `{name}` (`created`)")
log(f" ✓ collection '{name}' créée (id={cid}, {len(fields)} champs)")
return cid
def add_columns(cur, table, add_fields, resolve_collection, log):
"""Ajoute des champs à une collection existante (schema JSON + ALTER TABLE)."""
cid = collection_id(cur, table)
cur.execute("SELECT schema FROM _collections WHERE id=?", (cid,))
schema = json.loads(cur.fetchone()[0])
existing_field_names = {f["name"] for f in schema}
existing_cols = set(table_columns(cur, table))
added = []
for fname, ftype in add_fields:
# 1) ligne de schéma
if fname not in existing_field_names:
bt = base_ftype(ftype)
pb_type = {"select": "select", "rel": "relation"}.get(bt, bt)
schema.append({
"system": False, "id": rand_id(8), "name": fname, "type": pb_type,
"required": False, "unique": False,
"options": pb_field_options(ftype, resolve_collection),
})
# 2) colonne SQLite
if fname not in existing_cols:
cur.execute(f'ALTER TABLE `{table}` ADD COLUMN "{fname}" {sqlite_coltype(base_ftype(ftype))}')
added.append(fname)
cur.execute("UPDATE _collections SET schema=?, updated=? WHERE id=?",
(json.dumps(schema), now_pb(), cid))
if added:
log(f"'{table}' : {len(added)} colonnes ajoutées ({', '.join(added)})")
else:
log(f"'{table}' : aucune colonne à ajouter (déjà à jour)")
# ─────────────────────────────────────────────────────────────────────────────
# Conversion de valeurs pour la copie
# ─────────────────────────────────────────────────────────────────────────────
def num_to_text(v):
"""REAL → chaîne propre ('1000.0''1000', '12.5''12.5', None → '')."""
if v is None:
return ""
if isinstance(v, float):
return str(int(v)) if v.is_integer() else repr(v)
return str(v)
def default_for(ftype):
bt = base_ftype(ftype)
if bt == "bool":
return 0
if bt == "number":
return 0
if bt == "json":
return None
return "" # text / select / rel
def copy_value(fname, ftype, raw):
"""Valeur à écrire dans la collection cible en copiant `raw` (source) selon le type cible.
- JSON : on garantit toujours une valeur valide ('[]' tableau, '{}' objet) jamais NULL
car le code de génération fait .includes()/.forEach()/.proposition sans garde.
- text : un REAL source est converti en chaîne propre ('1000.0' '1000').
"""
bt = base_ftype(ftype)
if bt == "json":
if raw is None or raw == "":
return json_empty(fname)
return raw
if bt == "text":
if isinstance(raw, float):
return num_to_text(raw)
return raw if raw is not None else ""
# bool / number / select / rel
return raw if raw is not None else default_for(ftype)
# ─────────────────────────────────────────────────────────────────────────────
# Migration des données
# ─────────────────────────────────────────────────────────────────────────────
def insert_record(cur, table, values: dict):
"""Insère un enregistrement (ajoute id/created/updated)."""
rid = rand_id(15)
cur.execute(f"SELECT 1 FROM `{table}` WHERE id=?", (rid,))
while cur.fetchone():
rid = rand_id(15)
cur.execute(f"SELECT 1 FROM `{table}` WHERE id=?", (rid,))
ts = now_pb()
cols = ["id", "created", "updated"] + list(values.keys())
vals = [rid, ts, ts] + list(values.values())
ph = ",".join("?" * len(cols))
quoted = ",".join(f'"{c}"' for c in cols)
cur.execute(f'INSERT INTO `{table}` ({quoted}) VALUES ({ph})', vals)
return rid
def migrate_rc(cur, log, dry):
rc_cols = set(table_columns(cur, "rc"))
projet_field_types = dict(PROJET_RC_FIELDS)
projet_names = [n for n, _ in PROJET_RC_FIELDS]
cur.execute("SELECT * FROM rc")
rows = cur.fetchall()
colnames = [d[0] for d in cur.description]
idx = {c: i for i, c in enumerate(colnames)}
migrated = skipped = 0
for row in rows:
rc_id = row[idx["id"]]
# idempotence : déjà migré si projetRC renseigné
if "projetRC" in idx and row[idx["projetRC"]]:
skipped += 1
continue
# 1) projetRC : copie des champs projet depuis rc (mêmes noms)
projet_vals = {}
for fname in projet_names:
ftype = projet_field_types[fname]
raw = row[idx[fname]] if fname in rc_cols else None
projet_vals[fname] = copy_value(fname, ftype, raw)
# 2) tarifRC : enregistrement par défaut (aucune source ancienne)
tarif_vals = {n: default_for(t) for n, t in TARIF_RC_FIELDS}
# 3) rc principal : relations + remplissage "secours" + JSON vides → []
rc_update = {}
for fname, ftype in RC_MAIN_ADD:
if base_ftype(ftype) == "json":
rc_update[fname] = "[]" # jamais NULL (le front parse ces tableaux)
for dest, src in RC_MAIN_FILL.items():
if src in rc_cols:
rc_update[dest] = row[idx[src]]
if dry:
migrated += 1
continue
id_projet = insert_record(cur, "projetRC", projet_vals)
id_tarif = insert_record(cur, "tarifRC", tarif_vals)
rc_update["projetRC"] = id_projet
rc_update["tarifRC"] = id_tarif
sets = ", ".join(f'"{k}"=?' for k in rc_update)
cur.execute(f'UPDATE rc SET {sets}, updated=? WHERE id=?',
list(rc_update.values()) + [now_pb(), rc_id])
migrated += 1
log(f" RC : {migrated} migrés, {skipped} déjà à jour (total {len(rows)})")
return migrated, skipped
def migrate_fac(cur, log, dry):
fac_cols = set(table_columns(cur, "fac"))
projet_field_types = dict(FAC_PROJET_FIELDS)
projet_names = [n for n, _ in FAC_PROJET_FIELDS]
tarif_field_types = dict(FAC_TARIF_FIELDS)
cur.execute("SELECT * FROM fac")
rows = cur.fetchall()
colnames = [d[0] for d in cur.description]
idx = {c: i for i, c in enumerate(colnames)}
migrated = skipped = 0
for row in rows:
fac_id = row[idx["id"]]
if "projet" in idx and row[idx["projet"]]:
skipped += 1
continue
# 1) facprojet : champs projet (mêmes noms, conversion REAL→text si besoin)
projet_vals = {}
for fname in projet_names:
ftype = projet_field_types[fname]
raw = row[idx[fname]] if fname in fac_cols else None
projet_vals[fname] = copy_value(fname, ftype, raw)
# 2) factarif : défauts (JSON tableaux→[], objets→{} pour éviter tout crash)
tarif_vals = {n: (json_empty(n) if base_ftype(t) == "json" else default_for(t))
for n, t in FAC_TARIF_FIELDS}
# défaut bénin : évite que la génération du doc tarif (qui fait
# getSelectedFranchise(selectedFranchise).proposition sans garde) ne plante
# pour un ancien enregistrement dépourvu de chiffrage. Rendu vide mais valide.
tarif_vals["selectedFranchise"] = "sansFranchise"
# 3) fac principal : renommages + zones + relations + dérivation tppc
fac_update = {}
for dest, src in FAC_MAIN_RENAME.items():
if src in fac_cols:
raw = row[idx[src]]
# destinations text → convertir REAL en chaîne propre
fac_update[dest] = num_to_text(raw) if isinstance(raw, float) else (raw or "")
# zones JSON depuis zone1..zone6
zones = [z for z in ("zone1", "zone2", "zone3", "zone4", "zone5", "zone6")
if z in fac_cols and row[idx[z]]]
fac_update["zones"] = json.dumps(zones)
# tppc dérivé de l'ancien typeTPPC (rempli ⇒ TPPC actif)
if "typeTPPC" in fac_cols:
fac_update["tppc"] = 1 if (row[idx["typeTPPC"]] not in (None, "")) else 0
if dry:
migrated += 1
continue
id_projet = insert_record(cur, "facprojet", projet_vals)
id_tarif = insert_record(cur, "factarif", tarif_vals)
fac_update["projet"] = id_projet
fac_update["tarif"] = id_tarif
sets = ", ".join(f'"{k}"=?' for k in fac_update)
cur.execute(f'UPDATE fac SET {sets}, updated=? WHERE id=?',
list(fac_update.values()) + [now_pb(), fac_id])
migrated += 1
log(f" FAC : {migrated} migrés, {skipped} déjà à jour (total {len(rows)})")
return migrated, skipped
# ─────────────────────────────────────────────────────────────────────────────
# Vérification
# ─────────────────────────────────────────────────────────────────────────────
def verify(cur, log):
ok = True
def check(cond, msg):
nonlocal ok
log(("" if cond else "") + msg)
if not cond:
ok = False
# 1) collections présentes
for c in ("projetRC", "tarifRC", "facprojet", "factarif"):
check(collection_exists(cur, c), f"collection '{c}' présente")
cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (c,))
check(cur.fetchone() is not None, f"table SQLite '{c}' présente")
# 2) colonnes/relations ajoutées
rc_cols = set(table_columns(cur, "rc"))
check({"projetRC", "tarifRC"}.issubset(rc_cols), "rc possède projetRC & tarifRC")
fac_cols = set(table_columns(cur, "fac"))
check({"projet", "tarif", "zones"}.issubset(fac_cols), "fac possède projet, tarif, zones")
# 3) intégrité relationnelle + couverture
cur.execute("SELECT COUNT(*) FROM rc")
n_rc = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM rc WHERE projetRC!='' AND projetRC IS NOT NULL")
n_rc_mig = cur.fetchone()[0]
check(n_rc == n_rc_mig, f"tous les rc ont un projetRC ({n_rc_mig}/{n_rc})")
cur.execute("SELECT COUNT(*) FROM rc r LEFT JOIN projetRC p ON r.projetRC=p.id "
"WHERE r.projetRC!='' AND p.id IS NULL")
check(cur.fetchone()[0] == 0, "toutes les relations rc.projetRC résolvent")
cur.execute("SELECT COUNT(*) FROM rc r LEFT JOIN tarifRC t ON r.tarifRC=t.id "
"WHERE r.tarifRC!='' AND t.id IS NULL")
check(cur.fetchone()[0] == 0, "toutes les relations rc.tarifRC résolvent")
cur.execute("SELECT COUNT(*) FROM fac")
n_fac = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM fac WHERE projet!='' AND projet IS NOT NULL")
n_fac_mig = cur.fetchone()[0]
check(n_fac == n_fac_mig, f"tous les fac ont un facprojet ({n_fac_mig}/{n_fac})")
cur.execute("SELECT COUNT(*) FROM fac f LEFT JOIN facprojet p ON f.projet=p.id "
"WHERE f.projet!='' AND p.id IS NULL")
check(cur.fetchone()[0] == 0, "toutes les relations fac.projet résolvent")
cur.execute("SELECT COUNT(*) FROM fac f LEFT JOIN factarif t ON f.tarif=t.id "
"WHERE f.tarif!='' AND t.id IS NULL")
check(cur.fetchone()[0] == 0, "toutes les relations fac.tarif résolvent")
# 4) contrôle de copie : champs RC identiques entre rc et projetRC
cur.execute("""SELECT COUNT(*) FROM rc r JOIN projetRC p ON r.projetRC=p.id
WHERE r.actVoiturier IS NOT p.actVoiturier
OR IFNULL(r.dateEffet,'') != IFNULL(p.dateEffet,'')
OR r.zone1 IS NOT p.zone1""")
check(cur.fetchone()[0] == 0, "données RC copiées fidèlement (actVoiturier/dateEffet/zone1)")
# 5) contrôle FAC : facprojet récupère valeurAssuree & assureAdditionnel
cur.execute("""SELECT COUNT(*) FROM fac f JOIN facprojet p ON f.projet=p.id
WHERE IFNULL(f.valeurAssuree,'') != IFNULL(p.valeurAssuree,'')""")
check(cur.fetchone()[0] == 0, "données FAC projet copiées (valeurAssuree)")
# 6) zones JSON cohérentes avec les anciens booléens
cur.execute("""SELECT COUNT(*) FROM fac
WHERE (zone1 AND instr(IFNULL(zones,''),'zone1')=0)
OR (zone3 AND instr(IFNULL(zones,''),'zone3')=0)""")
check(cur.fetchone()[0] == 0, "fac.zones (JSON) cohérent avec zone1..6")
# 7) relations contrat intactes
cur.execute("SELECT COUNT(*) FROM contrat c LEFT JOIN rc r ON c.rc=r.id "
"WHERE c.rc!='' AND r.id IS NULL")
check(cur.fetchone()[0] == 0, "relations contrat.rc toujours valides")
cur.execute("SELECT COUNT(*) FROM contrat c LEFT JOIN fac f ON c.fac=f.id "
"WHERE c.fac!='' AND f.id IS NULL")
check(cur.fetchone()[0] == 0, "relations contrat.fac toujours valides")
log("")
log(" ═══ VÉRIFICATION " + ("RÉUSSIE ✓" if ok else "ÉCHOUÉE ✗") + " ═══")
return ok
# ─────────────────────────────────────────────────────────────────────────────
# Orchestration
# ─────────────────────────────────────────────────────────────────────────────
def run(db_path, dry_run, verify_only, assume_yes):
if not os.path.isfile(db_path):
print(f"ERREUR : fichier introuvable : {db_path}", file=sys.stderr)
return 2
# garde-fou : WAL non checkpointé ⇒ PocketBase tourne peut-être encore
wal = db_path + "-wal"
if os.path.isfile(wal) and os.path.getsize(wal) > 0 and not verify_only:
print("⚠️ Un fichier -wal non vide est présent : PocketBase est-il bien ARRÊTÉ ?")
if not assume_yes and input(" Continuer quand même ? [o/N] ").strip().lower() not in ("o", "y"):
return 1
def log(m):
print(m)
log("" * 70)
log(" Migration RC/FAC → modèle éclaté (main / projet / tarif)")
log(f" Base : {db_path}")
log(f" Mode : {'VÉRIFICATION SEULE' if verify_only else ('SIMULATION (dry-run)' if dry_run else 'MIGRATION RÉELLE')}")
log("" * 70)
# backup
if not dry_run and not verify_only:
bak = db_path + ".bak-" + datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
shutil.copy2(db_path, bak)
log(f" ✓ backup : {bak}")
if not assume_yes:
if input(" Lancer la migration ? [o/N] ").strip().lower() not in ("o", "y"):
log(" Annulé.")
return 1
conn = sqlite3.connect(db_path)
conn.isolation_level = None # gestion manuelle de la transaction
cur = conn.cursor()
try:
if verify_only:
verify(cur, log)
conn.close()
return 0
cur.execute("BEGIN")
resolver = lambda name: collection_id(cur, name)
log("\n[1/4] Création des collections manquantes")
create_collection(cur, "projetRC", PROJET_RC_FIELDS, resolver, log)
create_collection(cur, "tarifRC", TARIF_RC_FIELDS, resolver, log)
create_collection(cur, "facprojet", FAC_PROJET_FIELDS, resolver, log)
create_collection(cur, "factarif", FAC_TARIF_FIELDS, resolver, log)
log("\n[2/4] Ajout des colonnes/relations à rc et fac")
add_columns(cur, "rc", RC_MAIN_ADD, resolver, log)
add_columns(cur, "fac", FAC_MAIN_ADD, resolver, log)
log("\n[3/4] Migration des données")
migrate_rc(cur, log, dry_run)
migrate_fac(cur, log, dry_run)
if dry_run:
log("\n[dry-run] Aucune modification écrite → ROLLBACK")
cur.execute("ROLLBACK")
conn.close()
return 0
cur.execute("COMMIT")
log("\n[4/4] Vérification post-migration")
ok = verify(cur, log)
cur.execute("PRAGMA wal_checkpoint(TRUNCATE)")
conn.close()
return 0 if ok else 3
except Exception as e:
try:
cur.execute("ROLLBACK")
except Exception:
pass
conn.close()
log(f"\n✗ ERREUR — transaction annulée (ROLLBACK), aucune modification appliquée :\n {e}")
import traceback
traceback.print_exc()
return 4
def main():
ap = argparse.ArgumentParser(description="Migration PocketBase : éclatement RC/FAC.")
ap.add_argument("--db", required=True, help="chemin vers pb_data/data.db")
ap.add_argument("--dry-run", action="store_true", help="simulation (rollback systématique)")
ap.add_argument("--verify-only", action="store_true", help="vérifie l'état sans rien modifier")
ap.add_argument("--yes", action="store_true", help="ne pas demander de confirmation")
args = ap.parse_args()
sys.exit(run(args.db, args.dry_run, args.verify_only, args.yes))
if __name__ == "__main__":
main()

View File

@ -1,647 +0,0 @@
// controllers/historiqueParcoursController.js
const express = require("express");
const router = express.Router();
const renderPage = require("../utils/renderHelper");
const logger = require("../utils/logger");
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) => {
renderPage("historiqueParcours.ejs", res);});
/**
* /regionUser : requête sur la region de l'user actuel
*/
router.get("/:regionUser", async (req, res) => {
try {
const { regionUser } = req.params;
const data = await parcoursService.getParcoursByRegionsPage([regionUser], 1, 10, { filter: "", sort: "-created" });
if (data) {
res.json({ valid: true, data });
} else {
res.json({ valid: false });
}
} catch (error) {
logger.log("error", error);
res.status(500).json({
valid: false,
error: "Erreur lors de la récupération des parcours.",
});
}
});
/**
* /datatable : DataTables server-side (gestion de pagination)
*/
router.post("/datatable", async (req, res) => {
try {
const {
draw = 1,
start = 0,
length = 10,
regions = [],
search = { value: "" },
columns = [],
order = []
} = req.body || {};
const page = Math.floor(start / length) + 1; // nb de page
const perPage = Number(length) || 10; //nb d'éléments par page
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) {
// Ignorer silencieusement les erreurs d'abort (requête annulée côté client)
if (error && (error.name === "AbortError" || error.name === "DOMException" || error.message?.includes("aborted"))) {
return;
}
// Pour toutes les autres erreurs, on les log et on renvoie une réponse d'erreur
logger.log("error", "ça ne marche pas mais, aucune erreur" + error);
if (!res.headersSent) {
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(" && ");
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 pocket)
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(" && ");
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 Pocket)
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 {
logger.log("warn", `Pas d'ID FAC dans contrat pour parcours ${numParcours}`);
}
}
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}`);
}
}
return res.json({
valid: true,
produit,
parcours,
contrat,
produitRecord,
});
}
catch (e) {
logger.log("error", e);
return res.status(500).json({ valid: false, error: "Erreur recherche détails" });
}
});
module.exports = router;

View File

@ -1,2 +0,0 @@
Pocketbase_0.7.5.exe serve --http="127.0.0.1:8091"
pause

Binary file not shown.

View File

@ -1,68 +0,0 @@
const { db } = require('../db/db-connect');
const logger = require('../utils/logger');
const globalService = require("../services/globalService");
async function createClient() {
return await db.records.create('client', {});
}
async function getClient(id) {
const criteria = {
filter: `id='${id}'`,
};
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
* avec 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(" || ");
// 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 = {
createClient,
getClient,
getClientsBatch,
};

View File

@ -1,146 +0,0 @@
const { db } = require("../db/db-connect");
const logger = require("../utils/logger");
const numeral = require("numeral");
require("numeral/locales/fr");
numeral.locale("fr");
async function getRecordIdFromFieldValue(collection, field, value) {
try {
const resultList = await db.records.getList(collection, 1, 1, {
filter: `${field} = '${value}'`,
});
if (resultList.totalItems > 0) {
return resultList.items[0].id;
} else {
return null;
}
} catch (error) {
logger.log("error", error);
return null;
}
}
async function fetchInfoByCriteria(collection, criteria) {
try {
const resultList = await db.records.getList(collection, 1, 1, criteria);
if (resultList.totalItems > 0) {
return resultList.items[0];
}
} catch (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;
}
async function updateRecordFromData(collection, recordID, data) {
try {
const record = await db.records.update(collection, recordID, data);
return record;
} catch (error) {
logger.log("error", error);
}
return null;
}
function customFormatNumber(number, decimal, taux=false) {
if (Number.isInteger(number)) {
if (decimal == true) {
return numeral(number).format("0,0.00");
} else {
return numeral(number).format("0,0");
}
} else {
if (taux == true) {
return numeral(number).format("0,0.000");
} else {
return numeral(number).format("0,0.00");
}
}
}
function cleanDoubleSpaces(inputString) {
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 = {
getRecordIdFromFieldValue,
fetchInfoByCriteria,
updateRecordFromData,
cleanDoubleSpaces,
customFormatNumber,
fmtDateFR,
xmlEsc,
cellXml,
rowXml,
};

View File

@ -1,278 +0,0 @@
// services/parcoursService.js
const { db } = require("../db/db-connect");
const logger = require("../utils/logger");
const globalService = require("../services/globalService");
/**
* Récupère un parcours par son numéro (avec expand utiles)
*/
async function getParcoursByNumParcours(numParcours) {
const criteria = {
filter: `numParcours='${numParcours}'`,
expand: [
"dernierUtilisateur.region",
"contrat",
"contrat.client",
"contrat.intermediaire"
].join(",")
};
return globalService.fetchInfoByCriteria("parcours", criteria);
}
/**
* Full list (batch côté PocketBase). | Fetch l'ensemble de la BD via chunk "batch"
* avec 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 (Pocketbase 0.7 rejette les filtres vides)
if (filter && filter.trim() !== "") {
options.filter = filter;
}
// 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 {
let regFilter = "";
if (Array.isArray(regions) && regions.length > 0) {
const ors = regions.map(r => `dernierUtilisateur.region.nom = "${r}"`);
regFilter = `(${ors.join(" || ")})`;
}
const filter = [regFilter, opts.filter].filter(Boolean).join(" && ");
/**
* Récupération des parcours avec expands nécessaires
* Note: L'expand de contrat.client ne fonctionne pas toujours,
* d'où la nécessité d'un fallback dans le contrôleur
*/
const list = await db.records.getList("parcours", page, perPage, {
sort: opts.sort || "-created",
filter: filter || "",
expand: [
"contrat",
"contrat.client",
"contrat.intermediaire",
"dernierUtilisateur.region"
].join(","),
});
return {
page: list.page,
perPage: list.perPage,
totalItems: list.totalItems,
totalPages: list.totalPages,
items: list.items,
};
}
catch (error) {
logger.log('error', error);
throw error;
}
}
/**
* Création d'un parcours vide
*/
async function createNewEmptyParcours(numParcours) {
try {
const data = { ["numParcours"]: numParcours };
const record = await db.records.create("parcours", data);
if (record) {
return record.id;
} else {
return null;
}
}
catch (error) {
logger.log("error", error);
return null;
}
}
/**
* MAJ d'un champ d'un parcours
*/
async function updateFieldValueParcours(id, field, value) {
try {
const data = { [field]: value };
const record = await db.records.update("parcours", id, data);
if (record) {
return record.id;
} else {
return null;
}
}
catch (error) {
logger.log("error", error);
return null;
}
}
/**
* Génère le prochain numéro de parcours
*/
async function getNewParcoursNumber() {
try {
const list = await db.records.getList("parcours", 1, 1, { sort: "-numParcours" });
const last = list?.items?.[0];
if (!last?.numParcours) return null;
const numericValue = parseInt(String(last.numParcours).substring(1), 10);
if (Number.isNaN(numericValue)) return null;
const next = numericValue + 1;
return "P" + next.toString().padStart(9, "0");
}
catch (error) {
logger.log("error", error);
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 = {
getNewParcoursNumber,
getParcoursByNumParcours,
createNewEmptyParcours,
updateFieldValueParcours,
getParcoursByRegionsPage,
getParcoursFullList,
getParcoursForDetails,
getProduitRecordForContrat,
getDeepDetailsByNumParcours,
mapProduitToCollection,
};

View File

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

View File

@ -1,53 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Outil souscripteur Transport">
<title>EasyTransport</title>
<!-- Variable pour complément spécifique HEAD -->
<%- typeof extraHeadContent !=='undefined' ? extraHeadContent : '' %>
<!-- Init CSS -->
<!-- Favicon: icône affichée dans l'onglet du navigateur -->
<link rel="icon" href="/images/favicon-axa.ico">
<!-- Import de la police Roboto avec différents poids pour une utilisation dans le site -->
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap">
<!-- Icônes Material Design : Bibliothèque d'icônes pour utilisation dans le site -->
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<!-- Normalisation du CSS : élimine les différences de style par défaut entre les navigateurs -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css">
<!-- Materialize CSS : Bibliothèque CSS pour des éléments de conception matérialisée -->
<link rel="stylesheet" href="/materialize/css/materialize.min.css">
<!-- Feuille de style globale : Styles CSS personnalisés pour ce projet -->
<link rel="stylesheet" href="/css/global.css">
<!-- DataTables CSS -->
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/2.0.8/css/dataTables.dataTables.css">
</head>
<body>
<main>
<%- include('partials/navbar') %>
<div class="container">
<%- typeof body !=='undefined' ? body : '' %>
</div>
<!-- Script de Materialize -->
<script src="/materialize/js/materialize.min.js"></script>
<!-- Script de decoding de token -->
<script src="/js/jwt-decode.js"></script>
<!-- Feuille de script globale : JS personnalisés pour ce projet -->
<script src="/js/global.js"></script>
<!-- Feuille de script pour les contrôle de saisie formulaire : JS personnalisés pour ce projet -->
<script src="/js/verif-form.js"></script>
<!-- Feuille de script pour les donnée static JSON de saisie formulaire -->
<script src="/js/json/json-verif-form.js"></script>
</main>
</body>
</html>

View File

View File

@ -6,8 +6,7 @@
"scripts": { "scripts": {
"start": "nodemon ./src/server.js", "start": "nodemon ./src/server.js",
"build": "pkg ./src/server.js -o EasyTransport", "build": "pkg ./src/server.js -o EasyTransport",
"test": "jest", "test": "jest"
"db" : "cd ./src/db && start cmd /c Lancement_Pocketbase.cmd"
}, },
"pkg": { "pkg": {
"assets": [ "assets": [

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;
} }
@ -444,4 +451,64 @@ a.grille-garanties:hover{
.brand-logo img { .brand-logo img {
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

@ -0,0 +1,208 @@
/* Historique Parcours */
body>main>div>div.section.center-align {
width: 100% !important;
}
body>main>div>div.section.center-align>div.container {
width: 100% !important;
}
#historiqueParcours_wrapper {
width: 100% !important;
overflow-x: auto !important;
margin-top: 30px;
}
table#historiqueParcours {
width: 150%;
border-collapse: collapse !important;
border: 2px solid darkblue !important;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
#historiqueParcours th,
#historiqueParcours td {
color: black;
text-align: left;
font-size: 11px;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
}
table.dataTable thead th {
padding-right: 18px !important;
position: relative !important;
}
/* Div pour encapsuler le texte dans les cellules d'en-tête */
table.dataTable thead th>div {
position: absolute !important;
top: 0 !important;
left: 0 !important;
}
#historiqueParcours_filter label {
display: flex;
align-items: center;
}
.dataTables_wrapper .dataTables_filter input[type="search"] {
background-color: transparent;
border: none;
border-bottom: 1px solid #26a69a;
border-radius: 0;
outline: none;
height: 3rem;
width: 100%;
font-size: 16px;
margin: 0 0 8px 0;
padding: 0;
box-shadow: none;
box-sizing: content-box;
transition: box-shadow .3s, border .3s, -webkit-box-shadow .3s;
}
#historiqueParcours_length>label {
font-size: 14px;
display: flex !important;
align-items: center !important;
}
.dataTables_length select[name="historiqueParcours_length"] {
display: block !important;
font-size: 14px;
color: #555;
padding: 8px;
margin: 0 0.5em;
border: none;
background: none;
padding: 5px;
font-size: 16px;
outline: none;
width: 60px;
}
/* Style Input search by row */
#historiqueParcours>thead>tr:nth-child(2)>th>input {
font-size: 13px !important;
padding: 6px !important;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
}
/* icone de tri sur les colonnes */
table.dataTable thead .sorting,
table.dataTable thead .sorting_asc,
table.dataTable thead .sorting_desc {
background-position: right center;
background-repeat: no-repeat;
}
/* Ajouter un espacement entre le texte et l'icône */
table.dataTable thead .sorting:before,
table.dataTable thead .sorting_asc:before,
table.dataTable thead .sorting_desc:before {
content: "";
}
/* boutons de navigationw */
.dataTables_wrapper .dataTables_paginate .paginate_button {
background-color: white !important;
border: darkblue solid 1.5px !important;
color: black !important;
padding: 6px 12px !important;
margin: 0 2px !important;
cursor: pointer !important;
border-radius: 4px !important;
transition: background-color 0.3s, color 0.3s, border-color 0.3s !important;
}
#historiqueParcours_paginate>span>a.paginate_button.current,
.dataTables_wrapper .dataTables_paginate:hover .paginate_button:hover {
background: none !important;
background-color: darkblue !important;
border-color: white !important;
color: white !important;
}
/* NC value */
td.nc-value {
color: lightgray !important;
}
/* Les bouton pour le filtres et les extraction */
#divBtnFilter {
display: inline-grid;
width: 100%;
grid-template-columns: auto;
column-gap: 3%;
row-gap: 10%;
}
#checkRegionAdmin {
grid-column: 1 / span 3;
justify-self: center;
}
#divToggleSearch {
width: 300px;
grid-column: 2;
grid-row: 3;
justify-self: center;
}
#divExtractAll {
grid-column: 1;
grid-row: 1;
justify-self: start;
}
#divExtractFilter {
grid-column: 3;
grid-row: 1;
justify-self: end;
}
#divBtnFilter button {
border-radius: 8px;
box-shadow: 0 2px 4px rgba(16, 0, 75, 0.2),
0 4px 8px rgba(16, 0, 75, 0.1);
}
/* Bouton Reprendre */
#btnReprendre,
#btnGenerate {
border: none;
color: white;
padding: 0px 15px;
font-size: 10px;
cursor: pointer;
border-radius: 8px;
}
#btnReprendre i {
margin: 0;
}
/* checkbox Filter Region Admin */
#checkRegionAdmin {
border: 1px solid #ccc;
padding: 10px;
display: flex;
flex-wrap: wrap;
margin-bottom: 10px;
display: none;
}
[class^="checkbox-wrapper-"] {
margin-right: 20px;
}
#checkRegionAdmin input[type="checkbox"] {
display: none;
visibility: hidden;
}
#checkRegionAdmin label {
display: inline-block;
}

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;
}

Some files were not shown because too many files have changed in this diff Show More