Robustesse (backup, export Excel, journal) + notes libres et relances

- Sauvegarde automatique du CSV avant chaque écriture (backups/, 30 versions)
- Export Excel .xlsx sans dépendance (module excel.py)
- Journal de scraping (scrape.log) + panneau et endpoints /api/logs
- Note libre par prospect (colonne Notes, zone de texte)
- Notification de relance in-app (statut Contacté/En discussion + délai configurable)
This commit is contained in:
jerem
2026-06-13 15:47:49 +02:00
parent 1cf427a0f2
commit 02180f1c7b
5 changed files with 317 additions and 9 deletions

View File

@@ -28,7 +28,12 @@
#formulaire { display: none; margin-top: 16px; }
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
.champ input, .champ select { width: 100%; }
.champ input, .champ select, .champ textarea { width: 100%; }
.grille .champ.large { grid-column: 1 / -1; }
.banniere-relance { background: #fff4e2; border: 1px solid #f0c987; color: #8a5a00; border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; }
.relance-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.chip-relance { background: #e67e22; color: #fff; border: none; border-radius: 99px; padding: 4px 11px; font-size: 12px; cursor: pointer; }
.badge-relance { background: #e67e22; color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; }
.actions { margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
.carte { border: 1px solid var(--bordure); border-radius: 8px; margin-bottom: 10px; background: #fff; }
.carte-tete { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 10px 12px; cursor: pointer; }
@@ -80,6 +85,10 @@
<label>Tarif péage (€ / km d'autoroute)</label>
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
</div>
<div class="champ">
<label>Délai de relance (jours sans réponse)</label>
<input id="cfg-relance" type="number" step="1" min="0" placeholder="7">
</div>
</div>
<p class="indice">Le péage est estimé à partir des kilomètres d'autoroute du trajet × ce tarif (≈ 0,10 €/km pour une voiture). Mettez 0 pour ne pas compter de péage.</p>
<div class="champ" style="margin-top:12px">
@@ -123,9 +132,13 @@
</section>
<section>
<div id="relances" class="banniere-relance" style="display:none"></div>
<div class="barre-liste">
<h2 style="margin:0">Liste des prospects <span id="compteur"></span></h2>
<a href="/api/export"><button type="button" class="secondaire">Télécharger le CSV</button></a>
<div style="display:flex;gap:8px">
<a href="/api/export"><button type="button" class="secondaire">CSV</button></a>
<a href="/api/export.xlsx"><button type="button" class="secondaire">Excel (.xlsx)</button></a>
</div>
</div>
<div class="filtres">
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
@@ -134,10 +147,25 @@
<div id="liste"></div>
</section>
<section>
<details id="journal-details">
<summary style="cursor:pointer;font-weight:600;font-size:16px">🩺 Journal de scraping</summary>
<p class="indice">Historique des analyses : utile pour comprendre pourquoi une page n'a rien donné (mur de connexion, lien non reconnu, redirection…).</p>
<div class="actions" style="margin-top:0">
<button type="button" class="secondaire" id="journal-rafraichir">Rafraîchir</button>
<button type="button" class="danger" id="journal-vider">Vider le journal</button>
</div>
<div id="journal" style="margin-top:12px"></div>
</details>
</section>
</main>
<script>
const COLONNES = ["Nom du prospect","Statut","Département","Ville","Code Postal","Adresse","Date d'ajout",
"Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook"];
"Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook","Notes"];
// Statuts encore « actifs » pour lesquels une relance a du sens.
const STATUTS_A_RELANCER = ["Contacté", "En discussion"];
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
const STATUTS = {
@@ -179,7 +207,9 @@ async function chargerConfig() {
$("cfg-conso").value = config.conso_l_100km ?? "";
$("cfg-prix").value = config.prix_carburant ?? "";
$("cfg-peage").value = config.cout_peage_km ?? "";
$("cfg-relance").value = config.delai_relance_jours ?? "";
$("cfg-message").value = config.modele_message || "";
afficherRelances();
}
$("cfg-enregistrer").addEventListener("click", async () => {
@@ -188,6 +218,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
conso_l_100km: $("cfg-conso").value,
prix_carburant: $("cfg-prix").value,
cout_peage_km: $("cfg-peage").value,
delai_relance_jours: $("cfg-relance").value,
modele_message: $("cfg-message").value,
};
const rep = await fetch("/api/config", {
@@ -198,6 +229,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
if (rep.ok) {
config = (await rep.json()).config;
message("Paramètres enregistrés ✔", "info", "message-config");
afficherRelances();
} else {
message("Échec de l'enregistrement.", "erreur", "message-config");
}
@@ -228,6 +260,12 @@ function champControle(col, valeur) {
}
return sel;
}
if (col === "Notes") {
const ta = document.createElement("textarea");
ta.name = col; ta.rows = 3; ta.value = valeur || "";
ta.placeholder = "Notes libres : compte-rendu d'appel, contraintes, disponibilités…";
return ta;
}
const input = document.createElement("input");
input.name = col;
input.value = valeur || "";
@@ -239,7 +277,7 @@ function construireGrilleChamps(grille, valeurs) {
grille.innerHTML = "";
for (const col of COLONNES) {
const div = document.createElement("div");
div.className = "champ";
div.className = "champ" + (col === "Notes" ? " large" : "");
const label = document.createElement("label");
label.textContent = col;
div.append(label, champControle(col, valeurs[col]));
@@ -293,13 +331,14 @@ $("analyser").addEventListener("click", async () => {
} finally {
bouton.disabled = false;
bouton.textContent = "Analyser";
rafraichirLogsSiOuvert();
}
});
$("formulaire").addEventListener("submit", async (e) => {
e.preventDefault();
const ligne = {};
for (const ctrl of $("grille-champs").querySelectorAll("input, select")) ligne[ctrl.name] = ctrl.value;
for (const ctrl of $("grille-champs").querySelectorAll("input, select, textarea")) ligne[ctrl.name] = ctrl.value;
const rep = await fetch("/api/prospects", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -358,6 +397,7 @@ $("importer").addEventListener("click", async () => {
}
message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot");
chargerListe();
rafraichirLogsSiOuvert();
} catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
} finally {
@@ -395,6 +435,7 @@ function remplirFiltreStatut() {
async function chargerListe() {
prospects = await (await fetch("/api/prospects")).json();
afficherListe();
afficherRelances();
}
function afficherListe() {
@@ -414,15 +455,67 @@ function afficherListe() {
for (const p of visibles) liste.append(carte(p));
}
// --- Relances ---
function parseDateFr(s) {
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec((s || "").trim());
return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null;
}
// Nombre de jours depuis le contact si le prospect est à relancer, sinon null.
function joursDeRelance(p) {
if (!STATUTS_A_RELANCER.includes(statutDe(p))) return null;
const d = parseDateFr(p["Date de contact"]);
if (!d) return null;
const delai = Number(config.delai_relance_jours);
const seuil = Number.isFinite(delai) ? delai : 7;
const jours = Math.floor((new Date() - d) / 86400000);
return jours >= seuil ? jours : null;
}
function afficherRelances() {
const banniere = $("relances");
if (!banniere) return;
const liste = prospects
.map(p => ({ p, j: joursDeRelance(p) }))
.filter(x => x.j !== null)
.sort((a, b) => b.j - a.j);
if (!liste.length) { banniere.style.display = "none"; banniere.innerHTML = ""; return; }
const seuil = Number(config.delai_relance_jours) || 7;
banniere.style.display = "block";
banniere.innerHTML =
`🔔 <strong>${liste.length} prospect(s) à relancer</strong> ` +
`<span class="sous">(contactés il y a plus de ${seuil} jours sans réponse)</span>` +
`<div class="relance-chips">${liste.map(({ p, j }) =>
`<button class="chip-relance" data-index="${p.index}">${echap(p["Nom du prospect"] || "Sans nom")} · ${j} j</button>`
).join("")}</div>`;
banniere.querySelectorAll(".chip-relance").forEach(b =>
b.addEventListener("click", () => ouvrirFiche(+b.dataset.index)));
}
// Réinitialise les filtres, déplie la fiche du prospect et la fait défiler à l'écran.
function ouvrirFiche(index) {
$("recherche").value = "";
$("filtre-statut").value = "";
afficherListe();
const carteEl = $("liste").querySelector(`.carte[data-index="${index}"]`);
if (!carteEl) return;
const detail = carteEl.querySelector(".carte-detail");
if (detail.style.display === "none") carteEl.querySelector(".carte-tete").click();
carteEl.scrollIntoView({ behavior: "smooth", block: "center" });
}
function carte(p) {
const div = document.createElement("div");
div.className = "carte";
div.dataset.index = p.index;
const tete = document.createElement("div");
tete.className = "carte-tete";
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
const st = statutDe(p);
const couleur = STATUTS[st] || "#7f8c8d";
const relance = joursDeRelance(p);
const contacte = (p["Date de contact"] || "").trim()
? `<span class="sous">contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}</span>` : "";
tete.innerHTML = `
@@ -432,9 +525,11 @@ function carte(p) {
</div>
<div class="carte-infos">
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
${relance !== null ? `<span class="badge-relance">🔔 à relancer (${relance} j)</span>` : ""}
${lieu ? `<span class="sous">📍 ${echap(lieu)}</span>` : ""}
${p["Téléphone"] ? `<span>📞 ${echap(p["Téléphone"])}</span>` : ""}
${p["Email"] ? `<span>✉️ ${echap(p["Email"])}</span>` : ""}
${(p["Notes"] || "").trim() ? `<span class="sous" title="${echap(p["Notes"])}">📝 note</span>` : ""}
${contacte}
</div>`;
@@ -486,7 +581,7 @@ function construireDetail(detail, p) {
enregistrer.textContent = "Enregistrer";
enregistrer.addEventListener("click", async () => {
const donnees = {};
for (const ctrl of grille.querySelectorAll("input, select")) donnees[ctrl.name] = ctrl.value;
for (const ctrl of grille.querySelectorAll("input, select, textarea")) donnees[ctrl.name] = ctrl.value;
const rep = await fetch(`/api/prospects/${p.index}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -548,6 +643,41 @@ function construireDetail(detail, p) {
$("recherche").addEventListener("input", afficherListe);
$("filtre-statut").addEventListener("change", afficherListe);
// --- Journal de scraping ---
const COULEURS_LOG = { ok: "#27ae60", ajoute: "#27ae60", vide: "#f39c12", doublon: "#7f8c8d" };
async function chargerLogs() {
let entrees = [];
try { entrees = await (await fetch("/api/logs")).json(); } catch { return; }
const div = $("journal");
if (!entrees.length) {
div.innerHTML = '<p class="vide">Aucune analyse enregistrée pour le moment.</p>';
return;
}
div.innerHTML = entrees.map(e => {
const couleur = COULEURS_LOG[e.statut] || "#c0392b";
return `<div class="ligne-progres">
<span class="sous">${echap(e.date)}</span>
<span class="badge-statut" style="background:${couleur}">${echap(e.statut)}</span>
${echap(e.url)} <span class="sous">${echap(e.message)}</span>
</div>`;
}).join("");
}
$("journal-details").addEventListener("toggle", (e) => { if (e.target.open) chargerLogs(); });
$("journal-rafraichir").addEventListener("click", chargerLogs);
$("journal-vider").addEventListener("click", async () => {
if (!confirm("Vider tout le journal de scraping ?")) return;
await fetch("/api/logs", { method: "DELETE" });
chargerLogs();
});
// Rafraîchit le journal s'il est ouvert (après une analyse ou un import).
function rafraichirLogsSiOuvert() {
if ($("journal-details").open) chargerLogs();
}
remplirFiltreStatut();
chargerConfig();
chargerListe();