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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user