// 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,"&").replace(//g,">"); return ` ${numPacours} `; } }, { 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('
Chargement des détails…
').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 `
Détails ${prodKey || ""}
${body}
`; } // 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.
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.
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.
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.
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.
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); }